diff --git a/.typos.toml b/.typos.toml index 88dbd51e..66e396f2 100644 --- a/.typos.toml +++ b/.typos.toml @@ -6,3 +6,5 @@ ue = "ue" # Strang splitting (named after mathematician Gilbert Strang) strang = "strang" Strang = "Strang" +# Variable name for "p at iteration n" in Jacobi iteration +pn = "pn" diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..ab70a064 --- /dev/null +++ b/Makefile @@ -0,0 +1,70 @@ +# Makefile for Finite Difference Computing with PDEs book + +.PHONY: pdf html all preview clean test test-devito test-no-devito lint format check help + +# Default target +all: pdf + +# Build targets +pdf: + quarto render --to pdf + +html: + quarto render --to html + +# Build both PDF and HTML +book: + quarto render + +# Live preview with hot reload +preview: + quarto preview + +# Clean build artifacts +clean: + rm -rf _book/ + rm -rf .quarto/ + find . -name "*.aux" -delete + find . -name "*.log" -delete + find . -name "*.out" -delete + +# Test targets +test: + pytest tests/ -v + +test-devito: + pytest tests/ -v -m devito + +test-no-devito: + pytest tests/ -v -m "not devito" + +test-phase1: + pytest tests/test_elliptic_devito.py tests/test_burgers_devito.py tests/test_swe_devito.py -v + +# Linting and formatting +lint: + ruff check src/ + +format: + ruff check --fix src/ + isort src/ + +check: + pre-commit run --all-files + +# Help +help: + @echo "Available targets:" + @echo " pdf - Build PDF (default)" + @echo " html - Build HTML" + @echo " book - Build all formats (PDF + HTML)" + @echo " preview - Live preview with hot reload" + @echo " clean - Remove build artifacts" + @echo " test - Run all tests" + @echo " test-devito - Run only Devito tests" + @echo " test-no-devito - Run tests without Devito" + @echo " test-phase1 - Run Phase 1 tests (elliptic, burgers, swe)" + @echo " lint - Check code with ruff" + @echo " format - Auto-format code with ruff and isort" + @echo " check - Run all pre-commit hooks" + @echo " help - Show this help message" diff --git a/README.md b/README.md index 1c9c6967..e46181eb 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ Based on *Finite Difference Computing with Partial Differential Equations* by Ha Devito is a domain-specific language (DSL) embedded in Python for solving PDEs using finite differences. Instead of manually implementing stencil operations, you write mathematical expressions symbolically and Devito generates optimized C code: ```python -from devito import Grid, TimeFunction, Eq, Operator +import numpy as np +from devito import Constant, Eq, Grid, Operator, TimeFunction, solve # Define computational grid grid = Grid(shape=(101,), extent=(1.0,)) @@ -24,12 +25,21 @@ grid = Grid(shape=(101,), extent=(1.0,)) # Create field with time derivative capability u = TimeFunction(name='u', grid=grid, time_order=2, space_order=2) -# Write the wave equation symbolically -eq = Eq(u.dt2, c**2 * u.dx2) +# Wave speed parameter (passed at runtime) +c = Constant(name="c") + +# Set an initial condition (Gaussian pulse) +x = np.linspace(0.0, 1.0, 101) +u.data[0, :] = np.exp(-((x - 0.5) ** 2) / (2 * 0.1**2)) +u.data[1, :] = u.data[0, :] # zero initial velocity (demo) + +# Write the wave equation symbolically and derive an explicit update stencil +pde = Eq(u.dt2, c**2 * u.dx2) +update = Eq(u.forward, solve(pde, u.forward)) # Devito generates optimized C code automatically -op = Operator([eq]) -op.apply(time_M=100, dt=0.001) +op = Operator([update]) +op.apply(time_M=100, dt=0.001, c=1.0) ``` ## Quick Start diff --git a/_quarto.yml b/_quarto.yml index a5d21240..7761c357 100644 --- a/_quarto.yml +++ b/_quarto.yml @@ -6,8 +6,8 @@ book: title: "Finite Difference Computing with PDEs" subtitle: "A Devito Approach" author: - - name: Hans Petter Langtangen - - name: Svein Linge + - name: Gerard J. Gorman + affiliation: Imperial College London date: today chapters: - index.qmd @@ -19,6 +19,8 @@ book: - chapters/diffu/index.qmd - chapters/advec/index.qmd - chapters/nonlin/index.qmd + - chapters/elliptic/index.qmd + - chapters/systems/index.qmd - part: "Appendices" chapters: - chapters/appendices/formulas/index.qmd @@ -39,6 +41,93 @@ format: number-depth: 3 crossref: chapters: true + include-in-header: + text: | + pdf: documentclass: scrbook classoption: @@ -169,6 +258,8 @@ src_diffu: "https://github.com/devitocodes/devito_book/tree/devito/src/diffu" src_nonlin: "https://github.com/devitocodes/devito_book/tree/devito/src/nonlin" src_trunc: "https://github.com/devitocodes/devito_book/tree/devito/src/trunc" src_advec: "https://github.com/devitocodes/devito_book/tree/devito/src/advec" +src_elliptic: "https://github.com/devitocodes/devito_book/tree/devito/src/elliptic" +src_systems: "https://github.com/devitocodes/devito_book/tree/devito/src/systems" src_formulas: "https://github.com/devitocodes/devito_book/tree/devito/src/formulas" src_softeng2: "https://github.com/devitocodes/devito_book/tree/devito/src/softeng2" diff --git a/chapters/advec/advec.qmd b/chapters/advec/advec.qmd index e29d94c7..c699ed4c 100644 --- a/chapters/advec/advec.qmd +++ b/chapters/advec/advec.qmd @@ -72,7 +72,7 @@ u(i\Delta x, (n+1)\Delta t) &= I(i\Delta x - v(n+1)\Delta t) \nonumber \\ provided $v = \Delta x/\Delta t$. So, whenever we see a scheme that collapses to $$ -u^{n+1}**i = u**{i-1}^n, +u^{n+1}_i = u_{i-1}^n, $$ {#eq-advec-1D-pde1-uprop2} for the PDE in question, we have in fact a scheme that reproduces the analytical solution, and many of the schemes to be presented possess @@ -771,11 +771,12 @@ $$ $$ which results in the updating formula $$ -u^{n+1}_i = u^{n-1}**i - C(u**{i+1}^n-u_{i-1}^n)\tp +u^{n+1}_i = u^{n-1}_i - C(u_{i+1}^n-u_{i-1}^n)\tp $$ A special scheme is needed to compute $u^1$, but we leave that problem for -now. Anyway, this special scheme can be found in -[`advec1D.py`](https://github.com/devitocodes/devito_book/tree/main/src/advec/advec1D.py). +now. The Devito implementation handles this automatically; see +[`advec1D_devito.py`](https://github.com/devitocodes/devito_book/tree/main/src/advec/advec1D_devito.py) +and @sec-advec-devito for details. ### Implementation We now need to work with three time levels and must modify our solver a bit: @@ -888,7 +889,7 @@ $$ $$ {#eq-advec-1D-upwind} written out as $$ -u^{n+1}_i = u^n_i - C(u^{n}**{i}-u^{n}**{i-1}), +u^{n+1}_i = u^n_i - C(u^{n}_{i}-u^{n}_{i-1}), $$ gives a generally popular and robust scheme that is stable if $C\leq 1$. As with the Leapfrog scheme, it becomes exact if $C=1$, exactly as shown in @@ -929,7 +930,7 @@ $$ $$ by a forward difference in time and centered differences in space, $$ -D^+**t u + vD**{2x} u = \nu D_xD_x u]^n_i, +D^+_t u + vD_{2x} u = \nu D_xD_x u]^n_i, $$ actually gives the upwind scheme (@eq-advec-1D-upwind) if $\nu = v\Delta x/2$. That is, solving the PDE $u_t + vu_x=0$ @@ -1170,30 +1171,31 @@ def run(scheme='UP', case='gaussian', C=1, dt=0.01): os.system(cmd) print 'Integral of u:', integral.max(), integral.min() ``` -The complete code is found in the file -[`advec1D.py`](https://github.com/devitocodes/devito_book/tree/main/src/advec/advec1D.py). +The Devito implementation is found in +[`advec1D_devito.py`](https://github.com/devitocodes/devito_book/tree/main/src/advec/advec1D_devito.py). +See @sec-advec-devito for the complete implementation. ## A Crank-Nicolson discretization in time and centered differences in space {#sec-advec-1D-CN} Another obvious candidate for time discretization is the Crank-Nicolson method combined with centered differences in space: $$ -[D_t u]^n_i + v\half([D_{2x} u]^{n+1}**i + [D**{2x} u]^{n}_i) = 0\tp +[D_t u]^n_i + v\half([D_{2x} u]^{n+1}_i + [D_{2x} u]^{n}_i) = 0\tp $$ It can be nice to include the Backward Euler scheme too, via the $\theta$-rule, $$ -[D_t u]^n_i + v\theta [D_{2x} u]^{n+1}**i + v(1-\theta)[D**{2x} u]^{n}_i = 0\tp +[D_t u]^n_i + v\theta [D_{2x} u]^{n+1}_i + v(1-\theta)[D_{2x} u]^{n}_i = 0\tp $$ When $\theta$ is different from zero, this gives rise to an *implicit* scheme, $$ -u^{n+1}**i + \frac{\theta}{2} C (u^{n+1}**{i+1} - u^{n+1}_{i-1}) -= u^n_i - \frac{1-\theta}{2} C (u^{n}**{i+1} - u^{n}**{i-1}) +u^{n+1}_i + \frac{\theta}{2} C (u^{n+1}_{i+1} - u^{n+1}_{i-1}) += u^n_i - \frac{1-\theta}{2} C (u^{n}_{i+1} - u^{n}_{i-1}) $$ for $i=1,\ldots,N_x-1$. At the boundaries we set $u=0$ and simulate just to the point of time when the signal hits the boundary (and gets reflected). $$ -u^{n+1}**0 = u^{n+1}**{N_x} = 0\tp +u^{n+1}_0 = u^{n+1}_{N_x} = 0\tp $$ The elements on the diagonal in the matrix become: $$ @@ -1208,7 +1210,7 @@ And finally, the right-hand side becomes \begin{align*} b_0 &= u^n_{N_x}\\ -b_i &= u^n_i - \frac{1-\theta}{2} C (u^{n}**{i+1} - u^{n}**{i-1}),\quad i=1,\ldots,N_x-1\\ +b_i &= u^n_i - \frac{1-\theta}{2} C (u^{n}_{i+1} - u^{n}_{i-1}),\quad i=1,\ldots,N_x-1\\ b_{N_x} &= u^n_0 \end{align*} @@ -1273,7 +1275,7 @@ u^{n+1}_i = u^n_i -v \Delta t [D_{2x} u]^n_i $$ or written out, $$ -u^{n+1}_i = u^n_i - \frac{1}{2} C (u^{n}**{i+1} - u^{n}**{i-1}) +u^{n+1}_i = u^n_i - \frac{1}{2} C (u^{n}_{i+1} - u^{n}_{i-1}) + \frac{1}{2} C^2 (u^{n}_{i+1}-2u^n_i+u^n_{i-1})\tp $$ This is the explicit Lax-Wendroff scheme. @@ -1576,15 +1578,15 @@ is the dominating term, collect its information in the flow direction, i.e., upstream or upwind of the point in question. So, instead of using a centered difference $$ -\frac{du}{dx}**i\approx \frac{u**{i+1}-u_{i-1}}{2\Delta x}, +\frac{du}{dx}_i\approx \frac{u_{i+1}-u_{i-1}}{2\Delta x}, $$ we use the one-sided *upwind* difference $$ -\frac{du}{dx}**i\approx \frac{u**{i}-u_{i-1}}{\Delta x}, +\frac{du}{dx}_i\approx \frac{u_{i}-u_{i-1}}{\Delta x}, $$ in case $v>0$. For $v<0$ we set $$ -\frac{du}{dx}**i\approx \frac{u**{i+1}-u_{i}}{\Delta x}, +\frac{du}{dx}_i\approx \frac{u_{i+1}-u_{i}}{\Delta x}, $$ On compact operator notation form, our upwind scheme can be expressed as diff --git a/chapters/advec/advec1D_devito.qmd b/chapters/advec/advec1D_devito.qmd index afe63b34..657a552a 100644 --- a/chapters/advec/advec1D_devito.qmd +++ b/chapters/advec/advec1D_devito.qmd @@ -70,46 +70,11 @@ $$ u^{n+1}_i = u^n_i - C(u^n_i - u^n_{i-1}) $$ {#eq-advec-upwind-update} -In Devito, we express this using shifted indexing: +In Devito, we express this using shifted indexing. The key technique is +using `u.subs(x_dim, x_dim - x_dim.spacing)` to create a reference to +$u^n_{i-1}$: -```python -from devito import Grid, TimeFunction, Eq, Operator, Constant -import numpy as np - -def solve_advection_upwind(L, c, Nx, T, C, I): - """Upwind scheme for 1D advection.""" - # Grid setup - dx = L / Nx - dt = C * dx / c - - grid = Grid(shape=(Nx + 1,), extent=(L,)) - x_dim, = grid.dimensions - - u = TimeFunction(name='u', grid=grid, time_order=1, space_order=1) - - # Set initial condition - x_coords = np.linspace(0, L, Nx + 1) - u.data[0, :] = I(x_coords) - - # Courant number as constant - courant = Constant(name='C', value=C) - - # Upwind stencil: u^{n+1} = u - C*(u - u[x-dx]) - u_minus = u.subs(x_dim, x_dim - x_dim.spacing) - stencil = u - courant * (u - u_minus) - update = Eq(u.forward, stencil) - - op = Operator([update]) - # ... time stepping loop -``` - -The key line is: -```python -u_minus = u.subs(x_dim, x_dim - x_dim.spacing) -``` - -This creates a reference to $u^n_{i-1}$ by substituting `x_dim - x_dim.spacing` -for `x_dim` in the `TimeFunction` `u`. +{{< include snippets/advec_upwind.qmd >}} ### Lax-Wendroff Scheme Implementation @@ -120,36 +85,11 @@ $$ u^{n+1}_i = u^n_i - \frac{C}{2}(u^n_{i+1} - u^n_{i-1}) + \frac{C^2}{2}(u^n_{i+1} - 2u^n_i + u^n_{i-1}) $$ -This can be written using Devito's derivative operators: - -```python -def solve_advection_lax_wendroff(L, c, Nx, T, C, I): - """Lax-Wendroff scheme for 1D advection.""" - dx = L / Nx - dt = C * dx / c - - grid = Grid(shape=(Nx + 1,), extent=(L,)) - u = TimeFunction(name='u', grid=grid, time_order=1, space_order=2) - - x_coords = np.linspace(0, L, Nx + 1) - u.data[0, :] = I(x_coords) - - courant = Constant(name='C', value=C) - - # Lax-Wendroff: u - (C/2)*dx*u.dx + (C²/2)*dx²*u.dx2 - # u.dx = centered first derivative - # u.dx2 = centered second derivative - stencil = u - 0.5*courant*dx*u.dx + 0.5*courant**2*dx**2*u.dx2 - update = Eq(u.forward, stencil) - - op = Operator([update]) - # ... time stepping loop -``` - -Here we use Devito's built-in derivative operators: +This can be written using Devito's built-in derivative operators where +`u.dx` computes the centered first derivative and `u.dx2` computes the +centered second derivative: -- `u.dx` computes the centered first derivative $(u_{i+1} - u_{i-1})/(2\Delta x)$ -- `u.dx2` computes the centered second derivative $(u_{i+1} - 2u_i + u_{i-1})/\Delta x^2$ +{{< include snippets/advec_lax_wendroff.qmd >}} ### Lax-Friedrichs Scheme Implementation diff --git a/chapters/advec/snippets/advec_lax_wendroff.qmd b/chapters/advec/snippets/advec_lax_wendroff.qmd new file mode 100644 index 00000000..dc0d109d --- /dev/null +++ b/chapters/advec/snippets/advec_lax_wendroff.qmd @@ -0,0 +1,7 @@ +::: {.callout-note title="Snippet (tested)"} +This code is included from `src/book_snippets/advec_lax_wendroff.py` and exercised by `pytest` (see `tests/test_book_snippets.py`). +::: + +```python +{{< include ../../src/book_snippets/advec_lax_wendroff.py >}} +``` diff --git a/chapters/advec/snippets/advec_upwind.qmd b/chapters/advec/snippets/advec_upwind.qmd new file mode 100644 index 00000000..2adf7c8e --- /dev/null +++ b/chapters/advec/snippets/advec_upwind.qmd @@ -0,0 +1,7 @@ +::: {.callout-note title="Snippet (tested)"} +This code is included from `src/book_snippets/advec_upwind.py` and exercised by `pytest` (see `tests/test_book_snippets.py`). +::: + +```python +{{< include ../../src/book_snippets/advec_upwind.py >}} +``` diff --git a/chapters/appendices/softeng2/softeng2.qmd b/chapters/appendices/softeng2/softeng2.qmd index ebe7121e..2d49e5ef 100644 --- a/chapters/appendices/softeng2/softeng2.qmd +++ b/chapters/appendices/softeng2/softeng2.qmd @@ -41,23 +41,24 @@ and @sec-wave-pde2-var-c. ## A solver function +::: {.callout-note} +## Source Files + +The software engineering patterns presented in this appendix (classes like +`Storage`, `Parameters`, `Problem`, `Mesh`, `Function`, `Solver`) are +implemented in `src/softeng2/`. Key files include: + +- `src/softeng2/Storage.py` - Data persistence with joblib +- `src/softeng2/wave1D_oo.py` - Object-oriented wave solver with `Parameters` class +- `src/softeng2/wave2D_u0.py` - 2D wave implementations + +These serve as reference implementations for the patterns discussed. +::: + The general initial-boundary value problem -solved by finite difference methods can be implemented as shown in -the following `solver` function (taken from the -file [`wave1D_dn_vc.py`](https://github.com/devitocodes/devito_book/tree/main/src/wave/wave1D/wave1D_dn_vc.py)). -This function builds on -simpler versions described in -@sec-wave-pde1-impl, @sec-wave-pde1-impl-vec, -@sec-wave-pde2-Neumann, and @sec-wave-pde2-var-c. -There are several quite advanced -constructs that will be commented upon later. -The code is lengthy, but that is because we provide a lot of -flexibility with respect to input arguments, -boundary conditions, and optimization -(scalar versus vectorized loops). - -```{.python include="../../../src/wave/wave1D/wave1D_dn_vc.py" start-after="def solver" end-before="def test_quadratic"} -``` +solved by finite difference methods. For modern implementations using +Devito, see @sec-wave-devito. This section covers software engineering +principles that apply broadly to scientific computing. ## Storing simulation data in files {#sec-softeng2-wave1D-filestorage} @@ -435,16 +436,43 @@ where we have a more elegant solution in terms of a class: the `error` variable is not a class attribute and there is no need for a global error (which is always considered an advantage). -```{.python include="../../../src/wave/wave1D/wave1D_dn_vc.py" start-after="def convergence_rates" end-before="def test_convrate_sincos"} +```python +def convergence_rates( + u_exact, # Function for exact solution + I, V, f, c, L, # Problem parameters + dt_values, # List of dt values to test + solver_function, # Solver to test +): + """ + Compute convergence rates for a wave equation solver. + Returns list of observed convergence rates. + """ + E_values = [] + for dt in dt_values: + # Run solver and compute error + u, x, t = solver_function(I, V, f, c, L, dt, ...) + u_e = u_exact(x, t[-1]) + E = np.sqrt(dt * np.sum((u_e - u) ** 2)) + E_values.append(E) + + # Compute convergence rates + r = [np.log(E_values[i] / E_values[i-1]) / + np.log(dt_values[i] / dt_values[i-1]) + for i in range(1, len(dt_values))] + return r ``` +::: {.callout-note} +For a complete, tested implementation of convergence rate testing with Devito, +see `src/wave/wave1D_devito.py` and `tests/test_wave_devito.py`. +::: + The returned sequence `r` should converge to 2 since the error analysis in @sec-wave-pde1-analysis predicts various error measures to behave like $\Oof{\Delta t^2} + \Oof{\Delta x^2}$. We can easily run the case with standing waves and the analytical solution -$u(x,t) = \cos(\frac{2\pi}{L}t)\sin(\frac{2\pi}{L}x)$. The call will -be very similar to the one provided in the `test_convrate_sincos` function -in @sec-wave-pde1-impl-verify-rate, see the file `src/wave/wave1D/wave1D_dn_vc.py` for details. +$u(x,t) = \cos(\frac{2\pi}{L}t)\sin(\frac{2\pi}{L}x)$. See @sec-wave-devito-convergence +for details on convergence rate testing with Devito. Many who know about class programming prefer to organize their software in terms of classes. This gives a richer application programming interface @@ -792,8 +820,7 @@ user will not notice a change to properties. The only argument against direct attribute access in class `Mesh` is that the attributes are read-only so we could avoid offering -a set function. Instead, we rely on the user that she does not -assign new values to the attributes. +a set function. Instead, we rely on the user not to assign new values to the attributes. ::: ## Class Function @@ -1221,9 +1248,7 @@ The `wave2D_u0.py` file contains a `solver` function, which calls an in time. The function `advance_scalar` applies standard Python loops to implement the scheme, while `advance_vectorized` performs corresponding vectorized arithmetics with array slices. The statements -of this solver are explained in @sec-wave-2D3D-impl, in -particular @sec-wave2D3D-impl-scalar and -@sec-wave2D3D-impl-vectorized. +of this solver are explained in @sec-wave-2D3D-models. Although vectorization can bring down the CPU time dramatically compared with scalar code, there is still some factor 5-10 to win in @@ -1506,9 +1531,8 @@ to a single index. We write a Fortran subroutine `advance` in a file [`wave2D_u0_loop_f77.f`](https://github.com/devitocodes/devito_book/tree/main/src/softeng2/wave2D_u0_loop_f77.f) -for implementing the updating formula -(@eq-wave-2D3D-impl1-2Du0-ueq-discrete) and setting the solution to zero -at the boundaries: +for implementing the updating formula (@eq-wave-2D3D-models-unp1) and +setting the solution to zero at the boundaries: ```fortran subroutine advance(u, u_1, u_2, f, Cx2, Cy2, dt2, Nx, Ny) diff --git a/chapters/appendices/trunc/trunc.qmd b/chapters/appendices/trunc/trunc.qmd index 124e6653..30554444 100644 --- a/chapters/appendices/trunc/trunc.qmd +++ b/chapters/appendices/trunc/trunc.qmd @@ -1644,12 +1644,12 @@ $[D_xD_x (D_tD_t u)]^n_i$ gives \begin{align*} \frac{1}{\Delta t^2}\biggl( -&\frac{u^{n+1}**{i+1} - 2u^{n}**{i+1} + u^{n-1}_{i+1}}{\Delta x^2} -2\\ -&\frac{u^{n+1}**{i} - 2u^{n}**{i} + u^{n-1}_{i}}{\Delta x^2} + -&\frac{u^{n+1}**{i-1} - 2u^{n}**{i-1} + u^{n-1}_{i-1}}{\Delta x^2} +&\frac{u^{n+1}_{i+1} - 2u^{n}_{i+1} + u^{n-1}_{i+1}}{\Delta x^2} -2\\ +&\frac{u^{n+1}_{i} - 2u^{n}_{i} + u^{n-1}_{i}}{\Delta x^2} + +&\frac{u^{n+1}_{i-1} - 2u^{n}_{i-1} + u^{n-1}_{i-1}}{\Delta x^2} \biggr) \end{align*} -Now the unknown values $u^{n+1}**{i+1}$, $u^{n+1}**{i}$, +Now the unknown values $u^{n+1}_{i+1}$, $u^{n+1}_{i}$, and $u^{n+1}_{i-1}$ are *coupled*, and we must solve a tridiagonal system to find them. This is in principle straightforward, but it results in an implicit finite difference scheme, while we had diff --git a/chapters/devito_intro/boundary_conditions.qmd b/chapters/devito_intro/boundary_conditions.qmd index a7ecf35c..e378a5c0 100644 --- a/chapters/devito_intro/boundary_conditions.qmd +++ b/chapters/devito_intro/boundary_conditions.qmd @@ -15,41 +15,11 @@ $$ The most direct approach adds equations that set boundary values: -```python -from devito import Grid, TimeFunction, Eq, Operator - -grid = Grid(shape=(101,), extent=(1.0,)) -u = TimeFunction(name='u', grid=grid, time_order=2, space_order=2) - -# Get the time dimension for indexing -t = grid.stepping_dim - -# Interior update (wave equation) -update = Eq(u.forward, 2*u - u.backward + dt**2 * c**2 * u.dx2) - -# Boundary conditions: u = 0 at both ends -bc_left = Eq(u[t+1, 0], 0) -bc_right = Eq(u[t+1, 100], 0) - -# Include all equations in the operator -op = Operator([update, bc_left, bc_right]) -``` +{{< include snippets/boundary_dirichlet_wave.qmd >}} **Method 2: Using subdomain** -For interior-only updates, use `subdomain=grid.interior`: - -```python -# Update only interior points (automatically excludes boundaries) -update = Eq(u.forward, 2*u - u.backward + dt**2 * c**2 * u.dx2, - subdomain=grid.interior) - -# Set boundaries explicitly -bc_left = Eq(u[t+1, 0], 0) -bc_right = Eq(u[t+1, 100], 0) - -op = Operator([update, bc_left, bc_right]) -``` +The snippet above already uses `subdomain=grid.interior` to keep the interior PDE update separate from boundary treatment. The `subdomain=grid.interior` approach is often cleaner because it explicitly separates the physics (interior PDE) from the boundary treatment. @@ -71,87 +41,25 @@ $$ This gives $u_{-1} = u_1$, which we substitute into the interior equation: -```python -grid = Grid(shape=(101,), extent=(1.0,)) -u = TimeFunction(name='u', grid=grid, time_order=1, space_order=2) -x = grid.dimensions[0] -t = grid.stepping_dim - -# Interior update (diffusion equation) -update = Eq(u.forward, u + alpha * dt * u.dx2, subdomain=grid.interior) - -# Neumann BC at left (du/dx = 0): use one-sided update -# u_new[0] = u[0] + alpha*dt * 2*(u[1] - u[0])/dx^2 -dx = grid.spacing[0] -bc_left = Eq(u[t+1, 0], u[t, 0] + alpha * dt * 2 * (u[t, 1] - u[t, 0]) / dx**2) - -# Neumann BC at right (du/dx = 0) -bc_right = Eq(u[t+1, 100], u[t, 100] + alpha * dt * 2 * (u[t, 99] - u[t, 100]) / dx**2) - -op = Operator([update, bc_left, bc_right]) -``` +{{< include snippets/neumann_bc_diffusion_1d.qmd >}} ### Mixed Boundary Conditions Often we have different conditions on different boundaries: -```python -# Dirichlet on left, Neumann on right -bc_left = Eq(u[t+1, 0], 0) # u(0,t) = 0 -bc_right = Eq(u[t+1, 100], u[t+1, 99]) # du/dx(L,t) = 0 (copy from interior) - -op = Operator([update, bc_left, bc_right]) -``` +{{< include snippets/mixed_bc_diffusion_1d.qmd >}} ### 2D Boundary Conditions For 2D problems, boundary conditions apply to all four edges: -```python -grid = Grid(shape=(101, 101), extent=(1.0, 1.0)) -u = TimeFunction(name='u', grid=grid, time_order=2, space_order=2) - -x, y = grid.dimensions -t = grid.stepping_dim -Nx, Ny = 100, 100 - -# Interior update -update = Eq(u.forward, 2*u - u.backward + dt**2 * c**2 * u.laplace, - subdomain=grid.interior) - -# Dirichlet BCs on all four edges -bc_left = Eq(u[t+1, 0, y], 0) -bc_right = Eq(u[t+1, Nx, y], 0) -bc_bottom = Eq(u[t+1, x, 0], 0) -bc_top = Eq(u[t+1, x, Ny], 0) - -op = Operator([update, bc_left, bc_right, bc_bottom, bc_top]) -``` +{{< include snippets/bc_2d_dirichlet_wave.qmd >}} ### Time-Dependent Boundary Conditions For boundaries that vary in time, use the time index: -```python -from devito import Constant - -# Time-varying amplitude -A = Constant(name='A') - -# Sinusoidal forcing at left boundary -# u(0, t) = A * sin(omega * t) -import sympy as sp -omega = 2 * sp.pi # Angular frequency - -# The time value at step n -t_val = t * dt # Symbolic time value - -bc_left = Eq(u[t+1, 0], A * sp.sin(omega * t_val)) - -# Set the amplitude before running -op = Operator([update, bc_left, bc_right]) -op(time=Nt, dt=dt, A=1.0) # Pass A as keyword argument -``` +{{< include snippets/time_dependent_bc_sine.qmd >}} ### Absorbing Boundary Conditions @@ -163,14 +71,7 @@ $$ This can be discretized as: -```python -# Absorbing BC at right boundary (waves traveling right) -dx = grid.spacing[0] -bc_right_absorbing = Eq( - u[t+1, Nx], - u[t, Nx] - c * dt / dx * (u[t, Nx] - u[t, Nx-1]) -) -``` +{{< include snippets/absorbing_bc_right_wave.qmd >}} More sophisticated absorbing conditions use damping layers (sponges) near the boundaries. This is covered in detail in @sec-wave-1d-absorbing. @@ -185,11 +86,7 @@ $$ Devito doesn't directly support periodic BCs, but they can be implemented by copying values: -```python -# Periodic BCs: u[0] = u[Nx-1], u[Nx] = u[1] -bc_periodic_left = Eq(u[t+1, 0], u[t+1, Nx-1]) -bc_periodic_right = Eq(u[t+1, Nx], u[t+1, 1]) -``` +{{< include snippets/periodic_bc_advection_1d.qmd >}} Note: The order of equations matters. Update the interior first, then copy for periodicity. @@ -212,42 +109,7 @@ copy for periodicity. Here's a complete example combining interior updates with boundary conditions: -```python -from devito import Grid, TimeFunction, Eq, Operator -import numpy as np - -# Setup -L, c, T = 1.0, 1.0, 2.0 -Nx = 100 -C = 0.9 # Courant number -dx = L / Nx -dt = C * dx / c -Nt = int(T / dt) - -# Grid and field -grid = Grid(shape=(Nx + 1,), extent=(L,)) -u = TimeFunction(name='u', grid=grid, time_order=2, space_order=2) -t = grid.stepping_dim - -# Initial condition: plucked string -x_vals = np.linspace(0, L, Nx + 1) -u.data[0, :] = np.sin(np.pi * x_vals) -u.data[1, :] = u.data[0, :] # Zero initial velocity - -# Equations -update = Eq(u.forward, 2*u - u.backward + (c*dt)**2 * u.dx2, - subdomain=grid.interior) -bc_left = Eq(u[t+1, 0], 0) -bc_right = Eq(u[t+1, Nx], 0) - -# Solve -op = Operator([update, bc_left, bc_right]) -op(time=Nt, dt=dt) - -# Verify: solution should return to initial shape at t = 2L/c -print(f"Initial max: {np.max(u.data[1, :]):.6f}") -print(f"Final max: {np.max(u.data[0, :]):.6f}") -``` +{{< include snippets/boundary_dirichlet_wave.qmd >}} For a string with fixed ends and initial shape $\sin(\pi x)$, the solution oscillates with period $2L/c$. After one period, it should return to the diff --git a/chapters/devito_intro/first_pde.qmd b/chapters/devito_intro/first_pde.qmd index dcd0d725..d5dd629c 100644 --- a/chapters/devito_intro/first_pde.qmd +++ b/chapters/devito_intro/first_pde.qmd @@ -43,52 +43,7 @@ for $C \le 1$. Let's implement this step by step: -```python -from devito import Grid, TimeFunction, Eq, Operator -import numpy as np - -# Problem parameters -L = 1.0 # Domain length -c = 1.0 # Wave speed -T = 1.0 # Final time -Nx = 100 # Number of grid points -C = 0.5 # Courant number (for stability) - -# Derived parameters -dx = L / Nx -dt = C * dx / c -Nt = int(T / dt) - -# Create the computational grid -grid = Grid(shape=(Nx + 1,), extent=(L,)) - -# Create a time-varying field -# time_order=2 because we have second derivative in time -# space_order=2 for standard second-order accuracy -u = TimeFunction(name='u', grid=grid, time_order=2, space_order=2) - -# Set initial condition: a Gaussian pulse -x = grid.dimensions[0] -x_coord = 0.5 * L # Center of domain -sigma = 0.1 # Width of pulse -u.data[0, :] = np.exp(-((np.linspace(0, L, Nx+1) - x_coord)**2) / (2*sigma**2)) -u.data[1, :] = u.data[0, :] # Zero initial velocity - -# Define the update equation -# u.forward is u at time n+1, u is at time n, u.backward is at time n-1 -# u.dx2 is the second spatial derivative -eq = Eq(u.forward, 2*u - u.backward + (c*dt)**2 * u.dx2) - -# Create the operator -op = Operator([eq]) - -# Run the simulation -op(time=Nt, dt=dt) - -# The solution is now in u.data -print(f"Simulation complete: {Nt} time steps") -print(f"Max amplitude at t={T}: {np.max(np.abs(u.data[0, :])):.6f}") -``` +{{< include snippets/first_pde_wave1d.qmd >}} ### Understanding the Code @@ -114,11 +69,22 @@ u = TimeFunction(name='u', grid=grid, time_order=2, space_order=2) **Initial conditions:** ```python u.data[0, :] = ... # u at t=0 -u.data[1, :] = ... # u at t=dt (for zero initial velocity, same as t=0) +u.data[1, :] = ... # u at t=dt (computed from u_t(x,0)=0) ``` The `data` attribute provides direct access to the underlying NumPy arrays. Index 0 and 1 represent the two most recent time levels. +For the 2nd-order wave scheme, “zero initial velocity” does **not** mean +`u.data[1, :] = u.data[0, :]` if you want to keep 2nd-order accuracy at the first step. +Instead, the included (tested) snippet computes the first time level using the spatial +second derivative at `t=0`: + +```python +u1 = u0 + 0.5 * dt**2 * c**2 * u_xx_0 +``` + +and enforces the fixed-end boundary conditions at that first step. + **Update equation:** ```python eq = Eq(u.forward, 2*u - u.backward + (c*dt)**2 * u.dx2) diff --git a/chapters/devito_intro/snippets/absorbing_bc_right_wave.qmd b/chapters/devito_intro/snippets/absorbing_bc_right_wave.qmd new file mode 100644 index 00000000..24c4b7da --- /dev/null +++ b/chapters/devito_intro/snippets/absorbing_bc_right_wave.qmd @@ -0,0 +1,7 @@ +::: {.callout-note title="Snippet (tested)"} +This code is included from `src/book_snippets/absorbing_bc_right_wave.py` and exercised by `pytest` (see `tests/test_book_snippets.py`). +::: + +```python +{{< include ../../src/book_snippets/absorbing_bc_right_wave.py >}} +``` diff --git a/chapters/devito_intro/snippets/bc_2d_dirichlet_wave.qmd b/chapters/devito_intro/snippets/bc_2d_dirichlet_wave.qmd new file mode 100644 index 00000000..5bd9fcf5 --- /dev/null +++ b/chapters/devito_intro/snippets/bc_2d_dirichlet_wave.qmd @@ -0,0 +1,7 @@ +::: {.callout-note title="Snippet (tested)"} +This code is included from `src/book_snippets/bc_2d_dirichlet_wave.py` and exercised by `pytest` (see `tests/test_book_snippets.py`). +::: + +```python +{{< include ../../src/book_snippets/bc_2d_dirichlet_wave.py >}} +``` diff --git a/chapters/devito_intro/snippets/boundary_dirichlet_wave.qmd b/chapters/devito_intro/snippets/boundary_dirichlet_wave.qmd new file mode 100644 index 00000000..f7f207ed --- /dev/null +++ b/chapters/devito_intro/snippets/boundary_dirichlet_wave.qmd @@ -0,0 +1,7 @@ +::: {.callout-note title="Snippet (tested)"} +This code is included from `src/book_snippets/boundary_dirichlet_wave.py` and exercised by `pytest` (see `tests/test_book_snippets.py`). +::: + +```python +{{< include ../../src/book_snippets/boundary_dirichlet_wave.py >}} +``` diff --git a/chapters/devito_intro/snippets/first_pde_wave1d.qmd b/chapters/devito_intro/snippets/first_pde_wave1d.qmd new file mode 100644 index 00000000..19d2a41b --- /dev/null +++ b/chapters/devito_intro/snippets/first_pde_wave1d.qmd @@ -0,0 +1,7 @@ +::: {.callout-note title="Snippet (tested)"} +This code is included from `src/book_snippets/first_pde_wave1d.py` and exercised by `pytest` (see `tests/test_book_snippets.py`). +::: + +```python +{{< include ../../src/book_snippets/first_pde_wave1d.py >}} +``` diff --git a/chapters/devito_intro/snippets/mixed_bc_diffusion_1d.qmd b/chapters/devito_intro/snippets/mixed_bc_diffusion_1d.qmd new file mode 100644 index 00000000..1747f96e --- /dev/null +++ b/chapters/devito_intro/snippets/mixed_bc_diffusion_1d.qmd @@ -0,0 +1,7 @@ +::: {.callout-note title="Snippet (tested)"} +This code is included from `src/book_snippets/mixed_bc_diffusion_1d.py` and exercised by `pytest` (see `tests/test_book_snippets.py`). +::: + +```python +{{< include ../../src/book_snippets/mixed_bc_diffusion_1d.py >}} +``` diff --git a/chapters/devito_intro/snippets/neumann_bc_diffusion_1d.qmd b/chapters/devito_intro/snippets/neumann_bc_diffusion_1d.qmd new file mode 100644 index 00000000..a3cc48d6 --- /dev/null +++ b/chapters/devito_intro/snippets/neumann_bc_diffusion_1d.qmd @@ -0,0 +1,7 @@ +::: {.callout-note title="Snippet (tested)"} +This code is included from `src/book_snippets/neumann_bc_diffusion_1d.py` and exercised by `pytest` (see `tests/test_book_snippets.py`). +::: + +```python +{{< include ../../src/book_snippets/neumann_bc_diffusion_1d.py >}} +``` diff --git a/chapters/devito_intro/snippets/periodic_bc_advection_1d.qmd b/chapters/devito_intro/snippets/periodic_bc_advection_1d.qmd new file mode 100644 index 00000000..eac89fa1 --- /dev/null +++ b/chapters/devito_intro/snippets/periodic_bc_advection_1d.qmd @@ -0,0 +1,7 @@ +::: {.callout-note title="Snippet (tested)"} +This code is included from `src/book_snippets/periodic_bc_advection_1d.py` and exercised by `pytest` (see `tests/test_book_snippets.py`). +::: + +```python +{{< include ../../src/book_snippets/periodic_bc_advection_1d.py >}} +``` diff --git a/chapters/devito_intro/snippets/time_dependent_bc_sine.qmd b/chapters/devito_intro/snippets/time_dependent_bc_sine.qmd new file mode 100644 index 00000000..a451156b --- /dev/null +++ b/chapters/devito_intro/snippets/time_dependent_bc_sine.qmd @@ -0,0 +1,7 @@ +::: {.callout-note title="Snippet (tested)"} +This code is included from `src/book_snippets/time_dependent_bc_sine.py` and exercised by `pytest` (see `tests/test_book_snippets.py`). +::: + +```python +{{< include ../../src/book_snippets/time_dependent_bc_sine.py >}} +``` diff --git a/chapters/devito_intro/snippets/verification_convergence_wave.qmd b/chapters/devito_intro/snippets/verification_convergence_wave.qmd new file mode 100644 index 00000000..05173e73 --- /dev/null +++ b/chapters/devito_intro/snippets/verification_convergence_wave.qmd @@ -0,0 +1,7 @@ +::: {.callout-note title="Snippet (tested)"} +This code is included from `src/book_snippets/verification_convergence_wave.py` and exercised by `pytest` (see `tests/test_book_snippets.py`). +::: + +```python +{{< include ../../src/book_snippets/verification_convergence_wave.py >}} +``` diff --git a/chapters/devito_intro/snippets/verification_mms_diffusion.qmd b/chapters/devito_intro/snippets/verification_mms_diffusion.qmd new file mode 100644 index 00000000..fd5635b4 --- /dev/null +++ b/chapters/devito_intro/snippets/verification_mms_diffusion.qmd @@ -0,0 +1,7 @@ +::: {.callout-note title="Snippet (tested)"} +This code is included from `src/book_snippets/verification_mms_diffusion.py` and exercised by `pytest` (see `tests/test_book_snippets.py`). +::: + +```python +{{< include ../../src/book_snippets/verification_mms_diffusion.py >}} +``` diff --git a/chapters/devito_intro/snippets/verification_mms_symbolic.qmd b/chapters/devito_intro/snippets/verification_mms_symbolic.qmd new file mode 100644 index 00000000..e203b623 --- /dev/null +++ b/chapters/devito_intro/snippets/verification_mms_symbolic.qmd @@ -0,0 +1,7 @@ +::: {.callout-note title="Snippet (tested)"} +This code is included from `src/book_snippets/verification_mms_symbolic.py` and exercised by `pytest` (see `tests/test_book_snippets.py`). +::: + +```python +{{< include ../../src/book_snippets/verification_mms_symbolic.py >}} +``` diff --git a/chapters/devito_intro/snippets/verification_quick_checks.qmd b/chapters/devito_intro/snippets/verification_quick_checks.qmd new file mode 100644 index 00000000..b8c88c37 --- /dev/null +++ b/chapters/devito_intro/snippets/verification_quick_checks.qmd @@ -0,0 +1,7 @@ +::: {.callout-note title="Snippet (tested)"} +This code is included from `src/book_snippets/verification_quick_checks.py` and exercised by `pytest` (see `tests/test_book_snippets.py`). +::: + +```python +{{< include ../../src/book_snippets/verification_quick_checks.py >}} +``` diff --git a/chapters/devito_intro/snippets/what_is_devito_diffusion.qmd b/chapters/devito_intro/snippets/what_is_devito_diffusion.qmd new file mode 100644 index 00000000..857d9bb6 --- /dev/null +++ b/chapters/devito_intro/snippets/what_is_devito_diffusion.qmd @@ -0,0 +1,7 @@ +::: {.callout-note title="Snippet (tested)"} +This code is included from `src/book_snippets/what_is_devito_diffusion.py` and exercised by `pytest` (see `tests/test_book_snippets.py`). +::: + +```python +{{< include ../../src/book_snippets/what_is_devito_diffusion.py >}} +``` diff --git a/chapters/devito_intro/verification.qmd b/chapters/devito_intro/verification.qmd index 5769eaa6..a9fdb337 100644 --- a/chapters/devito_intro/verification.qmd +++ b/chapters/devito_intro/verification.qmd @@ -35,78 +35,7 @@ evidence the implementation is correct. ### Implementing a Convergence Test -```python -import numpy as np -from devito import Grid, TimeFunction, Eq, Operator - -def solve_wave_equation(Nx, L=1.0, T=0.5, c=1.0, C=0.5): - """Solve 1D wave equation and return error vs exact solution.""" - - dx = L / Nx - dt = C * dx / c - Nt = int(T / dt) - - grid = Grid(shape=(Nx + 1,), extent=(L,)) - u = TimeFunction(name='u', grid=grid, time_order=2, space_order=2) - t_dim = grid.stepping_dim - - # Initial condition: sin(pi*x) - x_vals = np.linspace(0, L, Nx + 1) - u.data[0, :] = np.sin(np.pi * x_vals) - u.data[1, :] = np.sin(np.pi * x_vals) * np.cos(np.pi * c * dt) - - # Wave equation - update = Eq(u.forward, 2*u - u.backward + (c*dt)**2 * u.dx2, - subdomain=grid.interior) - bc_left = Eq(u[t_dim+1, 0], 0) - bc_right = Eq(u[t_dim+1, Nx], 0) - - op = Operator([update, bc_left, bc_right]) - op(time=Nt, dt=dt) - - # Exact solution: u(x,t) = sin(pi*x)*cos(pi*c*t) - t_final = Nt * dt - u_exact = np.sin(np.pi * x_vals) * np.cos(np.pi * c * t_final) - - # Return max error - error = np.max(np.abs(u.data[0, :] - u_exact)) - return error, dx - - -def convergence_test(grid_sizes): - """Run convergence test and compute rates.""" - - errors = [] - dx_values = [] - - for Nx in grid_sizes: - error, dx = solve_wave_equation(Nx) - errors.append(error) - dx_values.append(dx) - print(f"Nx = {Nx:4d}, dx = {dx:.6f}, error = {error:.6e}") - - # Compute convergence rates - rates = [] - for i in range(len(errors) - 1): - rate = np.log(errors[i] / errors[i+1]) / np.log(dx_values[i] / dx_values[i+1]) - rates.append(rate) - - print("\nConvergence rates:") - for i, rate in enumerate(rates): - print(f" {grid_sizes[i]} -> {grid_sizes[i+1]}: rate = {rate:.2f}") - - return errors, dx_values, rates - - -# Run the test -grid_sizes = [20, 40, 80, 160, 320] -errors, dx_values, rates = convergence_test(grid_sizes) - -# Check: rates should be close to 2 for second-order scheme -expected_rate = 2.0 -assert all(abs(r - expected_rate) < 0.2 for r in rates), \ - f"Convergence rates {rates} differ from expected {expected_rate}" -``` +{{< include snippets/verification_convergence_wave.qmd >}} ### Method of Manufactured Solutions (MMS) @@ -118,144 +47,27 @@ Manufactured Solutions: 3. **Solve** the modified PDE with the computed source 4. **Compare** the numerical solution to $u_{\text{mms}}$ -**Example: Diffusion equation** - -Let's verify a diffusion solver using MMS: - -```python -import sympy as sp - -# Symbolic variables -x_sym, t_sym = sp.symbols('x t') -alpha_sym = sp.Symbol('alpha') - -# Manufactured solution (arbitrary smooth function) -u_mms = sp.sin(sp.pi * x_sym) * sp.exp(-t_sym) - -# Compute required source term: f = u_t - alpha * u_xx -u_t = sp.diff(u_mms, t_sym) -u_xx = sp.diff(u_mms, x_sym, 2) -f_mms = u_t - alpha_sym * u_xx +**Example: Computing MMS source terms** -print("Manufactured solution:") -print(f" u_mms = {u_mms}") -print(f"Required source term:") -print(f" f = {sp.simplify(f_mms)}") -``` +For the diffusion equation $u_t = \alpha u_{xx}$, we can compute the required +source term for any manufactured solution: -Now implement the solver with this source term: +{{< include snippets/verification_mms_symbolic.qmd >}} -```python -from devito import Grid, TimeFunction, Function, Eq, Operator -import numpy as np +**Practical approach: eigenfunction solutions** -def solve_diffusion_mms(Nx, alpha=1.0, T=0.5, F=0.4): - """Solve diffusion with MMS source term.""" +For diffusion problems, a simpler approach uses exact eigenfunction solutions +that require no source term. The solution $u(x,t) = \sin(\pi x) e^{-\alpha \pi^2 t}$ +satisfies both the PDE and homogeneous boundary conditions: - L = 1.0 - dx = L / Nx - dt = F * dx**2 / alpha - Nt = int(T / dt) - - grid = Grid(shape=(Nx + 1,), extent=(L,)) - u = TimeFunction(name='u', grid=grid, time_order=1, space_order=2) - t_dim = grid.stepping_dim - - # Spatial coordinates for evaluation - x_vals = np.linspace(0, L, Nx + 1) - - # MMS: u = sin(pi*x) * exp(-t) - # Source: f = sin(pi*x) * exp(-t) * (alpha*pi^2 - 1) - def u_exact(x, t): - return np.sin(np.pi * x) * np.exp(-t) - - def f_source(x, t): - return np.sin(np.pi * x) * np.exp(-t) * (alpha * np.pi**2 - 1) - - # Initial condition from MMS - u.data[0, :] = u_exact(x_vals, 0) - - # We need to add source term at each time step - # For simplicity, use time-lagged source - f = Function(name='f', grid=grid) - - # Update equation with source - update = Eq(u.forward, u + alpha * dt * u.dx2 + dt * f, - subdomain=grid.interior) - bc_left = Eq(u[t_dim+1, 0], 0) # u_mms(0,t) = 0 - bc_right = Eq(u[t_dim+1, Nx], 0) # u_mms(1,t) = 0 - - op = Operator([update, bc_left, bc_right]) - - # Time stepping with source update - for n in range(Nt): - t_current = n * dt - f.data[:] = f_source(x_vals, t_current) - op(time=1, dt=dt) - - # Compare to exact solution - t_final = Nt * dt - u_exact_final = u_exact(x_vals, t_final) - error = np.max(np.abs(u.data[0, :] - u_exact_final)) - - return error, dx - - -# Convergence test with MMS -print("MMS Convergence Test for Diffusion Equation:") -grid_sizes = [20, 40, 80, 160] -errors = [] -dx_vals = [] - -for Nx in grid_sizes: - error, dx = solve_diffusion_mms(Nx) - errors.append(error) - dx_vals.append(dx) - print(f"Nx = {Nx:4d}, error = {error:.6e}") - -# Compute rates -for i in range(len(errors) - 1): - rate = np.log(errors[i] / errors[i+1]) / np.log(2) - print(f"Rate {grid_sizes[i]}->{grid_sizes[i+1]}: {rate:.2f}") -``` +{{< include snippets/verification_mms_diffusion.qmd >}} ### Quick Verification Checks -Before running full convergence tests, use these quick checks: - -**1. Conservation properties** - -For problems that should conserve mass or energy: - -```python -# Check mass conservation for diffusion with Neumann BCs -mass_initial = np.sum(u.data[1, :]) * dx -mass_final = np.sum(u.data[0, :]) * dx -print(f"Mass change: {abs(mass_final - mass_initial):.2e}") -``` - -**2. Symmetry** - -For symmetric initial conditions and domains: - -```python -# Check symmetry is preserved -u_left = u.data[0, :Nx//2] -u_right = u.data[0, Nx//2+1:][::-1] # Reversed -symmetry_error = np.max(np.abs(u_left - u_right)) -print(f"Symmetry error: {symmetry_error:.2e}") -``` - -**3. Steady state** - -For problems with known steady states: +Before running full convergence tests, use these quick checks for conservation +and symmetry properties: -```python -# Run to steady state and check -u_steady_numerical = u.data[0, :] -u_steady_exact = ... # Known analytical steady state -error = np.max(np.abs(u_steady_numerical - u_steady_exact)) -``` +{{< include snippets/verification_quick_checks.qmd >}} ### Debugging Tips diff --git a/chapters/devito_intro/what_is_devito.qmd b/chapters/devito_intro/what_is_devito.qmd index 6d0c0546..3e41bd64 100644 --- a/chapters/devito_intro/what_is_devito.qmd +++ b/chapters/devito_intro/what_is_devito.qmd @@ -48,37 +48,7 @@ This approach has several limitations: With Devito, the same problem becomes: -```python -from devito import Grid, TimeFunction, Eq, Operator, solve, Constant - -# Problem parameters -Nx = 100 -L = 1.0 -alpha = 1.0 # diffusion coefficient -F = 0.5 # Fourier number (for stability, F <= 0.5) - -# Compute dt from stability condition: F = alpha * dt / dx^2 -dx = L / Nx -dt = F * dx**2 / alpha - -# Create computational grid -grid = Grid(shape=(Nx + 1,), extent=(L,)) - -# Define the unknown field -u = TimeFunction(name='u', grid=grid, time_order=1, space_order=2) - -# Set initial condition -u.data[0, Nx // 2] = 1.0 - -# Define the PDE symbolically and solve for u.forward -a = Constant(name='a') -pde = u.dt - a * u.dx2 -update = Eq(u.forward, solve(pde, u.forward)) - -# Create and run the operator -op = Operator([update]) -op(time=1000, dt=dt, a=alpha) -``` +{{< include snippets/what_is_devito_diffusion.qmd >}} This approach offers significant advantages: diff --git a/chapters/diffu/diffu_app.qmd b/chapters/diffu/diffu_app.qmd index 5b064ecb..f96f8c49 100644 --- a/chapters/diffu/diffu_app.qmd +++ b/chapters/diffu/diffu_app.qmd @@ -458,8 +458,7 @@ governed by the [Cable equation](http://en.wikipedia.org/wiki/Cable_equation): $$ c_m \frac{\partial V}{\partial t} = \frac{1}{r_l}\frac{\partial^2 V}{\partial x^2} - \frac{1}{r_m}V -label{} -$$ +$$ {#eq-diffu-app-cable} where $V(x,t)$ is the voltage to be determined, $c_m$ is capacitance of the neuronal fiber, while $r_l$ and $r_m$ are measures of the resistance. diff --git a/chapters/diffu/diffu_devito_exercises.qmd b/chapters/diffu/diffu_devito_exercises.qmd index 4ff87595..f4e2a389 100644 --- a/chapters/diffu/diffu_devito_exercises.qmd +++ b/chapters/diffu/diffu_devito_exercises.qmd @@ -521,63 +521,51 @@ The 2D solver should also achieve second-order spatial convergence when the Fourier number is held fixed. ::: -### Exercise 10: Comparison with Legacy Code {#exer-diffu-legacy} +### Exercise 10: Performance Scaling {#exer-diffu-scaling} -Compare the Devito solver with the legacy NumPy implementation. +Investigate how Devito's performance scales with problem size. -a) Run both solvers with the same parameters. -b) Verify they produce the same results. -c) Compare execution times. +a) Run the 1D solver with increasing grid sizes (Nx = 100, 500, 1000, 5000). +b) Measure and plot the execution time vs grid size. +c) Determine if the scaling is linear in Nx. ::: {.callout-note collapse="true" title="Solution"} ```python from src.diffu import solve_diffusion_1d -from src.diffu.diffu1D_u0 import solver_FE_simple import numpy as np import time +import matplotlib.pyplot as plt # Parameters L = 1.0 a = 1.0 -Nx = 200 F = 0.5 -T = 0.1 +T = 0.01 # Short time for timing tests -dx = L / Nx -dt = F * dx**2 / a - -# Devito solver -t0 = time.perf_counter() -result_devito = solve_diffusion_1d( - L=L, a=a, Nx=Nx, T=T, F=F, - I=lambda x: np.sin(np.pi * x), -) -t_devito = time.perf_counter() - t0 - -# Legacy NumPy solver -t0 = time.perf_counter() -u_legacy, x_legacy, t_legacy, cpu_legacy = solver_FE_simple( - I=lambda x: np.sin(np.pi * x), - a=a, - f=lambda x, t: 0, - L=L, - dt=dt, - F=F, - T=T, -) -t_numpy = time.perf_counter() - t0 +grid_sizes = [100, 500, 1000, 5000] +times = [] -# Compare results -diff = np.max(np.abs(result_devito.u - u_legacy)) -print(f"Maximum difference: {diff:.2e}") -print(f"Devito time: {t_devito:.4f} s") -print(f"NumPy time: {t_numpy:.4f} s") +for Nx in grid_sizes: + t0 = time.perf_counter() + result = solve_diffusion_1d( + L=L, a=a, Nx=Nx, T=T, F=F, + I=lambda x: np.sin(np.pi * x), + ) + times.append(time.perf_counter() - t0) + print(f"Nx={Nx}: {times[-1]:.4f} s") -# Note: For small problems, NumPy may be faster due to compilation -# overhead. For large problems, Devito's optimized C code wins. +# Plot scaling +plt.figure(figsize=(8, 6)) +plt.loglog(grid_sizes, times, 'bo-', label='Measured') +plt.loglog(grid_sizes, times[0]*(np.array(grid_sizes)/grid_sizes[0]), + 'r--', label='O(N)') +plt.xlabel('Grid size (Nx)') +plt.ylabel('Time (s)') +plt.legend() +plt.title('Devito 1D Diffusion Solver Scaling') +plt.grid(True) ``` -For large grids, Devito's automatically generated and optimized C code -typically outperforms pure Python/NumPy implementations. The advantage -grows with problem size. +The Devito solver typically shows linear scaling in Nx for 1D problems, +as expected for an explicit scheme where each time step is O(Nx). ::: diff --git a/chapters/diffu/diffu_exer.qmd b/chapters/diffu/diffu_exer.qmd index d515258d..6c8b8aa7 100644 --- a/chapters/diffu/diffu_exer.qmd +++ b/chapters/diffu/diffu_exer.qmd @@ -48,13 +48,13 @@ u(x,0) &= I(x), & x\in [0,L]\tp The energy estimate for this problem reads $$ -||u||**{L^2} \leq ||I||**{L^2}, +||u||_{L^2} \leq ||I||_{L^2}, $$ {#eq-diffu-exer-estimates-p1-result} where the $||\cdot ||_{L^2}$ norm is defined by $$ ||g||_{L^2} = \sqrt{\int_0^L g^2dx}\tp $$ {#eq-diffu-exer-estimates-L2} -The quantify $||u||**{L^2}$ or $\half ||u||**{L^2}$ is known +The quantify $||u||_{L^2}$ or $\half ||u||_{L^2}$ is known as the *energy* of the solution, although it is not the physical energy of the system. A mathematical tradition has introduced the notion *energy* in this context. @@ -89,7 +89,7 @@ u(x,0) &= 0, & x\in [0,L]\tp ``` The associated energy estimate is $$ -||u||**{L^2} \leq ||f||**{L^2}\tp +||u||_{L^2} \leq ||f||_{L^2}\tp $$ {#eq-diffu-exer-estimates-p2-result} (This result is more difficult to derive.) @@ -119,7 +119,7 @@ show that the energy estimate for the compound problem becomes $$ -||u||**{L^2} \leq ||I||**{L^2} + ||f||_{L^2}\tp +||u||_{L^2} \leq ||I||_{L^2} + ||f||_{L^2}\tp $$ {#eq-diffu-exer-estimates-p3-result} @@ -358,64 +358,46 @@ with $\bar L=4$ and $\bar L=8$, respectively (keep $\Delta x$ the same). ::: {.callout-tip collapse="true" title="Solution"} -We can use the `viz` function in `diff1D_vc.py` to do the number -crunching. Appropriate calls and visualization go here: +We can use the Devito solver from `diffu1D_devito.py` to perform the computation. +See @sec-diffu-devito for the complete implementation. ```python -import os -import sys - -sys.path.insert(0, os.path.join(os.pardir, "src-diffu")) -from diffu1D_vc import viz +from src.diffu import solve_diffusion_1d +import numpy as np +import matplotlib.pyplot as plt +from math import pi, sin sol = [] # store solutions for Nx, L in [[20, 4], [40, 8]]: dt = 0.1 dx = float(L) / Nx - D = dt / dx**2 - from math import pi, sin - + F = 0.5 * dt / dx**2 # Fourier number with a=0.5 T = 2 * pi * 6 - from numpy import zeros - - a = zeros(Nx + 1) + 0.5 - cpu, u_ = viz( - I=lambda x: 0, - a=a, - L=L, - Nx=Nx, - D=D, - T=T, - umin=-1.1, - umax=1.1, - theta=0.5, + + # Solve using Devito + result = solve_diffusion_1d( + L=L, a=0.5, Nx=Nx, T=T, F=F, + I=lambda x: np.zeros_like(x), u_L=lambda t: sin(t), - u_R=0, - animate=False, - store_u=True, + u_R=lambda t: 0, + save_history=True, ) - sol.append(u_) - print("computed solution for Nx=%d in [0,%g]" % (Nx, L)) - -print(sol[0].shape) -print(sol[1].shape) -import matplotlib.pyplot as plt + sol.append((result.x, result.history)) + print(f"computed solution for Nx={Nx} in [0,{L}]") +# Animate and save frames counter = 0 -for u0, u1 in zip(sol[0][2:], sol[1][2:], strict=False): - x0 = sol[0][0] - x1 = sol[1][0] +for u0, u1 in zip(sol[0][1], sol[1][1]): + x0, x1 = sol[0][0], sol[1][0] plt.clf() plt.plot(x0, u0, "r-", label="short") plt.plot(x1, u1, "b-", label="long") plt.legend() plt.axis([x1[0], x1[-1], -1.1, 1.1]) - plt.savefig("tmp_%04d.png" % counter) + plt.savefig(f"tmp_{counter:04d}.png") counter += 1 ``` -MOVIE: [https://github.com/hplgit/fdm-book/raw/master/doc/pub/book/html/mov-diffu/surface_osc/movie.mp4] - ::: @@ -854,8 +836,8 @@ u\sim1/\delta$ in simulations, we can just replace $\bar f$ by $\delta \bar f$ in the scaled PDE. Use this trick and implement the two scaled models. Reuse software for -the diffusion equation (e.g., the `solver` function in -`diffu1D_vc.py`). Make a function `run(gamma, beta=10, delta=40, +the diffusion equation (e.g., the Devito solver in +`diffu1D_devito.py`, see @sec-diffu-devito). Make a function `run(gamma, beta=10, delta=40, scaling=1, animate=False)` that runs the model with the given $\gamma$, $\beta$, and $\delta$ parameters as well as an indicator `scaling` that is 1 for the scaling in a) and 2 for the scaling in @@ -874,89 +856,58 @@ it is attractive to plot $\bar f/\delta$ together with $\bar u$. ::: {.callout-tip collapse="true" title="Solution"} -Here is a possible `run` function: +Here is a possible `run` function using the Devito solver: ```python -import os -import sys - -sys.path.insert(0, os.path.join(os.pardir, "src-diffu")) import numpy as np -from diffu1D_vc import solver +import matplotlib.pyplot as plt +from src.diffu import solve_diffusion_1d def run(gamma, beta=10, delta=40, scaling=1, animate=False): - """Run the scaled model for welding.""" + """Run the scaled model for welding using Devito.""" if scaling == 1: v = gamma - a = 1 + a = 1.0 elif scaling == 2: v = 1 a = 1.0 / gamma b = 0.5 * beta**2 L = 1.0 - ymin = 0 - global ymax - ymax = 1.2 - - I = lambda x: 0 - f = lambda x, t: delta * np.exp(-b * (x - v * t) ** 2) - - import time - - import matplotlib.pyplot as plt - - plot_arrays = [] - - def process_u(u, x, t, n): - global ymax - if animate: - plt.clf() - plt.plot(x, u, "r-", x, f(x, t[n]) / delta, "b-") - plt.axis([0, L, ymin, ymax]) - plt.title(f"t={t[n]:f}") - plt.xlabel("x") - plt.ylabel(f"u and f/{delta:g}") - plt.draw() - plt.pause(0.001) - if t[n] == 0: - time.sleep(1) - plot_arrays.append(x) - dt = t[1] - t[0] - tol = dt / 10.0 - if abs(t[n] - 0.2) < tol or abs(t[n] - 0.5) < tol: - plot_arrays.append((u.copy(), f(x, t[n]) / delta)) - if u.max() > ymax: - ymax = u.max() - Nx = 100 - D = 10 T = 0.5 - u_L = u_R = 0 - theta = 1.0 - cpu = solver(I, a, f, L, Nx, D, T, theta, u_L, u_R, user_action=process_u) - x = plot_arrays[0] + F = 0.5 # Fourier number for stability + + # Source function + def f(x, t): + return delta * np.exp(-b * (x - v * t) ** 2) + + # Solve using Devito with source term + result = solve_diffusion_1d( + L=L, a=a, Nx=Nx, T=T, F=F, + I=lambda x: np.zeros_like(x), + f=f, + save_history=True, + ) + + # Extract solutions at t=0.2 and t=0.5 + x = result.x + dt = result.dt + idx_02 = int(0.2 / dt) + idx_05 = int(0.5 / dt) + plt.figure() - for u, f in plot_arrays[1:]: - plt.plot(x, u, "r-", x, f, "b--") - plt.axis([x[0], x[-1], 0, ymax]) + for idx, t_val in [(idx_02, 0.2), (idx_05, 0.5)]: + u = result.history[idx] + f_vals = f(x, t_val) / delta + plt.plot(x, u, "r-", x, f_vals, "b--") + plt.xlabel("$x$") plt.ylabel(rf"$u, \ f/{delta:g}$") - plt.legend( - [ - "$u,\\ t=0.2$", - f"$f/{delta:g},\\ t=0.2$", - "$u,\\ t=0.5$", - f"$f/{delta:g},\\ t=0.5$", - ] - ) - filename = "tmp1_gamma%g_s%d" % (gamma, scaling) s = "diffusion" if scaling == 1 else "source" plt.title(rf"$\beta = {beta:g},\ \gamma = {gamma:g},\ $" + f"scaling={s}") - plt.savefig(filename + ".pdf") - plt.savefig(filename + ".png") - return cpu + plt.savefig(f"tmp1_gamma{gamma}_s{scaling}.png") ``` Note that we have dropped the bar notation in the plots. It is common to drop the bars as soon as the scaled problem is established. @@ -1014,9 +965,9 @@ time steps. Running the `investigate` function, we get the following plots: -![FIGURE: [fig-diffu/welding_gamma0_2, width=800 frac=1]](fig/welding_gamma0_025){width="100%"} +![Temperature distribution for $\gamma = 0.025$: the heat source moves very slowly on the diffusion time scale.](fig/welding_gamma0_025){width="100%"} -![FIGURE: [fig-diffu/welding_gamma5, width=800 frac=1]](fig/welding_gamma1){width="100%"} +![Temperature distribution for $\gamma = 1$: the two scaling approaches give identical results.](fig/welding_gamma1){width="100%"} ![For $\gamma\ll 1$ as in $\gamma = 0.025$, the heat source moves very slowly on the diffusion time scale and has hardly entered the medium, diff --git a/chapters/diffu/diffu_fd1.qmd b/chapters/diffu/diffu_fd1.qmd index 56a48ef5..c24292ab 100644 --- a/chapters/diffu/diffu_fd1.qmd +++ b/chapters/diffu/diffu_fd1.qmd @@ -51,6 +51,14 @@ as additional material on the topic. ## An explicit method for the 1D diffusion equation {#sec-diffu-pde1-FEsec} +::: {.callout-note} +## Devito Implementation + +After understanding the finite difference discretization in this section, +see @sec-diffu-devito for the Devito-based implementation. The tested solver +is available in `src/diffu/diffu1D_devito.py` with tests in `tests/test_diffu_devito.py`. +::: + Explicit finite difference methods for the wave equation $u_{tt}=c^2u_{xx}$ can be used, with small modifications, for solving $u_t = \dfc u_{xx}$ as well. @@ -195,10 +203,10 @@ Section @sec-diffu-pde1-analysis. ## Implementation {#sec-diffu-pde1-FE-code} -The file [`diffu1D_u0.py`](https://github.com/devitocodes/devito_book/tree/main/src/diffu/diffu1D_u0.py) -contains a complete function `solver_FE_simple` -for solving the 1D diffusion equation with $u=0$ on the boundary -as specified in the algorithm above: +The file [`diffu1D_devito.py`](https://github.com/devitocodes/devito_book/tree/main/src/diffu/diffu1D_devito.py) +contains a complete Devito implementation for solving the 1D diffusion equation +with $u=0$ on the boundary. See @sec-diffu-devito for the complete Devito implementation. +The algorithm above can be expressed as: ```python import numpy as np @@ -1015,8 +1023,8 @@ and a smooth Gaussian function, $$ I(x) = e^{-\frac{1}{2\sigma^2}(x-L/2)^2}\tp $$ -The functions `plug` and `gaussian` in [`diffu1D_u0.py`](https://github.com/devitocodes/devito_book/tree/main/src/diffu/diffu1D_u0.py) run the two cases, -respectively: +The Devito implementation in [`diffu1D_devito.py`](https://github.com/devitocodes/devito_book/tree/main/src/diffu/diffu1D_devito.py) can run these test cases. +Example functions for both cases: ```python def plug(scheme="FE", F=0.5, Nx=50): @@ -1106,8 +1114,8 @@ $F$: $F=0.5$. This resolution corresponds to $N_x=50$. A possible terminal command is ```bash -Terminal> python -c 'from diffu1D_u0 import gaussian - gaussian("solver_FE", F=0.5, dt=0.0002)' +Terminal> python -c 'from diffu1D_devito import solver_FE + solver_FE(L=1, Nx=50, F=0.5, T=0.1)' ``` The $u(x,t)$ curve as a function of $x$ is shown in Figure @@ -1168,7 +1176,7 @@ $$ $$ {#eq-diffu-pde1-step3aBE} which written out reads $$ -\frac{u^{n}_i-u^{n-1}**i}{\Delta t} = \dfc\frac{u^{n}**{i+1} - 2u^n_i + u^n_{i-1}}{\Delta x^2} + f_i^n\tp +\frac{u^{n}_i-u^{n-1}_i}{\Delta t} = \dfc\frac{u^{n}_{i+1} - 2u^n_i + u^n_{i-1}}{\Delta x^2} + f_i^n\tp $$ {#eq-diffu-pde1-step3bBE} Now we assume $u^{n-1}_i$ is already computed, but that all quantities at the "new" time level $n$ are unknown. This time it is not possible to solve @@ -1177,15 +1185,15 @@ in space, $u^n_{i-1}$ and $u^n_{i+1}$, which are also unknown. Let us examine this fact for the case when $N_x=3$. Equation (@eq-diffu-pde1-step3bBE) written for $i=1,\ldots,Nx-1= 1,2$ becomes \begin{align} -\frac{u^{n}_1-u^{n-1}**1}{\Delta t} &= \dfc\frac{u^{n}**{2} - 2u^n_1 + u^n_{0}}{\Delta x^2} + f_1^n\\ -\frac{u^{n}_2-u^{n-1}**2}{\Delta t} &= \dfc\frac{u^{n}**{3} - 2u^n_2 + u^n_{1}}{\Delta x^2} + f_2^n +\frac{u^{n}_1-u^{n-1}_1}{\Delta t} &= \dfc\frac{u^{n}_{2} - 2u^n_1 + u^n_{0}}{\Delta x^2} + f_1^n\\ +\frac{u^{n}_2-u^{n-1}_2}{\Delta t} &= \dfc\frac{u^{n}_{3} - 2u^n_2 + u^n_{1}}{\Delta x^2} + f_2^n \end{align} The boundary values $u^n_0$ and $u^n_3$ are known as zero. Collecting the unknown new values $u^n_1$ and $u^n_2$ on the left-hand side and multiplying by $\Delta t$ gives \begin{align} -\left(1+ 2F\right) u^{n}**1 - F u^{n}**{2} &= u^{n-1}_1 + \Delta t f_1^n,\\ +\left(1+ 2F\right) u^{n}_1 - F u^{n}_{2} &= u^{n-1}_1 + \Delta t f_1^n,\\ - F u^{n}_{1} + \left(1+ 2F\right) u^{n}_2 &= u^{n-1}_2 + \Delta t f_2^n\tp \end{align} This is a coupled $2\times 2$ system of algebraic equations for @@ -1232,7 +1240,7 @@ all the unknown $u^n_i$ at the interior spatial points $i=1,\ldots,N_x-1$. Collecting the unknowns on the left-hand side, (@eq-diffu-pde1-step3bBE) can be written $$ -- F u^n_{i-1} + \left(1+ 2F \right) u^{n}**i - F u^n**{i+1} = +- F u^n_{i-1} + \left(1+ 2F \right) u^{n}_i - F u^n_{i+1} = u_{i-1}^{n-1}, $$ {#eq-diffu-pde1-step4BE} for $i=1,\ldots,N_x-1$. @@ -1409,9 +1417,9 @@ The `scipy.sparse.linalg.spsolve` function utilizes the sparse storage structure of `A` and performs, in this case, a very efficient Gaussian elimination solve. -The program [`diffu1D_u0.py`](https://github.com/devitocodes/devito_book/tree/main/src/diffu/diffu1D_u0.py) -contains a function `solver_BE`, which implements the Backward Euler scheme -sketched above. +The program [`diffu1D_devito.py`](https://github.com/devitocodes/devito_book/tree/main/src/diffu/diffu1D_devito.py) +contains Devito implementations of both Forward and Backward Euler schemes +as described in @sec-diffu-devito. As mentioned in Section @sec-diffu-pde1-FE, the functions `plug` and `gaussian` run the case with $I(x)$ as a discontinuous plug or a smooth @@ -1437,14 +1445,14 @@ $$ $$ On the right-hand side we get an expression $$ -\frac{1}{\Delta x^2}\left(u^{n+\half}_{i-1} - 2u^{n+\half}**i + u^{n+\half}**{i+1}\right) + f_i^{n+\half}\tp +\frac{1}{\Delta x^2}\left(u^{n+\half}_{i-1} - 2u^{n+\half}_i + u^{n+\half}_{i+1}\right) + f_i^{n+\half}\tp $$ This expression is problematic since $u^{n+\half}_i$ is not one of the unknowns we compute. A possibility is to replace $u^{n+\half}_i$ by an arithmetic average: $$ u^{n+\half}_i\approx -\half\left(u^{n}**i +u^{n+1}**{i}\right)\tp +\half\left(u^{n}_i +u^{n+1}_{i}\right)\tp $$ In the compact notation, we can use the arithmetic average notation $\overline{u}^t$: @@ -1459,13 +1467,13 @@ After writing out the differences and average, multiplying by $\Delta t$, and collecting all unknown terms on the left-hand side, we get \begin{align} -u^{n+1}**i - \half F(u^{n+1}**{i-1} - 2u^{n+1}**i + u^{n+1}**{i+1}) -&= u^{n}**i + \half F(u^{n}**{i-1} - 2u^{n}**i + u^{n}**{i+1})\nonumber\\ +u^{n+1}_i - \half F(u^{n+1}_{i-1} - 2u^{n+1}_i + u^{n+1}_{i+1}) +&= u^{n}_i + \half F(u^{n}_{i-1} - 2u^{n}_i + u^{n}_{i+1})\nonumber\\ &\qquad + \half f_i^{n+1} + \half f_i^n\tp \end{align} Also here, as in the Backward Euler scheme, the new unknowns -$u^{n+1}**{i-1}$, $u^{n+1}**{i}$, and $u^{n+1}_{i+1}$ are coupled +$u^{n+1}_{i-1}$, $u^{n+1}_{i}$, and $u^{n+1}_{i+1}$ are coupled in a linear system $AU=b$, where $A$ has the same structure as in (@eq-diffu-pde1-matrix-sparsity), but with slightly different entries: @@ -1540,7 +1548,7 @@ Applied to the 1D diffusion problem, the $\theta$-rule gives \begin{align*} \frac{u^{n+1}_i-u^n_i}{\Delta t} &= -\dfc\left( \theta \frac{u^{n+1}_{i+1} - 2u^{n+1}**i + u^{n+1}**{i-1}}{\Delta x^2} +\dfc\left( \theta \frac{u^{n+1}_{i+1} - 2u^{n+1}_i + u^{n+1}_{i-1}}{\Delta x^2} + (1-\theta) \frac{u^{n}_{i+1} - 2u^n_i + u^n_{i-1}}{\Delta x^2}\right)\\ &\qquad + \theta f_i^{n+1} + (1-\theta)f_i^n \end{align*} \tp @@ -1614,7 +1622,7 @@ by just taking one large time step: $\Delta t\rightarrow\infty$. In the limit, the Backward Euler scheme gives $$ --\frac{u^{n+1}_{i+1} - 2u^{n+1}**i + u^{n+1}**{i-1}}{\Delta x^2} = f^{n+1}_i, +-\frac{u^{n+1}_{i+1} - 2u^{n+1}_i + u^{n+1}_{i-1}}{\Delta x^2} = f^{n+1}_i, $$ which is nothing but the discretization $[-D_xD_x u = f]^{n+1}_i=0$ of $-u_{xx}=f$. diff --git a/chapters/diffu/diffu_fd2.qmd b/chapters/diffu/diffu_fd2.qmd index e9b45e32..96544578 100644 --- a/chapters/diffu/diffu_fd2.qmd +++ b/chapters/diffu/diffu_fd2.qmd @@ -36,11 +36,11 @@ Written out, this becomes \begin{align*} \frac{u^{n+1}_i-u^{n}_i}{\Delta t} &= \theta\frac{1}{\Delta x^2} -(\dfc_{i+\half}(u^{n+1}**{i+1} - u^{n+1}**{i}) -- \dfc_{i-\half}(u^{n+1}**i - u^{n+1}**{i-1})) +\\ +(\dfc_{i+\half}(u^{n+1}_{i+1} - u^{n+1}_{i}) +- \dfc_{i-\half}(u^{n+1}_i - u^{n+1}_{i-1})) +\\ &\quad (1-\theta)\frac{1}{\Delta x^2} -(\dfc_{i+\half}(u^{n}**{i+1} - u^{n}**{i}) -- \dfc_{i-\half}(u^{n}**i - u^{n}**{i-1})) +\\ +(\dfc_{i+\half}(u^{n}_{i+1} - u^{n}_{i}) +- \dfc_{i-\half}(u^{n}_i - u^{n}_{i-1})) +\\ &\quad \theta f_i^{n+1} + (1-\theta)f_i^{n}, \end{align*} where, e.g., an arithmetic mean can to be used for $\dfc_{i+\half}$: @@ -108,7 +108,8 @@ def solver_theta(I, a, L, Nx, D, T, theta=0.5, u_L=1, u_R=0, u_n, u = u, u_n ``` -The code is found in the file [`diffu1D_vc.py`](https://github.com/devitocodes/devito_book/tree/main/src/diffu/diffu1D_vc.py). +The Devito implementation is found in [`diffu1D_devito.py`](https://github.com/devitocodes/devito_book/tree/main/src/diffu/diffu1D_devito.py). +See @sec-diffu-devito for the complete implementation. ## Stationary solution {#sec-diffu-varcoeff-stationary} @@ -362,7 +363,7 @@ is discretized in the usual way. $$ 2\dfc\frac{\partial^2}{\partial r^2}u(r_0,t_n) \approx [2\dfc D_rD_r u]^n_0 = -2\dfc \frac{u^{n}_{1} - 2u^{n}**0 + u^n**{-1}}{\Delta r^2}\tp +2\dfc \frac{u^{n}_{1} - 2u^{n}_0 + u^n_{-1}}{\Delta r^2}\tp $$ The fictitious value $u^n_{-1}$ can be eliminated using the discrete symmetry condition diff --git a/chapters/diffu/diffu_fd3.qmd b/chapters/diffu/diffu_fd3.qmd index 8db49c4a..99b28def 100644 --- a/chapters/diffu/diffu_fd3.qmd +++ b/chapters/diffu/diffu_fd3.qmd @@ -1,5 +1,14 @@ ## Diffusion in 2D {#sec-diffu-2D} +::: {.callout-note} +## Source Files + +This chapter presents matrix-based solvers (`solver_dense`, `solver_sparse`) +for implicit diffusion schemes. For Devito-based implementations that handle +multi-dimensional diffusion with automatic code generation, see +`src/diffu/diffu2D_devito.py` with tests in `tests/test_diffu_devito.py`. +::: + We now address diffusion in two space dimensions: \begin{align} @@ -28,15 +37,15 @@ point $(i,j,n+\half)$ and apply the difference approximations: Written out, \begin{align} -& \frac{u^{n+1}**{i,j}-u^n**{i,j}}{\Delta t} =\nonumber\\ +& \frac{u^{n+1}_{i,j}-u^n_{i,j}}{\Delta t} =\nonumber\\ &\qquad \theta (\dfc -(\frac{u^{n+1}**{i-1,j} - 2u^{n+1}**{i,j} + u^{n+1}_{i+1,j}}{\Delta x^2} + -\frac{u^{n+1}**{i,j-1} - 2u^{n+1}**{i,j} + u^{n+1}_{i,j+1}}{\Delta y^2}) + +(\frac{u^{n+1}_{i-1,j} - 2u^{n+1}_{i,j} + u^{n+1}_{i+1,j}}{\Delta x^2} + +\frac{u^{n+1}_{i,j-1} - 2u^{n+1}_{i,j} + u^{n+1}_{i,j+1}}{\Delta y^2}) + f^{n+1}_{i,j}) + \nonumber\\ &\qquad (1-\theta)(\dfc -(\frac{u^{n}**{i-1,j} - 2u^{n}**{i,j} + u^{n}_{i+1,j}}{\Delta x^2} + -\frac{u^{n}**{i,j-1} - 2u^{n}**{i,j} + u^{n}_{i,j+1}}{\Delta y^2}) + +(\frac{u^{n}_{i-1,j} - 2u^{n}_{i,j} + u^{n}_{i+1,j}}{\Delta x^2} + +\frac{u^{n}_{i,j-1} - 2u^{n}_{i,j} + u^{n}_{i,j+1}}{\Delta y^2}) + f^{n}_{i,j}) \end{align} We collect the unknowns on the left-hand side @@ -46,17 +55,17 @@ $$ & u^{n+1}_{i,j} - \theta\left( F_x -(u^{n+1}**{i-1,j} - 2u^{n+1}**{i,j} + u^{n+1}_{i+1,j}) + +(u^{n+1}_{i-1,j} - 2u^{n+1}_{i,j} + u^{n+1}_{i+1,j}) + F_y -(u^{n+1}**{i,j-1} - 2u^{n+1}**{i,j} + u^{n+1}_{i,j+1})\right) +(u^{n+1}_{i,j-1} - 2u^{n+1}_{i,j} + u^{n+1}_{i,j+1})\right) = \\ &\qquad (1-\theta)\left( F_x -(u^{n}**{i-1,j} - 2u^{n}**{i,j} + u^{n}_{i+1,j}) + +(u^{n}_{i-1,j} - 2u^{n}_{i,j} + u^{n}_{i+1,j}) + F_y -(u^{n}**{i,j-1} - 2u^{n}**{i,j} + u^{n}_{i,j+1})\right) + \\ -&\qquad \theta \Delta t f^{n+1}**{i,j} + (1-\theta) \Delta t f^{n}**{i,j} +(u^{n}_{i,j-1} - 2u^{n}_{i,j} + u^{n}_{i,j+1})\right) + \\ +&\qquad \theta \Delta t f^{n+1}_{i,j} + (1-\theta) \Delta t f^{n}_{i,j} + u^n_{i,j}, \end{split} $$ {#eq-diffu-2D-theta-scheme2} @@ -66,7 +75,7 @@ F_x = \frac{\dfc\Delta t}{\Delta x^2},\quad F_y = \frac{\dfc\Delta t}{\Delta y^2 $$ are the Fourier numbers in $x$ and $y$ direction, respectively. -![3x2 2D mesh.](fig/mesh3x2){#fig-diffu-2D-fig-mesh3x2 width="500px"} +![A 2D mesh with $N_x=3$ and $N_y=2$ cells, showing interior and boundary points.](fig/mesh3x2){#fig-diffu-2D-fig-mesh3x2 width="500px"} ## Numbering of mesh points versus equations and unknowns {#sec-diffu-2D-numbering} @@ -99,17 +108,17 @@ The corresponding equations are & u^{n+1}_{i,j} - \theta\left( F_x -(u^{n+1}**{i-1,j} - 2u^{n+1}**{i,j} + u^{n+1}_{i+1,j}) + +(u^{n+1}_{i-1,j} - 2u^{n+1}_{i,j} + u^{n+1}_{i+1,j}) + F_y -(u^{n+1}**{i,j-1} - 2u^{n+1}**{i,j} + u^{n+1}_{i,j+1})\right) +(u^{n+1}_{i,j-1} - 2u^{n+1}_{i,j} + u^{n+1}_{i,j+1})\right) = \\ &\qquad (1-\theta)\left( F_x -(u^{n}**{i-1,j} - 2u^{n}**{i,j} + u^{n}_{i+1,j}) + +(u^{n}_{i-1,j} - 2u^{n}_{i,j} + u^{n}_{i+1,j}) + F_y -(u^{n}**{i,j-1} - 2u^{n}**{i,j} + u^{n}_{i,j+1})\right) + \\ -&\qquad \theta \Delta t f^{n+1}**{i,j} + (1-\theta) \Delta t f^{n}**{i,j} +(u^{n}_{i,j-1} - 2u^{n}_{i,j} + u^{n}_{i,j+1})\right) + \\ +&\qquad \theta \Delta t f^{n+1}_{i,j} + (1-\theta) \Delta t f^{n}_{i,j} + u^n_{i,j}, \end{align*} @@ -246,10 +255,10 @@ interior points, we get for $p=5,6$, corresponding to $i=1,2$ and $j=1$: b_p &= u^{n}_{i,j} + (1-\theta)\left( F_x -(u^{n}**{i-1,j} - 2u^{n}**{i,j} + u^{n}_{i+1,j}) + +(u^{n}_{i-1,j} - 2u^{n}_{i,j} + u^{n}_{i+1,j}) + F_y -(u^{n}**{i,j-1} - 2u^{n}**{i,j} + u^{n}_{i,j+1})\right) + \\ -&\qquad \theta \Delta t f^{n+1}**{i,j} + (1-\theta) \Delta t f^{n}**{i,j}\tp +(u^{n}_{i,j-1} - 2u^{n}_{i,j} + u^{n}_{i,j+1})\right) + \\ +&\qquad \theta \Delta t f^{n+1}_{i,j} + (1-\theta) \Delta t f^{n}_{i,j}\tp \end{align*} Recall that $p=m(i,j)=j(N_x+1)+j$ in this expression. @@ -262,7 +271,7 @@ p = (j-1)(N_x-1) + i, $$ for $i=1,\ldots,N_x-1$, $j=1,\ldots,N_y-1$. -![4x3 2D mesh.](fig/mesh4x3){#fig-diffu-2D-fig-mesh4x3 width="700px"} +![A 2D mesh with $N_x=4$ and $N_y=3$ cells, illustrating the unknown numbering scheme.](fig/mesh4x3){#fig-diffu-2D-fig-mesh4x3 width="700px"} We can continue with illustrating a bit larger mesh, $N_x=4$ and $N_y=3$, see Figure @fig-diffu-2D-fig-mesh4x3. The corresponding coefficient matrix @@ -387,7 +396,7 @@ def solver_dense( Fy = a*dt/dy**2 ``` -The $u^{n+1}**{i,j}$ and $u^n**{i,j}$ mesh functions are represented +The $u^{n+1}_{i,j}$ and $u^n_{i,j}$ mesh functions are represented by their spatial values at the mesh points: ```python @@ -494,10 +503,9 @@ Another advantage of using `scipy.linalg` over numpy.linalg is that it is always Therefore, unless you don't want to add SciPy as a dependency to your NumPy program, use `scipy.linalg` instead of `numpy.linalg`. ::: -The code shown above is available in the `solver_dense` function -in the file [`diffu2D_u0.py`](https://github.com/devitocodes/devito_book/tree/main/src/diffu/diffu2D_u0.py), differing only -in the boundary conditions, which in the code can be an arbitrary function along -each side of the domain. +The Devito implementation is available in +[`diffu2D_devito.py`](https://github.com/devitocodes/devito_book/tree/main/src/diffu/diffu2D_devito.py). +See @sec-diffu-devito for the complete 2D diffusion implementation using Devito. We do not bother to look at vectorized versions of filling `A` since a dense matrix is just used of pedagogical reasons for the very first @@ -664,9 +672,9 @@ where $p$ can be arbitrary. The required source term is $$ f = (\dfc(k_x^2 + k_y^2) - p)\uex\tp $$ -The function `convergence_rates` in -[`diffu2D_u0.py`](https://github.com/devitocodes/devito_book/tree/main/src/diffu/diffu2D_u0.py) implements a convergence -rate test. Two potential difficulties are important to be aware of: +A convergence rate test can be implemented using the Devito solver in +[`diffu2D_devito.py`](https://github.com/devitocodes/devito_book/tree/main/src/diffu/diffu2D_devito.py). +Two potential difficulties are important to be aware of: 1. The error formula is assumed to be correct when $h\rightarrow 0$, so for coarse meshes the estimated rate @@ -923,9 +931,9 @@ version of Gaussian elimination suited for matrices described by diagonals. The algorithm is known as *sparse Gaussian elimination*, and `spsolve` calls up a well-tested C code called [SuperLU](http://crd-legacy.lbl.gov/~xiaoye/SuperLU/). -The complete code utilizing `spsolve` -is found in the `solver_sparse` function in the file -[`diffu2D_u0.py`](https://github.com/devitocodes/devito_book/tree/main/src/diffu/diffu2D_u0.py). +For reference, the Devito implementation automatically handles +sparse matrix operations internally. See +[`diffu2D_devito.py`](https://github.com/devitocodes/devito_book/tree/main/src/diffu/diffu2D_devito.py). ### Verification @@ -1011,7 +1019,7 @@ $$ {#eq-diffu-2D-Jacobi} We start the iteration with the computed values at the previous time level: $$ -u^{n+1,0}**{i,j} = u^{n}**{i,j},\quad i=0,\ldots,N_x,\ j=0,\ldots,N_y\tp +u^{n+1,0}_{i,j} = u^{n}_{i,j},\quad i=0,\ldots,N_x,\ j=0,\ldots,N_y\tp $$ {#eq-diffu-2D-iter-startvector} ### Relaxation @@ -1022,7 +1030,7 @@ approximation as suggested by the algorithm and the previous approximation. Naming the quantity on the left-hand side of (@eq-diffu-2D-Jacobi) as $u^{n+1,*}_{i,j}$, a new approximation based on relaxation reads $$ -u^{n+1,r+1} = \omega u^{n+1,*}**{i,j} + (1-\omega) u^{n+1,r}**{i,j}\tp +u^{n+1,r+1} = \omega u^{n+1,*}_{i,j} + (1-\omega) u^{n+1,r}_{i,j}\tp $$ {#eq-diffu-2D-iter-relaxation} Under-relaxation means $\omega < 1$, while over-relaxation has $\omega > 1$. @@ -1031,12 +1039,12 @@ $\omega > 1$. The iteration can be stopped when the change from one iteration to the next is sufficiently small ($\leq \epsilon$), using either an infinity norm, $$ -\max_{i,j}\left\vert u^{n+1,r+1}**{i,j}-u^{n+1,r}**{i,j} +\max_{i,j}\left\vert u^{n+1,r+1}_{i,j}-u^{n+1,r}_{i,j} \right\vert \leq \epsilon, $$ or an $L^2$ norm, $$ -\left(\Delta x\Delta y\sum_{i,j} (u^{n+1,r+1}**{i,j}-u^{n+1,r}**{i,j})^2 +\left(\Delta x\Delta y\sum_{i,j} (u^{n+1,r+1}_{i,j}-u^{n+1,r}_{i,j})^2 \right)^{\half} \leq \epsilon\tp $$ Another widely used criterion measures how well the equations are solved @@ -1062,9 +1070,9 @@ $$ ### Code-friendly notation To make the mathematics as close as possible to what we will write in a computer program, we may introduce some new notation: $u_{i,j}$ is a -short notation for $u^{n+1,r+1}**{i,j}$, $u^{-}**{i,j}$ is a short -notation for $u^{n+1,r}**{i,j}$, and $u^{(s)}**{i,j}$ denotes -$u^{n+1-s}**{i,j}$. That is, $u**{i,j}$ is the unknown, $u^{-}_{i,j}$ +short notation for $u^{n+1,r+1}_{i,j}$, $u^{-}_{i,j}$ is a short +notation for $u^{n+1,r}_{i,j}$, and $u^{(s)}_{i,j}$ denotes +$u^{n+1-s}_{i,j}$. That is, $u_{i,j}$ is the unknown, $u^{-}_{i,j}$ is its most recently computed approximation, and $s$ counts time levels backwards in time. The Jacobi method (@eq-diffu-2D-Jacobi)) takes the following form with the new @@ -1103,7 +1111,7 @@ F_y(u^{(1)}_{i,j-1}-2u^{(1)}_{i,j} + u^{(1)}_{i,j+1})))\tp $$ {#eq-diffu-2D-Jacobi3} The final update of $u$ applies relaxation: $$ -u_{i,j} = \omega u^{*}**{i,j} + (1-\omega)u^{-}**{i,j}\tp +u_{i,j} = \omega u^{*}_{i,j} + (1-\omega)u^{-}_{i,j}\tp $$ ## Implementation of the Jacobi method {#sec-diffu-2D-Jacobi-impl} @@ -1263,8 +1271,8 @@ two consecutive approximations, which is not exactly the error due to the iteration, but it is a kind of measure, and it should have about the same size as $E_i$. -The function `demo_classic_iterative` in [`diffu2D_u0.py`](https://github.com/devitocodes/devito_book/tree/main/src/diffu/diffu2D_u0.py) implements the idea above (also for the -methods in Section @sec-diffu-2D-SOR). The value of $E_i$ is in +These iterative methods are described here for pedagogical purposes. The Devito +implementation in [`diffu2D_devito.py`](https://github.com/devitocodes/devito_book/tree/main/src/diffu/diffu2D_devito.py) handles these cases automatically. The value of $E_i$ is in particular printed at each time level. By changing the tolerance in the convergence criterion of the Jacobi method, we can see that $E_i$ is of the same order of magnitude as the prescribed tolerance in the @@ -1348,9 +1356,9 @@ especially the SOR method, which is treated next. If we update the mesh points according to the Jacobi method (@eq-diffu-2D-Jacobi0) for a Backward Euler discretization with a loop over $i=1,\ldots,N_x-1$ and $j=1,\ldots,N_y-1$, we realize that -when $u^{n+1,r+1}**{i,j}$ is computed, $u^{n+1,r+1}**{i-1,j}$ and +when $u^{n+1,r+1}_{i,j}$ is computed, $u^{n+1,r+1}_{i-1,j}$ and $u^{n+1,r+1}_{i,j-1}$ are already computed, so these new values can be -used rather than $u^{n+1,r}**{i-1,j}$ and $u^{n+1,r}**{i,j-1}$ +used rather than $u^{n+1,r}_{i-1,j}$ and $u^{n+1,r}_{i,j-1}$ (respectively) in the formula for $u^{n+1,r+1}_{i,j}$. This idea gives rise to the *Gauss-Seidel* iteration method, which mathematically is just a small adjustment of (@eq-diffu-2D-Jacobi0): @@ -1672,11 +1680,10 @@ c = slice(1,-1) u[c,c] = omega*u_new[c,c] + (1-omega)*u_[c,c] ``` -The function `solver_classic_iterative` in -[`diffu2D_u0.py`](https://github.com/devitocodes/devito_book/tree/main/src/diffu/diffu2D_u0.py) -contains a unified implementation of the relaxed Jacobi and SOR -methods in scalar and vectorized versions using the techniques -explained above. +The Devito implementation in +[`diffu2D_devito.py`](https://github.com/devitocodes/devito_book/tree/main/src/diffu/diffu2D_devito.py) +provides an efficient solver that handles these cases. For explicit schemes, +Devito's automatic code generation replaces manual iterative implementations. ## Direct versus iterative methods ### Direct methods {#sec-diffu-2D-direct-vs-iter} diff --git a/chapters/diffu/diffu_rw.qmd b/chapters/diffu/diffu_rw.qmd index bfb55cbf..77753ddb 100644 --- a/chapters/diffu/diffu_rw.qmd +++ b/chapters/diffu/diffu_rw.qmd @@ -36,6 +36,15 @@ derivation of the models is provided by Hjorth-Jensen [@hjorten]. ## Random walk in 1D {#sec-diffu-randomwalk-1D} +::: {.callout-note} +## Source Files + +The random walk implementations presented in this chapter are available as +tested modules in `src/diffu/random_walk.py`. The source file includes +vectorized implementations, multi-dimensional walkers, and test functions. +The chapter shows the evolution of the code from simple to optimized versions. +::: + Imagine that we have some particles that perform random moves, either to the right or to the left. We may flip a coin to decide the movement of each particle, say head implies movement to the right and tail @@ -282,7 +291,7 @@ ways: either coming in from the left from $(i-1,n)$ or from the right ($i+1,n)$. Each has probability $\half$ (if we assume $p=q=\half$). The fundamental equation for $P^{n+1}_i$ is $$ -P^{n+1}**i = \half P^{n}**{i-1} + \half P^{n}_{i+1}\tp +P^{n+1}_i = \half P^{n}_{i-1} + \half P^{n}_{i+1}\tp $$ {#eq-diffu-randomwalk-1D-pde-Markov} (This equation is easiest to understand if one looks at the random walk as a Markov process and applies the transition probabilities, but this is @@ -291,7 +300,7 @@ beyond scope of the present text.) Subtracting $P^{n}_i$ from (@eq-diffu-randomwalk-1D-pde-Markov) results in $$ -P^{n+1}_i - P^{n}**i = \half (P^{n}**{i-1} -2P^{n}**i + \half P^{n}**{i+1})\tp +P^{n+1}_i - P^{n}_i = \half (P^{n}_{i-1} -2P^{n}_i + \half P^{n}_{i+1})\tp $$ Readers who have seen the Forward Euler discretization of a 1D diffusion equation recognize this scheme as very close to such a @@ -309,10 +318,10 @@ Similarly, we have \begin{align*} \frac{\partial^2}{\partial x^2}P(x_i,t_n) &= -\frac{P^{n}_{i-1} -2P^{n}**i + \half P^{n}**{i+1}}{\Delta x^2} +\frac{P^{n}_{i-1} -2P^{n}_i + \half P^{n}_{i+1}}{\Delta x^2} + \Oof{\Delta x^2},\\ \frac{\partial^2}{\partial x^2}P(\bar x_i,\bar t_n) &\approx -P^{n}_{i-1} -2P^{n}**i + \half P^{n}**{i+1}\tp +P^{n}_{i-1} -2P^{n}_i + \half P^{n}_{i+1}\tp \end{align*} Equation (@eq-diffu-randomwalk-1D-pde-Markov) is therefore equivalent with the dimensionless diffusion equation diff --git a/chapters/diffu/exer-diffu/axisymm_flow.py b/chapters/diffu/exer-diffu/axisymm_flow.py deleted file mode 100644 index 9b0a9c6f..00000000 --- a/chapters/diffu/exer-diffu/axisymm_flow.py +++ /dev/null @@ -1,344 +0,0 @@ -""" -Solve the diffusion equation for axi-symmetric case: - - u_t = 1/r * (r*a(r)*u_r)_r + f(r,t) - -on (0,R) with boundary conditions u(0,t)_r = 0 and u(R,t) = 0, -for t in (0,T]. Initial condition: u(r,0) = I(r). -Pressure gradient f. - -The following naming convention of variables are used. - -===== ========================================================== -Name Description -===== ========================================================== -Nx The total number of mesh cells; mesh points are numbered - from 0 to Nx. -T The stop time for the simulation. -I Initial condition (Python function of x). -a Variable coefficient (constant). -R Length of the domain ([0,R]). -r Mesh points in space. -t Mesh points in time. -n Index counter in time. -u Unknown at current/new time level. -u_1 u at the previous time level. -dr Constant mesh spacing in r. -dt Constant mesh spacing in t. -===== ========================================================== - -``user_action`` is a function of ``(u, r, t, n)``, ``u[i]`` is the -solution at spatial mesh point ``r[i]`` at time ``t[n]``, where the -calling code can add visualization, error computations, data analysis, -store solutions, etc. -""" - -import time - -import scipy.sparse -import scipy.sparse.linalg -import sympy as sym -from numpy import linspace, log, ones, sqrt, sum, zeros - - -def solver_theta(I, a, R, Nr, D, T, theta=0.5, u_L=None, u_R=0, user_action=None, f=0): - """ - The array a has length Nr+1 and holds the values of - a(x) at the mesh points. - - Method: (implicit) theta-rule in time. - - Nr is the total number of mesh cells; mesh points are numbered - from 0 to Nr. - D = dt/dr**2 and implicitly specifies the time step. - T is the stop time for the simulation. - I is a function of r. - u_L = None implies du/dr = 0, i.e. a symmetry condition - f(r,t) is pressure gradient with radius. - - user_action is a function of (u, x, t, n) where the calling code - can add visualization, error computations, data analysis, - store solutions, etc. - - r*alpha is needed midway between spatial mesh points, - use - arithmetic mean of successive mesh values (i.e. of r_i*alpha_i) - """ - t0 = time.perf_counter() - - r = linspace(0, R, Nr + 1) # mesh points in space - dr = r[1] - r[0] - dt = D * dr**2 - Nt = int(round(T / float(dt))) - t = linspace(0, T, Nt + 1) # mesh points in time - - if isinstance(u_L, (float, int)): - u_L_ = float(u_L) # must take copy of u_L number - u_L = lambda t: u_L_ - if isinstance(u_R, (float, int)): - u_R_ = float(u_R) # must take copy of u_R number - u_R = lambda t: u_R_ - if isinstance(f, (float, int)): - f_ = float(f) # must take copy of f number - f = lambda r, t: f_ - - ra = r * a # help array in scheme - - inv_r = zeros(len(r) - 2) # needed for inner mesh points - inv_r = 1.0 / r[1:-1] - - u = zeros(Nr + 1) # solution array at t[n+1] - u_1 = zeros(Nr + 1) # solution at t[n] - - Dl = 0.5 * D * theta - Dr = 0.5 * D * (1 - theta) - - # Representation of sparse matrix and right-hand side - diagonal = zeros(Nr + 1) - lower = zeros(Nr) - upper = zeros(Nr) - b = zeros(Nr + 1) - - # Precompute sparse matrix (scipy format) - diagonal[1:-1] = 1 + Dl * (ra[2:] + 2 * ra[1:-1] + ra[:-2]) * inv_r - lower[:-1] = -Dl * (ra[1:-1] + ra[:-2]) * inv_r - upper[1:] = -Dl * (ra[2:] + ra[1:-1]) * inv_r - # Insert boundary conditions - if u_L is None: # symmetry axis, du/dr = 0 - diagonal[0] = 1 + 8 * a[0] * Dl - upper[0] = -8 * a[0] * Dl - else: - diagonal[0] = 1 - upper[0] = 0 - diagonal[Nr] = 1 - lower[-1] = 0 - - A = scipy.sparse.diags( - diagonals=[diagonal, lower, upper], - offsets=[0, -1, 1], - shape=(Nr + 1, Nr + 1), - format="csr", - ) - # print A.todense() - - # Set initial condition - for i in range(0, Nr + 1): - u_1[i] = I(r[i]) - - if user_action is not None: - user_action(u_1, r, t, 0) - - # Time loop - for n in range(0, Nt): - b[1:-1] = ( - u_1[1:-1] - + Dr - * ( - (ra[2:] + ra[1:-1]) * (u_1[2:] - u_1[1:-1]) - - (ra[1:-1] + ra[0:-2]) * (u_1[1:-1] - u_1[:-2]) - ) - * inv_r - + dt * theta * f(r[1:-1], t[n + 1]) - + dt * (1 - theta) * f(r[1:-1], t[n]) - ) - - # Boundary conditions - if u_L is None: # symmetry axis, du/dr = 0 - b[0] = ( - u_1[0] - + 8 * a[0] * Dr * (u_1[1] - u_1[0]) - + dt * theta * f(0, (n + 1) * dt) - + dt * (1 - theta) * f(0, n * dt) - ) - else: - b[0] = u_L(t[n + 1]) - b[-1] = u_R(t[n + 1]) - # print b - - # Solve - u[:] = scipy.sparse.linalg.spsolve(A, b) - - if user_action is not None: - user_action(u, r, t, n + 1) - - # Switch variables before next step - u_1, u = u, u_1 - - t1 = time.perf_counter() - # return u_1, since u and u_1 are switched - return u_1, t, t1 - t0 - - -def compute_rates(h_values, E_values): - m = len(h_values) - q = [ - log(E_values[i + 1] / E_values[i]) / log(h_values[i + 1] / h_values[i]) - for i in range(0, m - 1, 1) - ] - q = [round(q_, 2) for q_ in q] - return q - - -def make_a(alpha, r): - """ - alpha is a func, generally of r, - but may be constant. - Note: when solution is to be axi-symmetric, alpha - must be so too. - """ - a = alpha(r) * ones(len(r)) - return a - - -def tests_with_alpha_and_u_exact(): - """ - Test solver performance when alpha is either const or - a fu of r, combined with a manufactured sol u_exact - that is either a fu of r only, or a fu of both r and t. - Note: alpha and u_e are defined as symb expr here, since - test_solver_symmetric needs to automatically generate - the source term f. After that, test_solver_symmetric - redefines alpha, u_e and f as num functions. - """ - R, r, t = sym.symbols("R r t") - - # alpha const ... - - # ue = const - print("Testing with alpha = 1.5 and u_e = R**2 - r**2...") - test_solver_symmetric(alpha=1.5, u_exact=R**2 - r**2) - - # ue = ue(t) - print("Testing with alpha = 1.5 and u_e = 5*t*(R**2 - r**2)...") - test_solver_symmetric(alpha=1.5, u_exact=5 * t * (R**2 - r**2)) - - # alpha function of r ... - - # ue = const - print("Testing with alpha = 1 + r**2 and u_e = R**2 - r**2...") - test_solver_symmetric(alpha=1 + r**2, u_exact=R**2 - r**2) - - # ue = ue(t) - print("Testing with alpha = 1+r**2 and u_e = 5*t*(R**2 - r**2)...") - test_solver_symmetric(alpha=1 + r**2, u_exact=5 * t * (R**2 - r**2)) - - -def test_solver_symmetric(alpha, u_exact): - """ - Test solver performance for manufactured solution - given in the function u_exact. Parameter alpha is - either a const or a function of r. In the latter - case, an "exact" sol can not be achieved, so then - testing switches to conv. rates. - R is tube radius and T is duration of simulation. - alpha constant: - Compares the manufactured solution with the - solution from the solver at each time step. - alpha function of r: - convergence rates are tested (using the sol - at the final point in time only). - """ - - def compare(u, r, t, n): # user_action function - """Compare exact and computed solution.""" - u_e = u_exact(r, t[n]) - diff = abs(u_e - u).max() - # print diff - tol = 1e-12 - assert diff < tol, f"max diff: {diff:g}" - - def pde_source_term(a, u): - """Return the terms in the PDE that the source term - must balance, here du/dt - (1/r) * d/dr(r*a*du/dr). - a, i.e. alpha, is either const or a fu of r. - u is a symbolic Python function of r and t.""" - - return sym.diff(u, t) - (1.0 / r) * sym.diff(r * a * sym.diff(u, r), r) - - R, r, t = sym.symbols("R r t") - - # fit source term - f = sym.simplify(pde_source_term(alpha, u_exact)) - - R = 1.0 # radius of tube - T = 2.0 # duration of simulation - - alpha_is_const = sym.diff(alpha, r) == 0 - - # make alpha, f and u_exact numerical functions - alpha = sym.lambdify([r], alpha, modules="numpy") - f = sym.lambdify([r, t], f.subs("R", R), modules="numpy") - u_exact = sym.lambdify([r, t], u_exact.subs("R", R), modules="numpy") - - I = lambda r: u_exact(r, 0) - - # some help variables - FE = 0 # Forward Euler method - BE = 1 # Backward Euler method - CN = 0.5 # Crank-Nicolson method - - # test all three schemes - for theta in (FE, BE, CN): - print("theta: ", theta) - E_values = [] - dt_values = [] - for Nr in (2, 4, 8, 16, 32, 64): - print("Nr:", Nr) - r = linspace(0, R, Nr + 1) # mesh points in space - dr = r[1] - r[0] - a_values = make_a(alpha, r) - if theta == CN: - dt = dr - else: # either FE or BE - # use most conservative dt as decided by FE - K = 1.0 / (4 * a_values.max()) - dt = K * dr**2 - D = dt / dr**2 - - if alpha_is_const: - u, t, cpu = solver_theta( - I, - a_values, - R, - Nr, - D, - T, - theta, - u_L=None, - u_R=0, - user_action=compare, - f=f, - ) - else: # alpha depends on r - u, t, cpu = solver_theta( - I, - a_values, - R, - Nr, - D, - T, - theta, - u_L=None, - u_R=0, - user_action=None, - f=f, - ) - - # compute L2 error at t = T - u_e = u_exact(r, t[-1]) - e = u_e - u - E = sqrt(dr * sum(e**2)) - E_values.append(E) - dt_values.append(dt) - - if alpha_is_const is False: - q = compute_rates(dt_values, E_values) - print(f"theta={theta:g}, q: {q}") - expected_rate = 2 if theta == CN else 1 - tol = 0.1 - diff = abs(expected_rate - q[-1]) - print("diff:", diff) - assert diff < tol - - -if __name__ == "__main__": - tests_with_alpha_and_u_exact() - print("This is just a start. More remaining for this Exerc.") diff --git a/chapters/diffu/exer-diffu/surface_osc.py b/chapters/diffu/exer-diffu/surface_osc.py deleted file mode 100644 index 0726b546..00000000 --- a/chapters/diffu/exer-diffu/surface_osc.py +++ /dev/null @@ -1,50 +0,0 @@ -import os -import sys - -sys.path.insert(0, os.path.join(os.pardir, "src-diffu")) -from diffu1D_vc import viz - -sol = [] # store solutions -for Nx, L in [[20, 4], [40, 8]]: - dt = 0.1 - dx = float(L) / Nx - D = dt / dx**2 - from math import pi, sin - - T = 2 * pi * 6 - from numpy import zeros - - a = zeros(Nx + 1) + 0.5 - cpu, u_ = viz( - I=lambda x: 0, - a=a, - L=L, - Nx=Nx, - D=D, - T=T, - umin=-1.1, - umax=1.1, - theta=0.5, - u_L=lambda t: sin(t), - u_R=0, - animate=False, - store_u=True, - ) - sol.append(u_) - print("computed solution for Nx=%d in [0,%g]" % (Nx, L)) - -print(sol[0].shape) -print(sol[1].shape) -import matplotlib.pyplot as plt - -counter = 0 -for u0, u1 in zip(sol[0][2:], sol[1][2:], strict=False): - x0 = sol[0][0] - x1 = sol[1][0] - plt.clf() - plt.plot(x0, u0, "r-", label="short") - plt.plot(x1, u1, "b-", label="long") - plt.legend() - plt.axis([x1[0], x1[-1], -1.1, 1.1]) - plt.savefig("tmp_%04d.png" % counter) - counter += 1 diff --git a/chapters/diffu/exer-diffu/welding.py b/chapters/diffu/exer-diffu/welding.py deleted file mode 100644 index a58805c8..00000000 --- a/chapters/diffu/exer-diffu/welding.py +++ /dev/null @@ -1,120 +0,0 @@ -import os -import sys - -sys.path.insert(0, os.path.join(os.pardir, "src-diffu")) -import numpy as np -from diffu1D_vc import solver - - -def run(gamma, beta=10, delta=40, scaling=1, animate=False): - """Run the scaled model for welding.""" - if scaling == 1: - v = gamma - a = 1 - elif scaling == 2: - v = 1 - a = 1.0 / gamma - - b = 0.5 * beta**2 - L = 1.0 - ymin = 0 - # Need gloal to be able change ymax in closure process_u - global ymax - ymax = 1.2 - - I = lambda x: 0 - f = lambda x, t: delta * np.exp(-b * (x - v * t) ** 2) - - import time - - import matplotlib.pyplot as plt - - plot_arrays = [] - - def process_u(u, x, t, n): - global ymax - if animate: - plt.clf() - plt.plot(x, u, "r-", x, f(x, t[n]) / delta, "b-") - plt.axis([0, L, ymin, ymax]) - plt.title(f"t={t[n]:f}") - plt.xlabel("x") - plt.ylabel(f"u and f/{delta:g}") - plt.draw() - plt.pause(0.001) - if t[n] == 0: - time.sleep(1) - plot_arrays.append(x) - dt = t[1] - t[0] - tol = dt / 10.0 - if abs(t[n] - 0.2) < tol or abs(t[n] - 0.5) < tol: - plot_arrays.append((u.copy(), f(x, t[n]) / delta)) - if u.max() > ymax: - ymax = u.max() - - Nx = 100 - D = 10 - T = 0.5 - u_L = u_R = 0 - theta = 1.0 - cpu = solver(I, a, f, L, Nx, D, T, theta, u_L, u_R, user_action=process_u) - x = plot_arrays[0] - plt.figure() - for u, f in plot_arrays[1:]: - plt.plot(x, u, "r-", x, f, "b--") - plt.axis([x[0], x[-1], 0, ymax]) - plt.xlabel("$x$") - plt.ylabel(rf"$u, \ f/{delta:g}$") - plt.legend( - [ - "$u,\\ t=0.2$", - f"$f/{delta:g},\\ t=0.2$", - "$u,\\ t=0.5$", - f"$f/{delta:g},\\ t=0.5$", - ] - ) - filename = "tmp1_gamma%g_s%d" % (gamma, scaling) - s = "diffusion" if scaling == 1 else "source" - plt.title(rf"$\beta = {beta:g},\ \gamma = {gamma:g},\ $" + f"scaling={s}") - plt.savefig(filename + ".pdf") - plt.savefig(filename + ".png") - return cpu - - -def investigate(): - """Do scienfic experiments with the run function above.""" - # Clean up old files - import glob - - for filename in glob.glob("tmp1_gamma*") + glob.glob("welding_gamma*"): - os.remove(filename) - - gamma_values = 1, 40, 5, 0.2, 0.025 - for gamma in gamma_values: - for scaling in 1, 2: - run(gamma=gamma, beta=10, delta=20, scaling=scaling) - - # Combine images - for gamma in gamma_values: - for ext in "pdf", "png": - cmd = ( - "montage " - "tmp1_gamma{gamma:g}_s1.{ext} " - "tmp1_gamma{gamma:g}_s2.{ext} " - "-tile 2x1 -geometry +0+0 " - "welding_gamma{gamma:g}.{ext}".format(**vars()) - ) - os.system(cmd) - # pdflatex doesn't like 0.2 in filenames... - if "." in str(gamma): - os.rename( - "welding_gamma{gamma:g}.{ext}".format(**vars()), - ("welding_gamma{gamma:g}".format(**vars())).replace(".", "_") - + "." - + ext, - ) - - -if __name__ == "__main__": - # run(gamma=1/40., beta=10, delta=40, scaling=2) - investigate() diff --git a/chapters/elliptic/elliptic.qmd b/chapters/elliptic/elliptic.qmd new file mode 100644 index 00000000..6cde00ab --- /dev/null +++ b/chapters/elliptic/elliptic.qmd @@ -0,0 +1,992 @@ +## Introduction to Elliptic PDEs {#sec-elliptic-intro} + +The previous chapters have focused on time-dependent PDEs: waves propagating, +heat diffusing, quantities being advected. These are *evolution equations* +where the solution changes in time from a given initial state. In this +chapter, we turn to a fundamentally different class: *elliptic PDEs*, +which describe steady-state or equilibrium phenomena. + +### Boundary Value Problems vs Initial Value Problems + +Time-dependent PDEs are *initial value problems* (IVPs): given the state +at $t=0$, we march forward in time to find the solution at later times. +Elliptic PDEs are *boundary value problems* (BVPs): the solution is +determined entirely by conditions prescribed on the boundary of the domain, +with no time evolution involved. + +| Property | IVPs (Wave, Diffusion) | BVPs (Elliptic) | +|----------|------------------------|-----------------| +| Time dependence | Solution evolves in time | No time variable | +| Initial condition | Required | Not applicable | +| Boundary conditions | Affect propagation | Fully determine solution | +| Information flow | Forward in time | Throughout domain simultaneously | +| Typical uses | Transient phenomena | Equilibrium, steady-state | + +### Physical Applications + +Elliptic PDEs arise in numerous physical contexts: + +- **Steady-state heat conduction**: Temperature distribution when heat + flow has reached equilibrium +- **Electrostatics**: Electric potential from fixed charge distributions +- **Incompressible fluid flow**: Pressure field, stream functions +- **Gravitation**: Gravitational potential from mass distributions +- **Structural mechanics**: Equilibrium deformations + +### The Canonical Elliptic Equations + +The two fundamental elliptic equations are: + +**Laplace equation** (homogeneous): +$$ +\nabla^2 u = \frac{\partial^2 u}{\partial x^2} + \frac{\partial^2 u}{\partial y^2} = 0 +$$ {#eq-elliptic-laplace} + +**Poisson equation** (with source term): +$$ +\nabla^2 u = \frac{\partial^2 u}{\partial x^2} + \frac{\partial^2 u}{\partial y^2} = f(x, y) +$$ {#eq-elliptic-poisson} + +The Laplace equation describes equilibrium with no internal sources. +The Poisson equation adds a source term $f(x, y)$ representing distributed +sources or sinks within the domain. + +### Boundary Conditions + +Elliptic problems require boundary conditions on the entire boundary +$\partial\Omega$ of the domain $\Omega$: + +**Dirichlet conditions**: Prescribe the value of $u$ on the boundary: +$$ +u = g(x, y) \quad \text{on } \partial\Omega +$$ + +**Neumann conditions**: Prescribe the normal derivative: +$$ +\frac{\partial u}{\partial n} = h(x, y) \quad \text{on } \partial\Omega +$$ + +**Mixed (Robin) conditions**: Linear combination of value and derivative. + +For the Laplace and Poisson equations, a unique solution exists with +Dirichlet conditions on the entire boundary, or Neumann conditions +(with a consistency requirement) plus specification of $u$ at one point. + +### Iterative Solution Methods + +Since elliptic PDEs have no time variable, we cannot simply "march" +to the solution. Instead, we use iterative methods that start with +an initial guess and progressively refine it until convergence. + +The classical approach is the *Jacobi iteration*: discretize the PDE +on a grid, solve the discrete equation for the central point in terms +of its neighbors, and sweep through the grid repeatedly until the +solution stops changing. + +For the 2D Laplace equation with equal grid spacing $h$: +$$ +u_{i,j} = \frac{1}{4}\left(u_{i+1,j} + u_{i-1,j} + u_{i,j+1} + u_{i,j-1}\right) +$$ {#eq-elliptic-jacobi} + +This is exactly the five-point stencil average. Jacobi iteration replaces +each interior value with the average of its four neighbors, while +boundary values are held fixed. + +### Chapter Overview + +In this chapter, we implement elliptic solvers using Devito. The key +challenge is that Devito's `TimeFunction` is designed for time-stepping, +but elliptic problems have no time. We explore two approaches: + +1. **Dual-buffer `Function` pattern**: Use two `Function` objects + as alternating buffers, with explicit buffer swapping in Python +2. **Pseudo-timestepping with `TimeFunction`**: Treat the iteration + index as a "pseudo-time" and let Devito handle buffer management + +Both approaches converge to the same steady-state solution, but they +differ in how the iteration loop is structured and how much control +we retain over the convergence process. + + +## The Laplace Equation {#sec-elliptic-laplace} + +::: {.callout-note} +## Tested Source Files + +The solvers presented in this section have been implemented as tested modules +in `src/elliptic/laplace_devito.py` and `src/elliptic/poisson_devito.py`. +The test suite in `tests/test_elliptic_devito.py` validates these implementations. +::: + +The Laplace equation models steady-state phenomena where the field +variable reaches equilibrium with its surroundings. We solve: +$$ +\frac{\partial^2 p}{\partial x^2} + \frac{\partial^2 p}{\partial y^2} = 0 +$$ +on a rectangular domain with prescribed boundary conditions. + +### Problem Setup + +Consider the domain $[0, 2] \times [0, 1]$ with: + +- $p = 0$ at $x = 0$ (left boundary) +- $p = y$ at $x = 2$ (right boundary, linear profile) +- $\frac{\partial p}{\partial y} = 0$ at $y = 0$ and $y = 1$ (top and bottom: zero normal derivative) + +The Neumann conditions at the top and bottom mean no flux crosses these +boundaries. Combined with the Dirichlet conditions on left and right, +this problem has a unique solution that smoothly interpolates between +the boundary values. + +### Discretization + +Using central differences on a uniform grid with spacing $\Delta x$ and $\Delta y$: +$$ +\frac{p_{i+1,j} - 2p_{i,j} + p_{i-1,j}}{\Delta x^2} + +\frac{p_{i,j+1} - 2p_{i,j} + p_{i,j-1}}{\Delta y^2} = 0 +$$ + +Solving for $p_{i,j}$: +$$ +p_{i,j} = \frac{\Delta y^2(p_{i+1,j} + p_{i-1,j}) + \Delta x^2(p_{i,j+1} + p_{i,j-1})}{2(\Delta x^2 + \Delta y^2)} +$$ {#eq-elliptic-laplace-discrete} + +This weighted average accounts for potentially different grid spacings +in $x$ and $y$. + +### The Dual-Buffer Pattern in Devito + +For steady-state problems without time derivatives, we use `Function` +objects instead of `TimeFunction`. Since we need to iterate, we require +two buffers: one holding the current estimate (`pn`) and one for the +updated values (`p`). + +```python +from devito import Grid, Function, Eq, solve, Operator +import numpy as np + +# Domain: [0, 2] x [0, 1] with 31 x 31 grid points +nx, ny = 31, 31 +grid = Grid(shape=(nx, ny), extent=(2.0, 1.0)) + +# Two Function objects for dual-buffer iteration +p = Function(name='p', grid=grid, space_order=2) +pn = Function(name='pn', grid=grid, space_order=2) +``` + +The `space_order=2` ensures we have sufficient ghost points for +second-order spatial derivatives. + +### Deriving the Stencil Symbolically + +We express the Laplace equation using `pn` and let SymPy solve for the +central point. The result is then assigned to `p`: + +```python +# Define the Laplace equation: laplacian(pn) = 0 +# Apply only on interior points via subdomain +eqn = Eq(pn.laplace, 0, subdomain=grid.interior) + +# Solve symbolically for the central point value +stencil = solve(eqn, pn) + +# Create update equation: p gets the new value from neighbors in pn +eq_stencil = Eq(p, stencil) + +print(f"Update stencil:\n{eq_stencil}") +``` + +The output shows the weighted average of neighbors from `pn` being +assigned to `p`: +``` +Eq(p(x, y), 0.5*(h_x**2*pn(x, y - h_y) + h_x**2*pn(x, y + h_y) + + h_y**2*pn(x - h_x, y) + h_y**2*pn(x + h_x, y))/(h_x**2 + h_y**2)) +``` + +### Implementing Boundary Conditions + +For the Dirichlet conditions, we assign fixed values. For the Neumann +conditions (zero normal derivative), we use a numerical trick: copy +the value from the adjacent interior row to the boundary row. + +```python +x, y = grid.dimensions + +# Create a 1D Function for the right boundary profile p = y +bc_right = Function(name='bc_right', shape=(ny,), dimensions=(y,)) +bc_right.data[:] = np.linspace(0, 1, ny) + +# Boundary condition equations +bc = [Eq(p[0, y], 0.0)] # p = 0 at x = 0 +bc += [Eq(p[nx-1, y], bc_right[y])] # p = y at x = 2 +bc += [Eq(p[x, 0], p[x, 1])] # dp/dy = 0 at y = 0 +bc += [Eq(p[x, ny-1], p[x, ny-2])] # dp/dy = 0 at y = 1 + +# Build the operator +op = Operator(expressions=[eq_stencil] + bc) +``` + +The Neumann boundary conditions `p[x, 0] = p[x, 1]` enforce +$\partial p/\partial y = 0$ by making the boundary value equal to +its neighbor, yielding a centered difference of zero. + +### Convergence Criterion: The L1 Norm + +We iterate until the solution stops changing appreciably. The L1 norm +measures the relative change between iterations: +$$ +L_1 = \frac{\sum_{i,j} \left|p_{i,j}^{(k+1)} - p_{i,j}^{(k)}\right|}{\sum_{i,j} \left|p_{i,j}^{(k)}\right| + \epsilon} +$$ {#eq-elliptic-l1norm} + +When $L_1$ drops below a tolerance (e.g., $10^{-4}$), we consider +the solution converged. + +### Solution with Data Copying + +The straightforward approach copies data between buffers each iteration: + +```python +from devito import configuration +configuration['log-level'] = 'ERROR' # Suppress logging + +# Initialize both buffers +p.data[:] = 0.0 +p.data[-1, :] = np.linspace(0, 1, ny) # Right boundary +pn.data[:] = 0.0 +pn.data[-1, :] = np.linspace(0, 1, ny) + +# Convergence loop with data copying +l1norm_target = 1.0e-4 +l1norm = 1.0 + +while l1norm > l1norm_target: + # Copy current solution to pn + pn.data[:] = p.data[:] + + # Apply one Jacobi iteration + op(p=p, pn=pn) + + # Compute L1 norm + l1norm = (np.sum(np.abs(p.data[:] - pn.data[:])) / + (np.sum(np.abs(pn.data[:])) + 1.0e-16)) + +print(f"Converged with L1 norm = {l1norm:.2e}") +``` + +This works but the data copy `pn.data[:] = p.data[:]` is expensive +for large grids. + +### Buffer Swapping Without Data Copy + +A more efficient approach exploits Devito's argument substitution. +Instead of copying data, we swap which `Function` plays each role: + +```python +# Initialize both buffers +p.data[:] = 0.0 +p.data[-1, :] = np.linspace(0, 1, ny) +pn.data[:] = 0.0 +pn.data[-1, :] = np.linspace(0, 1, ny) + +# Convergence loop with buffer swapping +l1norm_target = 1.0e-4 +l1norm = 1.0 +counter = 0 + +while l1norm > l1norm_target: + # Determine buffer roles based on iteration parity + if counter % 2 == 0: + _p, _pn = p, pn + else: + _p, _pn = pn, p + + # Apply operator with swapped arguments + op(p=_p, pn=_pn) + + # Compute L1 norm + l1norm = (np.sum(np.abs(_p.data[:]) - np.abs(_pn.data[:])) / + np.sum(np.abs(_pn.data[:]))) + counter += 1 + +print(f"Converged in {counter} iterations") +``` + +The key line is `op(p=_p, pn=_pn)`. We pass `Function` objects that +alternate roles: on even iterations, `p` gets updated from `pn`; +on odd iterations, `pn` gets updated from `p`. No data is copied; +we simply reinterpret which buffer is "current" vs "previous." + +### Complete Laplace Solver + +```python +from devito import Grid, Function, Eq, solve, Operator, configuration +import numpy as np + +def solve_laplace_2d(nx, ny, extent, l1norm_target=1e-4): + """ + Solve the 2D Laplace equation with: + - p = 0 at x = 0 + - p = y at x = x_max + - dp/dy = 0 at y = 0 and y = y_max + + Parameters + ---------- + nx, ny : int + Number of grid points in x and y directions. + extent : tuple + Domain size (Lx, Ly). + l1norm_target : float + Convergence tolerance for L1 norm. + + Returns + ------- + p : Function + Converged solution field. + iterations : int + Number of iterations to convergence. + """ + configuration['log-level'] = 'ERROR' + + # Create grid and functions + grid = Grid(shape=(nx, ny), extent=extent) + p = Function(name='p', grid=grid, space_order=2) + pn = Function(name='pn', grid=grid, space_order=2) + + # Symbolic equation and stencil + eqn = Eq(pn.laplace, 0, subdomain=grid.interior) + stencil = solve(eqn, pn) + eq_stencil = Eq(p, stencil) + + # Boundary conditions + x, y = grid.dimensions + bc_right = Function(name='bc_right', shape=(ny,), dimensions=(y,)) + bc_right.data[:] = np.linspace(0, extent[1], ny) + + bc = [Eq(p[0, y], 0.0)] + bc += [Eq(p[nx-1, y], bc_right[y])] + bc += [Eq(p[x, 0], p[x, 1])] + bc += [Eq(p[x, ny-1], p[x, ny-2])] + + op = Operator(expressions=[eq_stencil] + bc) + + # Initialize + p.data[:] = 0.0 + p.data[-1, :] = bc_right.data[:] + pn.data[:] = 0.0 + pn.data[-1, :] = bc_right.data[:] + + # Iterate with buffer swapping + l1norm = 1.0 + counter = 0 + + while l1norm > l1norm_target: + if counter % 2 == 0: + _p, _pn = p, pn + else: + _p, _pn = pn, p + + op(p=_p, pn=_pn) + + l1norm = (np.sum(np.abs(_p.data[:]) - np.abs(_pn.data[:])) / + np.sum(np.abs(_pn.data[:]))) + counter += 1 + + # Ensure result is in p (swap if needed) + if counter % 2 == 1: + p.data[:] = pn.data[:] + + return p, counter +``` + +### Visualizing the Solution + +```python +import matplotlib.pyplot as plt +from mpl_toolkits.mplot3d import Axes3D + +p, iterations = solve_laplace_2d(nx=31, ny=31, extent=(2.0, 1.0)) +print(f"Converged in {iterations} iterations") + +# Create coordinate arrays +x = np.linspace(0, 2.0, 31) +y = np.linspace(0, 1.0, 31) +X, Y = np.meshgrid(x, y, indexing='ij') + +fig = plt.figure(figsize=(12, 5)) + +# Surface plot +ax1 = fig.add_subplot(121, projection='3d') +ax1.plot_surface(X, Y, p.data[:], cmap='viridis') +ax1.set_xlabel('x') +ax1.set_ylabel('y') +ax1.set_zlabel('p') +ax1.set_title('Laplace Equation Solution') +ax1.view_init(30, 225) + +# Contour plot +ax2 = fig.add_subplot(122) +c = ax2.contourf(X, Y, p.data[:], levels=20, cmap='viridis') +plt.colorbar(c, ax=ax2) +ax2.set_xlabel('x') +ax2.set_ylabel('y') +ax2.set_title('Contour View') +ax2.set_aspect('equal') +``` + +The solution shows a smooth transition from $p=0$ on the left to $p=y$ +on the right, with level curves that respect the zero-flux condition +at top and bottom. + + +## The Poisson Equation {#sec-elliptic-poisson} + +The Poisson equation adds a source term to the Laplace equation: +$$ +\frac{\partial^2 p}{\partial x^2} + \frac{\partial^2 p}{\partial y^2} = b(x, y) +$$ {#eq-elliptic-poisson-pde} + +This models scenarios with internal sources or sinks, such as heat +generation, electric charges, or fluid injection. + +### Problem Setup + +Consider a domain $[0, 2] \times [0, 1]$ with: + +- $p = 0$ on all boundaries (homogeneous Dirichlet) +- Point sources: $b = +100$ at $(x, y) = (0.5, 0.25)$ and $b = -100$ at $(1.5, 0.75)$ + +The positive source creates a "hill" in the solution; the negative +source creates a "valley." The solution represents the equilibrium +field balancing these sources against the zero boundary conditions. + +### Discretization with Source Term + +The discretized Poisson equation becomes: +$$ +p_{i,j} = \frac{\Delta y^2(p_{i+1,j} + p_{i-1,j}) + \Delta x^2(p_{i,j+1} + p_{i,j-1}) - b_{i,j}\Delta x^2\Delta y^2}{2(\Delta x^2 + \Delta y^2)} +$$ {#eq-elliptic-poisson-discrete} + +The source term $b_{i,j}$ appears in the numerator, scaled by the +product of grid spacings squared. + +### Dual-Buffer Implementation + +Using the same dual-buffer pattern as for Laplace: + +```python +from devito import Grid, Function, Eq, solve, Operator, configuration +import numpy as np + +configuration['log-level'] = 'ERROR' + +# Grid setup +nx, ny = 50, 50 +grid = Grid(shape=(nx, ny), extent=(2.0, 1.0)) + +# Solution buffers +p = Function(name='p', grid=grid, space_order=2) +pd = Function(name='pd', grid=grid, space_order=2) + +# Source term +b = Function(name='b', grid=grid) +b.data[:] = 0.0 +b.data[int(nx/4), int(ny/4)] = 100 # Positive source +b.data[int(3*nx/4), int(3*ny/4)] = -100 # Negative source + +# Poisson equation: laplacian(pd) = b +eq = Eq(pd.laplace, b, subdomain=grid.interior) +stencil = solve(eq, pd) +eq_stencil = Eq(p, stencil) + +# Boundary conditions (p = 0 on all boundaries) +x, y = grid.dimensions +bc = [Eq(p[x, 0], 0.0)] +bc += [Eq(p[x, ny-1], 0.0)] +bc += [Eq(p[0, y], 0.0)] +bc += [Eq(p[nx-1, y], 0.0)] + +op = Operator([eq_stencil] + bc) +``` + +### Fixed Iteration Count + +For the Poisson equation with localized sources, we often use a fixed +number of iterations rather than a convergence criterion: + +```python +# Initialize +p.data[:] = 0.0 +pd.data[:] = 0.0 + +# Fixed number of iterations +nt = 100 + +for i in range(nt): + if i % 2 == 0: + _p, _pd = p, pd + else: + _p, _pd = pd, p + + op(p=_p, pd=_pd) + +# Ensure result is in p +if nt % 2 == 1: + p.data[:] = pd.data[:] +``` + +### Using TimeFunction for Pseudo-Timestepping + +An alternative approach treats the iteration index as a pseudo-time +dimension. This allows Devito to internalize the iteration loop, +improving performance by avoiding Python overhead. + +```python +from devito import TimeFunction + +# Reset grid +grid = Grid(shape=(nx, ny), extent=(2.0, 1.0)) + +# TimeFunction provides automatic buffer management +p = TimeFunction(name='p', grid=grid, space_order=2) +p.data[:] = 0.0 + +# Source term (unchanged) +b = Function(name='b', grid=grid) +b.data[:] = 0.0 +b.data[int(nx/4), int(ny/4)] = 100 +b.data[int(3*nx/4), int(3*ny/4)] = -100 + +# Poisson equation: solve for p, write to p.forward +eq = Eq(p.laplace, b) +stencil = solve(eq, p) +eq_stencil = Eq(p.forward, stencil) + +# Boundary conditions with explicit time index +t = grid.stepping_dim +bc = [Eq(p[t + 1, x, 0], 0.0)] +bc += [Eq(p[t + 1, x, ny-1], 0.0)] +bc += [Eq(p[t + 1, 0, y], 0.0)] +bc += [Eq(p[t + 1, nx-1, y], 0.0)] + +op = Operator([eq_stencil] + bc) +``` + +Note the boundary conditions now include `t + 1` to index the forward +time level, matching `p.forward` in the stencil update. + +### Executing the TimeFunction Approach + +The operator can now run multiple iterations internally: + +```python +# Run 100 pseudo-timesteps in one call +op(time=100) + +# Access result (buffer index depends on iteration count) +result = p.data[0] # or p.data[1] depending on parity +``` + +This approach is faster because the iteration loop runs in compiled +C code rather than Python, with no function call overhead per iteration. + +### Complete Poisson Solver + +```python +from devito import Grid, TimeFunction, Function, Eq, solve, Operator, configuration +import numpy as np + +def solve_poisson_2d(nx, ny, extent, sources, nt=100): + """ + Solve the 2D Poisson equation with point sources. + + Parameters + ---------- + nx, ny : int + Number of grid points. + extent : tuple + Domain size (Lx, Ly). + sources : list of tuples + Each tuple is ((i, j), value) specifying source location and strength. + nt : int + Number of iterations. + + Returns + ------- + p : ndarray + Solution field. + """ + configuration['log-level'] = 'ERROR' + + grid = Grid(shape=(nx, ny), extent=extent) + p = TimeFunction(name='p', grid=grid, space_order=2) + p.data[:] = 0.0 + + # Set up source term + b = Function(name='b', grid=grid) + b.data[:] = 0.0 + for (i, j), value in sources: + b.data[i, j] = value + + # Poisson equation + eq = Eq(p.laplace, b) + stencil = solve(eq, p) + eq_stencil = Eq(p.forward, stencil) + + # Boundary conditions + x, y = grid.dimensions + t = grid.stepping_dim + bc = [Eq(p[t + 1, x, 0], 0.0)] + bc += [Eq(p[t + 1, x, ny-1], 0.0)] + bc += [Eq(p[t + 1, 0, y], 0.0)] + bc += [Eq(p[t + 1, nx-1, y], 0.0)] + + op = Operator([eq_stencil] + bc) + op(time=nt) + + return p.data[0].copy() +``` + +### Visualizing the Poisson Solution + +```python +import matplotlib.pyplot as plt +from mpl_toolkits.mplot3d import Axes3D + +# Solve with positive and negative sources +sources = [ + ((12, 12), 100), # Positive source at ~(0.5, 0.25) + ((37, 37), -100), # Negative source at ~(1.5, 0.75) +] +result = solve_poisson_2d(nx=50, ny=50, extent=(2.0, 1.0), + sources=sources, nt=100) + +# Coordinate arrays +x = np.linspace(0, 2.0, 50) +y = np.linspace(0, 1.0, 50) +X, Y = np.meshgrid(x, y, indexing='ij') + +fig = plt.figure(figsize=(12, 5)) + +ax1 = fig.add_subplot(121, projection='3d') +ax1.plot_surface(X, Y, result, cmap='coolwarm') +ax1.set_xlabel('x') +ax1.set_ylabel('y') +ax1.set_zlabel('p') +ax1.set_title('Poisson Equation with Point Sources') +ax1.view_init(30, 225) + +ax2 = fig.add_subplot(122) +c = ax2.contourf(X, Y, result, levels=20, cmap='coolwarm') +plt.colorbar(c, ax=ax2) +ax2.plot(0.5, 0.25, 'k+', markersize=15, markeredgewidth=2) # Source + +ax2.plot(1.5, 0.75, 'ko', markersize=10, fillstyle='none') # Source - +ax2.set_xlabel('x') +ax2.set_ylabel('y') +ax2.set_title('Contour View with Source Locations') +ax2.set_aspect('equal') +``` + +The solution shows a peak at the positive source and a trough at +the negative source, with the field decaying to zero at the boundaries. + + +## Iterative Solver Analysis {#sec-elliptic-analysis} + +Having implemented Jacobi iteration for elliptic equations, we now +examine the convergence properties and performance considerations. + +### Convergence Rate of Jacobi Iteration + +The Jacobi method converges, but slowly. The error after $k$ iterations +satisfies: +$$ +\|e^{(k)}\| \leq \rho^k \|e^{(0)}\| +$$ + +where $\rho$ is the spectral radius of the iteration matrix. For Jacobi +on a square grid of size $N \times N$ with Dirichlet conditions: +$$ +\rho \approx 1 - \frac{\pi^2}{N^2} +$$ + +This means the number of iterations to reduce the error by a factor +$\epsilon$ is approximately: +$$ +k \approx \frac{\ln(1/\epsilon)}{\ln(1/\rho)} \approx \frac{N^2}{\pi^2} \ln(1/\epsilon) +$$ {#eq-elliptic-jacobi-iterations} + +For $N = 100$ and $\epsilon = 10^{-6}$, we need roughly $14{,}000$ +iterations. This quadratic scaling with grid size makes Jacobi +impractical for fine grids. + +### Monitoring Convergence + +The L1 norm we use measures relative change: +$$ +L_1^{(k)} = \frac{\sum_{i,j} |p_{i,j}^{(k+1)}| - |p_{i,j}^{(k)}|}{\sum_{i,j} |p_{i,j}^{(k)}|} +$$ + +A more rigorous metric is the residual norm: +$$ +r^{(k)} = \|\nabla^2 p^{(k)} - f\| +$$ + +which measures how well the current iterate satisfies the PDE. + +```python +def compute_residual(p, b, dx, dy): + """Compute the residual of the Poisson equation.""" + # Interior Laplacian using numpy + laplacian = ( + (p[2:, 1:-1] - 2*p[1:-1, 1:-1] + p[:-2, 1:-1]) / dx**2 + + (p[1:-1, 2:] - 2*p[1:-1, 1:-1] + p[1:-1, :-2]) / dy**2 + ) + residual = laplacian - b[1:-1, 1:-1] + return np.sqrt(np.sum(residual**2)) +``` + +### Convergence History + +Tracking the L1 norm over iterations reveals the convergence behavior: + +```python +from devito import Grid, Function, Eq, solve, Operator, configuration +import numpy as np +import matplotlib.pyplot as plt + +configuration['log-level'] = 'ERROR' + +def solve_laplace_with_history(nx, ny, max_iter=5000, l1norm_target=1e-6): + """Solve Laplace equation and record convergence history.""" + grid = Grid(shape=(nx, ny), extent=(2.0, 1.0)) + p = Function(name='p', grid=grid, space_order=2) + pn = Function(name='pn', grid=grid, space_order=2) + + eqn = Eq(pn.laplace, 0, subdomain=grid.interior) + stencil = solve(eqn, pn) + eq_stencil = Eq(p, stencil) + + x, y = grid.dimensions + bc_right = Function(name='bc_right', shape=(ny,), dimensions=(y,)) + bc_right.data[:] = np.linspace(0, 1, ny) + + bc = [Eq(p[0, y], 0.0)] + bc += [Eq(p[nx-1, y], bc_right[y])] + bc += [Eq(p[x, 0], p[x, 1])] + bc += [Eq(p[x, ny-1], p[x, ny-2])] + + op = Operator(expressions=[eq_stencil] + bc) + + p.data[:] = 0.0 + p.data[-1, :] = bc_right.data[:] + pn.data[:] = 0.0 + pn.data[-1, :] = bc_right.data[:] + + l1_history = [] + l1norm = 1.0 + counter = 0 + + while l1norm > l1norm_target and counter < max_iter: + if counter % 2 == 0: + _p, _pn = p, pn + else: + _p, _pn = pn, p + + op(p=_p, pn=_pn) + + l1norm = (np.sum(np.abs(_p.data[:]) - np.abs(_pn.data[:])) / + np.sum(np.abs(_pn.data[:]))) + l1_history.append(l1norm) + counter += 1 + + return l1_history + +# Compare convergence for different grid sizes +plt.figure(figsize=(10, 6)) +for n in [16, 32, 64]: + history = solve_laplace_with_history(n, n, max_iter=3000, l1norm_target=1e-8) + plt.semilogy(history, label=f'{n}x{n} grid') + +plt.xlabel('Iteration') +plt.ylabel('L1 Norm') +plt.title('Jacobi Iteration Convergence') +plt.legend() +plt.grid(True) +``` + +The plot shows that convergence slows dramatically as the grid is refined, +consistent with the $O(N^2)$ iteration count. + +### Dual-Buffer vs TimeFunction Performance + +The two implementation approaches have different performance characteristics: + +**Dual-buffer with Python loop**: + +- Full control over convergence criterion +- Can check convergence every iteration +- Python loop overhead per iteration +- Best for moderate iteration counts with tight convergence tolerance + +**TimeFunction with internal loop**: + +- Iteration loop in compiled code +- Much faster per iteration +- Can only check convergence after all iterations +- Best for fixed iteration counts or when speed matters most + +```python +import time + +# Benchmark dual-buffer approach +start = time.time() +p1, iters1 = solve_laplace_2d(nx=64, ny=64, extent=(2.0, 1.0), l1norm_target=1e-5) +time_dual = time.time() - start +print(f"Dual-buffer: {iters1} iterations in {time_dual:.3f} s") + +# For TimeFunction comparison, we would run with same iteration count +# and compare wall-clock time +``` + +### Improving Convergence: Gauss-Seidel and SOR + +Jacobi iteration updates all points simultaneously using values from +the previous iteration. The *Gauss-Seidel* method uses updated values +as soon as they are available: + +$$ +p_{i,j}^{(k+1)} = \frac{1}{4}\left(p_{i+1,j}^{(k)} + p_{i-1,j}^{(k+1)} + +p_{i,j+1}^{(k)} + p_{i,j-1}^{(k+1)}\right) +$$ + +This roughly halves the number of iterations but introduces data +dependencies that complicate parallelization. + +*Successive Over-Relaxation* (SOR) further accelerates convergence: +$$ +p_{i,j}^{(k+1)} = (1-\omega) p_{i,j}^{(k)} + \omega \cdot (\text{Gauss-Seidel update}) +$$ + +The optimal relaxation parameter is: +$$ +\omega_{\text{opt}} = \frac{2}{1 + \sin(\pi/N)} +$$ {#eq-elliptic-sor-omega} + +With optimal $\omega$, SOR requires $O(N)$ iterations instead of $O(N^2)$. +However, SOR is inherently sequential and harder to implement efficiently +in Devito's parallel framework. + +### Multigrid Methods + +For production use, *multigrid methods* achieve $O(N)$ complexity by +solving on a hierarchy of grids. The key insight is that Jacobi +efficiently reduces high-frequency error components but struggles +with low-frequency modes. Multigrid uses coarse grids to efficiently +handle low frequencies, then interpolates corrections back to fine grids. + +Multigrid implementation goes beyond basic Devito patterns but is +available in specialized libraries that can interface with Devito-generated +code. + +### Summary: Choosing an Approach + +| Criterion | Dual-Buffer | TimeFunction | +|-----------|-------------|--------------| +| Convergence control | Fine-grained | Per-batch | +| Python overhead | Per iteration | Once per call | +| Code complexity | Moderate | Simpler operator | +| Flexibility | More flexible | Faster execution | +| Best use case | Adaptive convergence | Fixed iterations | + +For problems where the number of iterations is predictable, the +`TimeFunction` approach is faster. For problems requiring tight +convergence tolerance or adaptive stopping criteria, the dual-buffer +approach offers more control. + +### Key Takeaways + +1. **Steady-state problems require iteration**, not time-stepping. + Devito supports both dual-buffer `Function` patterns and + pseudo-timestepping with `TimeFunction`. + +2. **Jacobi iteration converges slowly** with $O(N^2)$ iterations for + an $N \times N$ grid. For fine grids, consider Gauss-Seidel, + SOR, or multigrid methods. + +3. **Buffer swapping via argument substitution** avoids expensive + data copies: `op(p=_p, pn=_pn)` with alternating assignments. + +4. **The L1 norm** provides a practical convergence metric, but the + residual norm more directly measures how well the PDE is satisfied. + +5. **Boundary conditions for Neumann problems** use the "copy trick": + setting boundary values equal to adjacent interior values enforces + zero normal derivative. + +6. **Source terms in Poisson equation** are handled by a separate + `Function` object `b` that enters the symbolic equation. + + +## Exercises {#sec-elliptic-exercises} + +### Exercise 1: Grid Resolution Study + +Solve the Laplace problem from @sec-elliptic-laplace with grid sizes +$N = 16, 32, 64, 128$. For each: + +a) Record the number of iterations to achieve $L_1 < 10^{-5}$. +b) Plot iterations vs $N$ and verify the $O(N^2)$ scaling. +c) Compare the solution profiles along $y = 0.5$. + +### Exercise 2: Multiple Sources + +Modify the Poisson solver to handle four sources: + +- $b = +50$ at $(0.25, 0.25)$ and $(0.75, 0.75)$ +- $b = -50$ at $(0.25, 0.75)$ and $(0.75, 0.25)$ + +on the unit square with $p = 0$ on all boundaries. + +Visualize the solution and discuss the symmetry. + +### Exercise 3: Non-Homogeneous Dirichlet Conditions + +Solve the Laplace equation on $[0, 1]^2$ with: + +- $p = \sin(\pi y)$ at $x = 0$ +- $p = 0$ at $x = 1$, $y = 0$, and $y = 1$ + +Create a 1D `Function` for the $x = 0$ boundary condition, similar +to the `bc_right` pattern in @sec-elliptic-laplace. + +### Exercise 4: Convergence Comparison + +Implement both the dual-buffer approach with L1 convergence criterion +and the `TimeFunction` approach with fixed iterations. For a $64 \times 64$ +grid: + +a) Determine how many iterations the dual-buffer approach needs for + $L_1 < 10^{-5}$. +b) Run the `TimeFunction` approach for the same number of iterations. +c) Compare wall-clock times. Which is faster and by how much? + +### Exercise 5: Residual Monitoring + +Modify the convergence loop to compute both the L1 norm and the residual +$\|\nabla^2 p - f\|_2$ at each iteration. Plot both metrics vs iteration +number. Do they decrease at the same rate? + +### Exercise 6: Variable Coefficients + +The equation $\nabla \cdot (k(x,y) \nabla p) = 0$ with spatially varying +conductivity $k(x,y)$ arises in heterogeneous media. Consider +$k(x,y) = 1 + 0.5\sin(\pi x)\sin(\pi y)$ on the unit square. + +The discrete equation becomes: +$$ +\frac{1}{\Delta x}\left[k_{i+1/2,j}(p_{i+1,j} - p_{i,j}) - k_{i-1/2,j}(p_{i,j} - p_{i-1,j})\right] + \cdots = 0 +$$ + +Create a `Function` for $k$ and implement the variable-coefficient +Laplacian using explicit indexing. Solve with $p = 0$ at $x = 0$ and +$p = 1$ at $x = 1$, with zero-flux conditions at $y = 0$ and $y = 1$. diff --git a/chapters/elliptic/index.qmd b/chapters/elliptic/index.qmd new file mode 100644 index 00000000..c129e08c --- /dev/null +++ b/chapters/elliptic/index.qmd @@ -0,0 +1,3 @@ +# Elliptic PDEs {#sec-ch-elliptic} + +{{< include elliptic.qmd >}} diff --git a/chapters/nonlin/burgers.qmd b/chapters/nonlin/burgers.qmd new file mode 100644 index 00000000..79ee90d7 --- /dev/null +++ b/chapters/nonlin/burgers.qmd @@ -0,0 +1,262 @@ +## 2D Burgers Equation with Devito {#sec-burgers-devito} + +The Burgers equation is a fundamental nonlinear PDE that combines +advection and diffusion. It serves as a prototype for understanding +shock formation, numerical stability in nonlinear problems, and +provides insight into the Navier-Stokes equations. + +### The Coupled Burgers Equations + +The 2D coupled Burgers equations describe a simplified model of +viscous fluid flow: + +$$ +\frac{\partial u}{\partial t} + u \frac{\partial u}{\partial x} + v \frac{\partial u}{\partial y} = \nu \left(\frac{\partial^2 u}{\partial x^2} + \frac{\partial^2 u}{\partial y^2}\right) +$$ {#eq-burgers-u} + +$$ +\frac{\partial v}{\partial t} + u \frac{\partial v}{\partial x} + v \frac{\partial v}{\partial y} = \nu \left(\frac{\partial^2 v}{\partial x^2} + \frac{\partial^2 v}{\partial y^2}\right) +$$ {#eq-burgers-v} + +Here $u$ and $v$ are velocity components, and $\nu$ is the viscosity +(kinematic). The left-hand side represents nonlinear advection +(transport of the field by itself), while the right-hand side +represents viscous diffusion. + +### Physical Interpretation + +The Burgers equation exhibits several important physical phenomena: + +| Feature | Description | +|---------|-------------| +| **Advection** | $u \partial u/\partial x$ causes wave steepening | +| **Diffusion** | $\nu \nabla^2 u$ smooths gradients | +| **Shock formation** | When advection dominates, discontinuities develop | +| **Balance** | Viscosity prevents infinite gradients | + +The ratio of advection to diffusion is characterized by the Reynolds +number: $\text{Re} = UL/\nu$, where $U$ is a characteristic velocity +and $L$ is a length scale. High Reynolds numbers (low viscosity) lead +to steep gradients or shocks. + +### Discretization Strategy + +The Burgers equation requires careful treatment of the advection +terms. Using centered differences for $u \partial u/\partial x$ leads +to instability. Instead, we use **upwind differencing** for advection: + +**Advection terms (first-order backward):** +$$ +u \frac{\partial u}{\partial x} \approx u_{i,j}^n \frac{u_{i,j}^n - u_{i-1,j}^n}{\Delta x} +$$ + +**Diffusion terms (second-order centered):** +$$ +\frac{\partial^2 u}{\partial x^2} \approx \frac{u_{i+1,j}^n - 2u_{i,j}^n + u_{i-1,j}^n}{\Delta x^2} +$$ + +This **mixed discretization** uses: + +- First-order backward differences (`fd_order=1`, `side=left`) for advection +- Second-order centered differences (`.laplace`) for diffusion + +### Implementation with first_derivative + +Devito's `first_derivative` function allows explicit control over +the finite difference order and stencil direction: + +{{< include snippets/burgers_first_derivative.qmd >}} + +The key parameters are: + +| Parameter | Purpose | Example | +|-----------|---------|---------| +| `dim` | Differentiation dimension | `x` or `y` | +| `side` | Stencil direction | `left` (backward) | +| `fd_order` | Finite difference order | `1` for first-order | + +### Building the Burgers Equations and Boundary Conditions + +With the explicit derivatives defined, we write the equations and +boundary conditions. The `subdomain=grid.interior` ensures the stencil +is only applied away from boundaries, where we set Dirichlet conditions +separately: + +{{< include snippets/burgers_equations_bc.qmd >}} + +### Alternative: VectorTimeFunction Approach + +For coupled vector equations like Burgers, Devito's `VectorTimeFunction` +provides a more compact notation. The velocity field is represented as +a single vector $\mathbf{U} = (u, v)$: + +$$ +\frac{\partial \mathbf{U}}{\partial t} + (\nabla \mathbf{U}) \cdot \mathbf{U} = \nu \nabla^2 \mathbf{U} +$$ + +```python +from devito import VectorTimeFunction, grad + +# Create vector velocity field +U = VectorTimeFunction(name='U', grid=grid, space_order=2) + +# U[0] is u-component, U[1] is v-component +# Initialize components +U[0].data[0, :, :] = u_initial +U[1].data[0, :, :] = v_initial + +# Vector form of Burgers equation +# U_forward = U - dt * (grad(U)*U - nu * laplace(U)) +s = grid.time_dim.spacing # dt symbol +update_U = Eq(U.forward, U - s * (grad(U)*U - nu*U.laplace), + subdomain=grid.interior) + +# The grad(U)*U term represents advection: +# [u*u_x + v*u_y] +# [u*v_x + v*v_y] +``` + +This approach is mathematically elegant and maps directly to the +vector notation used in fluid dynamics. + +### Using the Solver + +The `src.nonlin.burgers_devito` module provides ready-to-use solvers: + +```python +from src.nonlin.burgers_devito import ( + solve_burgers_2d, + solve_burgers_2d_vector, + init_hat, +) + +# Solve with scalar TimeFunction approach +result = solve_burgers_2d( + Lx=2.0, Ly=2.0, # Domain size + nu=0.01, # Viscosity + Nx=41, Ny=41, # Grid points + T=0.5, # Final time + sigma=0.0009, # Stability parameter +) + +print(f"Final time: {result.t}") +print(f"u range: [{result.u.min():.3f}, {result.u.max():.3f}]") +print(f"v range: [{result.v.min():.3f}, {result.v.max():.3f}]") +``` + +### Stability Considerations + +The explicit scheme requires satisfying both advection and diffusion +stability conditions: + +**CFL condition for advection:** +$$ +C = \frac{|u|_{\max} \Delta t}{\Delta x} \leq 1 +$$ + +**Fourier condition for diffusion (2D):** +$$ +F = \frac{\nu \Delta t}{\Delta x^2} \leq 0.25 +$$ + +The solver uses: +$$ +\Delta t = \sigma \frac{\Delta x \cdot \Delta y}{\nu} +$$ +where $\sigma$ is a small stability parameter (default 0.0009). + +### Visualizing Shock Formation + +The evolution shows how the initially sharp "hat" profile evolves: + +```python +import matplotlib.pyplot as plt +from src.nonlin.burgers_devito import solve_burgers_2d + +# Low viscosity case - steeper gradients +result = solve_burgers_2d( + Lx=2.0, Ly=2.0, + nu=0.01, + Nx=41, Ny=41, + T=0.5, + save_history=True, + save_every=100, +) + +fig, axes = plt.subplots(1, len(result.t_history), figsize=(15, 4)) +for i, (t, u) in enumerate(zip(result.t_history, result.u_history)): + axes[i].contourf(result.x, result.y, u.T, levels=20) + axes[i].set_title(f't = {t:.3f}') + axes[i].set_xlabel('x') + axes[i].set_ylabel('y') +plt.tight_layout() +``` + +### Effect of Viscosity + +Comparing low and high viscosity reveals the balance between +advection and diffusion: + +```python +from src.nonlin.burgers_devito import solve_burgers_2d +import matplotlib.pyplot as plt + +fig, axes = plt.subplots(1, 2, figsize=(12, 5)) + +for ax, nu, title in zip(axes, [0.1, 0.01], ['High viscosity', 'Low viscosity']): + result = solve_burgers_2d( + Lx=2.0, Ly=2.0, + nu=nu, + Nx=41, Ny=41, + T=0.5, + ) + c = ax.contourf(result.x, result.y, result.u.T, levels=20) + ax.set_title(f'{title} (nu={nu})') + ax.set_xlabel('x') + ax.set_ylabel('y') + plt.colorbar(c, ax=ax) +``` + +With high viscosity ($\nu = 0.1$), diffusion dominates and the +solution smooths rapidly. With low viscosity ($\nu = 0.01$), +advection dominates, the "hat" moves and steepens, and gradients +remain sharper. + +### Comparison: Scalar vs Vector Implementation + +Both implementations solve the same equations but offer different +trade-offs: + +| Aspect | Scalar (`solve_burgers_2d`) | Vector (`solve_burgers_2d_vector`) | +|--------|---------------------------|-----------------------------------| +| **Derivatives** | Explicit `first_derivative()` | Implicit via `grad(U)*U` | +| **Control** | Full control over stencils | Uses default differentiation | +| **Code length** | More verbose | More compact | +| **Debugging** | Easier to inspect | More opaque | + +For production use where precise control over numerical schemes is +needed, the scalar approach with explicit `first_derivative()` is +preferred. The vector approach is useful for rapid prototyping and +when the default schemes are acceptable. + +### Summary + +Key points for solving Burgers equation with Devito: + +1. **Mixed discretization**: Use first-order upwind for advection, + second-order centered for diffusion +2. **first_derivative()**: Enables explicit control of stencil order + and direction via `fd_order` and `side` parameters +3. **VectorTimeFunction**: Alternative approach using `grad(U)*U` + for more compact code +4. **Stability**: Must satisfy both CFL and Fourier conditions +5. **Viscosity**: Controls the balance between sharp gradients + (shocks) and smooth solutions + +The module `src.nonlin.burgers_devito` provides: + +- `solve_burgers_2d`: Scalar implementation with explicit derivatives +- `solve_burgers_2d_vector`: Vector implementation using `VectorTimeFunction` +- `init_hat`: Classic hat-function initial condition +- `sinusoidal_initial_condition`: Smooth sinusoidal initial data +- `gaussian_initial_condition`: Gaussian pulse initial data diff --git a/chapters/nonlin/exer-nonlin/fu_fem_int.py b/chapters/nonlin/exer-nonlin/fu_fem_int.py deleted file mode 100644 index 5bd26569..00000000 --- a/chapters/nonlin/exer-nonlin/fu_fem_int.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -Explore algebraic forms arising from the integral f(u)*v in the finite -element method. - - | phi_im1 phi_i phi_ip1 - +1 /\\ /\\ /\ - | / \\ / \\ / \ - | / \\ / \\ / \ - | / \\ / \\ / \ - | / \\ / \\ / \ - | / \\ / \\ / \ - | / \\ / \\ / \ - | / \\ / \\ / \ - | / / \\/ \ - | / / \\ /\\ \ - | / / \\ / \\ \ - | / / \\ / \\ \ - | / / \\ / \\ \ - | / / \\ / \\ \ - | / / \\ / \\ \ - | / / \\ / \\ \ - | / / \\ / \\ \ --------------------------------------------------------------------------- - i-1 i i+1 - - cell L cell R -""" - -import sys - -from sympy import * - -x, u_im1, u_i, u_ip1, u, h, x_i = symbols("x u_im1 u_i u_ip1 u h x_i") - -# Left cell: [x_im1, x_i] -# Right cell: [x_i, x_ip1] -x_im1 = x_i - h -x_ip1 = x_i + h - -phi = { - "L": # Left cell - {"im1": 1 - (x - x_im1) / h, "i": (x - x_im1) / h}, - "R": # Right cell - {"i": 1 - (x - x_i) / h, "ip1": (x - x_i) / h}, -} - -u = { - "L": u_im1 * phi["L"]["im1"] + u_i * phi["L"]["i"], - "R": u_i * phi["R"]["i"] + u_ip1 * phi["R"]["ip1"], -} - -f = lambda u: eval(sys.argv[1]) - -integral_L = integrate(f(u["L"]) * phi["L"]["i"], (x, x_im1, x_i)) -integral_R = integrate(f(u["R"]) * phi["R"]["i"], (x, x_i, x_ip1)) -expr_i = simplify(expand(integral_L + integral_R)) -print(expr_i) -latex_code = latex(expr_i, mode="plain") -# Replace u_im1 sympy symbol name by latex symbol u_{i-1} -latex_code = latex_code.replace("im1", "{i-1}") -# Replace u_ip1 sympy symbol name by latex symbol u_{i+1} -latex_code = latex_code.replace("ip1", "{i+1}") -print(latex_code) -# Escape (quote) latex_code so it can be sent as HTML text -import cgi - -html_code = cgi.escape(latex_code) -print(html_code) -# Make a file with HTML code for displaying the LaTeX formula -f = open("tmp.html", "w") -# Include an image that can be clicked on to yield a new -# page with an interactive editor and display area where the -# formula can be further edited -text = """ - - - - """.format(**vars()) -f.write(text) -f.close() -# load tmp.html into a browser diff --git a/chapters/nonlin/exer-nonlin/logistic_p.py b/chapters/nonlin/exer-nonlin/logistic_p.py deleted file mode 100644 index 869bed0d..00000000 --- a/chapters/nonlin/exer-nonlin/logistic_p.py +++ /dev/null @@ -1,180 +0,0 @@ -import numpy as np - - -def FE_logistic(p, u0, dt, Nt): - u = np.zeros(Nt + 1) - u[0] = u0 - for n in range(Nt): - u[n + 1] = u[n] + dt * (1 - u[n]) ** p * u[n] - return u - - -def BE_logistic(p, u0, dt, Nt, choice="Picard", eps_r=1e-3, omega=1, max_iter=1000): - # u[n] = u[n-1] + dt*(1-u[n])**p*u[n] - # -dt*(1-u[n])**p*u[n] + u[n] = u[n-1] - if choice == "Picard1": - choice = "Picard" - max_iter = 1 - - u = np.zeros(Nt + 1) - iterations = [] - u[0] = u0 - for n in range(1, Nt + 1): - c = -u[n - 1] - if choice == "Picard": - - def F(u): - return -dt * (1 - u) ** p * u + u + c - - u_ = u[n - 1] - k = 0 - while abs(F(u_)) > eps_r and k < max_iter: - # u*(1-dt*(1-u_)**p) + c = 0 - u_ = omega * (-c / (1 - dt * (1 - u_) ** p)) + (1 - omega) * u_ - k += 1 - u[n] = u_ - iterations.append(k) - - elif choice == "Newton": - - def F(u): - return -dt * (1 - u) ** p * u + u + c - - def dF(u): - return dt * p * (1 - u) ** (p - 1) * u - dt * (1 - u) ** p + 1 - - u_ = u[n - 1] - k = 0 - while abs(F(u_)) > eps_r and k < max_iter: - u_ = u_ - F(u_) / dF(u_) - k += 1 - u[n] = u_ - iterations.append(k) - return u, iterations - - -def CN_logistic(p, u0, dt, Nt): - # u[n+1] = u[n] + dt*(1-u[n])**p*u[n+1] - # (1 - dt*(1-u[n])**p)*u[n+1] = u[n] - u = np.zeros(Nt + 1) - u[0] = u0 - for n in range(0, Nt): - u[n + 1] = u[n] / (1 - dt * (1 - u[n]) ** p) - return u - - -def test_asymptotic_value(): - T = 100 - dt = 0.1 - Nt = int(round(T / float(dt))) - u0 = 0.1 - p = 1.8 - - u_CN = CN_logistic(p, u0, dt, Nt) - u_BE_Picard, iter_Picard = BE_logistic( - p, u0, dt, Nt, choice="Picard", eps_r=1e-5, omega=1, max_iter=1000 - ) - u_BE_Newton, iter_Newton = BE_logistic( - p, u0, dt, Nt, choice="Newton", eps_r=1e-5, omega=1, max_iter=1000 - ) - u_FE = FE_logistic(p, u0, dt, Nt) - - for arr in u_CN, u_BE_Picard, u_BE_Newton, u_FE: - expected = 1 - computed = arr[-1] - tol = 0.01 - msg = f"expected={expected}, computed={computed}" - print(msg) - assert abs(expected - computed) < tol - - -import matplotlib.pyplot as plt - - -def demo(): - T = 12 - p = 1.2 - try: - dt = float(sys.argv[1]) - eps_r = float(sys.argv[2]) - omega = float(sys.argv[3]) - except: - dt = 0.8 - eps_r = 1e-3 - omega = 1 - N = int(round(T / float(dt))) - - u_FE = FE_logistic(p, 0.1, dt, N) - u_BE31, iter_BE31 = BE_logistic(p, 0.1, dt, N, "Picard1", eps_r, omega) - u_BE3, iter_BE3 = BE_logistic(p, 0.1, dt, N, "Picard", eps_r, omega) - u_BE4, iter_BE4 = BE_logistic(p, 0.1, dt, N, "Newton", eps_r, omega) - u_CN = CN_logistic(p, 0.1, dt, N) - - print(f"Picard mean no of iterations (dt={dt:g}):", int(round(np.mean(iter_BE3)))) - print(f"Newton mean no of iterations (dt={dt:g}):", int(round(np.mean(iter_BE4)))) - - t = np.linspace(0, dt * N, N + 1) - plt.figure() - plt.plot(t, u_FE, label="FE") - plt.plot(t, u_BE3, label="BE Picard") - plt.plot(t, u_BE31, label="BE Picard1") - plt.plot(t, u_BE4, label="BE Newton") - plt.plot(t, u_CN, label="CN gm") - plt.legend(loc="lower right") - plt.title(f"dt={dt:g}, eps={eps_r:.0E}") - plt.xlabel("t") - plt.ylabel("u") - filestem = "logistic_N%d_eps%03d" % (N, np.log10(eps_r)) - plt.savefig(filestem + "_u.png") - plt.savefig(filestem + "_u.pdf") - - plt.figure() - plt.plot(range(1, len(iter_BE3) + 1), iter_BE3, "r-o", label="Picard") - plt.plot(range(1, len(iter_BE4) + 1), iter_BE4, "b-o", label="Newton") - plt.legend() - plt.title(f"dt={dt:g}, eps={eps_r:.0E}") - plt.axis([1, N + 1, 0, max(iter_BE3 + iter_BE4) + 1]) - plt.xlabel("Time level") - plt.ylabel("No of iterations") - plt.savefig(filestem + "_iter.png") - plt.savefig(filestem + "_iter.pdf") - input() - - -def test_solvers(): - p = 2.5 - T = 5000 - dt = 0.5 - eps_r = 1e-6 - omega_values = [1] - tol = 0.01 - N = int(round(T / float(dt))) - - for omega in omega_values: - u_FE = FE_logistic(p, 0.1, dt, N) - u_BE31, iter_BE31 = BE_logistic(p, 0.1, dt, N, "Picard1", eps_r, omega) - u_BE3, iter_BE3 = BE_logistic(p, 0.1, dt, N, "Picard", eps_r, omega) - u_BE4, iter_BE4 = BE_logistic(p, 0.1, dt, N, "Newton", eps_r, omega) - u_CN = CN_logistic(p, 0.1, dt, N) - - print(u_FE[-1], u_BE31[-1], u_BE3[-1], u_CN[-1]) - for u_x in u_FE, u_BE31, u_BE3, u_CN: - print(u_x[-1]) - assert abs(u_x[-1] - 1) < tol, f"u={u_x[-1]:.16f}" - - """ - t = np.linspace(0, dt*N, N+1) - plot(t, u_FE, t, u_BE3, t, u_BE31, t, u_BE4, t, u_CN, - legend=['FE', 'BE Picard', 'BE Picard1', 'BE Newton', 'CN gm'], - title='dt=%g, eps=%.0E' % (dt, eps_r), xlabel='t', ylabel='u', - legend_loc='lower right') - filestem = 'tmp_N%d_eps%03d' % (N, log10(eps_r)) - savefig(filestem + '_u.png') - savefig(filestem + '_u.pdf') - """ - - -if __name__ == "__main__": - # demo() - # test_solvers() - test_asymptotic_value() diff --git a/chapters/nonlin/exer-nonlin/product_arith_mean_sympy.py b/chapters/nonlin/exer-nonlin/product_arith_mean_sympy.py deleted file mode 100644 index ab16d66b..00000000 --- a/chapters/nonlin/exer-nonlin/product_arith_mean_sympy.py +++ /dev/null @@ -1,34 +0,0 @@ -from sympy import * - -t, dt = symbols("t dt") -P, Q = symbols("P Q", cls=Function) - -# Target expression P(t_{n+1/2})*Q(t_{n+1/2}) -# Simpler: P(0)*Q(0) -# Arithmetic means of each factor: -# 1/4*(P(-dt/2) + P(dt/2))*(Q(-dt/2) + Q(dt/2)) -# Arithmetic mean of the product: -# 1/2*(P(-dt/2)*Q(-dt/2) + P(dt/2)*Q(dt/2)) -# Let's Taylor expand to compare - -target = P(0) * Q(0) -num_terms = 6 -P_p = P(t).series(t, 0, num_terms).subs(t, dt / 2) -print(P_p) -P_m = P(t).series(t, 0, num_terms).subs(t, -dt / 2) -print(P_m) -Q_p = Q(t).series(t, 0, num_terms).subs(t, dt / 2) -print(Q_p) -Q_m = Q(t).series(t, 0, num_terms).subs(t, -dt / 2) -print(Q_m) - -product_mean = Rational(1, 2) * (P_m * Q_m + P_p * Q_p) -product_mean = simplify(expand(product_mean)) -product_mean_error = product_mean - target - -factor_mean = Rational(1, 2) * (P_m + P_p) * Rational(1, 2) * (Q_m + Q_p) -factor_mean = simplify(expand(factor_mean)) -factor_mean_error = factor_mean - target - -print("product_mean_error:", product_mean_error) -print("factor_mean_error:", factor_mean_error) diff --git a/chapters/nonlin/index.qmd b/chapters/nonlin/index.qmd index 6687e1b4..a08909ff 100644 --- a/chapters/nonlin/index.qmd +++ b/chapters/nonlin/index.qmd @@ -6,6 +6,8 @@ {{< include nonlin1D_devito.qmd >}} +{{< include burgers.qmd >}} + {{< include nonlin_pde_gen.qmd >}} {{< include nonlin_split.qmd >}} diff --git a/chapters/nonlin/nonlin_ode.qmd b/chapters/nonlin/nonlin_ode.qmd index caaf27da..f48fe6af 100644 --- a/chapters/nonlin/nonlin_ode.qmd +++ b/chapters/nonlin/nonlin_ode.qmd @@ -561,111 +561,7 @@ Below is an extract of the file showing how the Picard and Newton methods are implemented for a Backward Euler discretization of the logistic equation. -def BE_logistic(u0, dt, Nt, choice="Picard", eps_r=1e-3, omega=1, max_iter=1000): - if choice == "Picard1": - choice = "Picard" - max_iter = 1 - - u = np.zeros(Nt + 1) - iterations = [] - u[0] = u0 - for n in range(1, Nt + 1): - a = dt - b = 1 - dt - c = -u[n - 1] - if choice in ("r1", "r2"): - r1, r2 = quadratic_roots(a, b, c) - u[n] = r1 if choice == "r1" else r2 - iterations.append(0) - - elif choice == "Picard": - - def F(u): - return a * u**2 + b * u + c - - u_ = u[n - 1] - k = 0 - while abs(F(u_)) > eps_r and k < max_iter: - u_ = omega * (-c / (a * u_ + b)) + (1 - omega) * u_ - k += 1 - u[n] = u_ - iterations.append(k) - - elif choice == "Newton": - - def F(u): - return a * u**2 + b * u + c - - def dF(u): - return 2 * a * u + b - - u_ = u[n - 1] - k = 0 - while abs(F(u_)) > eps_r and k < max_iter: - u_ = u_ - F(u_) / dF(u_) - k += 1 - u[n] = u_ - iterations.append(k) - return u, iterations -``` - -```python -def BE_logistic(u0, dt, Nt, choice='Picard', - eps_r=1E-3, omega=1, max_iter=1000): - if choice == 'Picard1': - choice = 'Picard' - max_iter = 1 - - u = np.zeros(Nt+1) - iterations = [] - u[0] = u0 - for n in range(1, Nt+1): - a = dt - b = 1 - dt - c = -u[n-1] - - if choice == 'Picard': - - def F(u): - return a*u**2 + b*u + c - - u_ = u[n-1] - k = 0 - while abs(F(u_)) > eps_r and k < max_iter: - u_ = omega*(-c/(a*u_ + b)) + (1-omega)*u_ - k += 1 - u[n] = u_ - iterations.append(k) - - elif choice == 'Newton': - - def F(u): - return a*u**2 + b*u + c - - def dF(u): - return 2*a*u + b - - u_ = u[n-1] - k = 0 - while abs(F(u_)) > eps_r and k < max_iter: - u_ = u_ - F(u_)/dF(u_) - k += 1 - u[n] = u_ - iterations.append(k) - return u, iterations -``` - -The Crank-Nicolson method utilizing a linearization based on the -geometric mean gives a simpler algorithm: - -```python -def CN_logistic(u0, dt, Nt): - u = np.zeros(Nt + 1) - u[0] = u0 - for n in range(0, Nt): - u[n + 1] = (1 + 0.5 * dt) / (1 + dt * u[n] - 0.5 * dt) * u[n] - return u -``` +{{< include snippets/nonlin_logistic_be_solver.qmd >}} We may run experiments with the model problem (@eq-nonlin-timediscrete-logistic-eq) and the different strategies for diff --git a/chapters/nonlin/nonlin_pde1D.qmd b/chapters/nonlin/nonlin_pde1D.qmd index b08830bf..d7fbdef2 100644 --- a/chapters/nonlin/nonlin_pde1D.qmd +++ b/chapters/nonlin/nonlin_pde1D.qmd @@ -523,12 +523,12 @@ We must then replace $u_{-1}$ by With Picard iteration we get \begin{align*} -\frac{1}{2\Delta x^2}(& -(\dfc(u^-**{-1}) + 2\dfc(u^-**{0}) +\frac{1}{2\Delta x^2}(& -(\dfc(u^-_{-1}) + 2\dfc(u^-_{0}) + \dfc(u^-_{1}))u_1\, +\\ -&(\dfc(u^-**{-1}) + 2\dfc(u^-**{0}) + \dfc(u^-_{1}))u_0 +&(\dfc(u^-_{-1}) + 2\dfc(u^-_{0}) + \dfc(u^-_{1}))u_0 + au_0\\ &=f(u^-_0) - -\frac{1}{\dfc(u^-**0)\Delta x}(\dfc(u^-**{-1}) + \dfc(u^-_{0}))C, +\frac{1}{\dfc(u^-_0)\Delta x}(\dfc(u^-_{-1}) + \dfc(u^-_{0}))C, \end{align*} where $$ @@ -540,18 +540,18 @@ condition as a separate equation, (@eq-nonlin-alglevel-1D-fd-2x2-x1) with Picard iteration becomes \begin{align*} -\frac{1}{2\Delta x^2}(&-(\dfc(u^-**{0}) + \dfc(u^-**{1}))u_{0}\, + \\ -&(\dfc(u^-**{0}) + 2\dfc(u^-**{1}) + \dfc(u^-_{2}))u_1\, -\\ -&(\dfc(u^-**{1}) + \dfc(u^-**{2})))u_2 + au_1 +\frac{1}{2\Delta x^2}(&-(\dfc(u^-_{0}) + \dfc(u^-_{1}))u_{0}\, + \\ +&(\dfc(u^-_{0}) + 2\dfc(u^-_{1}) + \dfc(u^-_{2}))u_1\, -\\ +&(\dfc(u^-_{1}) + \dfc(u^-_{2})))u_2 + au_1 =f(u^-_1)\tp \end{align*} We must now move the $u_2$ term to the right-hand side and replace all occurrences of $u_2$ by $D$: \begin{align*} -\frac{1}{2\Delta x^2}(&-(\dfc(u^-**{0}) + \dfc(u^-**{1}))u_{0}\, +\\ -& (\dfc(u^-**{0}) + 2\dfc(u^-**{1}) + \dfc(D)))u_1 + au_1\\ -&=f(u^-**1) + \frac{1}{2\Delta x^2}(\dfc(u^-**{1}) + \dfc(D))D\tp +\frac{1}{2\Delta x^2}(&-(\dfc(u^-_{0}) + \dfc(u^-_{1}))u_{0}\, +\\ +& (\dfc(u^-_{0}) + 2\dfc(u^-_{1}) + \dfc(D)))u_1 + au_1\\ +&=f(u^-_1) + \frac{1}{2\Delta x^2}(\dfc(u^-_{1}) + \dfc(D))D\tp \end{align*} The two equations can be written as a $2\times 2$ system: @@ -573,19 +573,19 @@ $$ where \begin{align} -B_{0,0} &=\frac{1}{2\Delta x^2}(\dfc(u^-**{-1}) + 2\dfc(u^-**{0}) + \dfc(u^-_{1})) +B_{0,0} &=\frac{1}{2\Delta x^2}(\dfc(u^-_{-1}) + 2\dfc(u^-_{0}) + \dfc(u^-_{1})) + a,\\ B_{0,1} &= --\frac{1}{2\Delta x^2}(\dfc(u^-**{-1}) + 2\dfc(u^-**{0}) +-\frac{1}{2\Delta x^2}(\dfc(u^-_{-1}) + 2\dfc(u^-_{0}) + \dfc(u^-_{1})),\\ B_{1,0} &= --\frac{1}{2\Delta x^2}(\dfc(u^-**{0}) + \dfc(u^-**{1})),\\ +-\frac{1}{2\Delta x^2}(\dfc(u^-_{0}) + \dfc(u^-_{1})),\\ B_{1,1} &= -\frac{1}{2\Delta x^2}(\dfc(u^-**{0}) + 2\dfc(u^-**{1}) + \dfc(D)) + a,\\ +\frac{1}{2\Delta x^2}(\dfc(u^-_{0}) + 2\dfc(u^-_{1}) + \dfc(D)) + a,\\ d_0 &= f(u^-_0) - -\frac{1}{\dfc(u^-**0)\Delta x}(\dfc(u^-**{-1}) + \dfc(u^-_{0}))C,\\ -d_1 &= f(u^-**1) + \frac{1}{2\Delta x^2}(\dfc(u^-**{1}) + \dfc(D))D\tp +\frac{1}{\dfc(u^-_0)\Delta x}(\dfc(u^-_{-1}) + \dfc(u^-_{0}))C,\\ +d_1 &= f(u^-_1) + \frac{1}{2\Delta x^2}(\dfc(u^-_{1}) + \dfc(D))D\tp \end{align} The system with the Dirichlet condition becomes @@ -611,7 +611,7 @@ with \begin{align} B_{1,1} &= -\frac{1}{2\Delta x^2}(\dfc(u^-**{0}) + 2\dfc(u^-**{1}) + \dfc(u_2)) + a,\\ +\frac{1}{2\Delta x^2}(\dfc(u^-_{0}) + 2\dfc(u^-_{1}) + \dfc(u_2)) + a,\\ B_{1,2} &= - \frac{1}{2\Delta x^2}(\dfc(u^-_{1}) + \dfc(u_2))),\\ d_1 &= f(u^-_1)\tp @@ -619,7 +619,7 @@ d_1 &= f(u^-_1)\tp Other entries are as in the $2\times 2$ system. ### Newton's method -The Jacobian must be derived in order to use Newton's method. Here it means +Using Newton's method requires deriving the Jacobian. Here it means that we need to differentiate $F(u)=A(u)u - b(u)$ with respect to the unknown parameters $u_0,u_1,\ldots,u_m$ ($m=N_x$ or $m=N_x-1$, depending on whether the diff --git a/chapters/nonlin/nonlin_split.qmd b/chapters/nonlin/nonlin_split.qmd index bc873c03..e909e859 100644 --- a/chapters/nonlin/nonlin_split.qmd +++ b/chapters/nonlin/nonlin_split.qmd @@ -146,61 +146,14 @@ The following function computes four solutions arising from the Forward Euler method, ordinary splitting, Strange splitting, as well as Strange splitting with exact treatment of $u'=f_0(u)$: -```python -import numpy as np - - -def solver(dt, T, f, f_0, f_1): - """ - Solve u'=f by the Forward Euler method and by ordinary and - Strange splitting: f(u) = f_0(u) + f_1(u). - """ - Nt = int(round(T / float(dt))) - t = np.linspace(0, Nt * dt, Nt + 1) - u_FE = np.zeros(len(t)) - u_split1 = np.zeros(len(t)) # 1st-order splitting - u_split2 = np.zeros(len(t)) # 2nd-order splitting - u_split3 = np.zeros(len(t)) # 2nd-order splitting w/exact f_0 - - u_FE[0] = 0.1 - u_split1[0] = 0.1 - u_split2[0] = 0.1 - u_split3[0] = 0.1 - - for n in range(len(t) - 1): - u_FE[n + 1] = u_FE[n] + dt * f(u_FE[n]) - - u_s_n = u_split1[n] - u_s = u_s_n + dt * f_0(u_s_n) - u_ss_n = u_s - u_ss = u_ss_n + dt * f_1(u_ss_n) - u_split1[n + 1] = u_ss - - u_s_n = u_split2[n] - u_s = u_s_n + dt / 2.0 * f_0(u_s_n) - u_sss_n = u_s - u_sss = u_sss_n + dt * f_1(u_sss_n) - u_ss_n = u_sss - u_ss = u_ss_n + dt / 2.0 * f_0(u_ss_n) - u_split2[n + 1] = u_ss - - u_s_n = u_split3[n] - u_s = u_s_n * np.exp(dt / 2.0) # exact - u_sss_n = u_s - u_sss = u_sss_n + dt * f_1(u_sss_n) - u_ss_n = u_sss - u_ss = u_ss_n * np.exp(dt / 2.0) # exact - u_split3[n + 1] = u_ss - - return u_FE, u_split1, u_split2, u_split3, t -``` +{{< include snippets/nonlin_split_logistic.qmd >}} ### Compact implementation We have used quite many lines for the steps in the splitting methods. Many will prefer to condense the code a bit, as done here: -```{.python include="../src/nonlin/split_logistic.py" start-after="# Ordinary splitting" end-before="return u_FE"} +```{.python include="../src/nonlin/split_logistic.py" start-after="# Ordinary splitting" end-before="# end-splitting-loop"} ``` ### Results @@ -279,9 +232,9 @@ is maximum $\Oof{\Delta t^2}$ for Strange splitting, otherwise it is just $\Oof{\Delta t}$. Higher-order methods for ODEs will therefore be a waste of work. The 2nd-order Adams-Bashforth method reads $$ -u^{\stepone,n+1}**{i,j} = u^{\stepone,n}**{i,j} + +u^{\stepone,n+1}_{i,j} = u^{\stepone,n}_{i,j} + \half\Delta t\left( 3f(u^{\stepone, n}_{i,j}, t_n) - -f(u^{\stepone, n-1}**{i,j}, t**{n-1}) +f(u^{\stepone, n-1}_{i,j}, t_{n-1}) \right) \tp $$ We can use a Forward Euler step to start the method, i.e, compute @@ -314,8 +267,16 @@ while differing in the step $h$ (being either $\Delta x^2$ or $\Delta x$) and the convergence rate $r$ (being either 1 or 2). All code commented below is found in the file -[`split_diffu_react.py`](https://github.com/devitocodes/devito_book/tree/main/src/nonlin/split_diffu_react.py). When executed, -a function `convergence_rates` is called, from which all convergence +[`split_diffu_react.py`](https://github.com/devitocodes/devito_book/tree/main/src/nonlin/split_diffu_react.py). + +::: {.callout-note} +The code in this section has been refactored into testable source files. +See `src/nonlin/split_diffu_react.py` for the complete implementation, +which is exercised by the test suite. The code shown below is adapted +from that source to match the original presentation style. +::: + +When executed, a function `convergence_rates` is called, from which all convergence rate computations are handled: ```python diff --git a/chapters/nonlin/snippets/burgers_equations_bc.qmd b/chapters/nonlin/snippets/burgers_equations_bc.qmd new file mode 100644 index 00000000..c0b6e300 --- /dev/null +++ b/chapters/nonlin/snippets/burgers_equations_bc.qmd @@ -0,0 +1,7 @@ +::: {.callout-note title="Snippet (tested)"} +This code is included from `src/book_snippets/burgers_equations_bc.py` and exercised by `pytest` (see `tests/test_book_snippets.py`). +::: + +```python +{{< include ../../src/book_snippets/burgers_equations_bc.py >}} +``` diff --git a/chapters/nonlin/snippets/burgers_first_derivative.qmd b/chapters/nonlin/snippets/burgers_first_derivative.qmd new file mode 100644 index 00000000..ceeb6cd8 --- /dev/null +++ b/chapters/nonlin/snippets/burgers_first_derivative.qmd @@ -0,0 +1,7 @@ +::: {.callout-note title="Snippet (tested)"} +This code is included from `src/book_snippets/burgers_first_derivative.py` and exercised by `pytest` (see `tests/test_book_snippets.py`). +::: + +```python +{{< include ../../src/book_snippets/burgers_first_derivative.py >}} +``` diff --git a/chapters/nonlin/snippets/nonlin_logistic_be_solver.qmd b/chapters/nonlin/snippets/nonlin_logistic_be_solver.qmd new file mode 100644 index 00000000..3db36815 --- /dev/null +++ b/chapters/nonlin/snippets/nonlin_logistic_be_solver.qmd @@ -0,0 +1,7 @@ +::: {.callout-note title="Snippet (tested)"} +This code is included from `src/book_snippets/nonlin_logistic_be_solver.py` and exercised by `pytest` (see `tests/test_book_snippets.py`). +::: + +```python +{{< include ../../src/book_snippets/nonlin_logistic_be_solver.py >}} +``` diff --git a/chapters/nonlin/snippets/nonlin_split_logistic.qmd b/chapters/nonlin/snippets/nonlin_split_logistic.qmd new file mode 100644 index 00000000..300561c5 --- /dev/null +++ b/chapters/nonlin/snippets/nonlin_split_logistic.qmd @@ -0,0 +1,7 @@ +::: {.callout-note title="Snippet (tested)"} +This code is included from `src/book_snippets/nonlin_split_logistic.py` and exercised by `pytest` (see `tests/test_book_snippets.py`). +::: + +```python +{{< include ../../src/book_snippets/nonlin_split_logistic.py >}} +``` diff --git a/chapters/preface/preface.qmd b/chapters/preface/preface.qmd index d4249385..b79a9b23 100644 --- a/chapters/preface/preface.qmd +++ b/chapters/preface/preface.qmd @@ -1,14 +1,14 @@ -## About This Adaptation {.unnumbered} +## About This Edition {.unnumbered} -This book is an adaptation of *Finite Difference Computing with PDEs: A Modern Software Approach* by Hans Petter Langtangen and Svein Linge, originally published by Springer in 2017 under a [Creative Commons Attribution 4.0 International License (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/). +This book is based on *Finite Difference Computing with PDEs: A Modern Software Approach* by Hans Petter Langtangen and Svein Linge, originally published by Springer in 2017 under a [Creative Commons Attribution 4.0 International License (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/). **Original Work:** > Langtangen, H.P., Linge, S. (2017). *Finite Difference Computing with PDEs: A Modern Software Approach*. Texts in Computational Science and Engineering, vol 16. Springer, Cham. [https://doi.org/10.1007/978-3-319-55456-3](https://doi.org/10.1007/978-3-319-55456-3) -### What Has Changed +### What's New in This Edition -This edition has been substantially adapted to feature [Devito](https://www.devitoproject.org/), a domain-specific language for symbolic PDE specification and automatic code generation. +This edition has been substantially rewritten to feature [Devito](https://www.devitoproject.org/), a domain-specific language for symbolic PDE specification and automatic code generation. **New Content:** @@ -24,17 +24,24 @@ This edition has been substantially adapted to feature [Devito](https://www.devi - Continuous integration and testing infrastructure - Updated external links and references -**Preserved Content:** +### Acknowledgment -- Mathematical derivations and theoretical foundations -- Pedagogical structure and learning philosophy -- Appendices on truncation errors and finite difference formulas +I first encountered Hans Petter Langtangen's work through his book *A Primer on Scientific Programming with Python* [@Langtangen_2012], which I used to develop my first lecture course on Python programming for geoscientists. When I contacted him for advice on teaching introductory programming to domain scientists, he was remarkably generous and helpful, even providing his lecture slides to help me get started. His approach to teaching computational science has been formative in shaping my own teaching ever since. -### Acknowledgment +Professor Langtangen passed away in October 2016. I am deeply grateful to both him and Svein Linge for their contributions to computational science education and their commitment to open-access publishing and open-source software. Their original work provided an excellent foundation for this edition. + +This work was prepared in collaboration with the Devito development team. + +### Use of Generative AI + +In keeping with principles of transparency and academic integrity, we acknowledge the use of generative AI tools in preparing this edition. Multiple AI assistants, including Claude (Anthropic), were used to support the following aspects of this work: -This adaptation was prepared by Gerard J. Gorman (Imperial College London) in collaboration with the Devito development team. +- **Formatting and drafting**: AI tools assisted with document formatting, conversion between markup formats, and initial drafts of some explanatory sections. +- **Code adaptation**: Initial rewrites of numerical examples from the original Python/NumPy implementations to Devito's domain-specific language, with subsequent manual review and verification. +- **Test development**: Generation of unit tests and code verification tests to support reproducibility and ensure that all code examples compile and produce correct results. +- **Editorial support**: Proofreading, consistency checking, and cross-reference verification. -Professor Hans Petter Langtangen passed away in October 2016. His profound contributions to computational science education continue to benefit students and practitioners worldwide. This adaptation aims to honor his legacy by bringing his pedagogical approach to modern tools. +All AI-generated content was reviewed, edited, and verified by Gerard Gorman, who takes full responsibility for this edition. --- diff --git a/chapters/systems/index.qmd b/chapters/systems/index.qmd new file mode 100644 index 00000000..8696f30e --- /dev/null +++ b/chapters/systems/index.qmd @@ -0,0 +1,3 @@ +# Systems of PDEs {#sec-ch-systems} + +{{< include systems.qmd >}} diff --git a/chapters/systems/systems.qmd b/chapters/systems/systems.qmd new file mode 100644 index 00000000..a4bbca74 --- /dev/null +++ b/chapters/systems/systems.qmd @@ -0,0 +1,693 @@ +## Introduction to PDE Systems {#sec-systems-intro} + +So far in this book, we have focused on solving single PDEs: the wave +equation, diffusion equation, advection equation, and nonlinear extensions. +In many physical applications, however, we encounter *systems* of coupled +PDEs where multiple unknowns evolve together, with each equation depending +on several fields. + +### Conservation Laws + +Many important physical systems are described by *conservation laws*, +which express the fundamental principle that certain quantities (mass, +momentum, energy) cannot be created or destroyed, only transported. +The general form of a conservation law in one dimension is: + +$$ +\frac{\partial \mathbf{U}}{\partial t} + \frac{\partial \mathbf{F}(\mathbf{U})}{\partial x} = \mathbf{S} +$$ {#eq-conservation-law} + +where: + +- $\mathbf{U}$ is the vector of conserved quantities +- $\mathbf{F}(\mathbf{U})$ is the flux function (how quantities move through space) +- $\mathbf{S}$ is a source/sink term + +In two dimensions, this extends to: + +$$ +\frac{\partial \mathbf{U}}{\partial t} + \frac{\partial \mathbf{F}}{\partial x} + \frac{\partial \mathbf{G}}{\partial y} = \mathbf{S} +$$ {#eq-conservation-law-2d} + +### Coupling Between Equations + +When we have multiple coupled PDEs, the unknowns in each equation depend +on the solutions of other equations. This creates computational challenges: + +1. **Temporal coupling**: The time derivative in one equation involves + terms from equations that have not yet been updated. + +2. **Spatial coupling**: Spatial derivatives may involve multiple fields + at the same location. + +3. **Nonlinear coupling**: The coupling terms are often nonlinear, + requiring careful treatment of products of unknowns. + +### Hyperbolic Systems + +The shallow water equations we study in this chapter form a *hyperbolic +system* of PDEs. Hyperbolic systems have the property that information +propagates at finite speeds, similar to the wave equation. This is in +contrast to parabolic systems (like coupled diffusion equations) where +information spreads instantaneously. + +For hyperbolic systems, the CFL stability condition becomes: + +$$ +\Delta t \leq \frac{\Delta x}{\max|\lambda_i|} +$$ + +where $\lambda_i$ are the eigenvalues of the flux Jacobian matrix. For +shallow water, these eigenvalues correspond to wave speeds. + +## The Shallow Water Equations {#sec-swe} + +The 2D Shallow Water Equations (SWE) are a fundamental model in +computational geophysics and coastal engineering. They are derived from +the Navier-Stokes equations under the assumption that horizontal +length scales are much larger than the water depth. + +### Physical Setup + +Consider a body of water with: + +- $h(x, y)$: bathymetry (depth from mean sea level to seafloor, static) +- $\eta(x, y, t)$: surface elevation above mean sea level (dynamic) +- $D = h + \eta$: total water column depth +- $u(x, y, t)$, $v(x, y, t)$: depth-averaged horizontal velocities + +The shallow water approximation assumes that: + +1. Horizontal length scales $L$ are much larger than depth $H$: $L \gg H$ +2. Vertical accelerations are negligible compared to gravity +3. The pressure is hydrostatic: $p = \rho g (\eta - z)$ + +### Governing Equations + +The 2D Shallow Water Equations consist of three coupled PDEs: + +**Continuity equation (mass conservation):** + +$$ +\frac{\partial \eta}{\partial t} + \frac{\partial M}{\partial x} + \frac{\partial N}{\partial y} = 0 +$$ {#eq-swe-continuity} + +**x-Momentum equation:** + +$$ +\frac{\partial M}{\partial t} + \frac{\partial}{\partial x}\left(\frac{M^2}{D}\right) + \frac{\partial}{\partial y}\left(\frac{MN}{D}\right) + gD\frac{\partial \eta}{\partial x} + \frac{g\alpha^2}{D^{7/3}}M\sqrt{M^2+N^2} = 0 +$$ {#eq-swe-xmom} + +**y-Momentum equation:** + +$$ +\frac{\partial N}{\partial t} + \frac{\partial}{\partial x}\left(\frac{MN}{D}\right) + \frac{\partial}{\partial y}\left(\frac{N^2}{D}\right) + gD\frac{\partial \eta}{\partial y} + \frac{g\alpha^2}{D^{7/3}}N\sqrt{M^2+N^2} = 0 +$$ {#eq-swe-ymom} + +### Discharge Fluxes + +Rather than solving for velocities $(u, v)$ directly, the SWE are typically +formulated in terms of *discharge fluxes* $M$ and $N$: + +$$ +\begin{aligned} +M &= \int_{-h}^{\eta} u\, dz = uD \\ +N &= \int_{-h}^{\eta} v\, dz = vD +\end{aligned} +$$ {#eq-discharge-flux} + +The discharge flux has units of $[\text{m}^2/\text{s}]$ and represents +the volume of water flowing per unit width per unit time. This formulation +has numerical advantages: + +1. Mass conservation becomes linear in $M$ and $N$ +2. The flux form handles moving shorelines better +3. Boundary conditions are more naturally expressed + +### Physical Interpretation of Terms + +Each term in the momentum equations has a physical meaning: + +| Term | Physical Meaning | +|------|------------------| +| $\partial M/\partial t$ | Local acceleration | +| $\partial(M^2/D)/\partial x$ | Advection of x-momentum in x | +| $\partial(MN/D)/\partial y$ | Advection of x-momentum in y | +| $gD\partial\eta/\partial x$ | Pressure gradient (hydrostatic) | +| $g\alpha^2 M\sqrt{M^2+N^2}/D^{7/3}$ | Bottom friction | + +### Manning's Roughness Coefficient + +The friction term uses Manning's formula for open channel flow. The +Manning's roughness coefficient $\alpha$ depends on the seafloor: + +| Surface Type | $\alpha$ | +|--------------|----------| +| Smooth concrete | 0.010 - 0.013 | +| Natural channels (good) | 0.020 - 0.030 | +| Natural channels (poor) | 0.050 - 0.070 | +| Vegetated floodplains | 0.100 - 0.200 | + +For tsunami modeling in the open ocean, $\alpha \approx 0.025$ is typical. + +### Applications + +The Shallow Water Equations are used to model: + +- **Tsunami propagation**: Large-scale ocean wave modeling +- **Storm surges**: Coastal flooding from hurricanes/cyclones +- **Dam breaks**: Sudden release of reservoir water +- **Tidal flows**: Estuarine and coastal circulation +- **River flooding**: Overbank flows and inundation + +## Devito Implementation {#sec-swe-devito} + +Implementing the Shallow Water Equations in Devito demonstrates several +powerful features for coupled systems: + +1. **Multiple TimeFunction fields** for the three unknowns +2. **Function for static fields** (bathymetry) +3. **The solve() function** for isolating forward time terms +4. **ConditionalDimension** for efficient snapshot saving + +### Setting Up the Grid and Fields + +We begin by creating the computational grid and the required fields: + +```python +from devito import Grid, TimeFunction, Function + +# Create 2D grid +grid = Grid(shape=(Ny, Nx), extent=(Ly, Lx), dtype=np.float32) + +# Three time-varying fields for the unknowns +eta = TimeFunction(name='eta', grid=grid, space_order=2) # wave height +M = TimeFunction(name='M', grid=grid, space_order=2) # x-discharge +N = TimeFunction(name='N', grid=grid, space_order=2) # y-discharge + +# Static fields +h = Function(name='h', grid=grid) # bathymetry +D = Function(name='D', grid=grid) # total depth (updated each step) +``` + +Note that `h` is a `Function` (not `TimeFunction`) because the bathymetry +is static---it does not change during the simulation. The total depth +`D` is also a `Function` but is updated at each time step as $D = h + \eta$. + +### Writing the PDEs Symbolically + +Devito allows us to write the PDEs in a form close to the mathematical +notation. For the continuity equation: + +```python +from devito import Eq, solve + +# Continuity: deta/dt + dM/dx + dN/dy = 0 +# Using centered differences in space (.dxc, .dyc) +pde_eta = Eq(eta.dt + M.dxc + N.dyc) + +# Solve for eta.forward +stencil_eta = solve(pde_eta, eta.forward) +``` + +The `.dxc` and `.dyc` operators compute centered finite differences: + +- `.dxc` $\approx \frac{u_{i+1,j} - u_{i-1,j}}{2\Delta x}$ +- `.dyc` $\approx \frac{u_{i,j+1} - u_{i,j-1}}{2\Delta y}$ + +### The solve() Function for Coupled Stencils + +When we have nonlinear coupled equations, isolating the forward time +term algebraically is tedious and error-prone. Devito's `solve()` function +handles this automatically: + +```python +from devito import sqrt + +# Friction term +friction_M = g * alpha**2 * sqrt(M**2 + N**2) / D**(7./3.) + +# x-Momentum PDE +pde_M = Eq( + M.dt + + (M**2 / D).dxc + + (M * N / D).dyc + + g * D * eta.forward.dxc + + friction_M * M +) + +# solve() isolates M.forward algebraically +stencil_M = solve(pde_M, M.forward) +``` + +The `solve()` function: + +1. Parses the equation for the target term (`M.forward`) +2. Algebraically isolates it on the left-hand side +3. Returns the right-hand side expression + +This is particularly valuable for the momentum equations where the +forward terms appear in multiple places. + +### Update Equations with Subdomain + +The update equations apply only to interior points, avoiding boundary +modifications: + +```python +update_eta = Eq(eta.forward, stencil_eta, subdomain=grid.interior) +update_M = Eq(M.forward, stencil_M, subdomain=grid.interior) +update_N = Eq(N.forward, stencil_N, subdomain=grid.interior) +``` + +The `subdomain=grid.interior` restricts updates to interior points, +leaving boundary values unchanged. For tsunami modeling, this effectively +implements open (non-reflecting) boundaries as a first approximation. + +### Updating the Total Depth + +After updating $\eta$, we must update the total water depth: + +```python +eq_D = Eq(D, eta.forward + h) +``` + +This equation is evaluated after the main updates, using the new value +of $\eta$. + +### Complete Operator Construction + +The full operator combines all equations: + +```python +from devito import Operator + +op = Operator([update_eta, update_M, update_N, eq_D]) +``` + +### ConditionalDimension for Snapshots + +For visualization and analysis, we often want to save the solution at +regular intervals without storing every time step (which would be +memory-prohibitive). Devito's `ConditionalDimension` provides efficient +subsampling: + +```python +from devito import ConditionalDimension + +# Save every 'factor' time steps +factor = round(Nt / nsnaps) +time_subsampled = ConditionalDimension( + 't_sub', parent=grid.time_dim, factor=factor +) + +# Create TimeFunction that saves at reduced frequency +eta_save = TimeFunction( + name='eta_save', grid=grid, space_order=2, + save=nsnaps, time_dim=time_subsampled +) + +# Add saving equation to operator +op = Operator([update_eta, update_M, update_N, eq_D, Eq(eta_save, eta)]) +``` + +The `ConditionalDimension`: + +1. Creates a time dimension that only activates every `factor` steps +2. Links it to a `TimeFunction` with `save=nsnaps` storage +3. Automatically manages indexing and memory allocation + +### Running the Simulation + +With all components in place, we run the simulation: + +```python +# Apply operator for Nt time steps +op.apply(eta=eta, M=M, N=N, D=D, h=h, time=Nt-2, dt=dt) +``` + +The `time=Nt-2` specifies the number of iterations (Devito uses 0-based +indexing for the time loop). + +## Example: Tsunami with Constant Depth {#sec-swe-constant-depth} + +Let us model tsunami propagation in an ocean with constant depth. +This is the simplest case for understanding the basic wave behavior. + +### Problem Setup + +- Domain: $100 \times 100$ m +- Grid: $401 \times 401$ points +- Depth: $h = 50$ m (constant) +- Gravity: $g = 9.81$ m/s$^2$ +- Manning's roughness: $\alpha = 0.025$ +- Simulation time: $T = 3$ s + +The initial condition is a Gaussian pulse at the center: + +$$ +\eta_0(x, y) = 0.5 \exp\left(-\frac{(x-50)^2}{10} - \frac{(y-50)^2}{10}\right) +$$ + +with initial discharge: + +$$ +M_0 = 100 \cdot \eta_0, \quad N_0 = 0 +$$ + +### Devito Implementation + +```python +from devito import Grid, TimeFunction, Function, Eq, Operator, solve, sqrt +import numpy as np + +# Physical parameters +Lx, Ly = 100.0, 100.0 # Domain size [m] +Nx, Ny = 401, 401 # Grid points +g = 9.81 # Gravity [m/s^2] +alpha = 0.025 # Manning's roughness +h0 = 50.0 # Constant depth [m] + +# Time stepping +Tmax = 3.0 +dt = 1/4500 +Nt = int(Tmax / dt) + +# Create coordinate arrays +x = np.linspace(0.0, Lx, Nx) +y = np.linspace(0.0, Ly, Ny) +X, Y = np.meshgrid(x, y) + +# Initial conditions +eta0 = 0.5 * np.exp(-((X - 50)**2 / 10) - ((Y - 50)**2 / 10)) +M0 = 100.0 * eta0 +N0 = np.zeros_like(M0) +h_array = h0 * np.ones_like(X) + +# Create Devito grid +grid = Grid(shape=(Ny, Nx), extent=(Ly, Lx), dtype=np.float32) + +# Create fields +eta = TimeFunction(name='eta', grid=grid, space_order=2) +M = TimeFunction(name='M', grid=grid, space_order=2) +N = TimeFunction(name='N', grid=grid, space_order=2) +h = Function(name='h', grid=grid) +D = Function(name='D', grid=grid) + +# Set initial data +eta.data[0, :, :] = eta0 +M.data[0, :, :] = M0 +N.data[0, :, :] = N0 +h.data[:] = h_array +D.data[:] = eta0 + h_array + +# Build equations +friction_M = g * alpha**2 * sqrt(M**2 + N**2) / D**(7./3.) +friction_N = g * alpha**2 * sqrt(M.forward**2 + N**2) / D**(7./3.) + +pde_eta = Eq(eta.dt + M.dxc + N.dyc) +pde_M = Eq(M.dt + (M**2/D).dxc + (M*N/D).dyc + + g*D*eta.forward.dxc + friction_M*M) +pde_N = Eq(N.dt + (M.forward*N/D).dxc + (N**2/D).dyc + + g*D*eta.forward.dyc + friction_N*N) + +stencil_eta = solve(pde_eta, eta.forward) +stencil_M = solve(pde_M, M.forward) +stencil_N = solve(pde_N, N.forward) + +update_eta = Eq(eta.forward, stencil_eta, subdomain=grid.interior) +update_M = Eq(M.forward, stencil_M, subdomain=grid.interior) +update_N = Eq(N.forward, stencil_N, subdomain=grid.interior) +eq_D = Eq(D, eta.forward + h) + +# Create and run operator +op = Operator([update_eta, update_M, update_N, eq_D]) +op.apply(eta=eta, M=M, N=N, D=D, h=h, time=Nt-2, dt=dt) +``` + +### Expected Behavior + +In constant depth, the tsunami propagates outward as a circular wave +at the shallow water wave speed: + +$$ +c = \sqrt{gD} \approx \sqrt{9.81 \times 50} \approx 22.1 \text{ m/s} +$$ + +The wave maintains its circular shape but decreases in amplitude due to: + +1. Geometric spreading (energy distributed over larger circumference) +2. Bottom friction (energy dissipation) + +## Example: Tsunami with Varying Bathymetry {#sec-swe-bathymetry} + +Real ocean bathymetry significantly affects tsunami propagation. +As waves approach shallow water, they slow down, their wavelength +decreases, and their amplitude increases---a process called *shoaling*. + +### Tanh Depth Profile + +A common test case uses a $\tanh$ profile to model a coastal transition: + +$$ +h(x, y) = h_{\text{deep}} - (h_{\text{deep}} - h_{\text{shallow}}) \cdot \frac{1 + \tanh((x - x_0)/w)}{2} +$$ + +This creates a smooth transition from deep water to shallow water. + +### Implementation + +```python +# Tanh bathymetry: deep on left, shallow on right +h_deep = 50.0 # Deep water depth [m] +h_shallow = 5.0 # Shallow water depth [m] +x_transition = 70.0 # Transition location +width = 8.0 # Transition width + +h_array = h_deep - (h_deep - h_shallow) * ( + 0.5 * (1 + np.tanh((X - x_transition) / width)) +) + +# Tsunami source in deep water +eta0 = 0.5 * np.exp(-((X - 30)**2 / 10) - ((Y - 50)**2 / 20)) +``` + +### Physical Effects + +As the tsunami propagates from deep to shallow water: + +1. **Speed decreases**: $c = \sqrt{gh}$ drops from $\sim 22$ m/s to $\sim 7$ m/s +2. **Wavelength shortens**: Waves compress as they slow +3. **Amplitude increases**: Energy conservation requires higher waves +4. **Wave steepening**: Front of wave catches up to back + +This shoaling effect is why tsunamis, barely noticeable in the open +ocean, become devastating near the coast. + +## Example: Tsunami Interacting with a Seamount {#sec-swe-seamount} + +Underwater topographic features like seamounts cause wave diffraction +and focusing effects. + +### Seamount Bathymetry + +A Gaussian seamount rising from a flat seafloor: + +$$ +h(x, y) = h_0 - A \exp\left(-\frac{(x-x_0)^2}{\sigma^2} - \frac{(y-y_0)^2}{\sigma^2}\right) +$$ + +where $A$ is the seamount height and $\sigma$ controls its width. + +### Implementation + +```python +# Constant depth with Gaussian seamount +h_base = 50.0 # Base depth [m] +x_mount, y_mount = 50.0, 50.0 # Seamount center +height = 45.0 # Height (leaves 5m above summit) +sigma = 20.0 # Width parameter + +h_array = h_base * np.ones_like(X) +h_array -= height * np.exp( + -((X - x_mount)**2 / sigma) - ((Y - y_mount)**2 / sigma) +) + +# Tsunami source to the left of seamount +eta0 = 0.5 * np.exp(-((X - 30)**2 / 5) - ((Y - 50)**2 / 5)) +``` + +### Physical Effects + +When the tsunami encounters the seamount: + +1. **Wave focusing**: Waves refract around the shallow region +2. **Energy concentration**: Waves converge behind the seamount +3. **Shadow zone**: Reduced amplitude directly behind +4. **Scattered waves**: Secondary circular waves radiate outward + +## Using the Module Interface {#sec-swe-module} + +The complete solver is available in `src/systems/swe_devito.py`. +The high-level interface simplifies common use cases: + +```python +from src.systems import solve_swe +import numpy as np + +# Constant depth simulation +result = solve_swe( + Lx=100.0, Ly=100.0, + Nx=201, Ny=201, + T=2.0, + dt=1/4000, + g=9.81, + alpha=0.025, + h0=50.0, + nsnaps=100 # Save 100 snapshots +) + +# Access results +print(f"Final max wave height: {result.eta.max():.4f} m") +print(f"Snapshots shape: {result.eta_snapshots.shape}") +``` + +### Custom Bathymetry + +For non-constant bathymetry, pass an array: + +```python +# Create coordinate arrays +x = np.linspace(0, 100, 201) +y = np.linspace(0, 100, 201) +X, Y = np.meshgrid(x, y) + +# Custom bathymetry with seamount +h_custom = 50.0 * np.ones_like(X) +h_custom -= 45.0 * np.exp(-((X-50)**2/20) - ((Y-50)**2/20)) + +# Solve with custom bathymetry +result = solve_swe( + Lx=100.0, Ly=100.0, + Nx=201, Ny=201, + T=2.0, + dt=1/4000, + h0=h_custom, # Pass array instead of scalar +) +``` + +### Custom Initial Conditions + +Both initial wave height and discharge can be specified: + +```python +# Two tsunami sources +eta0 = 0.5 * np.exp(-((X-35)**2/10) - ((Y-35)**2/10)) +eta0 -= 0.5 * np.exp(-((X-65)**2/10) - ((Y-65)**2/10)) + +# Directional initial discharge +M0 = 100.0 * eta0 +N0 = 50.0 * eta0 # Also some y-component + +result = solve_swe( + Lx=100.0, Ly=100.0, + Nx=201, Ny=201, + T=3.0, + dt=1/4000, + eta0=eta0, + M0=M0, + N0=N0, +) +``` + +### Helper Functions + +Utility functions for common scenarios: + +```python +from src.systems.swe_devito import ( + gaussian_tsunami_source, + seamount_bathymetry, + tanh_bathymetry +) + +# Create coordinate grid +x = np.linspace(0, 100, 201) +y = np.linspace(0, 100, 201) +X, Y = np.meshgrid(x, y) + +# Gaussian tsunami source +eta0 = gaussian_tsunami_source(X, Y, x0=30, y0=50, amplitude=0.5) + +# Seamount bathymetry +h = seamount_bathymetry(X, Y, h_base=50, height=45, sigma=20) + +# Or coastal profile +h = tanh_bathymetry(X, Y, h_deep=50, h_shallow=5, x_transition=70) +``` + +## Stability and Accuracy Considerations {#sec-swe-stability} + +### CFL Condition + +The shallow water equations have a CFL condition based on the gravity +wave speed: + +$$ +\Delta t \leq \frac{\min(\Delta x, \Delta y)}{\sqrt{g \cdot \max(D)}} +$$ + +For $g = 9.81$ m/s$^2$ and $D_{\max} = 50$ m: + +$$ +\sqrt{gD} \approx 22.1 \text{ m/s} +$$ + +With $\Delta x = 0.25$ m (for a 401-point grid over 100 m): + +$$ +\Delta t \leq \frac{0.25}{22.1} \approx 0.011 \text{ s} +$$ + +In practice, we use smaller time steps (e.g., $\Delta t = 1/4500 \approx 0.00022$ s) +for accuracy and to handle nonlinear effects. + +### Grid Resolution + +The grid must resolve the relevant wavelengths. For tsunami modeling: + +- Open ocean wavelengths: 100--500 km (coarse grid acceptable) +- Coastal wavelengths: 1--10 km (finer grid needed) +- Near-shore: 10--100 m (very fine grid required) + +### Boundary Conditions + +The current implementation uses implicit open boundaries (values at +boundaries remain unchanged). For more accurate modeling, consider: + +1. **Sponge layers**: Absorbing regions near boundaries +2. **Characteristic boundary conditions**: Based on wave directions +3. **Periodic boundaries**: For idealized studies + +## Key Takeaways {#sec-swe-summary} + +1. **Systems of PDEs** require careful treatment of coupling between + unknowns, both in time and space. + +2. **The Shallow Water Equations** are a fundamental hyperbolic system + used for tsunami, storm surge, and flood modeling. + +3. **Devito's solve() function** automatically isolates forward time + terms in coupled nonlinear equations. + +4. **Static fields** (like bathymetry) use `Function` instead of + `TimeFunction` to avoid unnecessary time indexing. + +5. **ConditionalDimension** enables efficient snapshot saving without + storing every time step. + +6. **Bathymetry effects** (shoaling, refraction, diffraction) are + captured automatically through the depth-dependent terms. + +7. **The friction term** using Manning's roughness accounts for + seafloor energy dissipation. diff --git a/chapters/vib/vib_undamped.qmd b/chapters/vib/vib_undamped.qmd index d5225f8b..0dcb4db6 100644 --- a/chapters/vib/vib_undamped.qmd +++ b/chapters/vib/vib_undamped.qmd @@ -186,6 +186,14 @@ $$ ## Making a solver function {#sec-vib-impl1} +::: {.callout-note} +## Source Files + +The solver functions presented in this section are implemented in +`src/vib/vib_undamped.py`. The source file includes a built-in test +function `test_three_steps()` that verifies correctness. +::: + The algorithm from the previous section is readily translated to a complete Python function for computing and returning $u^0,u^1,\ldots,u^{N_t}$ and $t_0,t_1,\ldots,t_{N_t}$, given the @@ -1705,7 +1713,7 @@ We are interested in the accumulated global error, which can be taken as the $\ell^2$ norm of $e^n$. The norm is simply computed by summing contributions from all mesh points: $$ -||e^n||**{\ell^2}^2 = \Delta t\sum**{n=0}^{N_t} \frac{1}{24^2}n^2\omega^6\Delta t^6 +||e^n||_{\ell^2}^2 = \Delta t\sum_{n=0}^{N_t} \frac{1}{24^2}n^2\omega^6\Delta t^6 =\frac{1}{24^2}\omega^6\Delta t^7 \sum_{n=0}^{N_t} n^2\tp $$ The sum $\sum_{n=0}^{N_t} n^2$ is approximately equal to @@ -2388,7 +2396,7 @@ approximation to $u^{\prime}$: $u^{\prime}(t_n)\approx [D_{2t}u]^n$. A useful norm of the mesh function $e_E^n$ for the discrete mechanical energy can be the maximum absolute value of $e_E^n$: $$ -||e_E^n||**{\ell^\infty} = \max**{1\leq n 0.00001: - periodic = True - - if version == "scalar": - for i in range(1, Nx): - u[i] = ( - -u_2[i] - + 2 * u_1[i] - + C2 * (u_1[i - 1] - 2 * u_1[i] + u_1[i + 1]) - + dt**2 * f(x[i], t[n]) - ) - elif version == "vectorized": # (1:-1 slice style) - f_a = f(x, t[n]) # Precompute in array - u[1:-1] = ( - -u_2[1:-1] - + 2 * u_1[1:-1] - + C2 * (u_1[0:-2] - 2 * u_1[1:-1] + u_1[2:]) - + dt**2 * f_a[1:-1] - ) - - # Insert boundary conditions - u[Nx] = u_1[Nx] - C * (u_1[Nx] - u_1[Nx - 1]) # open condition - if periodic: - u[0] = u[Nx] - else: - i = 0 - if bc_left == "dudx=0": - u[i] = ( - -u_2[i] - + 2 * u_1[i] - + C2 * (u_1[i + 1] - 2 * u_1[i] + u_1[i + 1]) - + dt**2 * f(x[i], t[n]) - ) - else: # open boundary condition - u[i] = u_1[i] + C * (u_1[i + 1] - u_1[i]) - - if user_action is not None and user_action(u, x, t, n + 1): - break - - # Switch variables before next step - u_2[:], u_1[:] = u_1, u - - cpu_time = t0 - time.perf_counter() - return u, x, t, cpu_time - - -def viz( - I, - V, - f, - c, - L, - dt, - C, - T, - umin, - umax, - animate=True, - version="vectorized", - bc_left="dudx=0", -): - """Run solver and visualize u at each time level.""" - import glob - import os - import time - - import matplotlib.pyplot as plt - - # num_frames = 100 # max no of frames in movie - - def plot_u(u, x, t, n): - """user_action function for solver.""" - bc0 = "periodic BC" if periodic else bc_left - try: - every = t.size / num_frames - except NameError: - every = 1 # plot every frame - if n % every == 0: - plt.plot( - x, - u, - "r-", - xlabel="x", - ylabel="u", - axis=[0, L, umin, umax], - title=f"t={t[n]:.3f}, x=0: {bc0}, x=L: open BC", - ) - # Let the initial condition stay on the screen for 2 - # seconds, else insert a pause of 0.2 s between each plot - time.sleep(2) if t[n] == 0 else time.sleep(0.2) - plt.savefig("frame_%04d.png" % n) # for movie making - - # Clean up old movie frames - for filename in glob.glob("frame_*.png"): - os.remove(filename) - - user_action = plot_u if animate else None - u, x, t, cpu = solver(I, V, f, c, L, dt, C, T, user_action, version, bc_left) - if not animate: - return cpu - - # Make movie files - fps = 6 # Frames per second - plt.movie("frame_*.png", encoder="html", fps=fps, output_file="movie.html") - # Ex: avconv -r 4 -i frame_%04d.png -vcodec libtheora movie.ogg - codec2ext = dict(flv="flv", libx64="mp4", libvpx="webm", libtheora="ogg") - for codec in codec2ext: - codec2ext[codec] - cmd = ( - "%(movie_program)s -r %(fps)d -i %(filespec)s " - "-vcodec %(codec)s movie.%(ext)s" % vars() - ) - print(cmd) - os.system(cmd) - return cpu - - -def plug(C=1, Nx=50, animate=True, T=2, loc=0): - """Plug profile as initial condition.""" - L = 1.0 - c = 1 - - I = lambda x: 1 if abs(x - loc) < 0.1 else 0 - - bc_left = "dudx=0" if loc == 0 else "open" - dt = (L / Nx) / c # choose the stability limit with given Nx - viz( - I, - None, - None, - c, - L, - dt, - C, - T, - umin=-0.3, - umax=1.1, - animate=animate, - bc_left=bc_left, - ) - - -def gaussian(C=1, Nx=50, animate=True, T=2, loc=0): - """Gaussian bell as initial condition.""" - L = 1.0 - c = 1 - - def I(x): - return exp(-0.5 * ((x - loc) / 0.05) ** 2) - - bc_left = "dudx=0" if loc == 0 else "open" - dt = (L / Nx) / c # choose the stability limit with given Nx - viz( - I, None, None, c, L, dt, C, T, umin=-0.2, umax=1, animate=animate, bc_left=bc_left - ) - - -if __name__ == "__main__": - import sys - - - - cmd = function_UI([plug, gaussian], sys.argv) - eval(cmd) diff --git a/chapters/wave/exer-wave/pulse1D/pulse1D.py b/chapters/wave/exer-wave/pulse1D/pulse1D.py deleted file mode 100644 index f8bb5b5e..00000000 --- a/chapters/wave/exer-wave/pulse1D/pulse1D.py +++ /dev/null @@ -1,22 +0,0 @@ -import wave1D_dn_vc as wave - -for pulse_tp in "gaussian", "cosinehat", "half-cosinehat", "plug": - for Nx in 40, 80, 160: - for sf in 2, 4: - if sf == 1 and Nx > 40: - continue # homogeneous medium with C=1: Nx=40 enough - print("wave1D.pulse:", pulse_tp, Nx, sf) - - wave.pulse( - C=1, - Nx=Nx, - animate=False, # just hardcopies - version="vectorized", - T=2, - loc="left", - pulse_tp=pulse_tp, - slowness_factor=sf, - medium=[0.7, 0.9], - skip_frame=1, - sigma=0.05, - ) diff --git a/chapters/wave/exer-wave/wave1D_n0_test_cubic.py b/chapters/wave/exer-wave/wave1D_n0_test_cubic.py deleted file mode 100644 index f4eb10ea..00000000 --- a/chapters/wave/exer-wave/wave1D_n0_test_cubic.py +++ /dev/null @@ -1,144 +0,0 @@ -import os -import sys - -sys.path.insert(0, os.path.join(os.pardir, "src-wave", "wave1D")) -import nose.tools as nt -from wave1D_n0 import solver - - -def test_cubic2(): - import sympy as sym - - x, t, c, L, dx = sym.symbols("x t c L dx") - T = lambda t: 1 + sym.Rational(1, 2) * t # Temporal term - # Set u as a 3rd-degree polynomial in space - X = lambda x: sum(a[i] * x**i for i in range(4)) - a = sym.symbols("a_0 a_1 a_2 a_3") - u = lambda x, t: X(x) * T(t) - # Force discrete boundary condition to be zero by adding - # a correction term the analytical suggestion x*(L-x)*T - # u_x = x*(L-x)*T(t) - 1/6*u_xxx*dx**2 - R = sym.diff(u(x, t), x) - ( - x * (L - x) - sym.Rational(1, 6) * sym.diff(u(x, t), x, x, x) * dx**2 - ) - # R is a polynomial: force all coefficients to vanish. - # Turn R to Poly to extract coefficients: - R = sym.poly(R, x) - coeff = R.all_coeffs() - s = sym.solve(coeff, a[1:]) # a[0] is not present in R - # s is dictionary with a[i] as keys - # Fix a[0] as 1 - s[a[0]] = 1 - X = lambda x: sym.simplify(sum(s[a[i]] * x**i for i in range(4))) - u = lambda x, t: X(x) * T(t) - print("u:", u(x, t)) - # Find source term - f = sym.diff(u(x, t), t, t) - c**2 * sym.diff(u(x, t), x, x) - f = sym.simplify(f) - print("f:", f) - - u_exact = sym.lambdify([x, t, L, dx], u(x, t), "numpy") - V_exact = sym.lambdify([x, t, L, dx], sym.diff(u(x, t), t), "numpy") - f_exact = sym.lambdify([x, t, L, dx, c], f) - - # Replace symbolic variables by numeric ones - L = 2.0 - Nx = 3 - C = 0.75 - c = 0.5 - dt = C * (L / Nx) / c - dx = dt * c / C - - I = lambda x: u_exact(x, 0, L, dx) - V = lambda x: V_exact(x, 0, L, dx) - f = lambda x, t: f_exact(x, t, L, dx, c) - - # user_action function asserts correct solution - def assert_no_error(u, x, t, n): - u_e = u_exact(x, t[n], L, dx) - diff = abs(u - u_e).max() - nt.assert_almost_equal(diff, 0, places=13) - - u, x, t, cpu = solver( - I=I, V=V, f=f, c=c, L=L, dt=dt, C=C, T=4 * dt, user_action=assert_no_error - ) - - -def test_cubic1(): - import sympy as sym - - x, t, c, L, dx, dt = sym.symbols("x t c L dx dt") - i, n = sym.symbols("i n", integer=True) - - # Assume discrete solution is a polynomial of degree 3 in x - T = lambda t: 1 + sym.Rational(1, 2) * t # Temporal term - a = sym.symbols("a_0 a_1 a_2 a_3") - X = lambda x: sum(a[q] * x**q for q in range(4)) # Spatial term - u = lambda x, t: X(x) * T(t) - - DxDx = ( - lambda u, i, n: ( - u((i - 1) * dx, n * dt) - 2 * u(i * dx, n * dt) + u((i + 1) * dx, n * dt) - ) - / dx**2 - ) - DtDt = ( - lambda u, i, n: ( - u(i * dx, (n - 1) * dt) - 2 * u(i * dx, n * dt) + u(i * dx, (n + 1) * dt) - ) - / dt**2 - ) - D2x = lambda u, i, n: (u((i + 1) * dx, n * dt) - u((i - 1) * dx, n * dt)) / (2 * dx) - R_0 = sym.simplify(D2x(u, 0, n)) # residual du/dx, x=0 - Nx = L / dx - R_L = sym.simplify(D2x(u, Nx, n)) # residual du/dx, x=L - print(R_0) - print(R_L) - # We have two equations, let a_0 and a_1 be free parameters, - # adjust a_2 and a_3 so that R_0=0 and R_L=0. - # For simplicity in final expressions, set a_0=0, a_1=1. - R_0 = R_0.subs(a[0], 0).subs(a[1], 1) - R_L = R_L.subs(a[0], 0).subs(a[1], 1) - a = list(a) # enable in-place assignment - a[0:2] = 0, 1 - s = sym.solve([R_0, R_L], a[2:]) - print(s) - a[2:] = s[a[2]], s[a[3]] - # Calling X(x) will now use new a since a has changed - print("u:", u(x, t)) - print("DxDx(x**3,i,n)", sym.simplify(DxDx(lambda x, t: x**3, i, n))) - f = DtDt(u, i, n) - c**2 * DxDx(u, i, n) - f = sym.expand(f) # Easier to simplify if expanded first - f = f.subs(i, x / dx).subs(n, t / dt) - f = sym.simplify(f) - print("f:", f) - - u_exact = sym.lambdify([x, t, L, dx], u(x, t), "numpy") - V_exact = sym.lambdify([x, t, L, dx], sym.diff(u(x, t), t), "numpy") - f_exact = sym.lambdify([x, t, L, dx, dt, c], f, "numpy") - - # Replace symbolic variables by numeric ones - L = 2.0 - Nx = 3 - C = 0.75 - c = 0.5 - dt = C * (L / Nx) / c - dx = dt * c / C - - I = lambda x: u_exact(x, 0, L, dx) - V = lambda x: V_exact(x, 0, L, dx) - f = lambda x, t: f_exact(x, t, L, dx, dt, c) - - # user_action function asserts correct solution - def assert_no_error(u, x, t, n): - u_e = u_exact(x, t[n], L, dx) - diff = abs(u - u_e).max() - nt.assert_almost_equal(diff, 0, places=13) - - u, x, t, cpu = solver( - I=I, V=V, f=f, c=c, L=L, dt=dt, C=C, T=4 * dt, user_action=assert_no_error - ) - - -test_cubic1() -test_cubic2() diff --git a/chapters/wave/exer-wave/wave1D_symmetric/wave1D_symmetric.py b/chapters/wave/exer-wave/wave1D_symmetric/wave1D_symmetric.py deleted file mode 100644 index fc0774be..00000000 --- a/chapters/wave/exer-wave/wave1D_symmetric/wave1D_symmetric.py +++ /dev/null @@ -1,335 +0,0 @@ -#!/usr/bin/env python -import numpy as np - -# Add an x0 coordinate for solving the wave equation on [x0, xL] - - -def solver(I, V, f, c, U_0, U_L, x0, xL, Nx, C, T, user_action=None, version="scalar"): - """ - Solve u_tt=c^2*u_xx + f on (0,L)x(0,T]. - u(0,t)=U_0(t) or du/dn=0 (U_0=None), u(L,t)=U_L(t) or du/dn=0 (u_L=None). - """ - x = np.linspace(x0, xL, Nx + 1) # Mesh points in space - dx = x[1] - x[0] - dt = C * dx / c - Nt = int(round(T / dt)) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points in time - C2 = C**2 - dt2 = dt * dt # Help variables in the scheme - - # Wrap user-given f, V, U_0, U_L - if f is None or f == 0: - f = (lambda x, t: 0) if version == "scalar" else lambda x, t: np.zeros(x.shape) - if V is None or V == 0: - V = (lambda x: 0) if version == "scalar" else lambda x: np.zeros(x.shape) - if U_0 is not None: - if isinstance(U_0, (float, int)) and U_0 == 0: - U_0 = lambda t: 0 - if U_L is not None: - if isinstance(U_L, (float, int)) and U_L == 0: - U_L = lambda t: 0 - - u = np.zeros(Nx + 1) # Solution array at new time level - u_1 = np.zeros(Nx + 1) # Solution at 1 time level back - u_2 = np.zeros(Nx + 1) # Solution at 2 time levels back - - Ix = range(0, Nx + 1) - It = range(0, Nt + 1) - - import time - - t0 = time.perf_counter() # CPU time measurement - # Load initial condition into u_1 - for i in Ix: - u_1[i] = I(x[i]) - - if user_action is not None: - user_action(u_1, x, t, 0) - - # Special formula for the first step - for i in Ix[1:-1]: - u[i] = ( - u_1[i] - + dt * V(x[i]) - + 0.5 * C2 * (u_1[i - 1] - 2 * u_1[i] + u_1[i + 1]) - + 0.5 * dt2 * f(x[i], t[0]) - ) - - i = Ix[0] - if U_0 is None: - # Set boundary values du/dn = 0 - # x=0: i-1 -> i+1 since u[i-1]=u[i+1] - # x=L: i+1 -> i-1 since u[i+1]=u[i-1]) - ip1 = i + 1 - im1 = ip1 # i-1 -> i+1 - u[i] = ( - u_1[i] - + dt * V(x[i]) - + 0.5 * C2 * (u_1[im1] - 2 * u_1[i] + u_1[ip1]) - + 0.5 * dt2 * f(x[i], t[0]) - ) - else: - u[0] = U_0(dt) - - i = Ix[-1] - if U_L is None: - im1 = i - 1 - ip1 = im1 # i+1 -> i-1 - u[i] = ( - u_1[i] - + dt * V(x[i]) - + 0.5 * C2 * (u_1[im1] - 2 * u_1[i] + u_1[ip1]) - + 0.5 * dt2 * f(x[i], t[0]) - ) - else: - u[i] = U_L(dt) - - if user_action is not None: - user_action(u, x, t, 1) - - # Update data structures for next step - u_2[:], u_1[:] = u_1, u - - for n in It[1:-1]: - # Update all inner points - if version == "scalar": - for i in Ix[1:-1]: - u[i] = ( - -u_2[i] - + 2 * u_1[i] - + C2 * (u_1[i - 1] - 2 * u_1[i] + u_1[i + 1]) - + dt2 * f(x[i], t[n]) - ) - - elif version == "vectorized": - u[1:-1] = ( - -u_2[1:-1] - + 2 * u_1[1:-1] - + C2 * (u_1[0:-2] - 2 * u_1[1:-1] + u_1[2:]) - + dt2 * f(x[1:-1], t[n]) - ) - else: - raise ValueError("version=%s" % version) - - # Insert boundary conditions - i = Ix[0] - if U_0 is None: - # Set boundary values - # x=0: i-1 -> i+1 since u[i-1]=u[i+1] when du/dn=0 - # x=L: i+1 -> i-1 since u[i+1]=u[i-1] when du/dn=0 - ip1 = i + 1 - im1 = ip1 - u[i] = ( - -u_2[i] - + 2 * u_1[i] - + C2 * (u_1[im1] - 2 * u_1[i] + u_1[ip1]) - + dt2 * f(x[i], t[n]) - ) - else: - u[0] = U_0(t[n + 1]) - - i = Ix[-1] - if U_L is None: - im1 = i - 1 - ip1 = im1 - u[i] = ( - -u_2[i] - + 2 * u_1[i] - + C2 * (u_1[im1] - 2 * u_1[i] + u_1[ip1]) - + dt2 * f(x[i], t[n]) - ) - else: - u[i] = U_L(t[n + 1]) - - if user_action is not None: - if user_action(u, x, t, n + 1): - break - - # Update data structures for next step - u_2[:], u_1[:] = u_1, u - - cpu_time = t0 - time.perf_counter() - return u, x, t, cpu_time - - -def viz( - I, - V, - f, - c, - U_0, - U_L, - x0, - xL, - Nx, - C, - T, - umin, - umax, - version="scalar", - animate=True, - movie_dir="tmp", -): - """Run solver and visualize u at each time level.""" - import glob - import os - import shutil - import time - - import matplotlib.pyplot as plt - - class PlotU: - def __init__(self): - self.lines = None - - def __call__(self, u, x, t, n): - """user_action function for solver.""" - if n == 0: - plt.ion() - self.lines = plt.plot(x, u, "r-") - plt.xlabel("x") - plt.ylabel("u") - plt.axis([x0, xL, umin, umax]) - plt.title("t=%f" % t[n]) - else: - self.lines[0].set_ydata(u) - plt.title("t=%f" % t[n]) - plt.draw() - time.sleep(2) if t[n] == 0 else time.sleep(0.2) - plt.savefig("frame_%04d.png" % n) - - # Clean up old movie frames - for filename in glob.glob("frame_*.png"): - os.remove(filename) - - plot_u = PlotU() - user_action = plot_u if animate else None - u, x, t, cpu = solver(I, V, f, c, U_0, U_L, x0, xL, Nx, C, T, user_action, version) - if animate: - # Make a directory with the frames - if os.path.isdir(movie_dir): - shutil.rmtree(movie_dir) - os.mkdir(movie_dir) - # Move all frame_*.png files to this subdirectory - for filename in glob.glob("frame_*.png"): - os.rename(filename, os.path.join(movie_dir, filename)) - # Create movie using ffmpeg - os.chdir(movie_dir) - fps = 4 - os.system(f"ffmpeg -r {fps} -i frame_%04d.png -vcodec libx264 movie.mp4") - os.chdir(os.pardir) - - return cpu - - -def test_quadratic(): - """ - Check the scalar and vectorized versions work for - a quadratic u(x,t)=x(L-x)(1+t/2) that is exactly reproduced. - We simulate in [0, L/2] and apply a symmetry condition - at the end x=L/2. - """ - exact_solution = lambda x, t: x * (L - x) * (1 + 0.5 * t) - I = lambda x: exact_solution(x, 0) - V = lambda x: 0.5 * exact_solution(x, 0) - f = lambda x, t: 2 * (1 + 0.5 * t) * c**2 - U_0 = lambda t: exact_solution(0, t) - U_L = None - L = 2.5 - c = 1.5 - Nx = 3 # very coarse mesh - C = 1 - T = 18 # long time integration - - def assert_no_error(u, x, t, n): - u_e = exact_solution(x, t[n]) - diff = abs(u - u_e).max() - assert diff < 1e-13, f"Max error: {diff}" - - solver( - I, - V, - f, - c, - U_0, - U_L, - 0, - L / 2, - Nx, - C, - T, - user_action=assert_no_error, - version="scalar", - ) - solver( - I, - V, - f, - c, - U_0, - U_L, - 0, - L / 2, - Nx, - C, - T, - user_action=assert_no_error, - version="vectorized", - ) - - -def plug(C=1, Nx=50, animate=True, version="scalar", T=2): - """Plug profile as initial condition.""" - L = 1.0 - c = 1 - delta = 0.1 - - def I(x): - if abs(x) > delta: - return 0 - else: - return 1 - - # Solution on [-L,L] - cpu = viz( - I, - 0, - 0, - c, - 0, - 0, - -L, - L, - 2 * Nx, - C, - T, - umin=-1.1, - umax=1.1, - version=version, - animate=animate, - movie_dir="full", - ) - - # Solution on [0,L] - cpu = viz( - I, - 0, - 0, - c, - None, - 0, - 0, - L, - Nx, - C, - T, - umin=-1.1, - umax=1.1, - version=version, - animate=animate, - movie_dir="half", - ) - - -if __name__ == "__main__": - plug() diff --git a/chapters/wave/exer-wave/wave1D_u0_s2c.py b/chapters/wave/exer-wave/wave1D_u0_s2c.py deleted file mode 100644 index 51949741..00000000 --- a/chapters/wave/exer-wave/wave1D_u0_s2c.py +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env python -""" -1D wave equation with u=0 at the boundary. -Simplest possible implementation. - -The key function is:: - - u, x, t, cpu = solver(I, V, f, c, L, dt, C, T, user_action) - -which solves the wave equation u_tt = c**2*u_xx on (0,L) with u=0 -on x=0,L, for t in (0,T]. Initial conditions: u=I(x), u_t=V(x). - -T is the stop time for the simulation. -dt is the desired time step. -C is the Courant number (=c*dt/dx), which specifies dx. -f(x,t) is a function for the source term (can be 0 or None). -I and V are functions of x. - -user_action is a function of (u, x, t, n) where the calling -code can add visualization, error computations, etc. -""" - -import numpy as np - - -def solver(I, V, f, c, L, dt, C, T, user_action=None): - """Solve u_tt=c^2*u_xx + f on (0,L)x(0,T].""" - Nt = int(round(T / dt)) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points in time - dx = dt * c / float(C) - Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - C2 = C**2 # Help variable in the scheme - # Make sure dx and dt are compatible with x and t - dx = x[1] - x[0] - dt = t[1] - t[0] - - if f is None or f == 0: - f = lambda x, t: 0 - if V is None or V == 0: - V = lambda x: 0 - - u = np.zeros(Nx + 1) # Solution array at new time level - u_1 = np.zeros(Nx + 1) # Solution at 1 time level back - u_2 = np.zeros(Nx + 1) # Solution at 2 time levels back - - import time - - t0 = time.perf_counter() # for measuring CPU time - # Load initial condition into u_1 - for i in range(0, Nx + 1): - u_1[i] = I(x[i]) - - if user_action is not None: - user_action(u_1, x, t, 0) - - # Special formula for first time step - n = 0 - for i in range(1, Nx): - u[i] = ( - u_1[i] - + dt * V(x[i]) - + 0.5 * C2 * (u_1[i - 1] - 2 * u_1[i] + u_1[i + 1]) - + 0.5 * dt**2 * f(x[i], t[n]) - ) - u[0] = 0 - u[Nx] = 0 - - if user_action is not None: - user_action(u, x, t, 1) - - # Switch variables before next step - u_2[:] = u_1 - u_1[:] = u - - for n in range(1, Nt): - # Update all inner points at time t[n+1] - for i in range(1, Nx): - u[i] = ( - -u_2[i] - + 2 * u_1[i] - + C2 * (u_1[i - 1] - 2 * u_1[i] + u_1[i + 1]) - + dt**2 * f(x[i], t[n]) - ) - - # Insert boundary conditions - u[0] = 0 - u[Nx] = 0 - if user_action is not None and user_action(u, x, t, n + 1): - break - - # Switch variables before next step - u_2[:] = u_1 - u_1[:] = u - - cpu_time = time.perf_counter() - t0 - return u, x, t, cpu_time - - -def test_quadratic(): - """Check that u(x,t)=x(L-x)(1+t/2) is exactly reproduced.""" - - def u_exact(x, t): - return x * (L - x) * (1 + 0.5 * t) - - def I(x): - return u_exact(x, 0) - - def V(x): - return 0.5 * u_exact(x, 0) - - def f(x, t): - return 2 * (1 + 0.5 * t) * c**2 - - L = 2.5 - c = 1.5 - C = 0.75 - Nx = 6 # Very coarse mesh for this exact test - dt = C * (L / Nx) / c - T = 18 - - def assert_no_error(u, x, t, n): - u_e = u_exact(x, t[n]) - diff = np.abs(u - u_e).max() - tol = 1e-13 - assert diff < tol - - solver(I, V, f, c, L, dt, C, T, user_action=assert_no_error) - - -def test_constant(): - """Check that u(x,t)=Q=0 is exactly reproduced.""" - u_const = 0 # Require 0 because of the boundary conditions - C = 0.75 - dt = C # Very coarse mesh - u, x, t, cpu = solver(I=lambda x: 0, V=0, f=0, c=1.5, L=2.5, dt=dt, C=C, T=18) - tol = 1e-14 - assert np.abs(u - u_const).max() < tol - - -def viz( - I, - V, - f, - c, - L, - dt, - C, - T, # PDE parameters - umin, - umax, # Interval for u in plots - animate=True, # Simulation with animation? - solver_function=solver, # Function with numerical algorithm -): - """Run solver, store and visualize u at each time level.""" - import glob - import os - import time - - import matplotlib.pyplot as plt - - class PlotMatplotlib: - def __init__(self): - self.all_u = [] - - def __call__(self, u, x, t, n): - """user_action function for solver.""" - if n == 0: - plt.ion() - self.lines = plt.plot(x, u, "r-") - plt.xlabel("x") - plt.ylabel("u") - plt.axis([0, L, umin, umax]) - plt.legend([f"t={t[n]:f}"], loc="lower left") - else: - self.lines[0].set_ydata(u) - plt.legend([f"t={t[n]:f}"], loc="lower left") - plt.draw() - time.sleep(2) if t[n] == 0 else time.sleep(0.2) - plt.savefig("tmp_%04d.png" % n) # for movie making - self.all_u.append(u.copy()) - - plot_u = PlotMatplotlib() - - # Clean up old movie frames - for filename in glob.glob("tmp_*.png"): - os.remove(filename) - - # Call solver and do the simulaton - user_action = plot_u if animate else None - u, x, t, cpu = solver_function(I, V, f, c, L, dt, C, T, user_action) - - # Make video files using ffmpeg - fps = 4 # frames per second - codec2ext = dict(flv="flv", libx264="mp4", libvpx="webm", libtheora="ogg") - for codec, ext in codec2ext.items(): - cmd = f"ffmpeg -r {fps} -i tmp_%04d.png -vcodec {codec} movie.{ext}" - os.system(cmd) - - return cpu, np.array(plot_u.all_u) - - -def guitar(C): - """Triangular wave (pulled guitar string).""" - L = 0.75 - x0 = 0.8 * L - a = 0.005 - freq = 440 - wavelength = 2 * L - c = freq * wavelength - from math import pi - - w = 2 * pi * freq - num_periods = 1 - T = 2 * pi / w * num_periods - # Choose dt the same as the stability limit for Nx=50 - dt = L / 50.0 / c - - def I(x): - return a * x / x0 if x < x0 else a / (L - x0) * (L - x) - - umin = -1.2 * a - umax = -umin - cpu, all_u = viz(I, 0, 0, c, L, dt, C, T, umin, umax, animate=True) - # checking - # for e in all_u: - # print e[int(len(all_u[1])/2)] - - -if __name__ == "__main__": - # test_quadratic() - import sys - - try: - C = float(sys.argv[1]) - print(f"C={C:g}") - except IndexError: - C = 0.85 - print(f"Courant number: {C:.2f}") - guitar(C) diff --git a/chapters/wave/exer-wave/wave1D_u0_s_store.py b/chapters/wave/exer-wave/wave1D_u0_s_store.py deleted file mode 100644 index 51949741..00000000 --- a/chapters/wave/exer-wave/wave1D_u0_s_store.py +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env python -""" -1D wave equation with u=0 at the boundary. -Simplest possible implementation. - -The key function is:: - - u, x, t, cpu = solver(I, V, f, c, L, dt, C, T, user_action) - -which solves the wave equation u_tt = c**2*u_xx on (0,L) with u=0 -on x=0,L, for t in (0,T]. Initial conditions: u=I(x), u_t=V(x). - -T is the stop time for the simulation. -dt is the desired time step. -C is the Courant number (=c*dt/dx), which specifies dx. -f(x,t) is a function for the source term (can be 0 or None). -I and V are functions of x. - -user_action is a function of (u, x, t, n) where the calling -code can add visualization, error computations, etc. -""" - -import numpy as np - - -def solver(I, V, f, c, L, dt, C, T, user_action=None): - """Solve u_tt=c^2*u_xx + f on (0,L)x(0,T].""" - Nt = int(round(T / dt)) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points in time - dx = dt * c / float(C) - Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - C2 = C**2 # Help variable in the scheme - # Make sure dx and dt are compatible with x and t - dx = x[1] - x[0] - dt = t[1] - t[0] - - if f is None or f == 0: - f = lambda x, t: 0 - if V is None or V == 0: - V = lambda x: 0 - - u = np.zeros(Nx + 1) # Solution array at new time level - u_1 = np.zeros(Nx + 1) # Solution at 1 time level back - u_2 = np.zeros(Nx + 1) # Solution at 2 time levels back - - import time - - t0 = time.perf_counter() # for measuring CPU time - # Load initial condition into u_1 - for i in range(0, Nx + 1): - u_1[i] = I(x[i]) - - if user_action is not None: - user_action(u_1, x, t, 0) - - # Special formula for first time step - n = 0 - for i in range(1, Nx): - u[i] = ( - u_1[i] - + dt * V(x[i]) - + 0.5 * C2 * (u_1[i - 1] - 2 * u_1[i] + u_1[i + 1]) - + 0.5 * dt**2 * f(x[i], t[n]) - ) - u[0] = 0 - u[Nx] = 0 - - if user_action is not None: - user_action(u, x, t, 1) - - # Switch variables before next step - u_2[:] = u_1 - u_1[:] = u - - for n in range(1, Nt): - # Update all inner points at time t[n+1] - for i in range(1, Nx): - u[i] = ( - -u_2[i] - + 2 * u_1[i] - + C2 * (u_1[i - 1] - 2 * u_1[i] + u_1[i + 1]) - + dt**2 * f(x[i], t[n]) - ) - - # Insert boundary conditions - u[0] = 0 - u[Nx] = 0 - if user_action is not None and user_action(u, x, t, n + 1): - break - - # Switch variables before next step - u_2[:] = u_1 - u_1[:] = u - - cpu_time = time.perf_counter() - t0 - return u, x, t, cpu_time - - -def test_quadratic(): - """Check that u(x,t)=x(L-x)(1+t/2) is exactly reproduced.""" - - def u_exact(x, t): - return x * (L - x) * (1 + 0.5 * t) - - def I(x): - return u_exact(x, 0) - - def V(x): - return 0.5 * u_exact(x, 0) - - def f(x, t): - return 2 * (1 + 0.5 * t) * c**2 - - L = 2.5 - c = 1.5 - C = 0.75 - Nx = 6 # Very coarse mesh for this exact test - dt = C * (L / Nx) / c - T = 18 - - def assert_no_error(u, x, t, n): - u_e = u_exact(x, t[n]) - diff = np.abs(u - u_e).max() - tol = 1e-13 - assert diff < tol - - solver(I, V, f, c, L, dt, C, T, user_action=assert_no_error) - - -def test_constant(): - """Check that u(x,t)=Q=0 is exactly reproduced.""" - u_const = 0 # Require 0 because of the boundary conditions - C = 0.75 - dt = C # Very coarse mesh - u, x, t, cpu = solver(I=lambda x: 0, V=0, f=0, c=1.5, L=2.5, dt=dt, C=C, T=18) - tol = 1e-14 - assert np.abs(u - u_const).max() < tol - - -def viz( - I, - V, - f, - c, - L, - dt, - C, - T, # PDE parameters - umin, - umax, # Interval for u in plots - animate=True, # Simulation with animation? - solver_function=solver, # Function with numerical algorithm -): - """Run solver, store and visualize u at each time level.""" - import glob - import os - import time - - import matplotlib.pyplot as plt - - class PlotMatplotlib: - def __init__(self): - self.all_u = [] - - def __call__(self, u, x, t, n): - """user_action function for solver.""" - if n == 0: - plt.ion() - self.lines = plt.plot(x, u, "r-") - plt.xlabel("x") - plt.ylabel("u") - plt.axis([0, L, umin, umax]) - plt.legend([f"t={t[n]:f}"], loc="lower left") - else: - self.lines[0].set_ydata(u) - plt.legend([f"t={t[n]:f}"], loc="lower left") - plt.draw() - time.sleep(2) if t[n] == 0 else time.sleep(0.2) - plt.savefig("tmp_%04d.png" % n) # for movie making - self.all_u.append(u.copy()) - - plot_u = PlotMatplotlib() - - # Clean up old movie frames - for filename in glob.glob("tmp_*.png"): - os.remove(filename) - - # Call solver and do the simulaton - user_action = plot_u if animate else None - u, x, t, cpu = solver_function(I, V, f, c, L, dt, C, T, user_action) - - # Make video files using ffmpeg - fps = 4 # frames per second - codec2ext = dict(flv="flv", libx264="mp4", libvpx="webm", libtheora="ogg") - for codec, ext in codec2ext.items(): - cmd = f"ffmpeg -r {fps} -i tmp_%04d.png -vcodec {codec} movie.{ext}" - os.system(cmd) - - return cpu, np.array(plot_u.all_u) - - -def guitar(C): - """Triangular wave (pulled guitar string).""" - L = 0.75 - x0 = 0.8 * L - a = 0.005 - freq = 440 - wavelength = 2 * L - c = freq * wavelength - from math import pi - - w = 2 * pi * freq - num_periods = 1 - T = 2 * pi / w * num_periods - # Choose dt the same as the stability limit for Nx=50 - dt = L / 50.0 / c - - def I(x): - return a * x / x0 if x < x0 else a / (L - x0) * (L - x) - - umin = -1.2 * a - umax = -umin - cpu, all_u = viz(I, 0, 0, c, L, dt, C, T, umin, umax, animate=True) - # checking - # for e in all_u: - # print e[int(len(all_u[1])/2)] - - -if __name__ == "__main__": - # test_quadratic() - import sys - - try: - C = float(sys.argv[1]) - print(f"C={C:g}") - except IndexError: - C = 0.85 - print(f"Courant number: {C:.2f}") - guitar(C) diff --git a/chapters/wave/exer-wave/wave1D_u0_sv_discont/wave1D_u0_sv_discont.py b/chapters/wave/exer-wave/wave1D_u0_sv_discont/wave1D_u0_sv_discont.py deleted file mode 100644 index 1c7c3e3d..00000000 --- a/chapters/wave/exer-wave/wave1D_u0_sv_discont/wave1D_u0_sv_discont.py +++ /dev/null @@ -1,294 +0,0 @@ -#!/usr/bin/env python -""" -Modification of wave1D_u0_sv.py for simulating waves on a -string with varying density. -""" - -from numpy import * - -# Change from parameter c to density and tension -# density is a two-list, tension is a constant -# We assume the jump in density is at x=L/2, but -# this could be a parameter. -# Note that the C2 help variable becomes different -# from the original code (C is misleading here since -# it actually has two values through the mesh, it is -# better to just use dt, dx, tension, and rho in -# the scheme!). - - -def solver( - I, V, f, density, tension, L, Nx, C, T, user_action=None, version="vectorized" -): - """Solve u_tt=c^2*u_xx + f on (0,L)x(0,T].""" - x = linspace(0, L, Nx + 1) # Mesh points in space - dx = x[1] - x[0] - # Must use largest wave velocity (c=sqrt(tension/density)) - # in stability criterion - c = sqrt(tension / min(density)) - dt = C * dx / c - # This means that the given C is applied to the medium with smallest - # density, while a lower effective C=c*dt/dx appears in the medium - # with the highest density. - rho = zeros(Nx + 1) - rho[: len(rho) / 2] = density[0] - rho[len(rho) / 2 :] = density[1] - - Nt = int(round(T / dt)) - t = linspace(0, Nt * dt, Nt + 1) # Mesh points in time - C2 = tension * dt**2 / dx**2 # Help variable in the scheme - - if f is None or f == 0: - f = (lambda x, t: 0) if version == "scalar" else lambda x, t: zeros(x.shape) - if V is None or V == 0: - V = (lambda x: 0) if version == "scalar" else lambda x: zeros(x.shape) - - u = zeros(Nx + 1) # Solution array at new time level - u_1 = zeros(Nx + 1) # Solution at 1 time level back - u_2 = zeros(Nx + 1) # Solution at 2 time levels back - - import time - - t0 = time.perf_counter() # for measuring CPU time - # Load initial condition into u_1 - for i in range(0, Nx + 1): - u_1[i] = I(x[i]) - - if user_action is not None: - user_action(u_1, x, t, 0) - - # Special formula for first time step - n = 0 - for i in range(1, Nx): - u[i] = ( - u_1[i] - + dt * V(x[i]) - + 1 - / rho[i] - * ( - 0.5 * C2 * (u_1[i - 1] - 2 * u_1[i] + u_1[i + 1]) - + 0.5 * dt**2 * f(x[i], t[n]) - ) - ) - u[0] = 0 - u[Nx] = 0 - - if user_action is not None: - user_action(u, x, t, 1) - - # Switch variables before next step - u_2[:], u_1[:] = u_1, u - - for n in range(1, Nt): - # Update all inner points at time t[n+1] - - if version == "scalar": - for i in range(1, Nx): - u[i] = ( - -u_2[i] - + 2 * u_1[i] - + 1 - / rho[i] - * ( - C2 * (u_1[i - 1] - 2 * u_1[i] + u_1[i + 1]) - + dt**2 * f(x[i], t[n]) - ) - ) - elif version == "vectorized": # (1:-1 slice style) - f_a = f(x, t[n]) # Precompute in array - u[1:-1] = ( - -u_2[1:-1] - + 2 * u_1[1:-1] - + 1 - / rho[1:-1] - * (C2 * (u_1[0:-2] - 2 * u_1[1:-1] + u_1[2:]) + dt**2 * f_a[1:-1]) - ) - - # Insert boundary conditions - u[0] = 0 - u[Nx] = 0 - if user_action is not None and user_action(u, x, t, n + 1): - break - - # Switch variables before next step - u_2[:], u_1[:] = u_1, u - - cpu_time = t0 - time.perf_counter() - return u, x, t, cpu_time - - -def viz( - I, - V, - f, - density, - tension, - L, - Nx, - C, - T, - umin, - umax, - animate=True, - movie_filename="movie", - version="vectorized", -): - """Run solver and visualize u at each time level.""" - import glob - import os - import time - - import matplotlib.pyplot as plt - - # num_frames = 100 # max no of frames in movie - - def plot_u(u, x, t, n): - """user_action function for solver.""" - try: - every = t.size / num_frames - except NameError: - every = 1 # plot every frame - if n % every == 0: - plt.plot( - x, - u, - "r-", - xlabel="x", - ylabel="u", - axis=[0, L, umin, umax], - title=f"t={t[n]:f}", - ) - # Let the initial condition stay on the screen for 2 - # seconds, else insert a pause of 0.2 s between each plot - time.sleep(2) if t[n] == 0 else time.sleep(0.2) - plt.savefig("frame_%04d.png" % n) # for movie making - - # Clean up old movie frames - for filename in glob.glob("frame_*.png"): - os.remove(filename) - - user_action = plot_u if animate else None - u, x, t, cpu = solver(I, V, f, density, tension, L, Nx, C, T, user_action, version) - if not animate: - return cpu - - # Make movie files - fps = 4 # Frames per second - plt.movie("frame_*.png", encoder="html", fps=fps, output_file="movie.html") - # Ex: avconv -r 4 -i frame_%04d.png -vcodec libtheora movie.ogg - # codec2ext = dict(flv='flv', libx64='mp4', libvpx='webm', - # libtheora='ogg') - codec2ext = dict(libtheora="ogg") - for codec in codec2ext: - codec2ext[codec] - cmd = ( - "%(movie_program)s -r %(fps)d -i %(filespec)s " - "-vcodec %(codec)s %(movie_filename)s.%(ext)s" % vars() - ) - os.system(cmd) - return cpu - - -import nose.tools as nt - - -def test_quadratic(): - """ - Check the scalar and vectorized versions work for - a quadratic u(x,t)=x(L-x)(1+t/2) that is exactly reproduced. - """ - # The following function must work for x as array or scalar - exact_solution = lambda x, t: x * (L - x) * (1 + 0.5 * t) - I = lambda x: exact_solution(x, 0) - V = lambda x: 0.5 * exact_solution(x, 0) - # f is a scalar (zeros_like(x) works for scalar x too) - f = lambda x, t: zeros_like(x) + 2 * c**2 * (1 + 0.5 * t) - - L = 2.5 - c = 1.5 - Nx = 3 # Very coarse mesh - C = 1 - T = 18 # Long time integration - - tension = 1 # just some number - # density follows from c=sqrt(tension/density) - density = [tension / c**2, tension / c**2] - - def assert_no_error(u, x, t, n): - u_e = exact_solution(x, t[n]) - diff = abs(u - u_e).max() - nt.assert_almost_equal(diff, 0, places=13) - - # solver(I, V, f, density, tension, L, Nx, C, T, - # user_action=assert_no_error, version='scalar') - solver( - I, - V, - f, - density, - tension, - L, - Nx, - C, - T, - user_action=assert_no_error, - version="vectorized", - ) - - -def guitar(): - """Triangular wave (pulled guitar string).""" - L = 0.75 - x0 = 0.8 * L - a = 0.005 - freq = 440 - wavelength = 2 * L - - def I(x): - return a * x / x0 if x < x0 else a / (L - x0) * (L - x) - - umin = -1.2 * a - umax = -umin - - # Relevant c from frequency (A tone) and wave length (string length) - c = freq * wavelength - # c = sqrt(tension/density) - # Set tension to 150 Newton (nylon string) - tension = 150 - - for jump in [0.1, 10]: - # Jump to the right where C=1, while 1/jump is the effective Courant - # number to the left - density = array([tension / c**2, jump * tension / c**2]) - # Compute period (in time) for the two pieces - # (not sure the reasoning for computing T is correct, the - # largest jump seems to give a shorter time history of the string) - c_variable = sqrt(tension / density) - omega = 2 * pi * c_variable / wavelength # omega = 2*pi*freq - P = 2 * pi / omega.min() # longest period of the two - num_periods = 1 - T = P * num_periods - Nx = 50 - C = 1.0 # perfect wave for smallest density - - print(f"*** Simulating with jump={jump:g}") - viz( - I, - 0, - 0, - density, - tension, - L, - Nx, - C, - T, - umin, - umax, - animate=True, - movie_filename=f"wave1D_u0_sv_discont_jump{jump:g}", - ) - - -if __name__ == "__main__": - # test_quadratic() # verify - guitar() diff --git a/chapters/wave/exer-wave/wave_numerics_comparison.py b/chapters/wave/exer-wave/wave_numerics_comparison.py deleted file mode 100644 index 8dacdd86..00000000 --- a/chapters/wave/exer-wave/wave_numerics_comparison.py +++ /dev/null @@ -1,222 +0,0 @@ -#!/usr/bin/env python -""" -1D wave equation with u=0 at the boundary. -Simplest possible implementation. - -The key function is:: - - u, x, t, cpu = solver(I, V, f, c, L, dt, C, T, user_action) - -which solves the wave equation u_tt = c**2*u_xx on (0,L) with u=0 -on x=0,L, for t in (0,T]. Initial conditions: u=I(x), u_t=V(x). - -T is the stop time for the simulation. -dt is the desired time step. -C is the Courant number (=c*dt/dx), which specifies dx. -f(x,t) is a function for the source term (can be 0 or None). -I and V are functions of x. - -user_action is a function of (u, x, t, n) where the calling -code can add visualization, error computations, etc. -""" - -import numpy as np - - -def solver(I, V, f, c, L, dt, C, T, user_action=None): - """Solve u_tt=c^2*u_xx + f on (0,L)x(0,T].""" - Nt = int(round(T / dt)) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points in time - dx = c * dt / C - Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - C2 = C**2 # Help variable in the scheme - # Recompute to make sure dx and dt are compatible with x and t - dx = x[1] - x[0] - dt = t[1] - t[0] - - if f is None or f == 0: - f = lambda x, t: 0 - if V is None or V == 0: - V = lambda x: 0 - - u = np.zeros(Nx + 1) # Solution array at new time level - u_1 = np.zeros(Nx + 1) # Solution at 1 time level back - u_2 = np.zeros(Nx + 1) # Solution at 2 time levels back - - import time - - t0 = time.perf_counter() # for measuring CPU time - # Load initial condition into u_1 - for i in range(0, Nx + 1): - u_1[i] = I(x[i]) - - if user_action is not None: - user_action(u_1, x, t, 0) - - # Special formula for first time step - n = 0 - for i in range(1, Nx): - u[i] = ( - u_1[i] - + dt * V(x[i]) - + 0.5 * C2 * (u_1[i - 1] - 2 * u_1[i] + u_1[i + 1]) - + 0.5 * dt**2 * f(x[i], t[n]) - ) - u[0] = 0 - u[Nx] = 0 - - if user_action is not None: - user_action(u, x, t, 1) - - # Switch variables before next step - u_2[:] = u_1 - u_1[:] = u - - for n in range(1, Nt): - # Update all inner points at time t[n+1] - for i in range(1, Nx): - u[i] = ( - -u_2[i] - + 2 * u_1[i] - + C2 * (u_1[i - 1] - 2 * u_1[i] + u_1[i + 1]) - + dt**2 * f(x[i], t[n]) - ) - - # Insert boundary conditions - u[0] = 0 - u[Nx] = 0 - if user_action is not None and user_action(u, x, t, n + 1): - break - - # Switch variables before next step - u_2[:] = u_1 - u_1[:] = u - - cpu_time = time.perf_counter() - t0 - return u, x, t, cpu_time - - -def viz( - I, - V, - f, - c, - L, - dt, - C, - T, # PDE parameters - umin, - umax, # Interval for u in plots - animate=True, # Simulation with animation? - solver_function=solver, # Function with numerical algorithm -): - """ - Run solver, store and viz. u at each time level with all C values. - """ - import glob - import os - import time - - import matplotlib.pyplot as plt - - class PlotMatplotlib: - def __init__(self): - self.all_u = [] - self.all_u_for_all_C = [] - self.x_mesh = [] # need each mesh for final plots - - def __call__(self, u, x, t, n): - """user_action function for solver.""" - self.all_u.append(u.copy()) - if t[n] == T: # i.e., whole time interv. done for this C - self.x_mesh.append(x.copy()) - self.all_u_for_all_C.append(self.all_u) - self.all_u = [] # reset to empty list - - if len(self.all_u_for_all_C) == len(C): # all C done - print("Finished all C. Proceed with plots...") - plt.ion() - # note: n will here be the last index in t[n] - for n_ in range(0, n + 1): # for each tn - plt.clf() - for j in range(len(C)): - # build plot at this tn with each - # sol. from the different C values - plt.plot(self.x_mesh[j], self.all_u_for_all_C[j][n_]) - plt.axis([0, L, umin, umax]) - plt.xlabel("x") - plt.ylabel("u") - plt.title(f"Solutions for all C at t={t[n_]:f}") - plt.draw() - # Let the init. cond. stay on the screen for - # 2 sec, else insert a pause of 0.2 s - # between each plot - time.sleep(2) if t[n_] == 0 else time.sleep(0.2) - plt.savefig("tmp_%04d.png" % n_) # for movie - - plot_u = PlotMatplotlib() - - # Clean up old movie frames - for filename in glob.glob("tmp_*.png"): - os.remove(filename) - - # Call solver and do the simulaton - user_action = plot_u if animate else None - for C_value in C: - print("C_value --------------------------------- ", C_value) - u, x, t, cpu = solver_function(I, V, f, c, L, dt, C_value, T, user_action) - - # Make video files using ffmpeg - fps = 4 # frames per second - codec2ext = dict(flv="flv", libx264="mp4", libvpx="webm", libtheora="ogg") - for codec, ext in codec2ext.items(): - cmd = f"ffmpeg -r {fps} -i tmp_%04d.png -vcodec {codec} movie.{ext}" - os.system(cmd) - - return cpu - - -def guitar(C): - """Triangular wave (pulled guitar string).""" - L = 0.75 - x0 = 0.8 * L - a = 0.005 - freq = 440 - wavelength = 2 * L - c = freq * wavelength - from math import pi - - w = 2 * pi * freq - num_periods = 1 - T = 2 * pi / w * num_periods - # Choose dt the same as the stability limit for Nx=50 - dt = L / 50.0 / c - dx = dt * c / float(C) - # Now dt is considered fixed and a list of C - # values is made by reducing increasing the dx value - # in steps of 10%. - all_C = [C] - all_C.append(c * dt / (1.1 * dx)) - all_C.append(c * dt / (1.2 * dx)) - - def I(x): - return a * x / x0 if x < x0 else a / (L - x0) * (L - x) - - umin = -1.2 * a - umax = -umin - cpu = viz(I, 0, 0, c, L, dt, all_C, T, umin, umax, animate=True) - print("cpu = ", cpu) - - -if __name__ == "__main__": - import sys - - try: - C = float(sys.argv[1]) - print(f"C={C:g}") - except IndexError: - C = 0.85 - print(f"Courant number: {C:.2f}") - # The list of C values will be generated from this C value - guitar(C) diff --git a/chapters/wave/exer-wave/wave_spectra.py b/chapters/wave/exer-wave/wave_spectra.py deleted file mode 100644 index 2a88bade..00000000 --- a/chapters/wave/exer-wave/wave_spectra.py +++ /dev/null @@ -1,42 +0,0 @@ -import matplotlib.pyplot as plt -import numpy as np - - -def spectrum(f, x): - # Discrete Fourier transform - A = np.fft.rfft(f(x)) - A_amplitude = np.abs(A) - - # Compute the corresponding frequencies - dx = x[1] - x[0] - freqs = np.linspace(0, np.pi / dx, A_amplitude.size) - - plt.plot(freqs[: len(freqs) / 2], A_amplitude[: len(freqs) / 2]) - - -# Mesh -L = 10 -Nx = 100 -x = np.linspace(0, L, Nx + 1) - -spectrum(lambda x: np.where(x < 5, 1, 0), x) -spectrum(lambda x: np.sin(np.pi * x / float(L)) + np.sin(np.pi * 20 * x / float(L)), x) -s = 0.5 -spectrum( - lambda x: 1.0 / (np.sqrt(2 * np.pi) * s) * np.exp(-0.5 * ((x - L / 2.0) / s) ** 2), x -) - - -def f(x): - r = np.zeros_like(x) - r[len(x) / 2] = 1 - return r - - -spectrum(f, x) - -figfile = "tmp" -plt.legend(["step", "2sin", "gauss", "peak"]) -plt.savefig(figfile + ".pdf") -plt.savefig(figfile + ".png") -plt.show() diff --git a/chapters/wave/exer-wave/wave_standing.py b/chapters/wave/exer-wave/wave_standing.py deleted file mode 100644 index a5d2049a..00000000 --- a/chapters/wave/exer-wave/wave_standing.py +++ /dev/null @@ -1,153 +0,0 @@ -import os -import sys - -sys.path.insert(0, os.path.join(os.pardir, os.pardir, "src-wave", "wave1D")) - -# from wave1D_u0v import solver # allows faster vectorized operations -import numpy as np -from wave1D_u0 import solver - - -def viz( - I, - V, - f, - c, - L, - dt, - C, - T, - ymax, # y axis: [-ymax, ymax] - u_exact, # u_exact(x, t) - animate="u and u_exact", # or 'error' - movie_filename="movie", -): - """Run solver and visualize u at each time level.""" - import glob - import os - - import matplotlib.pyplot as plt - - class Plot: - def __init__(self, ymax, frame_name="frame"): - self.max_error = [] # hold max amplitude errors - self.max_error_t = [] # time points corresp. to max_error - self.frame_name = frame_name - self.ymax = ymax - - def __call__(self, u, x, t, n): - """user_action function for solver.""" - if animate == "u and u_exact": - plt.clf() - plt.plot(x, u, "r-", x, u_exact(x, t[n]), "b--") - plt.xlabel("x") - plt.ylabel("u") - plt.axis([0, L, -self.ymax, self.ymax]) - plt.title(f"t={t[n]:f}") - plt.draw() - plt.pause(0.001) - else: - error = u_exact(x, t[n]) - u - local_max_error = np.abs(error).max() - # self.max_error holds the increasing amplitude error - if self.max_error == [] or local_max_error > max(self.max_error): - self.max_error.append(local_max_error) - self.max_error_t.append(t[n]) - # Use user's ymax until the error exceeds that value. - # This gives a growing max value of the yaxis (but - # never shrinking) - self.ymax = max(self.ymax, max(self.max_error)) - plt.clf() - plt.plot(x, error, "r-") - plt.xlabel("x") - plt.ylabel("error") - plt.axis([0, L, -self.ymax, self.ymax]) - plt.title(f"t={t[n]:f}") - plt.draw() - plt.pause(0.001) - plt.savefig("%s_%04d.png" % (self.frame_name, n)) - - # Clean up old movie frames - for filename in glob.glob("frame_*.png"): - os.remove(filename) - - plot = Plot(ymax) - u, x, t, cpu = solver(I, V, f, c, L, dt, C, T, plot) - - # Make plot of max error versus time - plt.figure() - plt.plot(plot.max_error_t, plot.max_error) - plt.xlabel("time") - plt.ylabel("max abs(error)") - plt.savefig("error.png") - plt.savefig("error.pdf") - - # Make .flv movie file - codec2ext = dict(flv="flv") # , libx64='mp4', - # libvpx='webm', libtheora='ogg') - - for codec in codec2ext: - codec2ext[codec] - cmd = ( - "%(movie_program)s -r %(fps)d -i %(filespec)s " - "-vcodec %(codec)s %(movie_filename)s.%(ext)s" % vars() - ) - os.system(cmd) - - -def simulations(): - from numpy import cos, pi, sin - - L = 12 # length of domain - m = 9 # 2L/m: wave length or period in space (2*pi/k, k=pi*m/L) - c = 2 # wave velocity - A = 1 # amplitude - C = 0.8 - P = 2 * pi / (pi * m * c / L) # 1 period in time - # T = 10*P - # Choose dt the same as the stability limit for Nx=50 - dt = L / 50.0 / c - - def u_exact(x, t): - return A * sin(pi * m * x / L) * cos(pi * m * c * t / L) - - def I(x): - return u_exact(x, 0) - - V = 0 - f = 0 - - viz( - I, - V, - f, - c, - L, - dt, - C, - 10.5 * P, - 0.1, - u_exact, - animate="error", - movie_filename="error", - ) - - # Very long simulation to demonstrate different curves - viz( - I, - V, - f, - c, - L, - dt, - C, - 30 * P, - 1.2 * A, - u_exact, - animate="u and u_exact", - movie_filename="solution", - ) - - -if __name__ == "__main__": - simulations() diff --git a/chapters/wave/exer-wave/wave_standing/wave_standing.py b/chapters/wave/exer-wave/wave_standing/wave_standing.py deleted file mode 100644 index 48a99f05..00000000 --- a/chapters/wave/exer-wave/wave_standing/wave_standing.py +++ /dev/null @@ -1,157 +0,0 @@ -import os -import sys - -sys.path.insert(0, os.path.join(os.pardir, os.pardir, "src-wave", "wave1D")) - -# from wave1D_u0v import solver # allows faster vectorized operations -import numpy as np -from wave1D_u0 import solver - - -def viz( - I, - V, - f, - c, - L, - Nx, - C, - T, - ymax, # y axis: [-ymax, ymax] - u_exact, # u_exact(x, t) - animate="u and u_exact", # or 'error' - movie_filename="movie", -): - """Run solver and visualize u at each time level.""" - import glob - import os - - import matplotlib.pyplot as plt - - class Plot: - def __init__(self, ymax, frame_name="frame"): - self.max_error = [] # hold max amplitude errors - self.max_error_t = [] # time points corresponding to max_error - self.frame_name = frame_name - self.ymax = ymax - - def __call__(self, u, x, t, n): - """user_action function for solver.""" - if animate == "u and u_exact": - plt.plot( - x, - u, - "r-", - x, - u_exact(x, t[n]), - "b--", - xlabel="x", - ylabel="u", - axis=[0, L, -self.ymax, self.ymax], - title=f"t={t[n]:f}", - show=True, - ) - else: - error = u_exact(x, t[n]) - u - local_max_error = np.abs(error).max() - # self.max_error holds the increasing amplitude error - if self.max_error == [] or local_max_error > max(self.max_error): - self.max_error.append(local_max_error) - self.max_error_t.append(t[n]) - # Use user's ymax until the error exceeds that value. - # This gives a growing max value of the yaxis (but - # never shrinking) - self.ymax = max(self.ymax, max(self.max_error)) - plt.plot( - x, - error, - "r-", - xlabel="x", - ylabel="error", - axis=[0, L, -self.ymax, self.ymax], - title=f"t={t[n]:f}", - show=True, - ) - plt.savefig("%s_%04d.png" % (self.frame_name, n)) - - # Clean up old movie frames - for filename in glob.glob("frame_*.png"): - os.remove(filename) - - plot = Plot(ymax) - u, x, t, cpu = solver(I, V, f, c, L, Nx, C, T, plot) - - # Make plot of max error versus time - plt.figure() - plt.plot(plot.max_error_t, plot.max_error) - plt.xlabel("time") - plt.ylabel("max abs(error)") - plt.savefig("error.png") - plt.savefig("error.pdf") - - # Make .flv movie file - codec2ext = dict(flv="flv") # , libx64='mp4', libvpx='webm', libtheora='ogg') - - for codec in codec2ext: - codec2ext[codec] - cmd = ( - "%(movie_program)s -r %(fps)d -i %(filespec)s " - "-vcodec %(codec)s %(movie_filename)s.%(ext)s" % vars() - ) - os.system(cmd) - - -def simulations(): - from numpy import cos, pi, sin - - L = 12 # length of domain - m = 8 # 2L/m is the wave length or period in space (2*pi/k, k=pi*m/L) - c = 2 # wave velocity - A = 1 # amplitude - Nx = 80 - C = 0.8 - P = 2 * pi / (pi * m * c / L) # 1 period in time - 6 * P - - def u_exact(x, t): - return A * sin(pi * m * x / L) * cos(pi * m * c * t / L) - - def I(x): - return u_exact(x, 0) - - V = 0 - f = 0 - - viz( - I, - V, - f, - c, - L, - Nx, - C, - 10.5 * P, - 0.1, - u_exact, - animate="error", - movie_filename="error", - ) - - # Very long simulation to demonstrate different curves - viz( - I, - V, - f, - c, - L, - Nx, - C, - 30 * P, - 1.2 * A, - u_exact, - animate="u and u_exact", - movie_filename="solution", - ) - - -simulations() diff --git a/chapters/wave/fig/stencil_generator.py b/chapters/wave/fig/stencil_generator.py new file mode 100644 index 00000000..e9054422 --- /dev/null +++ b/chapters/wave/fig/stencil_generator.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python +""" +Generate stencil figures for the wave equation chapter. + +This script generates three stencil diagrams: +- stencil_n_interior.png: Standard 5-point stencil at interior point +- stencil_n0_interior.png: Modified 4-point stencil for first time step +- stencil_n_left.png: Modified stencil at left boundary (Neumann condition) + +Each figure includes a legend explaining: +- Filled blue circles: Known values (computed at previous time steps) +- Empty black circle: Unknown value (to be computed) +""" + +import matplotlib.patches as mpatches +import matplotlib.pyplot as plt + + +def create_stencil_figure( + known_points, + unknown_point, + title, + filename, + xlim=(0, 5), + ylim=(0, 5), +): + """ + Create a stencil figure with legend. + + Parameters + ---------- + known_points : list of tuples + List of (i, n) coordinates for known values + unknown_point : tuple + (i, n) coordinate for the unknown value + title : str + Figure title + filename : str + Output filename + xlim, ylim : tuples + Axis limits + """ + fig, ax = plt.subplots(figsize=(8, 6)) + + # Plot grid + ax.set_xlim(xlim) + ax.set_ylim(ylim) + ax.set_xticks(range(xlim[0], xlim[1] + 1)) + ax.set_yticks(range(ylim[0], ylim[1] + 1)) + ax.grid(True, linestyle='--', alpha=0.5) + ax.set_aspect('equal') + + # Plot known points (filled blue circles) + for i, n in known_points: + circle = plt.Circle( + (i, n), 0.15, fill=True, color='blue', linewidth=2 + ) + ax.add_patch(circle) + + # Plot unknown point (empty black circle) + i, n = unknown_point + circle = plt.Circle( + (i, n), 0.15, fill=False, color='black', linewidth=2 + ) + ax.add_patch(circle) + + # Labels + ax.set_xlabel('index i', fontsize=12) + ax.set_ylabel('index n', fontsize=12) + ax.set_title(title, fontsize=14) + + # Create legend + known_patch = mpatches.Patch( + facecolor='blue', edgecolor='blue', + label='Known (from previous time steps)' + ) + unknown_patch = mpatches.Patch( + facecolor='white', edgecolor='black', + label='Unknown (to be computed)' + ) + ax.legend( + handles=[known_patch, unknown_patch], + loc='upper right', + fontsize=10, + framealpha=0.9 + ) + + plt.tight_layout() + plt.savefig(filename, dpi=150, bbox_inches='tight') + plt.close() + print(f"Generated: {filename}") + + +def main(): + # Figure 1: Standard interior stencil (5 points) + # Computing u[2]^3 from u[1]^2, u[2]^2, u[3]^2, u[2]^1 + create_stencil_figure( + known_points=[(1, 2), (2, 2), (3, 2), (2, 1)], + unknown_point=(2, 3), + title='Stencil at interior point', + filename='stencil_n_interior.png' + ) + + # Figure 2: First time step stencil (4 points, no n-1 level) + # Computing u[2]^1 from u[1]^0, u[2]^0, u[3]^0 + create_stencil_figure( + known_points=[(1, 0), (2, 0), (3, 0)], + unknown_point=(2, 1), + title='Stencil at interior point (first time step)', + filename='stencil_n0_interior.png' + ) + + # Figure 3: Left boundary stencil (Neumann condition) + # Computing u[0]^3 from u[0]^2, u[1]^2, u[0]^1 + create_stencil_figure( + known_points=[(0, 2), (1, 2), (0, 1)], + unknown_point=(0, 3), + title='Stencil at boundary point (Neumann condition)', + filename='stencil_n_left.png' + ) + + print("\nAll stencil figures generated successfully!") + print("\nLegend explanation:") + print(" - Filled blue circles: Known values (computed at previous time steps)") + print(" - Empty black circle: Unknown value (to be computed)") + + +if __name__ == '__main__': + main() diff --git a/chapters/wave/fig/stencil_n0_interior.png b/chapters/wave/fig/stencil_n0_interior.png index 5ddf0334..223bbd92 100644 Binary files a/chapters/wave/fig/stencil_n0_interior.png and b/chapters/wave/fig/stencil_n0_interior.png differ diff --git a/chapters/wave/fig/stencil_n_interior.png b/chapters/wave/fig/stencil_n_interior.png index a090e446..7a5a0633 100644 Binary files a/chapters/wave/fig/stencil_n_interior.png and b/chapters/wave/fig/stencil_n_interior.png differ diff --git a/chapters/wave/fig/stencil_n_left.png b/chapters/wave/fig/stencil_n_left.png index 66f836dc..e9ce4d59 100644 Binary files a/chapters/wave/fig/stencil_n_left.png and b/chapters/wave/fig/stencil_n_left.png differ diff --git a/chapters/wave/index.qmd b/chapters/wave/index.qmd index 0667a4eb..b61f3f8b 100644 --- a/chapters/wave/index.qmd +++ b/chapters/wave/index.qmd @@ -6,8 +6,6 @@ {{< include wave1D_features.qmd >}} -{{< include wave1D_prog.qmd >}} - {{< include wave1D_fd2.qmd >}} {{< include wave_analysis.qmd >}} @@ -16,8 +14,6 @@ {{< include wave2D_devito.qmd >}} -{{< include wave2D_prog.qmd >}} - {{< include wave_app.qmd >}} {{< include wave_app_exer.qmd >}} diff --git a/chapters/wave/wave1D_devito.qmd b/chapters/wave/wave1D_devito.qmd index 35c8b296..5aa52ff5 100644 --- a/chapters/wave/wave1D_devito.qmd +++ b/chapters/wave/wave1D_devito.qmd @@ -235,3 +235,117 @@ The key advantages of using Devito for wave equations: The explicit time-stepping loop remains visible to the user for educational purposes, but Devito handles the spatial discretization and can generate highly optimized code for the inner loop. + +### Neumann Boundary Conditions {#sec-wave-devito-neumann} + +For Neumann boundary conditions $\partial u/\partial x = 0$ at the +boundaries, Devito can use ghost points or modified stencils. The +ghost point approach extends the grid with extra points outside the +domain: + +```python +from devito import Grid, TimeFunction, Eq, solve, Operator + +# Grid with ghost points for Neumann BCs +Nx = 100 +grid = Grid(shape=(Nx + 3,), extent=(1.0,)) # Extra points at each end +u = TimeFunction(name='u', grid=grid, time_order=2, space_order=2) + +# PDE for interior points +pde = u.dt2 - c**2 * u.dx2 +stencil = Eq(u.forward, solve(pde, u.forward)) + +# Neumann BCs: u[0] = u[2] and u[-1] = u[-3] +bc_left = Eq(u.forward[0], u.forward[2]) +bc_right = Eq(u.forward[Nx+2], u.forward[Nx]) + +op = Operator([stencil, bc_left, bc_right]) +``` + +The symmetry condition $u_{-1} = u_1$ effectively implements the +zero-derivative condition at the boundary. + +### Verification with Exact Solutions {#sec-wave-devito-verification} + +The most rigorous verification approach uses solutions that the +numerical method should reproduce exactly. For the wave equation, +a quadratic polynomial in space and linear in time works: + +$$ +u_{\text{exact}}(x, t) = x(L-x)(1 + t/2) +$$ + +This satisfies: +- Boundary conditions: $u(0,t) = u(L,t) = 0$ +- Initial condition: $I(x) = x(L-x)$ +- Initial velocity: $V(x) = \frac{1}{2}x(L-x)$ + +The source term $f(x,t)$ is found by substitution into the PDE: +$$ +f(x,t) = 2c^2(1 + t/2) +$$ + +Since the finite difference truncation error involves fourth-order +derivatives (which vanish for polynomials of degree 3 or less), the +numerical solution should match the exact solution to machine precision: + +```python +import numpy as np + +def test_quadratic_solution(): + """Verify solver with exact polynomial solution.""" + L, c = 2.5, 1.5 + C = 0.75 + Nx = 20 + T = 2.0 + + def u_exact(x, t): + return x * (L - x) * (1 + 0.5 * t) + + def I(x): + return u_exact(x, 0) + + def V(x): + return 0.5 * x * (L - x) + + def f(x, t): + return 2 * c**2 * (1 + 0.5 * t) + + result = solve_wave_1d(L=L, c=c, Nx=Nx, T=T, C=C, I=I, V=V, f=f) + + error = np.abs(result.u - u_exact(result.x, T)).max() + assert error < 1e-12, f"Error {error} exceeds tolerance" +``` + +### Convergence Rate Testing {#sec-wave-devito-convergence} + +For more general solutions, we verify the expected $O(\Delta x^2 + \Delta t^2)$ +convergence rate by running simulations on successively refined meshes: + +```python +def compute_convergence_rate(errors, h_values): + """Compute convergence rate from error sequence.""" + rates = [] + for i in range(1, len(errors)): + rate = np.log(errors[i-1] / errors[i]) / np.log(h_values[i-1] / h_values[i]) + rates.append(rate) + return rates + +# Run with mesh refinement +grid_sizes = [20, 40, 80, 160, 320] +errors = [] + +for Nx in grid_sizes: + result = solve_wave_1d(L=1.0, c=1.0, Nx=Nx, T=0.5, C=0.9, I=I) + error = np.abs(result.u - u_exact(result.x, 0.5)).max() + errors.append(error) + +rates = compute_convergence_rate(errors, [1.0/Nx for Nx in grid_sizes]) +print(f"Observed rates: {rates}") # Should approach 2.0 +``` + +:::{.callout-note title="Automatic Optimization"} +Devito generates optimized C code with cache-efficient loops and +parallelization. This replaces manual NumPy vectorization while +achieving better performance on modern hardware. +::: diff --git a/chapters/wave/wave1D_fd1.qmd b/chapters/wave/wave1D_fd1.qmd index d3f30add..5f69c4c0 100644 --- a/chapters/wave/wave1D_fd1.qmd +++ b/chapters/wave/wave1D_fd1.qmd @@ -34,8 +34,16 @@ in the forthcoming text by finite difference methods. ## Simulation of waves on a string {#sec-wave-string} +::: {.callout-note} +## Devito Implementation + +After understanding the finite difference discretization in this section, +see @sec-wave-devito for the Devito-based implementation. The tested solver +is available in `src/wave/wave1D_devito.py` with tests in `tests/test_wave_devito.py`. +::: + We begin our study of wave equations by simulating one-dimensional -waves on a string, say on a guitar or violin. +waves on a string, such as those found on stringed instruments. Let the string in the undeformed state coincide with the interval $[0,L]$ on the $x$ axis, and let $u(x,t)$ be the displacement at @@ -184,7 +192,7 @@ a typical stencil is illustrated in Figure equations as *discrete equations*, *(finite) difference equations* or a *finite difference scheme*. -![Mesh in space and time. The circles show points connected in a finite difference equation.](fig/stencil_n_interior){#fig-wave-pde1-fig-mesh width="500px"} +![Mesh in space and time. Filled circles represent known values from previous time steps; the empty circle is the unknown to be computed.](fig/stencil_n_interior){#fig-wave-pde1-fig-mesh width="500px"} ### Algebraic version of the initial conditions We also need to replace the derivative in the initial condition @@ -192,7 +200,7 @@ We also need to replace the derivative in the initial condition A centered difference of the type $$ \frac{\partial}{\partial t} u(x_i,t_0)\approx -\frac{u^1_i - u^{-1}**i}{2\Delta t} = [D**{2t} u]^0_i, +\frac{u^1_i - u^{-1}_i}{2\Delta t} = [D_{2t} u]^0_i, $$ seems appropriate. Writing out this equation and ordering the terms give $$ @@ -211,7 +219,7 @@ $i=0,\ldots,N_x$. The only unknown quantity in solve for: $$ u^{n+1}_i = -u^{n-1}_i + 2u^n_i + C^2 -\left(u^{n}**{i+1}-2u^{n}**{i} + u^{n}_{i-1}\right)\tp +\left(u^{n}_{i+1}-2u^{n}_{i} + u^{n}_{i-1}\right)\tp $$ {#eq-wave-pde1-step4} We have here introduced the parameter $$ @@ -250,12 +258,12 @@ use the initial condition (@eq-wave-pde1-step3c) in combination with arrive at a special formula for $u_i^1$: $$ u_i^1 = u^0_i - \half -C^2\left(u^{0}**{i+1}-2u^{0}**{i} + u^{0}_{i-1}\right) \tp +C^2\left(u^{0}_{i+1}-2u^{0}_{i} + u^{0}_{i-1}\right) \tp $$ {#eq-wave-pde1-step4-1} Figure @fig-wave-pde1-fig-stencil-u1 illustrates how (@eq-wave-pde1-step4-1) connects four instead of five points: $u^1_2$, $u_1^0$, $u_2^0$, and $u_3^0$. -![Modified stencil for the first time step.](fig/stencil_n0_interior){#fig-wave-pde1-fig-stencil-u1 width="500px"} +![Modified stencil for the first time step. Only values at $n=0$ (initial condition) are needed since there is no $n-1$ level yet.](fig/stencil_n0_interior){#fig-wave-pde1-fig-stencil-u1 width="500px"} We can now summarize the computational algorithm: @@ -299,15 +307,15 @@ for i in range(0, Nx+1): for i in range(1, Nx): u[i] = u_n[i] - \ - 0.5*C**2(u_n[i+1] - 2*u_n[i] + u_n[i-1]) + 0.5*C**2 * (u_n[i+1] - 2*u_n[i] + u_n[i-1]) u[0] = 0; u[Nx] = 0 # Enforce boundary conditions u_nm1[:], u_n[:] = u_n, u for n in range(1, Nt): for i in range(1, Nx): - u[i] = 2u_n[i] - u_nm1[i] - \ - C**2(u_n[i+1] - 2*u_n[i] + u_n[i-1]) + u[i] = 2*u_n[i] - u_nm1[i] - \ + C**2 * (u_n[i+1] - 2*u_n[i] + u_n[i-1]) u[0] = 0; u[Nx] = 0 @@ -348,7 +356,7 @@ $$ {#eq-wave-pde2-fdop} Writing this out and solving for the unknown $u^{n+1}_i$ results in $$ u^{n+1}_i = -u^{n-1}_i + 2u^n_i + C^2 -(u^{n}**{i+1}-2u^{n}**{i} + u^{n}_{i-1}) + \Delta t^2 f^n_i \tp +(u^{n}_{i+1}-2u^{n}_{i} + u^{n}_{i-1}) + \Delta t^2 f^n_i \tp $$ {#eq-wave-pde2-step3b} The equation for the first time step must be rederived. The discretization @@ -362,7 +370,7 @@ the special formula $$ u^{1}_i = u^0_i - \Delta t V_i + {\half} C^2 -\left(u^{0}**{i+1}-2u^{0}**{i} + u^{0}_{i-1}\right) + \half\Delta t^2 f^0_i \tp +\left(u^{0}_{i+1}-2u^{0}_{i} + u^{0}_{i-1}\right) + \half\Delta t^2 f^0_i \tp $$ {#eq-wave-pde2-step3c} ## Using an analytical solution of physical significance {#sec-wave-pde2-fd-standing-waves} @@ -497,10 +505,10 @@ An alternative error measure is to use a spatial norm at one time step only, e.g., the end time $T$ ($n=N_t$): \begin{align} -E &= ||e^n_i||**{\ell^2} = \left( \Delta x\sum**{i=0}^{N_x} +E &= ||e^n_i||_{\ell^2} = \left( \Delta x\sum_{i=0}^{N_x} (e^n_i)^2\right)^{\half},\quad e^n_i = \uex(x_i,t_n)-u^n_i, \\ -E &= ||e^n_i||**{\ell^\infty} = \max**{0\leq i\leq N_x} |e^{n}_i|\tp +E &= ||e^n_i||_{\ell^\infty} = \max_{0\leq i\leq N_x} |e^{n}_i|\tp \end{align} The important point is that the error measure ($E$) for the simulation is represented by a single number. diff --git a/chapters/wave/wave1D_fd2.qmd b/chapters/wave/wave1D_fd2.qmd index 6534c88d..c7c24256 100644 --- a/chapters/wave/wave1D_fd2.qmd +++ b/chapters/wave/wave1D_fd2.qmd @@ -1,5 +1,14 @@ ## Neumann boundary conditions {#sec-wave-pde2-Neumann} +::: {.callout-note} +## Source Files + +The verification and convergence testing functions presented in this chapter +(`test_plug`, `convergence_rates`, `PlotAndStoreSolution`) demonstrate +important software engineering practices. For Devito-based wave solvers with +comprehensive tests, see `src/wave/wave1D_devito.py` and `tests/test_wave_devito.py`. +::: + The boundary condition $u=0$ in a wave equation reflects the wave, but $u$ changes sign at the boundary, while the condition $u_x=0$ reflects the wave as a mirror and preserves the sign. @@ -76,7 +85,7 @@ computed since the point is outside the mesh. However, if we combine (@eq-wave-pde1-Neumann-0-cd) with the scheme $$ u^{n+1}_i = -u^{n-1}_i + 2u^n_i + C^2 -\left(u^{n}**{i+1}-2u^{n}**{i} + u^{n}_{i-1}\right), +\left(u^{n}_{i+1}-2u^{n}_{i} + u^{n}_{i-1}\right), $$ {#eq-wave-pde1-Neumann-0-scheme} for $i=0$, we can eliminate the fictitious value $u_{-1}^n$. We see that $u_{-1}^n=u_1^n$ from (@eq-wave-pde1-Neumann-0-cd), which @@ -84,13 +93,13 @@ can be used in (@eq-wave-pde1-Neumann-0-scheme) to arrive at a modified scheme for the boundary point $u_0^{n+1}$: $$ u^{n+1}_i = -u^{n-1}_i + 2u^n_i + 2C^2 -\left(u^{n}**{i+1}-u^{n}**{i}\right),\quad i=0 \tp +\left(u^{n}_{i+1}-u^{n}_{i}\right),\quad i=0 \tp $$ Figure @fig-wave-pde1-fig-Neumann-stencil visualizes this equation for computing $u^3_0$ in terms of $u^2_0$, $u^1_0$, and $u^2_1$. -![Modified stencil at a boundary with a Neumann condition.](fig/stencil_n_left){#fig-wave-pde1-fig-Neumann-stencil width="500px"} +![Modified stencil at a boundary with a Neumann condition. The ghost point $u_{-1}^n$ is eliminated using $u_{-1}^n = u_1^n$.](fig/stencil_n_left){#fig-wave-pde1-fig-Neumann-stencil width="500px"} Similarly, (@eq-wave-pde1-Neumann-0) applied at $x=L$ is discretized by a central difference @@ -101,7 +110,7 @@ Combined with the scheme for $i=N_x$ we get a modified scheme for the boundary value $u_{N_x}^{n+1}$: $$ u^{n+1}_i = -u^{n-1}_i + 2u^n_i + 2C^2 -\left(u^{n}**{i-1}-u^{n}**{i}\right),\quad i=N_x \tp +\left(u^{n}_{i-1}-u^{n}_{i}\right),\quad i=N_x \tp $$ The modification of the scheme at the boundary is also required for the special formula for the first time step. @@ -146,22 +155,10 @@ for i in range(0, Nx+1): u[i] = u_n[i] + C2*(u_n[im1] - 2*u_n[i] + u_n[ip1]) ``` -The program [`wave1D_n0.py`](https://github.com/devitocodes/devito_book/tree/main/src/wave/wave1D/wave1D_n0.py) -contains a complete implementation of the 1D wave equation with -boundary conditions $u_x = 0$ at $x=0$ and $x=L$. - -It would be nice to modify the `test_quadratic` test case from the -`wave1D_u0.py` with Dirichlet conditions, described in Section -@sec-wave-pde1-impl-vec-verify-quadratic. However, the Neumann -conditions require the polynomial variation in the $x$ direction to -be of third degree, which causes challenging problems when -designing a test where the numerical solution is known exactly. -Exercise @sec-wave-fd2-exer-verify-cubic outlines ideas and code -for this purpose. The only test in `wave1D_n0.py` is to start -with a plug wave at rest and see that the initial condition is -reached again perfectly after one period of motion, but such -a test requires $C=1$ (so the numerical solution coincides with -the exact solution of the PDE, see Section @sec-wave-pde1-num-dispersion). +For a complete implementation of Neumann boundary conditions using Devito, see +@sec-wave-devito in the wave equation chapter. Devito handles boundary +conditions through SubDomains, which provide a clean separation between +interior updates and boundary treatment. ## Index set notation {#sec-wave-indexset} @@ -232,10 +229,10 @@ A finite difference scheme can with the index set notation be specified as \begin{align*} u_i^{n+1} &= u^n_i - \half -C^2\left(u^{n}**{i+1}-2u^{n}**{i} + u^{n}_{i-1}\right),\quad, +C^2\left(u^{n}_{i+1}-2u^{n}_{i} + u^{n}_{i-1}\right),\quad, i\in\seti{\Ix},\ n=0,\\ u^{n+1}_i &= -u^{n-1}_i + 2u^n_i + C^2 -\left(u^{n}**{i+1}-2u^{n}**{i}+u^{n}_{i-1}\right), +\left(u^{n}_{i+1}-2u^{n}_{i}+u^{n}_{i-1}\right), \quad i\in\seti{\Ix},\ n\in\seti{\It},\\ u_i^{n+1} &= 0, \quad i=\setb{\Ix},\ n\in\setl{\It},\\ @@ -256,19 +253,17 @@ for n in It[1:-1]: i = Ix[-1]; u[i] = 0 ``` -:::{.callout-note title="The program [`wave1D_dn.py`](https://github.com/devitocodes/devito_book/tree/main/src/wave/wave1D/wave1D_dn.py)"} -applies the index set notation and -solves the 1D wave equation $u_{tt}=c^2u_{xx}+f(x,t)$ with -quite general boundary and initial conditions: +:::{.callout-note title="Boundary conditions in Devito"} +The 1D wave equation $u_{tt}=c^2u_{xx}+f(x,t)$ with general boundary +and initial conditions can be solved using Devito. See @sec-wave-devito +for the implementation that handles: * $x=0$: $u=U_0(t)$ or $u_x=0$ * $x=L$: $u=U_L(t)$ or $u_x=0$ * $t=0$: $u=I(x)$ * $t=0$: $u_t=V(x)$ -The program combines Dirichlet and Neumann conditions, scalar and vectorized -implementation of schemes, and the index set notation into one piece of code. -A lot of test examples are also included in the program: +Common test cases include: * A rectangular plug-shaped initial condition. (For $C=1$ the solution will be a rectangle that jumps one cell per time step, making the case @@ -276,83 +271,19 @@ A lot of test examples are also included in the program: * A Gaussian function as initial condition. * A triangular profile as initial condition, which resembles the typical initial shape of a guitar string. - * A sinusoidal variation of $u$ at $x=0$ and either $u=0$ or - $u_x=0$ at $x=L$. - * An analytical solution $u(x,t)=\cos(m\pi t/L)\sin({\half}m\pi x/L)$, which can be used for convergence rate tests. ::: ## Verifying the implementation of Neumann conditions {#sec-wave-pde1-verify} How can we test that the Neumann conditions are correctly implemented? -The `solver` function in the `wave1D_dn.py` program described in the -box above accepts Dirichlet or Neumann conditions at $x=0$ and $x=L$. It is tempting to apply a quadratic solution as described in -Sections @sec-wave-pde2-fd and @sec-wave-pde1-impl-verify-quadratic, -but it turns out that this solution is no longer an exact solution +@sec-wave-pde2-fd-verify-quadratic, but it turns out that this solution +is no longer an exact solution of the discrete equations if a Neumann condition is implemented on the boundary. A linear solution does not help since we only have -homogeneous Neumann conditions in `wave1D_dn.py`, and we are +homogeneous Neumann conditions, and we are consequently left with testing just a constant solution: $u=\hbox{const}$. -```python -def test_constant(): - """ - Check the scalar and vectorized versions for - a constant u(x,t). We simulate in [0, L] and apply - Neumann and Dirichlet conditions at both ends. - """ - u_const = 0.45 - u_exact = lambda x, t: u_const - I = lambda x: u_exact(x, 0) - V = lambda x: 0 - f = lambda x, t: 0 - - def assert_no_error(u, x, t, n): - u_e = u_exact(x, t[n]) - diff = np.abs(u - u_e).max() - msg = "diff=%E, t_%d=%g" % (diff, n, t[n]) - tol = 1e-13 - assert diff < tol, msg - - for U_0 in (None, lambda t: u_const): - for U_L in (None, lambda t: u_const): - L = 2.5 - c = 1.5 - C = 0.75 - Nx = 3 # Very coarse mesh for this exact test - dt = C * (L / Nx) / c - T = 18 # long time integration - - solver( - I, - V, - f, - c, - U_0, - U_L, - L, - dt, - C, - T, - user_action=assert_no_error, - version="scalar", - ) - solver( - I, - V, - f, - c, - U_0, - U_L, - L, - dt, - C, - T, - user_action=assert_no_error, - version="vectorized", - ) - print(U_0, U_L) -``` The quadratic solution is very useful for testing, but it requires Dirichlet conditions at both ends. @@ -508,8 +439,8 @@ u[i+1] = u[i-1] The physical solution to be plotted is now in `u[1:-1]`, or equivalently `u[Ix[0]:Ix[-1]+1]`, so this slice is the quantity to be returned from a solver function. -A complete implementation appears in the program -[`wave1D_n0_ghost.py`](https://github.com/devitocodes/devito_book/tree/main/src/wave/wave1D/wave1D_n0_ghost.py). +In Devito, ghost cells are handled automatically through the halo +region mechanism. See @sec-wave-devito for the Devito implementation. :::{.callout-warning title="We have to be careful with how the spatial and temporal mesh"} points are stored. Say we let `x` be the physical mesh points, @@ -946,7 +877,7 @@ gives the scheme $$ u^{n+1}_i = (1 + {\half}b\Delta t)^{-1}(({\half}b\Delta t -1) u^{n-1}_i + 2u^n_i + C^2 -\left(u^{n}**{i+1}-2u^{n}**{i} + u^{n}_{i-1}\right) + \Delta t^2 f^n_i), +\left(u^{n}_{i+1}-2u^{n}_{i} + u^{n}_{i-1}\right) + \Delta t^2 f^n_i), $$ {#eq-wave-pde3-fd2} for $i\in\seti{\Ix}$ and $n\geq 1$. New equations must be derived for $u^1_i$, and for boundary points in case @@ -958,9 +889,7 @@ without damping relevant for a lot of applications. ## Building a general 1D wave equation solver {#sec-wave-pde2-software} -The program [`wave1D_dn_vc.py`](https://github.com/devitocodes/devito_book/tree/main/src/wave/wave1D/wave1D_dn_vc.py) -is a fairly general code for 1D wave propagation problems that -targets the following initial-boundary value problem +A general 1D wave propagation solver targets the following initial-boundary value problem $$ u_{tt} = (c^2(x)u_x)_x + f(x,t),\quad x\in (0,L),\ t\in (0,T] @@ -989,37 +918,26 @@ i = Ix[-1] # x=L u[i] = U_L(t[n+1]) ``` -The `solver` function is a natural extension of the simplest -`solver` function in the initial `wave1D_u0.py` program, -extended with Neumann boundary conditions ($u_x=0$), -time-varying Dirichlet conditions, as well as -a variable wave velocity. The different code segments needed -to make these extensions have been shown and commented upon in the -preceding text. We refer to the `solver` function in the -`wave1D_dn_vc.py` file for all the details. Note in that - `solver` function, however, that the technique of "hashing" is -used to check whether a certain simulation has been run before, or not. -This technique is further explained in Section @sec-softeng2-wave1D-filestorage-hash. - -The vectorization is only applied inside the time loop, not for the -initial condition or the first time steps, since this initial work -is negligible for long time simulations in 1D problems. +For the Devito implementation of the 1D wave equation with general +boundary conditions and variable wave velocity, see @sec-wave-devito. +The Devito solver extends the basic solver with: + +- Neumann boundary conditions ($u_x=0$) +- Time-varying Dirichlet conditions +- Variable wave velocity $c(x)$ The following sections explain various more advanced programming -techniques applied in the general 1D wave equation solver. +techniques for wave equation solvers. ## User action function as a class -A useful feature in the `wave1D_dn_vc.py` program is the specification -of the `user_action` function as a class. This part of the program may -need some motivation and explanation. Although the `plot_u_st` -function (and the `PlotMatplotlib` class) in the `wave1D_u0.viz` -function remembers the local variables in the `viz` function, it is a -cleaner solution to store the needed variables together with the -function, which is exactly what a class offers. + +When building flexible solvers, it is useful to implement the +callback function for visualization and data storage as a class. +This provides a clean way to store state needed between calls. ### The code -A class for flexible plotting, cleaning up files, making movie -files, like the function `wave1D_u0.viz` did, can be coded as follows: +A class for flexible plotting, cleaning up files, and making movie +files can be coded as follows: ```python class PlotAndStoreSolution: @@ -1122,10 +1040,9 @@ More details on storing the solution in files appear in ## Pulse propagation in two media -The function `pulse` in `wave1D_dn_vc.py` demonstrates wave motion in -heterogeneous media where $c$ varies. One can specify an interval -where the wave velocity is decreased by a factor `slowness_factor` -(or increased by making this factor less than one). +Wave motion in heterogeneous media where $c$ varies is an important +application. One can specify an interval where the wave velocity is +decreased by a factor (or increased by making this factor less than one). Figure @fig-wave-pde1-fig-pulse1-two-media shows a typical simulation scenario. @@ -1309,26 +1226,15 @@ value of $C$ (i.e., $\Delta x$ is varied when the Courant number varies). ::: -The reader is encouraged to play around with the `pulse` function: - -```python ->>> import wave1D_dn_vc as w ->>> w.pulse(Nx=50, loc='left', pulse_tp='cosinehat', slowness_factor=2) -``` -To easily kill the graphics by Ctrl-C and restart a new simulation it might be -easier to run the above two statements from the command line -with - -```bash -Terminal> python -c 'import wave1D_dn_vc as w; w.pulse(...)' -``` +The reader is encouraged to experiment with pulse propagation using +the Devito solver from @sec-wave-devito, which can be configured +with different initial pulse shapes and variable wave velocities. ## Exercise: Find the analytical solution to a damped wave equation {#sec-wave-exer-standingwave-damped-uex} Consider the wave equation with damping (@eq-wave-pde3). The goal is to find an exact solution to a wave problem with damping and zero source term. -A starting point is the standing wave solution from -Exercise @sec-wave-exer-standingwave. It becomes necessary to +A starting point is the standing wave solution $u = A \sin(k x) \cos(\omega t)$. It becomes necessary to include a damping term $e^{-\beta t}$ and also have both a sine and cosine component in time: $$ @@ -1484,24 +1390,13 @@ confirm that they are the same. ::: {.callout-tip collapse="true" title="Solution"} -We can utilize the `wave1D_dn.py` code which allows Dirichlet and -Neumann conditions. The `solver` and `viz` functions must take $x_0$ -and $x_L$ as parameters instead of just $L$ such that we can solve the -wave equation in $[x_0, x_L]$. The we can call up `solver` for the two -problems on $[-L,L]$ and $[0,L]$ with boundary conditions -$u(-L,t)=u(L,t)=0$ and $u_x(0,t)=u(L,t)=0$, respectively. - -The original `wave1D_dn.py` code makes a movie by playing all the -`.png` files in a browser. It can then be wise to let the `viz` -function create a movie directory and place all the frames and HTML -player file in that directory. Alternatively, one can just make -some ordinary movie file (Ogg, WebM, MP4, Flash) with `avconv` or -`ffmpeg` and give it a name. It is a point that the name is -transferred to `viz` so it is easy to call `viz` twice and get two -separate movie files or movie directories. - -The plots produced by the code (below) shows that the solutions indeed -are the same. +The approach is to solve the two problems: on $[-L,L]$ with boundary conditions +$u(-L,t)=u(L,t)=0$, and on $[0,L]$ with boundary conditions +$u_x(0,t)=0$ and $u(L,t)=0$. See @sec-wave-devito for +the Devito implementation with Neumann boundary conditions. + +The solutions from the two formulations should be identical, +demonstrating the validity of the symmetry approach. ::: @@ -1640,35 +1535,20 @@ def test_quadratic(): ## Exercise: Send pulse waves through a layered medium {#sec-wave-app-exer-pulse1D} -Use the `pulse` function in `wave1D_dn_vc.py` to investigate -sending a pulse, located with its peak at $x=0$, through two +Investigate sending a pulse, located with its peak at $x=0$, through two media with different wave velocities. The (scaled) velocity in the left medium is 1 while it is $\frac{1}{s_f}$ in the right medium. Report what happens with a Gaussian pulse, a "cosine hat" pulse, half a "cosine hat" pulse, and a plug pulse for resolutions $N_x=40,80,160$, and $s_f=2,4$. Simulate until $T=2$. +Use the Devito solver from @sec-wave-devito with variable wave velocity. + ::: {.callout-tip collapse="true" title="Solution"} In all cases, the change in velocity causes some of the wave to be reflected back (while the rest is let through). When the waves go from higher to lower velocity, the amplitude builds, and vice versa. - -```python -import os -import sys - -path = os.path.join( - os.pardir, os.pardir, os.pardir, os.pardir, "wave", "src-wave", "wave1D" -) -sys.path.insert(0, path) -from wave1D_dn_vc import pulse - -pulse_tp = sys.argv[1] -C = float(sys.argv[2]) -pulse(pulse_tp=pulse_tp, C=C, Nx=100, animate=False, slowness_factor=4) -``` - ::: @@ -1761,8 +1641,8 @@ Because this simplified implementation of the open boundary condition works, there is no need to pursue the more complicated discretization in a). -:::{.callout-tip title="Modify the solver function in"} -[`wave1D_dn.py`](https://github.com/devitocodes/devito_book/tree/main/src/wave/wave1D/wave1D_dn.py). +:::{.callout-tip title="Hint"} +Modify the Devito solver from @sec-wave-devito to implement this condition. ::: @@ -1918,14 +1798,12 @@ a) or b). ## Exercise: Verification by a cubic polynomial in space {#sec-wave-fd2-exer-verify-cubic} -The purpose of this exercise is to verify the implementation of the -`solver` function in the program [`wave1D_n0.py`](https://github.com/devitocodes/devito_book/tree/main/src/wave/wave1D/wave1D_n0.py) by using an exact numerical solution +The purpose of this exercise is to verify the implementation of a wave +equation solver using an exact numerical solution for the wave equation $u_{tt}=c^2u_{xx} + f$ with Neumann boundary -conditions $u_x(0,t)=u_x(L,t)=0$. +conditions $u_x(0,t)=u_x(L,t)=0$. Use the Devito solver from @sec-wave-devito. -A similar verification is used in the file [`wave1D_u0.py`](https://github.com/devitocodes/devito_book/tree/main/src/wave/wave1D/wave1D_u0.py), which solves the same PDE, but with -Dirichlet boundary conditions $u(0,t)=u(L,t)=0$. The idea of the -verification test in function `test_quadratic` in `wave1D_u0.py` is to +The idea of verification using a quadratic polynomial is to produce a solution that is a lower-order polynomial such that both the PDE problem, the boundary conditions, and all the discrete equations are exactly fulfilled. Then the `solver` function should reproduce @@ -1978,7 +1856,7 @@ There are two different ways of determining the coefficients $a_0,\ldots,a_3$ such that both the discretized PDE and the discretized boundary conditions are fulfilled, under the constraint that we can specify a function $f(x,t)$ for the PDE to feed -to the `solver` function in `wave1D_n0.py`. Both approaches +to the solver function. Both approaches are explained in the subexercises. **a)** diff --git a/chapters/wave/wave1D_prog.qmd b/chapters/wave/wave1D_prog.qmd deleted file mode 100644 index 11bda849..00000000 --- a/chapters/wave/wave1D_prog.qmd +++ /dev/null @@ -1,2019 +0,0 @@ -## Implementation {#sec-wave-pde1-impl} - -This section presents the complete computational algorithm, its -implementation in Python code, animation of the solution, and -verification of the implementation. - -A real implementation of the basic computational algorithm from -Sections @sec-wave-string-alg and @sec-wave-string-impl can be -encapsulated in a function, taking all the input data for the problem -as arguments. The physical input data consists of $c$, $I(x)$, -$V(x)$, $f(x,t)$, $L$, and $T$. The numerical input is the mesh -parameters $\Delta t$ and $\Delta x$. - -Instead of specifying $\Delta t$ *and* $\Delta x$, we can specify one -of them and the Courant number $C$ instead, since having explicit -control of the Courant number is convenient when investigating the -numerical method. Many find it natural to prescribe the resolution of -the spatial grid and set $N_x$. The solver function can then compute -$\Delta t = CL/(cN_x)$. However, for comparing $u(x,t)$ curves (as -functions of $x$) for various Courant numbers -it is more convenient to keep $\Delta t$ fixed for -all $C$ and let $\Delta x$ vary according to $\Delta x = c\Delta t/C$. -With $\Delta t$ fixed, all frames correspond to the same time $t$, -and this simplifies animations that compare simulations with different -mesh resolutions. Plotting functions of $x$ -with different spatial resolution is trivial, -so it is easier to let $\Delta x$ vary in the simulations than $\Delta t$. - -## Callback function for user-specific actions {#sec-wave-pde1-impl-useraction} - -The solution at all spatial points at a new time level is stored in an -array `u` of length $N_x+1$. We need to decide what to do with -this solution, e.g., visualize the curve, analyze the values, or write -the array to file for later use. The decision about what to do is left to -the user in the form of a user-supplied function - -```python -user_action(u, x, t, n) -``` - -where `u` is the solution at the spatial points `x` at time `t[n]`. -The `user_action` function is called from the solver at each time level `n`. - -If the user wants to plot the solution or store the solution at a -time point, she needs to write such a function and take appropriate -actions inside it. We will show examples on many such `user_action` -functions. - -Since the solver function makes calls back to the user's code -via such a function, this type of function is called a *callback function*. -When writing general software, like our solver function, which also needs -to carry out special problem- or solution-dependent actions -(like visualization), -it is a common technique to leave those actions to user-supplied -callback functions. - -The callback function can be used to terminate the solution process -if the user returns `True`. For example, - -```python -def my_user_action_function(u, x, t, n): - return np.abs(u).max() > 10 -``` - -is a callback function that will terminate the solver function (given below) of the -amplitude of the waves exceed 10, which is here considered as a numerical -instability. - -## The solver function {#sec-wave-pde1-impl-solver} - -A first attempt at a solver function is listed below. - -```python -import numpy as np - - -def solver(I, V, f, c, L, dt, C, T, user_action=None): - """Solve u_tt=c^2*u_xx + f on (0,L)x(0,T].""" - Nt = int(round(T / dt)) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points in time - dx = dt * c / float(C) - Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - C2 = C**2 # Help variable in the scheme - dx = x[1] - x[0] - dt = t[1] - t[0] - - if f is None or f == 0: - f = lambda x, t: 0 - if V is None or V == 0: - V = lambda x: 0 - - u = np.zeros(Nx + 1) # Solution array at new time level - u_n = np.zeros(Nx + 1) # Solution at 1 time level back - u_nm1 = np.zeros(Nx + 1) # Solution at 2 time levels back - - import time - - t0 = time.perf_counter() # Measure CPU time - - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - - if user_action is not None: - user_action(u_n, x, t, 0) - - n = 0 - for i in range(1, Nx): - u[i] = ( - u_n[i] - + dt * V(x[i]) - + 0.5 * C2 * (u_n[i - 1] - 2 * u_n[i] + u_n[i + 1]) - + 0.5 * dt**2 * f(x[i], t[n]) - ) - u[0] = 0 - u[Nx] = 0 - - if user_action is not None: - user_action(u, x, t, 1) - - u_nm1[:] = u_n - u_n[:] = u - - for n in range(1, Nt): - for i in range(1, Nx): - u[i] = ( - -u_nm1[i] - + 2 * u_n[i] - + C2 * (u_n[i - 1] - 2 * u_n[i] + u_n[i + 1]) - + dt**2 * f(x[i], t[n]) - ) - - u[0] = 0 - u[Nx] = 0 - if user_action is not None: - if user_action(u, x, t, n + 1): - break - - u_nm1[:] = u_n - u_n[:] = u - - cpu_time = time.perf_counter() - t0 - return u, x, t, cpu_time -``` - -A couple of remarks about the above code is perhaps necessary: - -* Although we give `dt` and compute `dx` via `C` and `c`, the resulting - `t` and `x` meshes do not necessarily correspond exactly to these values - because of rounding errors. To explicitly ensure that `dx` and `dt` - correspond to the cell sizes in `x` and `t`, we recompute the values. -* According to the particular choice made in Section @sec-wave-pde1-impl-useraction, a true value returned from `user_action` should terminate the simulation. This is here implemented by a `break` statement inside the for loop in the solver. - -## Verification: exact quadratic solution {#sec-wave-pde1-impl-verify-quadratic} - -We use the test problem derived in Section @sec-wave-pde2-fd for -verification. Below is a unit test based on this test problem -and realized as a proper *test function* compatible with the unit test -frameworks nose or pytest. - -```python -def test_quadratic(): - """Check that u(x,t)=x(L-x)(1+t/2) is exactly reproduced.""" - - def u_exact(x, t): - return x * (L - x) * (1 + 0.5 * t) - - def I(x): - return u_exact(x, 0) - - def V(x): - return 0.5 * u_exact(x, 0) - - def f(x, t): - return 2 * (1 + 0.5 * t) * c**2 - - L = 2.5 - c = 1.5 - C = 0.75 - Nx = 6 # Very coarse mesh for this exact test - dt = C * (L / Nx) / c - T = 18 - - def assert_no_error(u, x, t, n): - u_e = u_exact(x, t[n]) - diff = np.abs(u - u_e).max() - tol = 1e-13 - assert diff < tol - - solver(I, V, f, c, L, dt, C, T, user_action=assert_no_error) -``` - -When this function resides in the file `wave1D_u0.py`, one can run -pytest to check that all test functions with names `test_*()` -in this file work: - -```bash -Terminal> py.test -s -v wave1D_u0.py -``` - -## Verification: convergence rates {#sec-wave-pde1-impl-verify-rate} - -A more general method, but not so reliable as a verification method, -is to compute the convergence rates and see if they coincide with -theoretical estimates. Here we expect a rate of 2 according to -the various results in Section @sec-wave-pde1-analysis. -A general function for computing convergence rates can be written like -this: - -```python -""" -1D wave equation with u=0 at the boundary. -Simplest possible implementation. - -The key function is:: - - u, x, t, cpu = (I, V, f, c, L, dt, C, T, user_action) - -which solves the wave equation u_tt = c**2*u_xx on (0,L) with u=0 -on x=0,L, for t in (0,T]. Initial conditions: u=I(x), u_t=V(x). - -T is the stop time for the simulation. -dt is the desired time step. -C is the Courant number (=c*dt/dx), which specifies dx. -f(x,t) is a function for the source term (can be 0 or None). -I and V are functions of x. - -user_action is a function of (u, x, t, n) where the calling -code can add visualization, error computations, etc. -""" - -import numpy as np - - -def solver(I, V, f, c, L, dt, C, T, user_action=None): - """Solve u_tt=c^2*u_xx + f on (0,L)x(0,T].""" - Nt = int(round(T / dt)) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points in time - dx = dt * c / float(C) - Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - C2 = C**2 # Help variable in the scheme - dx = x[1] - x[0] - dt = t[1] - t[0] - - if f is None or f == 0: - f = lambda x, t: 0 - if V is None or V == 0: - V = lambda x: 0 - - u = np.zeros(Nx + 1) # Solution array at new time level - u_n = np.zeros(Nx + 1) # Solution at 1 time level back - u_nm1 = np.zeros(Nx + 1) # Solution at 2 time levels back - - import time - - t0 = time.perf_counter() # Measure CPU time - - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - - if user_action is not None: - user_action(u_n, x, t, 0) - - n = 0 - for i in range(1, Nx): - u[i] = ( - u_n[i] - + dt * V(x[i]) - + 0.5 * C2 * (u_n[i - 1] - 2 * u_n[i] + u_n[i + 1]) - + 0.5 * dt**2 * f(x[i], t[n]) - ) - u[0] = 0 - u[Nx] = 0 - - if user_action is not None: - user_action(u, x, t, 1) - - u_nm1[:] = u_n - u_n[:] = u - - for n in range(1, Nt): - for i in range(1, Nx): - u[i] = ( - -u_nm1[i] - + 2 * u_n[i] - + C2 * (u_n[i - 1] - 2 * u_n[i] + u_n[i + 1]) - + dt**2 * f(x[i], t[n]) - ) - - u[0] = 0 - u[Nx] = 0 - if user_action is not None: - if user_action(u, x, t, n + 1): - break - - u_nm1[:] = u_n - u_n[:] = u - - cpu_time = time.perf_counter() - t0 - return u, x, t, cpu_time - - -def test_quadratic(): - """Check that u(x,t)=x(L-x)(1+t/2) is exactly reproduced.""" - - def u_exact(x, t): - return x * (L - x) * (1 + 0.5 * t) - - def I(x): - return u_exact(x, 0) - - def V(x): - return 0.5 * u_exact(x, 0) - - def f(x, t): - return 2 * (1 + 0.5 * t) * c**2 - - L = 2.5 - c = 1.5 - C = 0.75 - Nx = 6 # Very coarse mesh for this exact test - dt = C * (L / Nx) / c - T = 18 - - def assert_no_error(u, x, t, n): - u_e = u_exact(x, t[n]) - diff = np.abs(u - u_e).max() - tol = 1e-13 - assert diff < tol - - solver(I, V, f, c, L, dt, C, T, user_action=assert_no_error) - - -def test_constant(): - """Check that u(x,t)=Q=0 is exactly reproduced.""" - u_const = 0 # Require 0 because of the boundary conditions - C = 0.75 - dt = C # Very coarse mesh - u, x, t, cpu = solver(I=lambda x: 0, V=0, f=0, c=1.5, L=2.5, dt=dt, C=C, T=18) - tol = 1e-14 - assert np.abs(u - u_const).max() < tol - - -def viz( - I, - V, - f, - c, - L, - dt, - C, - T, # PDE parameters - umin, - umax, # Interval for u in plots - animate=True, # Simulation with animation? - solver_function=solver, # Function with numerical algorithm -): - """Run solver and visualize u at each time level.""" - import glob - import os - import time - - import matplotlib.pyplot as plt - - class PlotMatplotlib: - def __call__(self, u, x, t, n): - """user_action function for solver.""" - if n == 0: - plt.ion() - self.lines = plt.plot(x, u, "r-") - plt.xlabel("x") - plt.ylabel("u") - plt.axis([0, L, umin, umax]) - plt.legend(["t=%f" % t[n]], loc="lower left") - else: - self.lines[0].set_ydata(u) - plt.legend(["t=%f" % t[n]], loc="lower left") - plt.draw() - time.sleep(2) if t[n] == 0 else time.sleep(0.2) - plt.savefig("tmp_%04d.png" % n) # for movie making - - plot_u = PlotMatplotlib() - - for filename in glob.glob("tmp_*.png"): - os.remove(filename) - - user_action = plot_u if animate else None - u, x, t, cpu = solver_function(I, V, f, c, L, dt, C, T, user_action) - - fps = 4 # frames per second - codec2ext = dict( - flv="flv", libx264="mp4", libvpx="webm", libtheora="ogg" - ) # video formats - filespec = "tmp_%04d.png" - movie_program = "ffmpeg" - for codec in codec2ext: - ext = codec2ext[codec] - cmd = ( - "%(movie_program)s -r %(fps)d -i %(filespec)s " - "-vcodec %(codec)s movie.%(ext)s" % vars() - ) - os.system(cmd) - - return cpu - - -def guitar(C): - """Triangular wave (pulled guitar string).""" - L = 0.75 - x0 = 0.8 * L - a = 0.005 - freq = 440 - wavelength = 2 * L - c = freq * wavelength - omega = 2 * np.pi * freq - num_periods = 1 - T = 2 * np.pi / omega * num_periods - dt = L / 50.0 / c - - def I(x): - return a * x / x0 if x < x0 else a / (L - x0) * (L - x) - - umin = -1.2 * a - umax = -umin - cpu = viz(I, 0, 0, c, L, dt, C, T, umin, umax, animate=True) - - -def convergence_rates( - u_exact, # Python function for exact solution - I, - V, - f, - c, - L, # physical parameters - dt0, - num_meshes, - C, - T, -): # numerical parameters - """ - Half the time step and estimate convergence rates for - for num_meshes simulations. - """ - global error - error = 0 # error computed in the user action function - - def compute_error(u, x, t, n): - global error # must be global to be altered here - if n == 0: - error = 0 - else: - error = max(error, np.abs(u - u_exact(x, t[n])).max()) - - E = [] - h = [] # dt, solver adjusts dx such that C=dt*c/dx - dt = dt0 - for i in range(num_meshes): - solver(I, V, f, c, L, dt, C, T, user_action=compute_error) - E.append(error) - h.append(dt) - dt /= 2 # halve the time step for next simulation - print("E:", E) - print("h:", h) - r = [np.log(E[i] / E[i - 1]) / np.log(h[i] / h[i - 1]) for i in range(1, num_meshes)] - return r -``` - -Using the analytical solution from Section -@sec-wave-pde2-fd-standing-waves, we can call `convergence_rates` to -see if we get a convergence rate that approaches 2 and use the final -estimate of the rate in an `assert` statement such that this function becomes -a proper test function: - -```python -def test_convrate_sincos(): - n = m = 2 - L = 1.0 - u_exact = lambda x, t: np.cos(m * np.pi / L * t) * np.sin(m * np.pi / L * x) - - r = convergence_rates( - u_exact=u_exact, - I=lambda x: u_exact(x, 0), - V=lambda x: 0, - f=0, - c=1, - L=L, - dt0=0.1, - num_meshes=6, - C=0.9, - T=1, - ) - print("rates sin(x)*cos(t) solution:", [round(r_, 2) for r_ in r]) - assert abs(r[-1] - 2) < 0.002 -``` - -Doing `py.test -s -v wave1D_u0.py` will run also this test function and -show the rates 2.05, 1.98, 2.00, 2.00, and 2.00 (to two decimals). - -## Visualization: animating the solution {#sec-wave-pde1-impl-animate} - -Now that we have verified the implementation it is time to do a -real computation where we also display evolution of the waves -on the screen. Since the `solver` function knows nothing about -what type of visualizations we may want, it calls the callback function -`user_action(u, x, t, n)`. We must therefore write this function and -find the proper statements for plotting the solution. - -### Function for administering the simulation - -The following `viz` function - - 1. defines a `user_action` callback function - for plotting the solution at each time level, - 1. calls the `solver` function, and - 1. combines all the plots (in files) to video in different formats. - -```python -def viz( - I, - V, - f, - c, - L, - dt, - C, - T, # PDE parameters - umin, - umax, # Interval for u in plots - animate=True, # Simulation with animation? - solver_function=solver, # Function with numerical algorithm -): - """Run solver and visualize u at each time level.""" - import glob - import os - import time - - import matplotlib.pyplot as plt - - class PlotMatplotlib: - def __call__(self, u, x, t, n): - """user_action function for solver.""" - if n == 0: - plt.ion() - self.lines = plt.plot(x, u, "r-") - plt.xlabel("x") - plt.ylabel("u") - plt.axis([0, L, umin, umax]) - plt.legend(["t=%f" % t[n]], loc="lower left") - else: - self.lines[0].set_ydata(u) - plt.legend(["t=%f" % t[n]], loc="lower left") - plt.draw() - time.sleep(2) if t[n] == 0 else time.sleep(0.2) - plt.savefig("tmp_%04d.png" % n) # for movie making - - plot_u = PlotMatplotlib() - - for filename in glob.glob("tmp_*.png"): - os.remove(filename) - - user_action = plot_u if animate else None - u, x, t, cpu = solver_function(I, V, f, c, L, dt, C, T, user_action) - - fps = 4 # frames per second - codec2ext = dict( - flv="flv", libx264="mp4", libvpx="webm", libtheora="ogg" - ) # video formats - filespec = "tmp_%04d.png" - movie_program = "ffmpeg" - for codec in codec2ext: - ext = codec2ext[codec] - cmd = ( - "%(movie_program)s -r %(fps)d -i %(filespec)s " - "-vcodec %(codec)s movie.%(ext)s" % vars() - ) - os.system(cmd) - - return cpu -``` - -### Dissection of the code - -The `viz` function uses Matplotlib for visualizing the solution. -The `user_action` function is realized as a class and -needs statements that differ from those for making static plots. - -With Matplotlib, one has to make the first plot the standard way, and -then update the $y$ data in the plot at every time level. The update -requires active use of the returned value from `plt.plot` in the first -plot. This value would need to be stored in a local variable if we -were to use a closure for the `user_action` function when doing the -animation with Matplotlib. It is much easier to store the -variable as a class attribute `self.lines`. Since the class is essentially a -function, we implement the function as the special method `__call__` -such that the instance `plot_u(u, x, t, n)` can be called as a standard -callback function from `solver`. - -To achieve a smooth animation, we want to save each -frame in the animation to file. We then need a filename where the -frame number is padded with zeros, here `tmp_0000.png`, -`tmp_0001.png`, and so on. The proper printf construction is then -`tmp_%04d.png`. - -### Making movie files - -From the -`frame_*.png` files containing the frames in the animation we can -make video files using the `ffmpeg` (or `avconv`) program to produce -videos in modern formats: Flash, MP4, Webm, and Ogg. - -The `viz` function creates an `ffmpeg` or `avconv` command -with the proper arguments for each of the formats Flash, MP4, WebM, -and Ogg. The task is greatly simplified by having a -`codec2ext` dictionary for mapping -video codec names to filename extensions. -In practice, only two formats are needed to ensure that all browsers can -successfully play the video: MP4 and WebM. - -Some animations having a large number of plot files may not -be properly combined into a video using `ffmpeg` or `avconv`. -One alternative is to play the PNG files directly in an image viewer -or create an animated GIF using ImageMagick's `convert` command: - -```bash -Terminal> convert -delay 25 tmp_*.png animation.gif -``` - -The `-delay` option specifies the delay between frames in hundredths of a second. - -### Skipping frames for animation speed - -Sometimes the time step is small and $T$ is large, leading to an -inconveniently large number of plot files and a slow animation on the -screen. The solution to such a problem is to decide on a total number -of frames in the animation, `num_frames`, and plot the solution only for -every `skip_frame` frames. For example, setting `skip_frame=5` leads -to plots of every 5 frames. The default value `skip_frame=1` plots -every frame. -The total number of time levels (i.e., maximum -possible number of frames) is the length of `t`, `t.size` (or `len(t)`), -so if we want `num_frames` frames in the animation, -we need to plot every `t.size/num_frames` frames: - -```python -skip_frame = int(t.size/float(num_frames)) -if n % skip_frame == 0 or n == t.size-1: - st.plot(x, u, 'r-', ...) -``` - -The initial condition (`n=0`) is included by `n % skip_frame == 0`, -as well as every `skip_frame`-th frame. -As `n % skip_frame == 0` will very seldom be true for the -very final frame, we must also check if `n == t.size-1` to -get the final frame included. - -A simple choice of numbers may illustrate the formulas: say we have -801 frames in total (`t.size`) and we allow only 60 frames to be -plotted. As `n` then runs from 801 to 0, we need to plot every 801/60 -frame, which with integer division yields 13 as `skip_frame`. Using -the mod function, `n % skip_frame`, this operation is zero every time -`n` can be divided by 13 without a remainder. That is, the `if` test -is true when `n` equals $0, 13, 26, 39, ..., 780, 801$. The associated -code is included in the `plot_u` function, inside the `viz` function, -in the file [`wave1D_u0.py`](https://github.com/devitocodes/devito_book/tree/main/src/wave/wave1D/wave1D_u0.py). - -## Running a case {#sec-wave-pde1-guitar-data} - -The first demo of our 1D wave equation solver concerns vibrations of a -string that is initially deformed to a triangular shape, like when picking -a guitar string: -$$ -I(x) = \left\lbrace -\begin{array}{ll} -ax/x_0, & x < x_0,\\ -a(L-x)/(L-x_0), & \hbox{otherwise} -\end{array}\right. -$$ {#eq-wave-pde1-guitar-I} -We choose $L=75$ cm, $x_0=0.8L$, $a=5$ mm, and a time frequency -$\nu = 440$ Hz. The relation between the wave speed $c$ and $\nu$ is -$c=\nu\lambda$, where $\lambda$ is the wavelength, taken as $2L$ because -the longest wave on the string forms half a wavelength. There is no -external force, so $f=0$ (meaning we can neglect gravity), -and the string is at rest initially, implying $V=0$. - -Regarding numerical parameters, we need to specify a $\Delta t$. -Sometimes it is more natural to think of a spatial resolution instead -of a time step. A natural semi-coarse spatial resolution in the present -problem is $N_x=50$. We can then choose the associated $\Delta t$ (as required -by the `viz` and `solver` functions) as the stability limit: -$\Delta t = L/(N_xc)$. This is the $\Delta t$ to be specified, -but notice that if $C<1$, the actual $\Delta x$ computed in `solver` gets -larger than $L/N_x$: $\Delta x = c\Delta t/C = L/(N_xC)$. (The reason -is that we fix $\Delta t$ and adjust $\Delta x$, so if $C$ gets -smaller, the code implements this effect in terms of a larger $\Delta x$.) - -A function for setting the physical and numerical parameters and -calling `viz` in this application goes as follows: - -```python -def guitar(C): - """Triangular wave (pulled guitar string).""" - L = 0.75 - x0 = 0.8 * L - a = 0.005 - freq = 440 - wavelength = 2 * L - c = freq * wavelength - omega = 2 * np.pi * freq - num_periods = 1 - T = 2 * np.pi / omega * num_periods - dt = L / 50.0 / c - - def I(x): - return a * x / x0 if x < x0 else a / (L - x0) * (L - x) - - umin = -1.2 * a - umax = -umin - cpu = viz(I, 0, 0, c, L, dt, C, T, umin, umax, animate=True) -``` -The associated program has the name [`wave1D_u0.py`](https://github.com/devitocodes/devito_book/tree/main/src/wave/wave1D/wave1D_u0.py). Run -the program and watch the [movie of the vibrating string](http://hplgit.github.io/fdm-book/doc/pub/wave/html/mov-wave/guitar_C0.8/movie.html). -The string should ideally consist of straight segments, but these are -somewhat wavy due to numerical approximation. Run the case with the -`wave1D_u0.py` code and $C=1$ to see the exact solution. - -## Working with a scaled PDE model - -Depending on the model, it may be a substantial job to establish -consistent and relevant physical parameter values for a case. The -guitar string example illustrates the point. However, by *scaling* -the mathematical problem we can often reduce the need to estimate -physical parameters dramatically. The scaling technique consists of -introducing new independent and dependent variables, with the aim that -the absolute values of these lie in $[0,1]$. We introduce the -dimensionless variables (details are found in Section 3.1.1 in [@Langtangen_scaling]) -$$ -\bar x = \frac{x}{L},\quad \bar t = \frac{c}{L}t,\quad -\bar u = \frac{u}{a} \tp -$$ -Here, $L$ is a typical length scale, e.g., the length of the domain, -and $a$ is a typical size of $u$, e.g., determined from the -initial condition: $a=\max_x|I(x)|$. - -We get by the chain rule that -$$ -\frac{\partial u}{\partial t} = -\frac{\partial}{\partial\bar t}\left(a\bar u\right) -\frac{d\bar t}{dt} = -\frac{ac}{L}\frac{\partial\bar u}{\partial\bar t}\tp -$$ -Similarly, -$$ -\frac{\partial u}{\partial x} -= \frac{a}{L}\frac{\partial\bar u}{\partial\bar x}\tp -$$ -Inserting the dimensionless variables in the PDE gives, in case $f=0$, -$$ -\frac{a^2c^2}{L^2}\frac{\partial^2\bar u}{\partial\bar t^2} -= \frac{a^2c^2}{L^2}\frac{\partial^2\bar u}{\partial\bar x^2}\tp -$$ -Dropping the bars, we arrive at the scaled PDE -$$ -\frac{\partial^2 u}{\partial t^2} = \frac{\partial^2 u}{\partial x^2}, -\quad x\in (0,1),\ t\in (0,cT/L), -$$ -which has no parameter $c^2$ anymore. The initial conditions are scaled -as -$$ -a\bar u(\bar x, 0) = I(L\bar x) -$$ -and -$$ -\frac{a}{L/c}\frac{\partial\bar u}{\partial\bar t}(\bar x,0) = V(L\bar x), -$$ -resulting in -$$ -\bar u(\bar x, 0) = \frac{I(L\bar x)}{\max_x |I(x)|},\quad -\frac{\partial\bar u}{\partial\bar t}(\bar x,0) = \frac{L}{ac}V(L\bar x)\tp -$$ -In the common case $V=0$ we see that there are no physical parameters to be -estimated in the PDE model! - -If we have a program implemented for the physical wave equation with -dimensions, we can obtain the dimensionless, scaled version by -setting $c=1$. The initial condition of a guitar string, -given in (@eq-wave-pde1-guitar-I), gets its scaled form by choosing -$a=1$, $L=1$, and $x_0\in [0,1]$. This means that we only need to -decide on the $x_0$ value as a fraction of unity, because -the scaled problem corresponds to setting all -other parameters to unity. In the code we can just set -`a=c=L=1`, `x0=0.8`, and there is no need to calculate with -wavelengths and frequencies to estimate $c$! - -The only non-trivial parameter to estimate in the scaled problem -is the final end time of the simulation, or more precisely, how it relates -to periods in periodic solutions in time, since we often want to -express the end time as a certain number of periods. -The period in the dimensionless problem is 2, so the end time can be -set to the desired number of periods times 2. - -Why the dimensionless period is 2 can be explained by the following -reasoning. -Suppose that $u$ behaves as $\cos (\omega t)$ in time in the original -problem with dimensions. The corresponding period is then $P=2\pi/\omega$, but -we need to estimate $\omega$. A typical solution of the wave -equation is $u(x,t)=A\cos(kx)\cos(\omega t)$, where $A$ is an amplitude -and $k$ is related to the wave length $\lambda$ in space: $\lambda = 2\pi/k$. -Both $\lambda$ and $A$ will be given by the initial condition $I(x)$. -Inserting this $u(x,t)$ in the PDE yields $-\omega^2 = -c^2k^2$, i.e., -$\omega = kc$. The period is therefore $P=2\pi/(kc)$. -If the boundary conditions are $u(0,t)=u(L,t)$, we need to have -$kL = n\pi$ for integer $n$. The period becomes $P=2L/nc$. The longest -period is $P=2L/c$. The dimensionless period $\tilde P$ is obtained -by dividing $P$ by the time scale $L/c$, which results in $\tilde P=2$. -Shorter waves in the initial condition will have a dimensionless -shorter period $\tilde P=2/n$ ($n>1$). - -## Vectorized computations {#sec-wave-pde1-impl-vec} - -The computational algorithm for solving the wave equation visits one -mesh point at a time and evaluates a formula for the new value -$u_i^{n+1}$ at that point. Technically, this is implemented by a loop -over array elements in a program. Such loops may run slowly in Python -(and similar interpreted languages such as R and MATLAB). One -technique for speeding up loops is to perform operations on entire -arrays instead of working with one element at a time. This is referred -to as *vectorization*, *vector computing*, or *array computing*. -Operations on whole arrays are possible if the computations involving -each element is independent of each other and therefore can, at least -in principle, be performed simultaneously. That is, vectorization not -only speeds up the code on serial computers, but also makes it easy to -exploit parallel computing. Actually, there are Python tools like -[Numba](http://numba.pydata.org) that can automatically turn -vectorized code into parallel code. - -## Operations on slices of arrays {#sec-wave-pde1-impl-vec-slices-basics} - -Efficient computing with `numpy` arrays demands that we avoid loops -and compute with entire arrays at once (or at least large portions of them). -Consider this calculation of differences $d_i = u_{i+1}-u_i$: -```python -n = u.size -for i in range(0, n-1): - d[i] = u[i+1] - u[i] -``` -All the differences here are independent of each other. -The computation of `d` can therefore alternatively be done by -subtracting the array $(u_0,u_1,\ldots,u_{n-1})$ from -the array where the elements are shifted one index upwards: -$(u_1,u_2,\ldots,u_n)$, see Figure @fig-wave-pde1-vec-fig1. -The former subset of the array can be -expressed by `u[0:n-1]`, -`u[0:-1]`, or just -`u[:-1]`, meaning from index 0 up to, -but not including, the last element (`-1`). The latter subset -is obtained by `u[1:n]` or `u[1:]`, -meaning from index 1 and the rest of the array. -The computation of `d` can now be done without an explicit Python loop: -```python -d = u[1:] - u[:-1] -``` -or with explicit limits if desired: -```python -d = u[1:n] - u[0:n-1] -``` -Indices with a colon, going from an index to (but not including) another -index are called *slices*. With `numpy` arrays, the computations -are still done by loops, but in efficient, compiled, highly optimized -C or Fortran code. Such loops are sometimes referred to as *vectorized -loops*. Such loops can also easily be distributed -among many processors on parallel computers. We say that the *scalar code* -above, working on an element (a scalar) at a time, has been replaced by -an equivalent *vectorized code*. The process of vectorizing code is called -*vectorization*. - -![Illustration of subtracting two slices of two arrays.](fig/vectorized_diff){#fig-wave-pde1-vec-fig1 width="400px"} - -:::{.callout-tip title="Test your understanding"} -Newcomers to vectorization are encouraged to choose -a small array `u`, say with five elements, -and simulate with pen and paper -both the loop version and the vectorized version above. -::: - -Finite difference schemes basically contain differences between array -elements with shifted indices. As an example, -consider the updating formula - -```python -for i in range(1, n-1): - u2[i] = u[i-1] - 2*u[i] + u[i+1] -``` -The vectorization consists of replacing the loop by arithmetics on -slices of arrays of length `n-2`: - -```python -u2 = u[:-2] - 2*u[1:-1] + u[2:] -u2 = u[0:n-2] - 2*u[1:n-1] + u[2:n] # alternative -``` -Note that the length of `u2` becomes `n-2`. If `u2` is already an array of -length `n` and we want to use the formula to update all the "inner" -elements of `u2`, as we will when solving a 1D wave equation, we can write -```python -u2[1:-1] = u[:-2] - 2*u[1:-1] + u[2:] -u2[1:n-1] = u[0:n-2] - 2*u[1:n-1] + u[2:n] # alternative -``` -The first expression's right-hand side is realized by the -following steps, involving temporary arrays with intermediate results, -since each array operation can only involve one or two arrays. -The `numpy` package performs (behind the scenes) the first line above in -four steps: - -```python -temp1 = 2*u[1:-1] -temp2 = u[:-2] - temp1 -temp3 = temp2 + u[2:] -u2[1:-1] = temp3 -``` -We need three temporary arrays, but a user does not need to worry about -such temporary arrays. - -:::{.callout-note title="Common mistakes with array slices"} -Array expressions with slices demand that the slices have the same -shape. It easy to make a mistake in, e.g., - -```python -u2[1:n-1] = u[0:n-2] - 2*u[1:n-1] + u[2:n] -``` -and write - -```python -u2[1:n-1] = u[0:n-2] - 2*u[1:n-1] + u[1:n] -``` -Now `u[1:n]` has wrong length (`n-1`) compared to the other array -slices, causing a `ValueError` and the message -`could not broadcast input array from shape 103 into shape 104` -(if `n` is 105). When such errors occur one must closely examine -all the slices. Usually, it is easier to get upper limits of slices -right when they use `-1` or `-2` or empty limit rather than -expressions involving the length. - -Another common mistake, when `u2` has length `n`, is to forget the slice in the array on the -left-hand side, - -```python -u2 = u[0:n-2] - 2*u[1:n-1] + u[1:n] -``` -This is really crucial: now `u2` becomes a *new* array of length -`n-2`, which is the wrong length as we have no entries for the boundary -values. We meant to insert the right-hand side array *into* the -original `u2` array for the entries that correspond to the -internal points in the mesh (`1:n-1` or `1:-1`). -::: - -Vectorization may also work nicely with functions. To illustrate, we may -extend the previous example as follows: - -```python -def f(x): - return x**2 + 1 - -for i in range(1, n-1): - u2[i] = u[i-1] - 2*u[i] + u[i+1] + f(x[i]) -``` -Assuming `u2`, `u`, and `x` all have length `n`, the vectorized -version becomes -```python -u2[1:-1] = u[:-2] - 2*u[1:-1] + u[2:] + f(x[1:-1]) -``` -Obviously, `f` must be able to take an array as argument for `f(x[1:-1])` -to make sense. - -## Finite difference schemes expressed as slices {#sec-wave-pde1-impl-vec-slices-fdm} - -We now have the necessary tools to vectorize the wave equation -algorithm as described mathematically in Section @sec-wave-string-alg -and through code in Section @sec-wave-pde1-impl-solver. There are -three loops: one for the initial condition, one for the first time -step, and finally the loop that is repeated for all subsequent time -levels. Since only the latter is repeated a potentially large number -of times, we limit our vectorization efforts to this loop. Within the time -loop, the space loop reads: - -```python -for i in range(1, Nx): - u[i] = 2*u_n[i] - u_nm1[i] + \ - C2*(u_n[i-1] - 2*u_n[i] + u_n[i+1]) -``` -The vectorized version becomes - -```python -u[1:-1] = - u_nm1[1:-1] + 2*u_n[1:-1] + \ - C2*(u_n[:-2] - 2*u_n[1:-1] + u_n[2:]) -``` -or -```python -u[1:Nx] = 2*u_n[1:Nx]- u_nm1[1:Nx] + \ - C2*(u_n[0:Nx-1] - 2*u_n[1:Nx] + u_n[2:Nx+1]) -``` - -The program -[`wave1D_u0v.py`](https://github.com/devitocodes/devito_book/tree/main/src/wave/wave1D/wave1D_u0v.py) -contains a new version of the function `solver` where both the scalar -and the vectorized loops are included (the argument `version` is -set to `scalar` or `vectorized`, respectively). - -## Verification {#sec-wave-pde1-impl-vec-verify-quadratic} - -We may reuse the quadratic solution $\uex(x,t)=x(L-x)(1+{\half}t)$ for -verifying also the vectorized code. A test function can now verify -both the scalar and the vectorized version. Moreover, we may -use a `user_action` function that compares the computed and exact -solution at each time level and performs a test: - -```python -def test_quadratic(): - """ - Check the scalar and vectorized versions for - a quadratic u(x,t)=x(L-x)(1+t/2) that is exactly reproduced. - """ - u_exact = lambda x, t: x * (L - x) * (1 + 0.5 * t) - I = lambda x: u_exact(x, 0) - V = lambda x: 0.5 * u_exact(x, 0) - f = lambda x, t: np.zeros_like(x) + 2 * c**2 * (1 + 0.5 * t) - - L = 2.5 - c = 1.5 - C = 0.75 - Nx = 3 # Very coarse mesh for this exact test - dt = C * (L / Nx) / c - T = 18 - - def assert_no_error(u, x, t, n): - u_e = u_exact(x, t[n]) - tol = 1e-13 - diff = np.abs(u - u_e).max() - assert diff < tol - - solver(I, V, f, c, L, dt, C, T, user_action=assert_no_error, version="scalar") - solver(I, V, f, c, L, dt, C, T, user_action=assert_no_error, version="vectorized") -``` - -:::{.callout-note title="Lambda functions"} -The code segment above demonstrates how to achieve very -compact code, without degraded readability, -by use of lambda functions for the various -input parameters that require a Python function. In essence, - -```python -f = lambda x, t: L*(x-t)**2 -``` -is equivalent to - -```python -def f(x, t): - return L(x-t)**2 -``` -Note that lambda functions can just contain a single expression and no -statements. - -One advantage with lambda functions is that they can be used directly -in calls: - -```python -solver(I=lambda x: sin(pi*x/L), V=0, f=0, ...) -``` -::: - -## Efficiency measurements - -The `wave1D_u0v.py` contains our new `solver` function with both -scalar and vectorized code. For comparing the efficiency -of scalar versus vectorized code, we need a `viz` function -as discussed in Section @sec-wave-pde1-impl-animate. -All of this `viz` function can be reused, except the call -to `solver_function`. This call lacks the parameter -`version`, which we want to set to `vectorized` and `scalar` -for our efficiency measurements. - -One solution is to copy the `viz` code from `wave1D_u0` into -`wave1D_u0v.py` and add a `version` argument to the `solver_function` call. -Taking into account how much animation code we -then duplicate, this is not a good idea. -Alternatively, -introducing the `version` argument in `wave1D_u0.viz`, so that this function -can be imported into `wave1D_u0v.py`, is not -a good solution either, since `version` has no meaning in that file. -We need better ideas! - -### Solution 1 -Calling `viz` in `wave1D_u0` with `solver_function` as our new -solver in `wave1D_u0v` works fine, since this solver has -`version='vectorized'` as default value. The problem arises when we -want to test `version='scalar'`. The simplest solution is then -to use `wave1D_u0.solver` instead. We make a new `viz` function -in `wave1D_u0v.py` that has a `version` argument and that just -calls `wave1D_u0.viz`: - -```python -def viz( - I, - V, - f, - c, - L, - dt, - C, - T, # PDE parameters - umin, - umax, # Interval for u in plots - animate=True, # Simulation with animation? - solver_function=solver, # Function with numerical algorithm - version="vectorized", # 'scalar' or 'vectorized' -): - import wave1D_u0 - - if version == "vectorized": - cpu = wave1D_u0.viz( - I, V, f, c, L, dt, C, T, umin, umax, animate, solver_function=solver - ) - elif version == "scalar": - cpu = wave1D_u0.viz( - I, - V, - f, - c, - L, - dt, - C, - T, - umin, - umax, - animate, - solver_function=wave1D_u0.solver, - ) - return cpu - -def test_quadratic(): - """ - Check the scalar and vectorized versions for - a quadratic u(x,t)=x(L-x)(1+t/2) that is exactly reproduced. - """ - u_exact = lambda x, t: x * (L - x) * (1 + 0.5 * t) - I = lambda x: u_exact(x, 0) - V = lambda x: 0.5 * u_exact(x, 0) - f = lambda x, t: np.zeros_like(x) + 2 * c**2 * (1 + 0.5 * t) - - L = 2.5 - c = 1.5 - C = 0.75 - Nx = 3 # Very coarse mesh for this exact test - dt = C * (L / Nx) / c - T = 18 - - def assert_no_error(u, x, t, n): - u_e = u_exact(x, t[n]) - tol = 1e-13 - diff = np.abs(u - u_e).max() - assert diff < tol - - solver(I, V, f, c, L, dt, C, T, user_action=assert_no_error, version="scalar") - solver(I, V, f, c, L, dt, C, T, user_action=assert_no_error, version="vectorized") - -def guitar(C): - """Triangular wave (pulled guitar string).""" - L = 0.75 - x0 = 0.8 * L - a = 0.005 - freq = 440 - wavelength = 2 * L - c = freq * wavelength - omega = 2 * pi * freq - num_periods = 1 - T = 2 * pi / omega * num_periods - dt = L / 50.0 / c - - def I(x): - return a * x / x0 if x < x0 else a / (L - x0) * (L - x) - - umin = -1.2 * a - umax = -umin - cpu = viz(I, 0, 0, c, L, dt, C, T, umin, umax, animate=True) - -def run_efficiency_experiments(): - L = 1 - x0 = 0.8 * L - a = 1 - c = 2 - T = 8 - C = 0.9 - umin = -1.2 * a - umax = -umin - - def I(x): - return a * x / x0 if x < x0 else a / (L - x0) * (L - x) - - intervals = [] - speedup = [] - for Nx in [50, 100, 200, 400, 800]: - dx = float(L) / Nx - dt = C / c * dx - print("solving scalar Nx=%d" % Nx, end=" ") - cpu_s = viz(I, 0, 0, c, L, dt, C, T, umin, umax, animate=False, version="scalar") - print(cpu_s) - print("solving vectorized Nx=%d" % Nx, end=" ") - cpu_v = viz( - I, 0, 0, c, L, dt, C, T, umin, umax, animate=False, version="vectorized" - ) - print(cpu_v) - intervals.append(Nx) - speedup.append(cpu_s / float(cpu_v)) - print("Nx=%3d: cpu_v/cpu_s: %.3f" % (Nx, 1.0 / speedup[-1])) - print("Nx:", intervals) - print("Speed-up:", speedup) - -if __name__ == "__main__": - test_quadratic() # verify - import sys - - try: - C = float(sys.argv[1]) - print("C=%g" % C) - except IndexError: - C = 0.85 - guitar(C) -``` - -### Solution 2 -There is a more advanced and fancier solution featuring a very useful trick: -we can make a new function that will always call `wave1D_u0v.solver` -with `version='scalar'`. The `functools.partial` function from -standard Python takes a function `func` as argument and -a series of positional and keyword arguments and returns a -new function that will call `func` with the supplied arguments, -while the user can control all the other arguments in `func`. -Consider a trivial example, - -```python -def f(a, b, c=2): - return a + b + c -``` -We want to ensure that `f` is always called with `c=3`, i.e., `f` -has only two "free" arguments `a` and `b`. -This functionality is obtained by - -```python -import functools -f2 = functools.partial(f, c=3) - -print f2(1, 2) # results in 1+2+3=6 -``` -Now `f2` calls `f` with whatever the user supplies as `a` and `b`, -but `c` is always `3`. - -Back to our `viz` code, we can do - -```python -import functools -scalar_solver = functools.partial(wave1D_u0.solver, version='scalar') -cpu = wave1D_u0.viz( - I, V, f, c, L, dt, C, T, umin, umax, - animate, tool, solver_function=scalar_solver) -``` -The new `scalar_solver` takes the same arguments as -`wave1D_u0.scalar` and calls `wave1D_u0v.scalar`, -but always supplies the extra argument -`version='scalar'`. When sending this `solver_function` -to `wave1D_u0.viz`, the latter will call `wave1D_u0v.solver` -with all the `I`, `V`, `f`, etc., arguments we supply, plus -`version='scalar'`. - -### Efficiency experiments -We now have a `viz` function that can call our solver function both in -scalar and vectorized mode. The function `run_efficiency_experiments` -in `wave1D_u0v.py` performs a set of experiments and reports the -CPU time spent in the scalar and vectorized solver for -the previous string vibration example with spatial mesh resolutions -$N_x=50,100,200,400,800$. Running this function reveals -that the vectorized -code runs substantially faster: the vectorized code runs approximately -$N_x/10$ times as fast as the scalar code! - -## Remark on the updating of arrays {#sec-wave-pde1-impl-ref-switch} - -At the end of each time step we need to update the `u_nm1` and `u_n` -arrays such that they have the right content for the next time step: - -```python -u_nm1[:] = u_n -u_n[:] = u -``` -The order here is important: updating `u_n` first, makes `u_nm1` equal -to `u`, which is wrong! - -The assignment `u_n[:] = u` copies the content of the `u` array into -the elements of the `u_n` array. Such copying takes time, but -that time is negligible compared to the time needed for -computing `u` from the finite difference formula, -even when the formula has a vectorized implementation. -However, efficiency of program code is a key topic when solving -PDEs numerically (particularly when there are two or three -space dimensions), so it must be mentioned that there exists a -much more efficient way of making the arrays `u_nm1` and `u_n` -ready for the next time step. The idea is based on *switching -references* and explained as follows. - -A Python variable is actually a reference to some object (C programmers -may think of pointers). Instead of copying data, we can let `u_nm1` -refer to the `u_n` object and `u_n` refer to the `u` object. -This is a very efficient operation (like switching pointers in C). -A naive implementation like - -```python -u_nm1 = u_n -u_n = u -``` -will fail, however, because now `u_nm1` refers to the `u_n` object, -but then the name `u_n` refers to `u`, so that this `u` object -has two references, `u_n` and `u`, while our third array, originally -referred to by `u_nm1`, has no more references and is lost. -This means that the variables `u`, `u_n`, and `u_nm1` refer to two -arrays and not three. Consequently, the computations at the next -time level will be messed up, since updating the elements in -`u` will imply updating the elements in `u_n` too, thereby destroying -the solution at the previous time step. - -While `u_nm1 = u_n` is fine, `u_n = u` is problematic, so -the solution to this problem is to ensure that `u` -points to the `u_nm1` array. This is mathematically wrong, but -new correct values will be filled into `u` at the next time step -and make it right. - -The correct switch of references is - -```python -tmp = u_nm1 -u_nm1 = u_n -u_n = u -u = tmp -``` -We can get rid of the temporary reference `tmp` by writing - -```python -u_nm1, u_n, u = u_n, u, u_nm1 -``` -This switching of references for updating our arrays -will be used in later implementations. - -:::{.callout-warning title="Caution:"} -The update `u_nm1, u_n, u = u_n, u, u_nm1` leaves wrong content in `u` -at the final time step. This means that if we return `u`, as we -do in the example codes here, we actually return `u_nm1`, which is -obviously wrong. It is therefore important to adjust the content -of `u` to `u = u_n` before returning `u`. (Note that -the `user_action` function -reduces the need to return the solution from the solver.) -::: - -## Making Movies - -We could also add making a hardcopy of the plot for later production of -a movie file. The hardcopies must be numbered consecutively, say -`tmp_0000.png`, `tmp_0001.png`, `tmp_0002.png`, and so forth. -The filename construction can be based on the `n` counter supplied to -the user action function: -```python -filename = 'tmp_%04d.png' % n -``` -The `04d` format implies formatting of an integer in a field of width -4 characters and padded with zeros from the left. -An animated GIF file `movie.gif` -can be made from these individual frames by using -the `convert` program from the ImageMagick suite: -```bash -Unix> convert -delay 50 tmp_*.png movie.gif -Unix> animate movie.gif -``` -The delay is measured in units of 1/100 s. -The `animate` program, also in the ImageMagick suite, can play the movie file. -Alternatively, the `display` program can be used to walk through each -frame, i.e., solution curve, by pressing the space bar. - -## Exercise: Simulate a standing wave {#sec-wave-exer-standingwave} - -The purpose of this exercise is to simulate standing waves on $[0,L]$ -and illustrate the error in the simulation. -Standing waves arise from an initial condition -$$ -u(x,0)= A \sin\left(\frac{\pi}{L}mx\right), -$$ -where $m$ is an integer and $A$ is a freely chosen amplitude. -The corresponding exact solution can be computed and reads -$$ -\uex(x,t) = A\sin\left(\frac{\pi}{L}mx\right) -\cos\left(\frac{\pi}{L}mct\right)\tp -$$ -**a)** - -Explain that for a function $\sin kx\cos \omega t$ the wave length -in space is $\lambda = 2\pi /k$ and the period in time is $P=2\pi/\omega$. -Use these expressions to find the wave length in space and period in -time of $\uex$ above. - - -::: {.callout-tip collapse="true" title="Solution"} -Since the sin and cos functions depend on $x$ and $t$, respectively, -the sin function will run through one period as $x$ increases by $\frac{2\pi}{k}$, while the cos function starts repeating as $t$ increases by $\frac{2\pi}{\omega}$. - -The wave length in space becomes -$$ -\lambda = \frac{2\pi}{\frac{\pi}{L}m} = \frac{2L}{m}\tp -$$ -The period in time becomes -$$ -P = \frac{2\pi}{\frac{\pi}{L}mc} = \frac{2L}{mc}\tp -$$ -::: - - - - -**b)** - -Import the `solver` function from `wave1D_u0.py` into a new file -where the `viz` function is reimplemented such that it -plots either the numerical *and* the exact solution, *or* the error. - - -::: {.callout-tip collapse="true" title="Solution"} -See code below. -::: - - - -**c)** - -Make animations where you illustrate how the error -$e^n_i =\uex(x_i, t_n)- u^n_i$ -develops and increases in time. Also make animations of -$u$ and $\uex$ simultaneously. - -:::{.callout-tip title="Quite long time simulations are needed in order to display significant"} -discrepancies between the numerical and exact solution. -::: - -:::{.callout-tip title="A possible set of parameters is $L=12$, $m=9$, $c=2$, $A=1$, $N_x=80$,"} -$C=0.8$. The error mesh function $e^n$ can be simulated for 10 periods, -while 20-30 periods are needed to show significant differences between -the curves for the numerical and exact solution. -::: - - -::: {.callout-tip collapse="true" title="Solution"} -The code: - -```python -import os -import sys - -sys.path.insert(0, os.path.join(os.pardir, os.pardir, "src-wave", "wave1D")) - -import numpy as np -from wave1D_u0 import solver - - -def viz( - I, V, f, c, L, dt, C, T, - ymax, # y axis: [-ymax, ymax] - u_exact, # u_exact(x, t) - animate="u and u_exact", # or 'error' - movie_filename="movie", -): - """Run solver and visualize u at each time level.""" - import glob - import os - - import matplotlib.pyplot as plt - - class Plot: - def __init__(self, ymax, frame_name="frame"): - self.max_error = [] # hold max amplitude errors - self.max_error_t = [] # time points corresp. to max_error - self.frame_name = frame_name - self.ymax = ymax - - def __call__(self, u, x, t, n): - """user_action function for solver.""" - if animate == "u and u_exact": - plt.clf() - plt.plot(x, u, "r-", x, u_exact(x, t[n]), "b--") - plt.xlabel("x") - plt.ylabel("u") - plt.axis([0, L, -self.ymax, self.ymax]) - plt.title(f"t={t[n]:f}") - plt.draw() - plt.pause(0.001) - else: - error = u_exact(x, t[n]) - u - local_max_error = np.abs(error).max() - if self.max_error == [] or local_max_error > max(self.max_error): - self.max_error.append(local_max_error) - self.max_error_t.append(t[n]) - self.ymax = max(self.ymax, max(self.max_error)) - plt.clf() - plt.plot(x, error, "r-") - plt.xlabel("x") - plt.ylabel("error") - plt.axis([0, L, -self.ymax, self.ymax]) - plt.title(f"t={t[n]:f}") - plt.draw() - plt.pause(0.001) - plt.savefig("%s_%04d.png" % (self.frame_name, n)) - - # Clean up old movie frames - for filename in glob.glob("frame_*.png"): - os.remove(filename) - - plot = Plot(ymax) - u, x, t, cpu = solver(I, V, f, c, L, dt, C, T, plot) - - # Make plot of max error versus time - plt.figure() - plt.plot(plot.max_error_t, plot.max_error) - plt.xlabel("time") - plt.ylabel("max abs(error)") - plt.savefig("error.png") - plt.savefig("error.pdf") -``` - -::: - - - - - -::: {.callout-note title="Remarks"} -The important -parameters for numerical quality are $C$ and $k\Delta x$, where -$C=c\Delta t/\Delta x$ is the Courant number and $k$ is defined above -($k\Delta x$ is proportional to how many mesh points we have per wave length -in space, see Section @sec-wave-pde1-num-dispersion for explanation). -::: - - -## Exercise: Add storage of solution in a user action function {#sec-wave-exer-store-list} - -Extend the `plot_u` function in the file `wave1D_u0.py` to also store -the solutions `u` in a list. -To this end, declare `all_u` as -an empty list in the `viz` function, outside `plot_u`, and perform -an append operation inside the `plot_u` function. Note that a -function, like `plot_u`, inside another function, like `viz`, -remembers all local variables in `viz` function, including `all_u`, -even when `plot_u` is called (as `user_action`) in the `solver` function. -Test both `all_u.append(u)` and `all_u.append(u.copy())`. -Why does one of these constructions fail to store the solution correctly? -Let the `viz` function return the `all_u` list -converted to a two-dimensional `numpy` array. - - -::: {.callout-tip collapse="true" title="Solution"} -We have to explicitly use a copy of u, i.e. as `all_u.append(u.copy())`, otherwise we just get a reference to `u`, which goes on changing with the computations. - -```python -def viz( - I, V, f, c, L, dt, C, T, - umin, umax, - animate=True, - solver_function=solver, -): - """Run solver, store and visualize u at each time level.""" - import glob - import os - import time - - import matplotlib.pyplot as plt - - all_u = [] # store solutions - - def plot_u(u, x, t, n): - """user_action function for solver.""" - if n == 0: - plt.ion() - lines = plt.plot(x, u, "r-") - plt.xlabel("x") - plt.ylabel("u") - plt.axis([0, L, umin, umax]) - plt.legend([f"t={t[n]:f}"], loc="lower left") - else: - lines[0].set_ydata(u) - plt.legend([f"t={t[n]:f}"], loc="lower left") - plt.draw() - time.sleep(2) if t[n] == 0 else time.sleep(0.2) - plt.savefig("tmp_%04d.png" % n) - all_u.append(u.copy()) # must use copy! - - # Clean up old movie frames - for filename in glob.glob("tmp_*.png"): - os.remove(filename) - - user_action = plot_u if animate else None - u, x, t, cpu = solver_function(I, V, f, c, L, dt, C, T, user_action) - return cpu, np.array(all_u) -``` - -::: - - -## Exercise: Use a class for the user action function {#sec-wave-exer-store-list-class} - -Redo Exercise @sec-wave-exer-store-list using a class for the user -action function. Let the `all_u` list be an attribute in this class -and implement the user action function as a method (the special method -`__call__` is a natural choice). The class versions avoid that the -user action function depends on parameters defined outside the -function (such as `all_u` in Exercise @sec-wave-exer-store-list). - - -::: {.callout-tip collapse="true" title="Solution"} -Using a class, we get - -```python -class PlotMatplotlib: - def __init__(self): - self.all_u = [] - - def __call__(self, u, x, t, n): - """user_action function for solver.""" - if n == 0: - plt.ion() - self.lines = plt.plot(x, u, "r-") - plt.xlabel("x") - plt.ylabel("u") - plt.axis([0, L, umin, umax]) - plt.legend([f"t={t[n]:f}"], loc="lower left") - else: - self.lines[0].set_ydata(u) - plt.legend([f"t={t[n]:f}"], loc="lower left") - plt.draw() - time.sleep(2) if t[n] == 0 else time.sleep(0.2) - plt.savefig("tmp_%04d.png" % n) # for movie making - self.all_u.append(u.copy()) - - -def viz(I, V, f, c, L, dt, C, T, umin, umax, - animate=True, solver_function=solver): - """Run solver, store and visualize u at each time level.""" - import glob - import os - - plot_u = PlotMatplotlib() - - # Clean up old movie frames - for filename in glob.glob("tmp_*.png"): - os.remove(filename) - - user_action = plot_u if animate else None - u, x, t, cpu = solver_function(I, V, f, c, L, dt, C, T, user_action) - return cpu, np.array(plot_u.all_u) -``` - -::: - - -## Exercise: Compare several Courant numbers in one movie {#sec-wave-exer-multiple-C} - -The goal of this exercise is to make movies where several curves, -corresponding to different Courant numbers, are visualized. Write a -program that resembles `wave1D_u0_s2c.py` in Exercise @sec-wave-exer-store-list-class, but with a `viz` function that -can take a list of `C` values as argument and create a movie with -solutions corresponding to the given `C` values. The `plot_u` function -must be changed to store the solution in an array (see Exercise -@sec-wave-exer-store-list or @sec-wave-exer-store-list-class for -details), `solver` must be computed for each value of the Courant -number, and finally one must run through each time step and plot all -the spatial solution curves in one figure and store it in a file. - -The challenge in such a visualization is to ensure that the curves in -one plot correspond to the same time point. The easiest remedy is to -keep the time resolution constant and change the space resolution -to change the Courant number. Note that each spatial grid is needed for -the final plotting, so it is an option to store those grids too. - - -::: {.callout-tip collapse="true" title="Solution"} -Modifying the code to store all solutions for each $C$ value and also each corresponding spatial grid (needed for final plotting), we get - -```python -class PlotMatplotlib: - def __init__(self): - self.all_u = [] - self.all_u_for_all_C = [] - self.x_mesh = [] # need each mesh for final plots - - def __call__(self, u, x, t, n): - """user_action function for solver.""" - self.all_u.append(u.copy()) - if t[n] == T: # i.e., whole time interv. done for this C - self.x_mesh.append(x.copy()) - self.all_u_for_all_C.append(self.all_u) - self.all_u = [] # reset to empty list - - if len(self.all_u_for_all_C) == len(C): # all C done - print("Finished all C. Proceed with plots...") - plt.ion() - for n_ in range(0, n + 1): # for each tn - plt.clf() - for j in range(len(C)): - plt.plot(self.x_mesh[j], self.all_u_for_all_C[j][n_]) - plt.axis([0, L, umin, umax]) - plt.xlabel("x") - plt.ylabel("u") - plt.title(f"Solutions for all C at t={t[n_]:f}") - plt.draw() - time.sleep(2) if t[n_] == 0 else time.sleep(0.2) - plt.savefig("tmp_%04d.png" % n_) # for movie - - -def viz(I, V, f, c, L, dt, C, T, umin, umax, - animate=True, solver_function=solver): - """Run solver, store and viz. u at each time level with all C values.""" - import glob - import os - - plot_u = PlotMatplotlib() - - # Clean up old movie frames - for filename in glob.glob("tmp_*.png"): - os.remove(filename) - - user_action = plot_u if animate else None - for C_value in C: - print("C_value:", C_value) - u, x, t, cpu = solver_function(I, V, f, c, L, dt, C_value, T, user_action) - - return cpu -``` - -::: - - -## Exercise: Implementing the solver function as a generator {#sec-wave-exer-useraction-generator} - -The callback function `user_action(u, x, t, n)` is called from the -`solver` function (in, e.g., `wave1D_u0.py`) at every time level and lets -the user work perform desired actions with the solution, like plotting it -on the screen. We have implemented the callback function in the typical -way it would have been done in C and Fortran. Specifically, the code looks -like - -```python -if user_action is not None: - if user_action(u, x, t, n): - break -``` -Many Python programmers, however, may claim that `solver` is an iterative -process, and that iterative processes with callbacks to the user code is -more elegantly implemented as *generators*. The rest of the text has little -meaning unless you are familiar with Python generators and the `yield` -statement. - -Instead of calling `user_action`, the `solver` function -issues a `yield` statement, which is a kind of `return` statement: - -```python -yield u, x, t, n -``` -The program control is directed back to the calling code: - -```python -for u, x, t, n in solver(...): -``` -When the block is done, `solver` continues with the statement after `yield`. -Note that the functionality of terminating the solution process if -`user_action` returns a `True` value is not possible to implement in the -generator case. - -Implement the `solver` function as a generator, and plot the solution -at each time step. - - -::: {.callout-tip collapse="true" title="Solution"} -::: - - -## Project: Calculus with 1D mesh functions {#sec-wave-exer-mesh1D-calculus} - -This project explores integration and differentiation of -mesh functions, both with scalar and vectorized implementations. -We are given a mesh function $f_i$ on a spatial one-dimensional -mesh $x_i=i\Delta x$, $i=0,\ldots,N_x$, over the interval $[a,b]$. - -**a)** - -Define the discrete derivative of $f_i$ by using centered -differences at internal mesh points and one-sided differences -at the end points. Implement a scalar version of -the computation in a Python function and write an associated unit test -for the linear case $f(x)=4x-2.5$ where the discrete derivative should -be exact. - - -::: {.callout-tip collapse="true" title="Solution"} -See code below. -::: - - - - -**b)** - -Vectorize the implementation of the discrete derivative. -Extend the unit test to check the validity of the implementation. - - -::: {.callout-tip collapse="true" title="Solution"} -See code below. -::: - - - - -**c)** - -To compute the discrete integral $F_i$ of $f_i$, we assume that -the mesh function $f_i$ varies linearly between the mesh points. -Let $f(x)$ be such a linear interpolant of $f_i$. We then -have -$$ -F_i = \int_{x_0}^{x_i} f(x) dx\tp -$$ -The exact integral of a piecewise linear function $f(x)$ is -given by the Trapezoidal rule. Show -that if $F_{i}$ is already computed, we can find $F_{i+1}$ -from -$$ -F_{i+1} = F_i + \half(f_i + f_{i+1})\Delta x\tp -$$ -Make a function for the scalar implementation of the discrete integral -as a mesh function. That is, the function should return -$F_i$ for $i=0,\ldots,N_x$. -For a unit test one can use the fact that the above defined -discrete integral of a linear -function (say $f(x)=4x-2.5$) is exact. - - -::: {.callout-tip collapse="true" title="Solution"} -We know that the difference $F_{i+1} - F_i$ must amount to the area -of a trapezoid, which is exactly what $\half(f_i + f_{i+1})\Delta x$ is. -To show the relation above, we may start with the Trapezoidal rule: -$$ -F_{i+1} = \Delta x \left[\frac{1}{2}f(x_0) + \sum_{j=1}^{n-1}f(x_j) + \frac{1}{2}f(x_n) \right] \thinspace . \nonumber -$$ -Since $n = i+1$, and since the final term in the sum may be separated out from the sum and split in two, this may be written as -$$ -F_{i+1} = \Delta x \left[\frac{1}{2}f(x_0) + \sum_{j=1}^{i-1}f(x_j) + \frac{1}{2}f(x_i) + \frac{1}{2}f(x_i) + \frac{1}{2}f(x_{i+1}) \right] \thinspace . \nonumber -$$ -This may further be written as -$$ -F_{i+1} = \Delta x \left[\frac{1}{2}f(x_0) + \sum_{j=1}^{i-1}f(x_j) + \frac{1}{2}f(x_i)\right] + \Delta x \left[\frac{1}{2}f(x_i) + \frac{1}{2}f(x_{i+1}) \right] \thinspace . \nonumber -$$ -Finally, this gives -$$ -F_{i+1} = F_i + \half(f_i + f_{i+1})\Delta x\tp -$$ -See code below for implementation. -::: - - - - -**d)** - -Vectorize the implementation of the discrete integral. -Extend the unit test to check the validity of the implementation. - -:::{.callout-tip title="Interpret the recursive formula for $F_{i+1}$ as a sum."} -Make an array with each element of the sum and use the "cumsum" -(`numpy.cumsum`) operation to compute the accumulative sum: -`numpy.cumsum([1,3,5])` is `[1,4,9]`. -::: - - -::: {.callout-tip collapse="true" title="Solution"} -See code below. -::: - - - - -**e)** - -Create a class `MeshCalculus` that can integrate and differentiate -mesh functions. The class can just define some methods that call -the previously implemented Python functions. Here is an example -on the usage: - -```python -import numpy as np -calc = MeshCalculus(vectorized=True) -x = np.linspace(0, 1, 11) # mesh -f = np.exp(x) # mesh function -df = calc.differentiate(f, x) # discrete derivative -F = calc.integrate(f, x) # discrete anti-derivative -``` - - -::: {.callout-tip collapse="true" title="Solution"} -See code below. -::: - - - - - -::: {.callout-tip collapse="true" title="Solution"} -The final version of the code reads - -```python -""" -Calculus with a 1D mesh function. -""" - -import numpy as np - - -class MeshCalculus: - def __init__(self, vectorized=True): - self.vectorized = vectorized - - def differentiate(self, f, x): - """ - Computes the derivative of f by centered differences, but - forw and back difference at the start and end, respectively. - """ - dx = x[1] - x[0] - Nx = len(x) - 1 # number of spatial steps - num_dfdx = np.zeros(Nx + 1) - # Compute approximate derivatives at end-points first - num_dfdx[0] = (f(x[1]) - f(x[0])) / dx # FD approx. - num_dfdx[Nx] = (f(x[Nx]) - f(x[Nx - 1])) / dx # BD approx. - # proceed with approximate derivatives for inner mesh points - if self.vectorized: - num_dfdx[1:-1] = (f(x[2:]) - f(x[:-2])) / (2 * dx) - else: # scalar version - for i in range(1, Nx): - num_dfdx[i] = (f(x[i + 1]) - f(x[i - 1])) / (2 * dx) - return num_dfdx - - def integrate(self, f, x): - """ - Computes the integral of f(x) over the interval - covered by x. - """ - dx = x[1] - x[0] - F = np.zeros(len(x)) - F[0] = 0 # starting value for iterative scheme - if self.vectorized: - all_trapezoids = np.zeros(len(x) - 1) - all_trapezoids[:] = 0.5 * (f(x[:-1]) + f(x[1:])) * dx - F[1:] = np.cumsum(all_trapezoids) - else: # scalar version - for i in range(0, len(x) - 1): - F[i + 1] = F[i] + 0.5 * (f(x[i]) + f(x[i + 1])) * dx - return F - - -def test_differentiate(): - def f(x): - return 4 * x - 2.5 - - def dfdx(x): - derivatives = np.zeros(len(x)) - derivatives[:] = 4 - return derivatives - - a = 0 - b = 1 - Nx = 10 - x = np.linspace(a, b, Nx + 1) - exact_dfdx = dfdx(x) - # test vectorized version - calc_v = MeshCalculus(vectorized=True) - num_dfdx = calc_v.differentiate(f, x) - diff = np.abs(num_dfdx - exact_dfdx).max() - tol = 1e-14 - assert diff < tol - # test scalar version - calc = MeshCalculus(vectorized=False) - num_dfdx = calc.differentiate(f, x) - diff = np.abs(num_dfdx - exact_dfdx).max() - assert diff < tol - - -def test_integrate(): - def f(x): - return 4 * x - 2.5 - - a = 0 - b = 1 - Nx = 10 - x = np.linspace(a, b, Nx + 1) - # The exact integral amounts to the total area of two triangles - I_exact = 0.5 * abs(2.5 / 4 - a) * f(a) + 0.5 * abs(b - 2.5 / 4) * f(b) - # test vectorized version - calc_v = MeshCalculus(vectorized=True) - F = calc_v.integrate(f, x) - diff = np.abs(F[-1] - I_exact) - tol = 1e-14 - assert diff < tol - # test scalar version - calc = MeshCalculus(vectorized=False) - F = calc.integrate(f, x) - diff = np.abs(F[-1] - I_exact) - assert diff < tol -``` - -::: diff --git a/chapters/wave/wave2D_fd.qmd b/chapters/wave/wave2D_fd.qmd index 8278f434..986699e1 100644 --- a/chapters/wave/wave2D_fd.qmd +++ b/chapters/wave/wave2D_fd.qmd @@ -136,29 +136,29 @@ $$ $$ which becomes $$ -\frac{u^{n+1}**{i,j} - 2u^{n}**{i,j} + u^{n-1}_{i,j}}{\Delta t^2} +\frac{u^{n+1}_{i,j} - 2u^{n}_{i,j} + u^{n-1}_{i,j}}{\Delta t^2} = c^2 -\frac{u^{n}**{i+1,j} - 2u^{n}**{i,j} + u^{n}_{i-1,j}}{\Delta x^2} +\frac{u^{n}_{i+1,j} - 2u^{n}_{i,j} + u^{n}_{i-1,j}}{\Delta x^2} + c^2 -\frac{u^{n}**{i,j+1} - 2u^{n}**{i,j} + u^{n}_{i,j-1}}{\Delta y^2} +\frac{u^{n}_{i,j+1} - 2u^{n}_{i,j} + u^{n}_{i,j-1}}{\Delta y^2} + f^n_{i,j}, $$ Assuming, as usual, that all values at time levels $n$ and $n-1$ are known, we can solve for the only unknown $u^{n+1}_{i,j}$. The result can be compactly written as $$ -u^{n+1}**{i,j} = 2u^n**{i,j} + u^{n-1}_{i,j} + c^2\Delta t^2[D_xD_x u + D_yD_y u]^n_{i,j}\tp +u^{n+1}_{i,j} = 2u^n_{i,j} + u^{n-1}_{i,j} + c^2\Delta t^2[D_xD_x u + D_yD_y u]^n_{i,j}\tp $$ {#eq-wave-2D3D-models-unp1} As in the 1D case, we need to develop a special formula for $u^1_{i,j}$ where we combine the general scheme for $u^{n+1}_{i,j}$, when $n=0$, with the discretization of the initial condition: $$ -[D_{2t}u = V]^0_{i,j}\quad\Rightarrow\quad u^{-1}**{i,j} = u^1**{i,j} - 2\Delta t V_{i,j} \tp +[D_{2t}u = V]^0_{i,j}\quad\Rightarrow\quad u^{-1}_{i,j} = u^1_{i,j} - 2\Delta t V_{i,j} \tp $$ The result becomes, in compact form, $$ -u^{1}**{i,j} = u^0**{i,j} -2\Delta V_{i,j} + {\half} +u^{1}_{i,j} = u^0_{i,j} -2\Delta V_{i,j} + {\half} c^2\Delta t^2[D_xD_x u + D_yD_y u]^0_{i,j}\tp $$ {#eq-wave-2D3D-models-u1} @@ -173,20 +173,20 @@ When written out and solved for the unknown $u^{n+1}_{i,j,k}$, one gets the scheme \begin{align*} -u^{n+1}**{i,j,k} &= - u^{n-1}**{i,j,k} + 2u^{n}_{i,j,k} + \\ -&\quad \frac{1}{\varrho_{i,j,k}}\frac{1}{\Delta x^2} ( \half(q_{i,j,k} + q_{i+1,j,k})(u^{n}**{i+1,j,k} - u^{n}**{i,j,k}) - \\ -&\qquad\qquad \half(q_{i-1,j,k} + q_{i,j,k})(u^{n}**{i,j,k} - u^{n}**{i-1,j,k})) + \\ -&\quad \frac{1}{\varrho_{i,j,k}}\frac{1}{\Delta y^2} ( \half(q_{i,j,k} + q_{i,j+1,k})(u^{n}**{i,j+1,k} - u^{n}**{i,j,k}) - \\ -&\qquad\qquad\half(q_{i,j-1,k} + q_{i,j,k})(u^{n}**{i,j,k} - u^{n}**{i,j-1,k})) + \\ -&\quad \frac{1}{\varrho_{i,j,k}}\frac{1}{\Delta z^2} ( \half(q_{i,j,k} + q_{i,j,k+1})(u^{n}**{i,j,k+1} - u^{n}**{i,j,k}) -\\ -&\qquad\qquad \half(q_{i,j,k-1} + q_{i,j,k})(u^{n}**{i,j,k} - u^{n}**{i,j,k-1})) + \\ +u^{n+1}_{i,j,k} &= - u^{n-1}_{i,j,k} + 2u^{n}_{i,j,k} + \\ +&\quad \frac{1}{\varrho_{i,j,k}}\frac{1}{\Delta x^2} ( \half(q_{i,j,k} + q_{i+1,j,k})(u^{n}_{i+1,j,k} - u^{n}_{i,j,k}) - \\ +&\qquad\qquad \half(q_{i-1,j,k} + q_{i,j,k})(u^{n}_{i,j,k} - u^{n}_{i-1,j,k})) + \\ +&\quad \frac{1}{\varrho_{i,j,k}}\frac{1}{\Delta y^2} ( \half(q_{i,j,k} + q_{i,j+1,k})(u^{n}_{i,j+1,k} - u^{n}_{i,j,k}) - \\ +&\qquad\qquad\half(q_{i,j-1,k} + q_{i,j,k})(u^{n}_{i,j,k} - u^{n}_{i,j-1,k})) + \\ +&\quad \frac{1}{\varrho_{i,j,k}}\frac{1}{\Delta z^2} ( \half(q_{i,j,k} + q_{i,j,k+1})(u^{n}_{i,j,k+1} - u^{n}_{i,j,k}) -\\ +&\qquad\qquad \half(q_{i,j,k-1} + q_{i,j,k})(u^{n}_{i,j,k} - u^{n}_{i,j,k-1})) + \\ &\quad \Delta t^2 f^n_{i,j,k} \end{align*} \tp Also here we need to develop a special formula for $u^1_{i,j,k}$ by combining the scheme for $n=0$ with the discrete initial condition, which is just a matter of inserting -$u^{-1}**{i,j,k}=u^1**{i,j,k} - 2\Delta tV_{i,j,k}$ in the scheme +$u^{-1}_{i,j,k}=u^1_{i,j,k} - 2\Delta tV_{i,j,k}$ in the scheme and solving for $u^1_{i,j,k}$. ### Handling boundary conditions where $u$ is known @@ -216,11 +216,11 @@ $$ From this it follows that $u^n_{i,-1}=u^n_{i,1}$. The discretized PDE at the boundary point $(i,0)$ reads $$ -\frac{u^{n+1}**{i,0} - 2u^{n}**{i,0} + u^{n-1}_{i,0}}{\Delta t^2} +\frac{u^{n+1}_{i,0} - 2u^{n}_{i,0} + u^{n-1}_{i,0}}{\Delta t^2} = c^2 -\frac{u^{n}**{i+1,0} - 2u^{n}**{i,0} + u^{n}_{i-1,0}}{\Delta x^2} +\frac{u^{n}_{i+1,0} - 2u^{n}_{i,0} + u^{n}_{i-1,0}}{\Delta x^2} + c^2 -\frac{u^{n}**{i,1} - 2u^{n}**{i,0} + u^{n}_{i,-1}}{\Delta y^2} +\frac{u^{n}_{i,1} - 2u^{n}_{i,0} + u^{n}_{i,-1}}{\Delta y^2} + f^n_{i,j}, $$ We can then just insert $u^n_{i,1}$ for $u^n_{i,-1}$ in this equation @@ -237,4 +237,4 @@ mesh is to have $u^n_{i,-1}$ available as a ghost value. The mesh is extended with one extra line (2D) or plane (3D) of ghost cells at a Neumann boundary. In the present example it means that we need a line with ghost cells below the $y$ axis. The ghost values must be updated -according to $u^{n+1}**{i,-1}=u^{n+1}**{i,1}$. +according to $u^{n+1}_{i,-1}=u^{n+1}_{i,1}$. diff --git a/chapters/wave/wave2D_prog.qmd b/chapters/wave/wave2D_prog.qmd deleted file mode 100644 index a02dad04..00000000 --- a/chapters/wave/wave2D_prog.qmd +++ /dev/null @@ -1,632 +0,0 @@ -## Implementation of 2D and 3D wave equations {#sec-wave-2D3D-impl} - -We shall now describe in detail various Python implementations -for solving a standard 2D, linear wave equation with constant -wave velocity and $u=0$ on the -boundary. The wave equation is to be solved -in the space-time domain $\Omega\times (0,T]$, -where $\Omega = (0,L_x)\times (0,L_y)$ is a rectangular spatial -domain. More precisely, -the complete initial-boundary value problem is defined by - -```{=latex} -\begin{alignat}{2} -&u_{tt} = c^2(u_{xx} + u_{yy}) + f(x,y,t),\quad &(x,y)\in \Omega,\ t\in (0,T],\\ -&u(x,y,0) = I(x,y),\quad &(x,y)\in\Omega,\\ -&u_t(x,y,0) = V(x,y),\quad &(x,y)\in\Omega,\\ -&u = 0,\quad &(x,y)\in\partial\Omega,\ t\in (0,T], -\end{alignat} -``` - -where $\partial\Omega$ is the boundary of $\Omega$, in this case -the four sides of the rectangle $\Omega = [0,L_x]\times [0,L_y]$: -$x=0$, $x=L_x$, $y=0$, and $y=L_y$. - -The PDE is discretized as -$$ -[D_t D_t u = c^2(D_xD_x u + D_yD_y u) + f]^n_{i,j}, -$$ -which leads to an explicit updating formula to be implemented in a -program: - -$$ -\begin{split} -u^{n+1}_{i,j} &= -u^{n-1}_{i,j} + 2u^n_{i,j} + \\ -&\quad C_x^2( -u^{n}_{i+1,j} - 2u^{n}_{i,j} + u^{n}_{i-1,j}) + C_y^2 -(u^{n}_{i,j+1} - 2u^{n}_{i,j} + u^{n}_{i,j-1}) + \Delta t^2 f_{i,j}^n, -\end{split} -$$ {#eq-wave-2D3D-impl1-2Du0-ueq-discrete} -for all interior mesh points $i\in\seti{\Ix}$ and -$j\in\seti{\Iy}$, for $n\in\setr{\It}$. -The constants $C_x$ and $C_y$ are defined as -$$ -C_x = c\frac{\Delta t}{\Delta x},\quad C_y = c\frac{\Delta t}{\Delta y} \tp -$$ -At the boundary, we simply set $u^{n+1}_{i,j}=0$ for -$i=0$, $j=0,\ldots,N_y$; $i=N_x$, $j=0,\ldots,N_y$; -$j=0$, $i=0,\ldots,N_x$; and $j=N_y$, $i=0,\ldots,N_x$. -For the first step, $n=0$, (@eq-wave-2D3D-impl1-2Du0-ueq-discrete) -is combined with the discretization of the initial condition $u_t=V$, -$[D_{2t} u = V]^0_{i,j}$ to obtain a special formula for -$u^1_{i,j}$ at the interior mesh points: - -$$ -\begin{split} -u^{1}_{i,j} &= u^0_{i,j} + \Delta t V_{i,j} + \\ -&\quad {\half}C_x^2( -u^{0}_{i+1,j} - 2u^{0}_{i,j} + u^{0}_{i-1,j}) + {\half}C_y^2 -(u^{0}_{i,j+1} - 2u^{0}_{i,j} + u^{0}_{i,j-1}) +\\ -&\quad \half\Delta t^2f_{i,j}^n, -\end{split} -$$ {#eq-wave-2D3D-impl1-2Du0-ueq-n0-discrete} - -The algorithm is very similar to the one in 1D: - - 1. Set initial condition $u^0_{i,j}=I(x_i,y_j)$ - 1. Compute $u^1_{i,j}$ from (@eq-wave-2D3D-impl1-2Du0-ueq-discrete) - 1. Set $u^1_{i,j}=0$ for the boundaries $i=0,N_x$, $j=0,N_y$ - 1. For $n=1,2,\ldots,N_t$: - 1. Find $u^{n+1}_{i,j}$ from (@eq-wave-2D3D-impl1-2Du0-ueq-discrete) - for all internal mesh points, $i\in\seti{\Ix}$, $j\in\seti{\Iy}$ - 1. Set $u^{n+1}_{i,j}=0$ for the boundaries $i=0,N_x$, $j=0,N_y$ - -## Scalar computations {#sec-wave2D3D-impl-scalar} - -The `solver` function for a 2D case with constant wave velocity and -boundary condition $u=0$ is analogous to the 1D case with similar parameter -values (see `wave1D_u0.py`), apart from a few necessary -extensions. The code is found in the program -[`wave2D_u0.py`](https://github.com/devitocodes/devito_book/tree/main/src/wave/wave2D_u0/wave2D_u0.py). - -### Domain and mesh - -The spatial domain is now $[0,L_x]\times [0,L_y]$, specified -by the arguments `Lx` and `Ly`. Similarly, the number of mesh -points in the $x$ and $y$ directions, -$N_x$ and $N_y$, become the arguments `Nx` and `Ny`. -In multi-dimensional problems it makes less sense to specify a -Courant number since the wave velocity is a vector and mesh spacings -may differ in the various spatial directions. -We therefore give $\Delta t$ explicitly. The signature of -the `solver` function is then - -```python -def solver(I, V, f, c, Lx, Ly, Nx, Ny, dt, T, - user_action=None, version='scalar'): -``` - -Key parameters used in the calculations are created as - -```python -x = linspace(0, Lx, Nx+1) # mesh points in x dir -y = linspace(0, Ly, Ny+1) # mesh points in y dir -dx = x[1] - x[0] -dy = y[1] - y[0] -Nt = int(round(T/float(dt))) -t = linspace(0, N*dt, N+1) # mesh points in time -Cx2 = (c*dt/dx)**2; Cy2 = (c*dt/dy)**2 # help variables -dt2 = dt**2 -``` - -### Solution arrays - -We store $u^{n+1}**{i,j}$, $u^{n}**{i,j}$, and -$u^{n-1}_{i,j}$ in three two-dimensional arrays, - -```python -u = zeros((Nx+1,Ny+1)) # solution array -u_n = [zeros((Nx+1,Ny+1)), zeros((Nx+1,Ny+1))] # t-dt, t-2*dt -``` - -where $u^{n+1}_{i,j}$ corresponds to `u[i,j]`, -$u^{n}_{i,j}$ to `u_n[i,j]`, and -$u^{n-1}_{i,j}$ to `u_nm1[i,j]`. - -### Index sets - -It is also convenient to introduce the index sets (cf. Section -@sec-wave-indexset) - -```python -Ix = range(0, u.shape[0]) -It = range(0, u.shape[1]) -It = range(0, t.shape[0]) -``` - -### Computing the solution - -Inserting the initial -condition `I` in `u_n` and making a callback to the user in terms of -the `user_action` function is a straightforward generalization of -the 1D code from Section @sec-wave-string-impl: - -```python -for i in Ix: - for j in It: - u_n[i,j] = I(x[i], y[j]) - -if user_action is not None: - user_action(u_n, x, xv, y, yv, t, 0) -``` - -The `user_action` function has additional arguments compared to the -1D case. The arguments `xv` and `yv` will be commented -upon in Section @sec-wave2D3D-impl-vectorized. - -The key finite difference formula (@eq-wave-2D3D-models-unp1) -for updating the solution at -a time level is implemented in a separate function as - -```python -def advance_scalar(u, u_n, u_nm1, f, x, y, t, n, Cx2, Cy2, dt2, - V=None, step1=False): - Ix = range(0, u.shape[0]); It = range(0, u.shape[1]) - if step1: - dt = sqrt(dt2) # save - Cx2 = 0.5*Cx2; Cy2 = 0.5*Cy2; dt2 = 0.5*dt2 # redefine - D1 = 1; D2 = 0 - else: - D1 = 2; D2 = 1 - for i in Ix[1:-1]: - for j in It[1:-1]: - u_xx = u_n[i-1,j] - 2*u_n[i,j] + u_n[i+1,j] - u_yy = u_n[i,j-1] - 2*u_n[i,j] + u_n[i,j+1] - u[i,j] = D1*u_n[i,j] - D2*u_nm1[i,j] + \ - Cx2*u_xx + Cy2*u_yy + dt2*f(x[i], y[j], t[n]) - if step1: - u[i,j] += dt*V(x[i], y[j]) - j = It[0] - for i in Ix: u[i,j] = 0 - j = It[-1] - for i in Ix: u[i,j] = 0 - i = Ix[0] - for j in It: u[i,j] = 0 - i = Ix[-1] - for j in It: u[i,j] = 0 - return u -``` - -The `step1` variable has been introduced to allow the formula to be -reused for the first step, computing $u^1_{i,j}$: - -```python -u = advance_scalar(u, u_n, f, x, y, t, - n, Cx2, Cy2, dt, V, step1=True) -``` - -Below, we will make many alternative implementations of the -`advance_scalar` function to speed up the code since most of -the CPU time in simulations is spent in this function. - -:::{.callout-note title="Remark: How to use the solution"} -The `solver` function in the `wave2D_u0.py` code -updates arrays for the next time step by switching references as -described in Section @sec-wave-pde1-impl-ref-switch. Any use of `u` on the -user's side is assumed to take place in the user action function. However, -should the code be changed such that `u` is returned and used as solution, -have in mind that you must return `u_n` after the time limit, otherwise -a `return u` will actually return `u_nm1` (due to the switching of array -indices in the loop)! -::: - -## Vectorized computations {#sec-wave2D3D-impl-vectorized} - -The scalar code above turns out to be extremely slow for large 2D -meshes, and probably useless in 3D beyond debugging of small test cases. -Vectorization is therefore a must for multi-dimensional -finite difference computations in Python. For example, -with a mesh consisting of $30\times 30$ cells, vectorization -brings down the CPU time by a factor of 70 (!). Equally important, -vectorized code can also easily be parallelized to take (usually) -optimal advantage of parallel computer platforms. - -In the vectorized case, we must be able to evaluate user-given -functions like $I(x,y)$ and $f(x,y,t)$ for the entire mesh in one -operation (without loops). These user-given functions are provided as -Python functions `I(x,y)` and `f(x,y,t)`, respectively. Having the -one-dimensional coordinate arrays `x` and `y` is not sufficient when -calling `I` and `f` in a vectorized way. We must extend `x` and `y` -to their vectorized versions `xv` and `yv`: - -```python -from numpy import newaxis -xv = x[:,newaxis] -yv = y[newaxis,:] -xv = x.reshape((x.size, 1)) -yv = y.reshape((1, y.size)) -``` - -This is a standard required technique when evaluating functions over -a 2D mesh, say `sin(xv)*cos(xv)`, which then gives a result with shape -`(Nx+1,Ny+1)`. Calling `I(xv, yv)` and `f(xv, yv, t[n])` will now -return `I` and `f` values for the entire set of mesh points. - -With the `xv` and `yv` arrays for vectorized computing, -setting the initial condition is just a matter of - -```python -u_n[:,:] = I(xv, yv) -``` - -One could also have written `u_n = I(xv, yv)` and let `u_n` point to a -new object, but vectorized operations often make use of direct -insertion in the original array through `u_n[:,:]`, because sometimes -not all of the array is to be filled by such a function -evaluation. This is the case with the computational scheme for -$u^{n+1}_{i,j}$: - -```python -def advance_vectorized(u, u_n, u_nm1, f_a, Cx2, Cy2, dt2, - V=None, step1=False): - if step1: - dt = sqrt(dt2) # save - Cx2 = 0.5*Cx2; Cy2 = 0.5*Cy2; dt2 = 0.5*dt2 # redefine - D1 = 1; D2 = 0 - else: - D1 = 2; D2 = 1 - u_xx = u_n[:-2,1:-1] - 2*u_n[1:-1,1:-1] + u_n[2:,1:-1] - u_yy = u_n[1:-1,:-2] - 2*u_n[1:-1,1:-1] + u_n[1:-1,2:] - u[1:-1,1:-1] = D1*u_n[1:-1,1:-1] - D2*u_nm1[1:-1,1:-1] + \ - Cx2*u_xx + Cy2*u_yy + dt2*f_a[1:-1,1:-1] - if step1: - u[1:-1,1:-1] += dt*V[1:-1, 1:-1] - j = 0 - u[:,j] = 0 - j = u.shape[1]-1 - u[:,j] = 0 - i = 0 - u[i,:] = 0 - i = u.shape[0]-1 - u[i,:] = 0 - return u -``` - -Array slices in 2D are more complicated to understand than those in -1D, but the logic from 1D applies to each dimension separately. -For example, when doing $u^{n}**{i,j} - u^{n}**{i-1,j}$ for $i\in\setr{\Ix}$, -we just keep `j` constant and make a slice in the first index: -`u_n[1:,j] - u_n[:-1,j]`, exactly as in 1D. The `1:` slice -specifies all the indices $i=1,2,\ldots,N_x$ (up to the last -valid index), -while `:-1` specifies the relevant indices for the second term: -$0,1,\ldots,N_x-1$ (up to, but not including the last index). - -In the above code segment, the situation is slightly more complicated, -because each displaced slice in one direction is -accompanied by a `1:-1` slice in the other direction. The reason is -that we only work with the internal points for the index that is -kept constant in a difference. - -The boundary conditions along the four sides make use of -a slice consisting of all indices along a boundary: - -```python -u[: ,0] = 0 -u[:,Ny] = 0 -u[0 ,:] = 0 -u[Nx,:] = 0 -``` - -In the vectorized update of `u` (above), the function `f` is first computed -as an array over all mesh points: - -```python -f_a = f(xv, yv, t[n]) -``` - -We could, alternatively, have used the call `f(xv, yv, t[n])[1:-1,1:-1]` -in the last term of the update statement, but other implementations -in compiled languages benefit from having `f` available in an array -rather than calling our Python function `f(x,y,t)` for -every point. - -Also in the `advance_vectorized` function we have introduced a -boolean `step1` to reuse the formula for the first time step -in the same way as we did with `advance_scalar`. -We refer to the `solver` function in `wave2D_u0.py` -for the details on how the overall algorithm is implemented. - -The callback function now has the arguments -`u, x, xv, y, yv, t, n`. The inclusion of `xv` and `yv` makes it -easy to, e.g., compute an exact 2D solution in the callback function -and compute errors, through an expression like -`u - u_exact(xv, yv, t[n])`. - -## Verification - -### Testing a quadratic solution {#sec-wave2D3D-impl-verify} - -The 1D solution from Section @sec-wave-pde2-fd-verify-quadratic can be -generalized to multi-dimensions and provides a test case where the -exact solution also fulfills the discrete equations, such that we know -(to machine precision) what numbers the solver function should -produce. In 2D we use the following generalization of -(@eq-wave-pde2-fd-verify-quadratic-uex): -$$ -\uex(x,y,t) = x(L_x-x)y(L_y-y)(1+{\half}t) \tp -$$ {#eq-wave2D3D-impl-verify-quadratic} -This solution fulfills the PDE problem if $I(x,y)=\uex(x,y,0)$, -$V=\half\uex(x,y,0)$, and $f=2c^2(1+{\half}t)(y(L_y-y) + -x(L_x-x))$. To show that $\uex$ also solves the discrete equations, -we start with the general results $[D_t D_t 1]^n=0$, $[D_t D_t t]^n=0$, -and $[D_t D_t t^2]=2$, and use these to compute - -\begin{align*} -[D_xD_x \uex]^n_{i,j} &= [y(L_y-y)(1+{\half}t) D_xD_x x(L_x-x)]^n_{i,j}\\ -&= y_j(L_y-y_j)(1+{\half}t_n)(-2)\tp -\end{align*} -A similar calculation must be carried out for the $[D_yD_y -\uex]^n_{i,j}$ and $[D_tD_t \uex]^n_{i,j}$ terms. One must also show -that the quadratic solution fits the special formula for -$u^1_{i,j}$. The details are left as Exercise -@sec-wave-exer-quadratic-2D. -The `test_quadratic` function in the -[`wave2D_u0.py`](https://github.com/devitocodes/devito_book/tree/main/src/wave/wave2D_u0/wave2D_u0.py) -program implements this verification as a proper test function -for the pytest and nose frameworks. - -## Visualization - -Eventually, we are ready for a real application with our code! -Look at the `wave2D_u0.py` and the `gaussian` function. It -starts with a Gaussian function to see how it propagates in a square -with $u=0$ on the boundaries: - -```python -def gaussian(plot_method=2, version='vectorized', save_plot=True): - """ - Initial Gaussian bell in the middle of the domain. - plot_method=1 applies mesh function, - =2 means surf, =3 means Matplotlib, =4 means mayavi, - =0 means no plot. - """ - for name in glob('tmp_*.png'): - os.remove(name) - - Lx = 10 - Ly = 10 - c = 1.0 - - from numpy import exp - - def I(x, y): - """Gaussian peak at (Lx/2, Ly/2).""" - return exp(-0.5*(x-Lx/2.0)**2 - 0.5*(y-Ly/2.0)**2) - - def plot_u(u, x, xv, y, yv, t, n): - """User action function for plotting.""" - ... - - Nx = 40; Ny = 40; T = 20 - dt, cpu = solver(I, None, None, c, Lx, Ly, Nx, Ny, -1, T, - user_action=plot_u, version=version) -``` - -### Matplotlib -We want to animate a 3D surface in Matplotlib, but this is a really -slow process and not recommended, so we consider Matplotlib not an -option as long as on-screen animation is desired. One can use the -recipes for single shots of $u$, where it does produce high-quality -3D plots. - -### Gnuplot -Let us look at different ways for visualization using Gnuplot. -If you have the C package Gnuplot and the `Gnuplot.py` Python interface -module installed, you can get nice 3D surface plots with contours beneath -(Figure @fig-wave2D3D-impl-viz-fig-gnuplot1). -It gives a nice visualization with lifted surface and contours beneath. -Figure @fig-wave2D3D-impl-viz-fig-gnuplot1 shows four plots of $u$. - -![Snapshots of the surface plotted by Gnuplot.](fig/wave2D_u0_gnuplot_gaussian.png){#fig-wave2D3D-impl-viz-fig-gnuplot1 width="100%"} - -Video files can be made of the PNG frames: - -```bash -Terminal> ffmpeg -i tmp_%04d.png -r 25 -vcodec flv movie.flv -Terminal> ffmpeg -i tmp_%04d.png -r 25 -vcodec linx264 movie.mp4 -Terminal> ffmpeg -i tmp_%04d.png -r 25 -vcodec libvpx movie.webm -Terminal> ffmpeg -i tmp_%04d.png -r 25 -vcodec libtheora movie.ogg -``` -It is wise to use a high frame rate -- a low one will just skip many -frames. There may also be considerable quality differences between the -different formats. - -MOVIE: [https://raw.githubusercontent.com/hplgit/fdm-book/master/doc/pub/book/html/mov-wave/gnuplot/wave2D_u0_gaussian/movie25.mp4] - -### Mayavi -The best option for doing visualization of 2D and 3D scalar and vector fields -in Python programs is Mayavi, which is an interface to the high-quality -package VTK in C++. There is good online documentation and also -an introduction in Chapter 5 of [@Langtangen_2012]. - -To obtain Mayavi on Ubuntu platforms you can write - -```bash -pip install mayavi --upgrade -``` -For Mac OS X and Windows, we recommend using Anaconda. -To obtain Mayavi for Anaconda you can write - -```bash -conda install mayavi -``` - -Mayavi has a MATLAB-like interface called `mlab`. We can do - -```python -import mayavi.mlab as plt -from mayavi import mlab -``` -and have `plt` (as usual) or `mlab` -as a kind of MATLAB visualization access inside our program (just -more powerful and with higher visual quality). - -The official documentation of the `mlab` module is provided in two -places, one for the [basic functionality](http://docs.enthought.com/mayavi/mayavi/auto/mlab_helper_functions.html) -and one for [further functionality](http://docs.enthought.com/mayavi/mayavi/auto/mlab_other_functions.html). -Basic [figure -handling](http://docs.enthought.com/mayavi/mayavi/auto/mlab_figure.html) -is very similar to the one we know from Matplotlib. Just as for -Matplotlib, all plotting commands you do in `mlab` will go into the -same figure, until you manually change to a new figure. - -Back to our application, the following code for the user action -function with plotting in Mayavi is relevant to add. - -```python -try: - import mayavi.mlab as mlab -except: - pass - -def solver(...): - ... - -def gaussian(...): - ... - if plot_method == 3: - from mpl_toolkits.mplot3d import axes3d - import matplotlib.pyplot as plt - from matplotlib import cm - plt.ion() - fig = plt.figure() - u_surf = None - - def plot_u(u, x, xv, y, yv, t, n): - """User action function for plotting.""" - if t[n] == 0: - time.sleep(2) - if plot_method == 1: - st.mesh(x, y, u, title='t=%g' % t[n], zlim=[-1,1], - caxis=[-1,1]) - elif plot_method == 2: - st.surfc(xv, yv, u, title='t=%g' % t[n], zlim=[-1, 1], - colorbar=True, colormap=st.hot(), caxis=[-1,1], - shading='flat') - elif plot_method == 3: - print 'Experimental 3D matplotlib...not recommended' - elif plot_method == 4: - mlab.clf() - extent1 = (0, 20, 0, 20,-2, 2) - s = mlab.surf(x , y, u, - colormap='Blues', - warp_scale=5,extent=extent1) - mlab.axes(s, color=(.7, .7, .7), extent=extent1, - ranges=(0, 10, 0, 10, -1, 1), - xlabel='', ylabel='', zlabel='', - x_axis_visibility=False, - z_axis_visibility=False) - mlab.outline(s, color=(0.7, .7, .7), extent=extent1) - mlab.text(6, -2.5, '', z=-4, width=0.14) - mlab.colorbar(object=None, title=None, - orientation='horizontal', - nb_labels=None, nb_colors=None, - label_fmt=None) - mlab.title('Gaussian t=%g' % t[n]) - mlab.view(142, -72, 50) - f = mlab.gcf() - camera = f.scene.camera - camera.yaw(0) - - if plot_method > 0: - time.sleep(0) # pause between frames - if save_plot: - filename = 'tmp_%04d.png' % n - if plot_method == 4: - mlab.savefig(filename) # time consuming! - elif plot_method in (1,2): - st.savefig(filename) # time consuming! -``` -This is a point to get started -- visualization is as always a very -time-consuming and experimental discipline. With the PNG files we -can use `ffmpeg` to create videos. - -![Plot with Mayavi.](fig/mayavi2D_gaussian1.png){width="600px"} - -MOVIE: [https://github.com/hplgit/fdm-book/blob/master/doc/pub/book/html/mov-wave/mayavi/wave2D_u0_gaussian/movie.mp4] - -## Exercise: Check that a solution fulfills the discrete model {#sec-wave-exer-quadratic-2D} - -Carry out all mathematical details to show that -(@eq-wave2D3D-impl-verify-quadratic) is indeed a solution of the -discrete model for a 2D wave equation with $u=0$ on the boundary. -One must check the boundary conditions, the initial conditions, -the general discrete equation at a time level and the special -version of this equation for the first time level. - -## Project: Calculus with 2D mesh functions {#sec-wave-exer-mesh3D-calculus} - -The goal of this project is to redo -Project @sec-wave-exer-mesh1D-calculus with 2D -mesh functions ($f_{i,j}$). - -__Differentiation.__ -The differentiation results in a discrete gradient -function, which in the 2D case can be represented by a three-dimensional -array `df[d,i,j]` where `d` represents the direction of -the derivative, and `i,j` is a mesh point in 2D. -Use centered differences for -the derivative at inner points and one-sided forward or backward -differences at the boundary points. Construct unit tests and -write a corresponding test function. - -__Integration.__ -The integral of a 2D mesh function $f_{i,j}$ is defined as -$$ -F_{i,j} = \int_{y_0}^{y_j} \int_{x_0}^{x_i} f(x,y)dxdy, -$$ -where $f(x,y)$ is a function that takes on the values of the -discrete mesh function $f_{i,j}$ at the mesh points, but can also -be evaluated in between the mesh points. The particular variation -between mesh points can be taken as bilinear, but this is not -important as we will use a product Trapezoidal rule to approximate -the integral over a cell in the mesh and then we only need to -evaluate $f(x,y)$ at the mesh points. - -Suppose $F_{i,j}$ is computed. The calculation of $F_{i+1,j}$ -is then - -\begin{align*} -F_{i+1,j} &= F_{i,j} + \int_{x_i}^{x_{i+1}}\int_{y_0}^{y_j} f(x,y)dydx\\ -& \approx \Delta x \half\left( -\int_{y_0}^{y_j} f(x_{i},y)dy -+ \int_{y_0}^{y_j} f(x_{i+1},y)dy\right) -\end{align*} -The integrals in the $y$ direction can be approximated by a Trapezoidal -rule. A similar idea can be used to compute $F_{i,j+1}$. Thereafter, -$F_{i+1,j+1}$ can be computed by adding the integral over the final -corner cell to $F_{i+1,j} + F_{i,j+1} - F_{i,j}$. Carry out the -details of these computations and implement a function that can -return $F_{i,j}$ for all mesh indices $i$ and $j$. Use the -fact that the Trapezoidal rule is exact for linear functions and -write a test function. - -## Exercise: Implement Neumann conditions in 2D {#sec-wave-app-exer-wave2D-Neumann} - -Modify the [`wave2D_u0.py`](https://github.com/devitocodes/devito_book/tree/main/src/wave/wave2D_u0/wave2D_u0.py) -program, which solves the 2D wave equation $u_{tt}=c^2(u_{xx}+u_{yy})$ -with constant wave velocity $c$ and $u=0$ on the boundary, to have -Neumann boundary conditions: $\partial u/\partial n=0$. -Include both scalar code (for debugging and reference) and -vectorized code (for speed). - -To test the code, use $u=1.2$ as solution ($I(x,y)=1.2$, $V=f=0$, and -$c$ arbitrary), which should be exactly reproduced with any mesh -as long as the stability criterion is satisfied. -Another test is to use the plug-shaped pulse -in the `pulse` function from Section @sec-wave-pde2-software -and the [`wave1D_dn_vc.py`](https://github.com/devitocodes/devito_book/tree/main/src/wave/wave1D/wave1D_dn_vc.py) -program. This pulse -is exactly propagated in 1D if $c\Delta t/\Delta x=1$. Check -that also the 2D program can propagate this pulse exactly -in $x$ direction ($c\Delta t/\Delta x=1$, $\Delta y$ arbitrary) -and $y$ direction ($c\Delta t/\Delta y=1$, $\Delta x$ arbitrary). - -## Exercise: Test the efficiency of compiled loops in 3D {#sec-wave-exer-3D-f77-cy-efficiency} - -Extend the `wave2D_u0.py` code and the Cython, Fortran, and C versions to 3D. -Set up an efficiency experiment to determine the relative efficiency of -pure scalar Python code, vectorized code, Cython-compiled loops, -Fortran-compiled loops, and C-compiled loops. -Normalize the CPU time for each mesh by the fastest version. diff --git a/chapters/wave/wave_app.qmd b/chapters/wave/wave_app.qmd index 53d43ae0..38fdddc6 100644 --- a/chapters/wave/wave_app.qmd +++ b/chapters/wave/wave_app.qmd @@ -216,8 +216,8 @@ $$ {#eq-wave-app-elastic-membrane-eq} This is nothing but a wave equation in $w(x,y,t)$, which needs the usual initial conditions on $w$ and $w_t$ as well as a boundary condition $w=0$. When computing the stress in the membrane, one needs to split $\stress$ -into a constant high-stress component due to the fact that all membranes are -normally pre-stressed, plus a component proportional to the displacement and +into a constant high-stress component (since all membranes are +normally pre-stressed) plus a component proportional to the displacement and governed by the wave motion. ## The acoustic model for seismic waves {#sec-wave-app-acoustic-seismic} @@ -519,7 +519,7 @@ wave equation. First, multiply (@eq-wave-app-sw-2D-ueq) and (@eq-wave-app-sw-2D-veq) by $H$, differentiate (@eq-wave-app-sw-2D-ueq)) with respect to $x$ and (@eq-wave-app-sw-2D-veq) with respect to $y$. Second, differentiate (@eq-wave-app-sw-2D-eeq) with respect to $t$ -and use that $(Hu)_{xt}=(Hu_t)**x$ and $(Hv)**{yt}=(Hv_t)_y$ when $H$ +and use that $(Hu)_{xt}=(Hu_t)_x$ and $(Hv)_{yt}=(Hv_t)_y$ when $H$ is independent of $t$. Third, eliminate $(Hu_t)_x$ and $(Hv_t)_y$ with the aid of the other two differentiated equations. These manipulations result in a standard, linear wave equation for $\eta$: @@ -543,7 +543,7 @@ of (@eq-wave-app-sw-2D-eeq). A moving bottom is best described by introducing $z=H_0$ as the still-water level, $z=B(x,y,t)$ as the time- and space-varying bottom topography, so that $H=H_0-B(x,y,t)$. In the elimination of $u$ and $v$ one may assume that the dependence of -$H$ on $t$ can be neglected in the terms $(Hu)**{xt}$ and $(Hv)**{yt}$. +$H$ on $t$ can be neglected in the terms $(Hu)_{xt}$ and $(Hv)_{yt}$. We then end up with a source term in (@eq-wave-app-sw-2D-eta-2ndoeq), because of the moving (accelerating) bottom: $$ @@ -554,7 +554,7 @@ The reduction of (@eq-wave-app-sw-2D-eta-2ndoeq-Ht) to 1D, for long waves in a straight channel, or for approximately plane waves in the ocean, is trivial by assuming no change in $y$ direction ($\partial/\partial y=0$): $$ -\eta_{tt} = (gH\eta_x)**x + B**{tt} \tp +\eta_{tt} = (gH\eta_x)_x + B_{tt} \tp $$ {#eq-wave-app-sw-1D-eta-2ndoeq-Ht} ### Wind drag on the surface diff --git a/chapters/wave/wave_app_exer.qmd b/chapters/wave/wave_app_exer.qmd index bc5d51c1..ac9521fc 100644 --- a/chapters/wave/wave_app_exer.qmd +++ b/chapters/wave/wave_app_exer.qmd @@ -8,18 +8,16 @@ effect of the jump on the wave motion. :::{.callout-tip title="According to Section @sec-wave-app-string,"} the density enters the mathematical model as $\varrho$ in -$\varrho u_{tt} = Tu_{xx}$, where $T$ is the string tension. Modify, e.g., the -`wave1D_u0v.py` code to incorporate the tension and two density values. -Make a mesh function `rho` with density values at each spatial mesh point. -A value for the tension may be 150 N. Corresponding density values can -be computed from the wave velocity estimations in the `guitar` function -in the `wave1D_u0v.py` file. +$\varrho u_{tt} = Tu_{xx}$, where $T$ is the string tension. Modify the +Devito solver from @sec-wave-devito to incorporate the tension and two +density values. Make a mesh function `rho` with density values at each +spatial mesh point. A value for the tension may be 150 N. ::: ## Exercise: Simulate damped waves on a string {#sec-wave-app-exer-string-damping} Formulate a mathematical model for damped waves on a string. -Use data from Section @sec-wave-pde1-guitar-data, and +Use typical guitar string parameters (e.g., $L = 0.75$ m, frequency 440 Hz), and tune the damping parameter so that the string is very close to the rest state after 15 s. Make a movie of the wave motion. @@ -64,8 +62,8 @@ Q\exp{(-\frac{r^2}{2\Delta r^2})}\sin\omega t,& \sin\omega t\geq 0\\ $$ Here, $Q$ and $\omega$ are constants to be chosen. -:::{.callout-tip title="Use the program `wave1D_u0v.py` as a starting point. Let `solver`"} -compute the $v$ function and then set $u=v/r$. However, +:::{.callout-tip title="Use the Devito solver from @sec-wave-devito as a starting point."} +Compute the $v$ function and then set $u=v/r$. However, $u=v/r$ for $r=0$ requires special treatment. One possibility is to compute `u[1:] = v[1:]/r[1:]` and then set `u[0]=u[1]`. The latter makes it evident that $\partial u/\partial r = 0$ in a plot. @@ -139,8 +137,8 @@ $$ {#eq-wave-app-exer-tsunami1D-hill-box} for $x\in [B_m - B_s, B_m + B_s]$ while $B=B_0$ outside this interval. -The [`wave1D_dn_vc.py`](https://github.com/devitocodes/devito_book/tree/main/src/wave/wave1D/wave1D_dn_vc.py) -program can be used as starting point for the implementation. +The Devito solver from @sec-wave-devito can be used as a starting point +for the implementation. Visualize both the bottom topography and the water surface elevation in the same plot. @@ -569,13 +567,13 @@ the value of $I(C^{-1}(C(x)-t_n))$. Make movies showing a comparison of the numerical and exact solutions for the two initial conditions -(@sec-wave-app-exer-advec1D-I-sin) and (@eq-wave-app-exer-advec1D-I-gauss). +(@eq-wave-app-exer-advec1D-I-sin) and (@eq-wave-app-exer-advec1D-I-gauss). Choose $\Delta t = \Delta x /\max_{0,L} c(x)$ and the velocity of the medium as 1. $c(x) = 1 + \epsilon\sin(k\pi x/L)$, $\epsilon <1$, 1. $c(x) = 1 + I(x)$, where $I$ is given by - (@sec-wave-app-exer-advec1D-I-sin) or (@eq-wave-app-exer-advec1D-I-gauss). + (@eq-wave-app-exer-advec1D-I-sin) or (@eq-wave-app-exer-advec1D-I-gauss). The PDE $u_t + cu_x=0$ expresses that the initial condition $I(x)$ is transported with velocity $c(x)$. diff --git a/index.qmd b/index.qmd index cfc61278..07310f63 100644 --- a/index.qmd +++ b/index.qmd @@ -8,14 +8,12 @@ This book teaches finite difference methods for solving partial differential equ ## About this Edition {.unnumbered} -This is an adaptation of *[Finite Difference Computing with PDEs: A Modern Software Approach](https://doi.org/10.1007/978-3-319-55456-3)* by Hans Petter Langtangen and Svein Linge (Springer, 2017). This Devito edition features: +This edition is based on *[Finite Difference Computing with PDEs: A Modern Software Approach](https://doi.org/10.1007/978-3-319-55456-3)* by Hans Petter Langtangen and Svein Linge (Springer, 2017). This Devito edition features: - **[Devito](https://www.devitoproject.org/)** - A domain-specific language for symbolic PDE specification and automatic code generation - **[Quarto](https://quarto.org/)** - Modern scientific publishing for web and PDF output - **Modern Python** - Type hints, testing, and CI/CD practices -Adapted by Gerard J. Gorman (Imperial College London). - ## License {.unnumbered} ::: {.content-visible when-format="html"} diff --git a/pyproject.toml b/pyproject.toml index 8bac3efb..123c122d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "fdm-book" version = "1.0.0" description = "Finite Difference Computing with PDEs - A Modern Software Approach" readme = "README.md" -license = {text = "CC BY-NC 4.0"} +license = {text = "CC BY 4.0"} authors = [ {name = "Hans Petter Langtangen"}, {name = "Svein Linge"}, @@ -20,7 +20,6 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Education", "Intended Audience :: Science/Research", - "License :: OSI Approved :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -47,6 +46,7 @@ dev = [ "flake8>=7.0.0", "flake8-pyproject>=1.2.0", "pre-commit>=3.0.0", + "pint>=0.23", ] devito = [ "devito @ git+https://github.com/devitocodes/devito.git@main", @@ -130,22 +130,6 @@ ignore = [ "W504","W605" ] -[tool.pytest.ini_options] -testpaths = ["tests"] -python_files = ["test_*.py"] -python_classes = ["Test*"] -python_functions = ["test_*"] -markers = [ - "slow: marks tests as slow (deselect with '-m \"not slow\"')", - "devito: marks tests that require Devito installation", - "derivation: marks tests that verify mathematical derivations", -] -addopts = "-v --tb=short" -filterwarnings = [ - "ignore::DeprecationWarning", - "ignore::PendingDeprecationWarning", -] - [tool.coverage.run] source = ["src"] branch = true diff --git a/src/advec/advec1D.py b/src/advec/advec1D.py deleted file mode 100644 index 57a5cb88..00000000 --- a/src/advec/advec1D.py +++ /dev/null @@ -1,385 +0,0 @@ -import matplotlib.pyplot as plt -import numpy as np - - -def solver_FECS(I, U0, v, L, dt, C, T, user_action=None): - Nt = int(round(T / float(dt))) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points in time - dx = v * dt / C - Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - # Make sure dx and dt are compatible with x and t - dx = x[1] - x[0] - dt = t[1] - t[0] - C = v * dt / dx - - u = np.zeros(Nx + 1) - u_n = np.zeros(Nx + 1) - - # Set initial condition u(x,0) = I(x) - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - - if user_action is not None: - user_action(u_n, x, t, 0) - - for n in range(0, Nt): - # Compute u at inner mesh points - for i in range(1, Nx): - u[i] = u_n[i] - 0.5 * C * (u_n[i + 1] - u_n[i - 1]) - - # Insert boundary condition - u[0] = U0 - - if user_action is not None: - user_action(u, x, t, n + 1) - - # Switch variables before next step - u_n, u = u, u_n - - -def solver(I, U0, v, L, dt, C, T, user_action=None, scheme="FE", periodic_bc=True): - Nt = int(round(T / float(dt))) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points in time - dx = v * dt / C - Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - # Make sure dx and dt are compatible with x and t - dx = x[1] - x[0] - dt = t[1] - t[0] - C = v * dt / dx - print("dt=%g, dx=%g, Nx=%d, C=%g" % (dt, dx, Nx, C)) - - u = np.zeros(Nx + 1) - u_n = np.zeros(Nx + 1) - u_nm1 = np.zeros(Nx + 1) - integral = np.zeros(Nt + 1) - - # Set initial condition u(x,0) = I(x) - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - - # Insert boundary condition - u[0] = U0 - - # Compute the integral under the curve - integral[0] = dx * (0.5 * u_n[0] + 0.5 * u_n[Nx] + np.sum(u_n[1:-1])) - - if user_action is not None: - user_action(u_n, x, t, 0) - - for n in range(0, Nt): - if scheme == "FE": - if periodic_bc: - i = 0 - u[i] = u_n[i] - 0.5 * C * (u_n[i + 1] - u_n[Nx]) - u[Nx] = u[0] - # u[i] = u_n[i] - 0.5*C*(u_n[1] - u_n[Nx]) - for i in range(1, Nx): - u[i] = u_n[i] - 0.5 * C * (u_n[i + 1] - u_n[i - 1]) - elif scheme == "LF": - if n == 0: - # Use upwind for first step - if periodic_bc: - i = 0 - # u[i] = u_n[i] - C*(u_n[i] - u_n[Nx-1]) - u_n[i] = u_n[Nx] - for i in range(1, Nx + 1): - u[i] = u_n[i] - C * (u_n[i] - u_n[i - 1]) - else: - if periodic_bc: - i = 0 - # Must have this, - u[i] = u_nm1[i] - C * (u_n[i + 1] - u_n[Nx - 1]) - # not this: - # u_n[i] = u_n[Nx] - for i in range(1, Nx): - u[i] = u_nm1[i] - C * (u_n[i + 1] - u_n[i - 1]) - if periodic_bc: - u[Nx] = u[0] - elif scheme == "UP": - if periodic_bc: - u_n[0] = u_n[Nx] - for i in range(1, Nx + 1): - u[i] = u_n[i] - C * (u_n[i] - u_n[i - 1]) - elif scheme == "LW": - if periodic_bc: - i = 0 - # Must have this, - u[i] = ( - u_n[i] - - 0.5 * C * (u_n[i + 1] - u_n[Nx - 1]) - + 0.5 * C * (u_n[i + 1] - 2 * u_n[i] + u_n[Nx - 1]) - ) - # not this: - # u_n[i] = u_n[Nx] - for i in range(1, Nx): - u[i] = ( - u_n[i] - - 0.5 * C * (u_n[i + 1] - u_n[i - 1]) - + 0.5 * C * (u_n[i + 1] - 2 * u_n[i] + u_n[i - 1]) - ) - if periodic_bc: - u[Nx] = u[0] - else: - raise ValueError('scheme="%s" not implemented' % scheme) - - if not periodic_bc: - # Insert boundary condition - u[0] = U0 - - # Compute the integral under the curve - integral[n + 1] = dx * (0.5 * u[0] + 0.5 * u[Nx] + np.sum(u[1:-1])) - - if user_action is not None: - user_action(u, x, t, n + 1) - - # Switch variables before next step - u_nm1, u_n, u = u_n, u, u_nm1 - print("I:", integral[n + 1]) - return integral - - -def run_FECS(case): - """Special function for the FECS case.""" - if case == "gaussian": - - def I(x): - return np.exp(-0.5 * ((x - L / 10) / sigma) ** 2) - elif case == "cosinehat": - - def I(x): - return np.cos(np.pi * 5 / L * (x - L / 10)) if x < L / 5 else 0 - - L = 1.0 - sigma = 0.02 - legends = [] - - def plot(u, x, t, n): - """Animate and plot every m steps in the same figure.""" - plt.figure(1) - if n == 0: - lines = plot(x, u) - else: - lines[0].set_ydata(u) - plt.draw() - # plt.savefig() - plt.figure(2) - m = 40 - if n % m != 0: - return - print( - "t=%g, n=%d, u in [%g, %g] w/%d points" % (t[n], n, u.min(), u.max(), x.size) - ) - if np.abs(u).max() > 3: # Instability? - return - plt.plot(x, u) - legends.append("t=%g" % t[n]) - - plt.ion() - U0 = 0 - dt = 0.001 - C = 1 - T = 1 - solver(I=I, U0=U0, v=1.0, L=L, dt=dt, C=C, T=T, user_action=plot) - plt.legend(legends, loc="lower left") - plt.savefig("tmp.png") - plt.savefig("tmp.pdf") - plt.axis([0, L, -0.75, 1.1]) - plt.show() - - -def run(scheme="UP", case="gaussian", C=1, dt=0.01): - """General admin routine for explicit and implicit solvers.""" - - if case == "gaussian": - - def I(x): - return np.exp(-0.5 * ((x - L / 10) / sigma) ** 2) - elif case == "cosinehat": - - def I(x): - return np.cos(np.pi * 5 / L * (x - L / 10)) if 0 < x < L / 5 else 0 - - L = 1.0 - sigma = 0.02 - global lines # needs to be saved between calls to plot - - def plot(u, x, t, n): - """Plot t=0 and t=0.6 in the same figure.""" - plt.figure(1) - global lines - if n == 0: - lines = plt.plot(x, u) - plt.axis([x[0], x[-1], -0.5, 1.5]) - plt.xlabel("x") - plt.ylabel("u") - plt.axes().set_aspect(0.15) - plt.savefig("tmp_%04d.png" % n) - plt.savefig("tmp_%04d.pdf" % n) - else: - lines[0].set_ydata(u) - plt.axis([x[0], x[-1], -0.5, 1.5]) - plt.title("C=%g, dt=%g, dx=%g" % (C, t[1] - t[0], x[1] - x[0])) - plt.legend(["t=%.3f" % t[n]]) - plt.xlabel("x") - plt.ylabel("u") - plt.draw() - plt.savefig("tmp_%04d.png" % n) - plt.figure(2) - eps = 1e-14 - if abs(t[n] - 0.6) > eps and abs(t[n] - 0) > eps: - return - print( - "t=%g, n=%d, u in [%g, %g] w/%d points" % (t[n], n, u.min(), u.max(), x.size) - ) - if np.abs(u).max() > 3: # Instability? - return - plt.plot(x, u) - plt.draw() - if n > 0: - y = [I(x_ - v * t[n]) for x_ in x] - plt.plot(x, y, "k--") - if abs(t[n] - 0.6) < eps: - filename = ("tmp_%s_dt%s_C%s" % (scheme, t[1] - t[0], C)).replace(".", "") - np.savez(filename, x=x, u=u, u_e=y) - - plt.ion() - U0 = 0 - T = 0.7 - v = 1 - # Define video formats and libraries - codecs = dict(flv="flv", mp4="libx264", webm="libvpx", ogg="libtheora") - # Remove video files - import glob - import os - - for name in glob.glob("tmp_*.png"): - os.remove(name) - for ext in codecs: - name = "movie.%s" % ext - if os.path.isfile(name): - os.remove(name) - - if scheme == "CN": - integral = solver_theta(I, v, L, dt, C, T, user_action=plot, FE=False) - elif scheme == "BE": - integral = solver_theta(I, v, L, dt, C, T, theta=1, user_action=plot) - else: - integral = solver( - I=I, U0=U0, v=v, L=L, dt=dt, C=C, T=T, scheme=scheme, user_action=plot - ) - # Finish figure(2) - plt.figure(2) - plt.axis([0, L, -0.5, 1.1]) - plt.xlabel("$x$") - plt.ylabel("$u$") - plt.axes().set_aspect(0.5) # no effect - plt.savefig("tmp1.png") - plt.savefig("tmp1.pdf") - plt.show() - # Make videos from figure(1) animation files - for codec in codecs: - cmd = "ffmpeg -i tmp_%%04d.png -r 25 -vcodec %s movie.%s" % (codecs[codec], codec) - os.system(cmd) - print("Integral of u:", integral.max(), integral.min()) - - -def solver_theta(I, v, L, dt, C, T, theta=0.5, user_action=None, FE=False): - """ - Full solver for the model problem using the theta-rule - difference approximation in time (no restriction on F, - i.e., the time step when theta >= 0.5). - Vectorized implementation and sparse (tridiagonal) - coefficient matrix. - """ - import time - - t0 = time.perf_counter() # for measuring the CPU time - Nt = int(round(T / float(dt))) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points in time - dx = v * dt / C - Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - # Make sure dx and dt are compatible with x and t - dx = x[1] - x[0] - dt = t[1] - t[0] - C = v * dt / dx - print("dt=%g, dx=%g, Nx=%d, C=%g" % (dt, dx, Nx, C)) - - u = np.zeros(Nx + 1) - u_n = np.zeros(Nx + 1) - u_nm1 = np.zeros(Nx + 1) - integral = np.zeros(Nt + 1) - - # Set initial condition u(x,0) = I(x) - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - - # Compute the integral under the curve - integral[0] = dx * (0.5 * u_n[0] + 0.5 * u_n[Nx] + np.sum(u_n[1:-1])) - - if user_action is not None: - user_action(u_n, x, t, 0) - - # Representation of sparse matrix and right-hand side - diagonal = np.zeros(Nx + 1) - lower = np.zeros(Nx) - upper = np.zeros(Nx) - b = np.zeros(Nx + 1) - - # Precompute sparse matrix (scipy format) - diagonal[:] = 1 - lower[:] = -0.5 * theta * C - upper[:] = 0.5 * theta * C - if FE: - diagonal[:] += 4.0 / 6 - lower[:] += 1.0 / 6 - upper[:] += 1.0 / 6 - # Insert boundary conditions - upper[0] = 0 - lower[-1] = 0 - - diags = [0, -1, 1] - import scipy.sparse - import scipy.sparse.linalg - - A = scipy.sparse.diags( - diagonals=[diagonal, lower, upper], - offsets=[0, -1, 1], - shape=(Nx + 1, Nx + 1), - format="csr", - ) - # print A.todense() - - # Time loop - for n in range(0, Nt): - b[1:-1] = u_n[1:-1] + 0.5 * (1 - theta) * C * (u_n[:-2] - u_n[2:]) - if FE: - b[1:-1] += 1.0 / 6 * u_n[:-2] + 1.0 / 6 * u_n[:-2] + 4.0 / 6 * u_n[1:-1] - b[0] = u_n[Nx] - b[-1] = u_n[0] # boundary conditions - b[0] = 0 - b[-1] = 0 # boundary conditions - u[:] = scipy.sparse.linalg.spsolve(A, b) - - if user_action is not None: - user_action(u, x, t, n + 1) - - # Compute the integral under the curve - integral[n + 1] = dx * (0.5 * u[0] + 0.5 * u[Nx] + np.sum(u[1:-1])) - - # Update u_n before next step - u_n, u = u, u_n - - t1 = time.perf_counter() - return integral - - -if __name__ == "__main__": - # run(scheme='LF', case='gaussian', C=1) - # run(scheme='UP', case='gaussian', C=0.8, dt=0.01) - # run(scheme='LF', case='gaussian', C=0.8, dt=0.001) - # run(scheme='LF', case='cosinehat', C=0.8, dt=0.01) - # run(scheme='CN', case='gaussian', C=1, dt=0.01) - run(scheme="LW", case="gaussian", C=1, dt=0.01) diff --git a/src/book_snippets/__init__.py b/src/book_snippets/__init__.py new file mode 100644 index 00000000..ab8270a9 --- /dev/null +++ b/src/book_snippets/__init__.py @@ -0,0 +1 @@ +"""Executable, tested code snippets included in the book via Quarto includes.""" diff --git a/src/book_snippets/absorbing_bc_right_wave.py b/src/book_snippets/absorbing_bc_right_wave.py new file mode 100644 index 00000000..56d436e4 --- /dev/null +++ b/src/book_snippets/absorbing_bc_right_wave.py @@ -0,0 +1,31 @@ +import numpy as np +from devito import Eq, Grid, Operator, TimeFunction + +# First-order absorbing boundary condition at the right boundary (1D wave). +L = 1.0 +Nx = 200 +c = 1.0 +C = 0.9 + +dx = L / Nx +dt = C * dx / c +Nt = 10 + +grid = Grid(shape=(Nx + 1,), extent=(L,)) +t = grid.stepping_dim +u = TimeFunction(name="u", grid=grid, time_order=2, space_order=2) + +x = np.linspace(0.0, L, Nx + 1) +u.data[0, :] = np.exp(-((x - 0.8) ** 2) / (2 * 0.03**2)) +u.data[1, :] = u.data[0, :] + +update = Eq(u.forward, 2 * u - u.backward + (c * dt) ** 2 * u.dx2, subdomain=grid.interior) +bc_left = Eq(u[t + 1, 0], 0.0) + +dx_sym = grid.spacing[0] +bc_right_absorbing = Eq(u[t + 1, Nx], u[t, Nx] - c * dt / dx_sym * (u[t, Nx] - u[t, Nx - 1])) + +op = Operator([update, bc_left, bc_right_absorbing]) +op(time=Nt, dt=dt) + +RESULT = float(np.max(np.abs(u.data[0, :]))) diff --git a/src/book_snippets/advec_lax_wendroff.py b/src/book_snippets/advec_lax_wendroff.py new file mode 100644 index 00000000..710c1c09 --- /dev/null +++ b/src/book_snippets/advec_lax_wendroff.py @@ -0,0 +1,45 @@ +import numpy as np +from devito import Constant, Eq, Grid, Operator, TimeFunction + + +def solve_advection_lax_wendroff(L, c, Nx, T, C, I): + """Lax-Wendroff scheme for 1D advection.""" + dx = L / Nx + dt = C * dx / c + Nt = int(T / dt) + + grid = Grid(shape=(Nx + 1,), extent=(L,)) + u = TimeFunction(name="u", grid=grid, time_order=1, space_order=2) + + x_coords = np.linspace(0, L, Nx + 1) + u.data[0, :] = I(x_coords) + + courant = Constant(name="C", value=C) + + # Lax-Wendroff: u - (C/2)*dx*u.dx + (C²/2)*dx²*u.dx2 + # u.dx = centered first derivative + # u.dx2 = centered second derivative + stencil = u - 0.5 * courant * dx * u.dx + 0.5 * courant**2 * dx**2 * u.dx2 + update = Eq(u.forward, stencil) + + # Periodic boundary conditions + t_dim = grid.stepping_dim + bc_left = Eq(u[t_dim + 1, 0], u[t_dim, Nx]) + bc_right = Eq(u[t_dim + 1, Nx], u[t_dim + 1, 0]) + + op = Operator([update, bc_left, bc_right]) + op(time=Nt, dt=dt) + + return u.data[0, :].copy(), x_coords + + +def I_gaussian(x): + """Gaussian pulse initial condition.""" + return np.exp(-0.5 * ((x - 0.25) / 0.05) ** 2) + + +# Test the Lax-Wendroff scheme +u_final, x = solve_advection_lax_wendroff( + L=1.0, c=1.0, Nx=100, T=0.5, C=0.8, I=I_gaussian +) +RESULT = {"max_u": float(np.max(u_final)), "u_shape": u_final.shape} diff --git a/src/book_snippets/advec_upwind.py b/src/book_snippets/advec_upwind.py new file mode 100644 index 00000000..f0db3019 --- /dev/null +++ b/src/book_snippets/advec_upwind.py @@ -0,0 +1,47 @@ +import numpy as np +from devito import Constant, Eq, Grid, Operator, TimeFunction + + +def solve_advection_upwind(L, c, Nx, T, C, I): + """Upwind scheme for 1D advection.""" + # Grid setup + dx = L / Nx + dt = C * dx / c + Nt = int(T / dt) + + grid = Grid(shape=(Nx + 1,), extent=(L,)) + (x_dim,) = grid.dimensions + + u = TimeFunction(name="u", grid=grid, time_order=1, space_order=1) + + # Set initial condition + x_coords = np.linspace(0, L, Nx + 1) + u.data[0, :] = I(x_coords) + + # Courant number as constant + courant = Constant(name="C", value=C) + + # Upwind stencil: u^{n+1} = u - C*(u - u[x-dx]) + u_minus = u.subs(x_dim, x_dim - x_dim.spacing) + stencil = u - courant * (u - u_minus) + update = Eq(u.forward, stencil) + + # Periodic boundary conditions + t_dim = grid.stepping_dim + bc_left = Eq(u[t_dim + 1, 0], u[t_dim, Nx]) + bc_right = Eq(u[t_dim + 1, Nx], u[t_dim + 1, 0]) + + op = Operator([update, bc_left, bc_right]) + op(time=Nt, dt=dt) + + return u.data[0, :].copy(), x_coords + + +def I_gaussian(x): + """Gaussian pulse initial condition.""" + return np.exp(-0.5 * ((x - 0.25) / 0.05) ** 2) + + +# Test the upwind scheme +u_final, x = solve_advection_upwind(L=1.0, c=1.0, Nx=100, T=0.5, C=0.8, I=I_gaussian) +RESULT = {"max_u": float(np.max(u_final)), "u_shape": u_final.shape} diff --git a/src/book_snippets/bc_2d_dirichlet_wave.py b/src/book_snippets/bc_2d_dirichlet_wave.py new file mode 100644 index 00000000..e337d10b --- /dev/null +++ b/src/book_snippets/bc_2d_dirichlet_wave.py @@ -0,0 +1,46 @@ +import numpy as np +from devito import Eq, Grid, Operator, TimeFunction + +# 2D Dirichlet boundary conditions on all edges (wave equation). +Lx = 1.0 +Ly = 1.0 +Nx = 51 +Ny = 51 +c = 1.0 +C = 0.5 + +dx = Lx / (Nx - 1) +dt = C * dx / c +Nt = 5 + +grid = Grid(shape=(Ny, Nx), extent=(Ly, Lx)) +t = grid.stepping_dim +x, y = grid.dimensions + +u = TimeFunction(name="u", grid=grid, time_order=2, space_order=2) + +xx = np.linspace(0.0, Lx, Nx) +yy = np.linspace(0.0, Ly, Ny) +X, Y = np.meshgrid(xx, yy) + +u0 = np.exp(-((X - 0.5) ** 2 + (Y - 0.5) ** 2) / (2 * 0.08**2)) +u.data[0, :, :] = u0 +u.data[1, :, :] = u0 # demo first step + +update = Eq(u.forward, 2 * u - u.backward + (c * dt) ** 2 * u.laplace, subdomain=grid.interior) + +bc_left = Eq(u[t + 1, x, 0], 0.0) +bc_right = Eq(u[t + 1, x, Ny - 1], 0.0) +bc_bottom = Eq(u[t + 1, 0, y], 0.0) +bc_top = Eq(u[t + 1, Nx - 1, y], 0.0) + +op = Operator([update, bc_left, bc_right, bc_bottom, bc_top]) +op(time=Nt, dt=dt) + +edges = [ + u.data[0, :, 0], + u.data[0, :, -1], + u.data[0, 0, :], + u.data[0, -1, :], +] +RESULT = float(max(np.max(np.abs(e)) for e in edges)) diff --git a/src/book_snippets/boundary_dirichlet_wave.py b/src/book_snippets/boundary_dirichlet_wave.py new file mode 100644 index 00000000..e508e90b --- /dev/null +++ b/src/book_snippets/boundary_dirichlet_wave.py @@ -0,0 +1,32 @@ +import numpy as np +from devito import Eq, Grid, Operator, TimeFunction + +# Setup +L, c, T = 1.0, 1.0, 0.2 +Nx = 100 +C = 0.9 # Courant number +dx = L / Nx +dt = C * dx / c +Nt = int(T / dt) + +# Grid and field +grid = Grid(shape=(Nx + 1,), extent=(L,)) +t = grid.stepping_dim +u = TimeFunction(name="u", grid=grid, time_order=2, space_order=2) + +# Initial condition: plucked string +x_vals = np.linspace(0, L, Nx + 1) +u.data[0, :] = np.sin(np.pi * x_vals) +u.data[1, :] = u.data[0, :] # Zero initial velocity (demo) + +# Equations +update = Eq(u.forward, 2 * u - u.backward + (c * dt) ** 2 * u.dx2, subdomain=grid.interior) +bc_left = Eq(u[t + 1, 0], 0.0) +bc_right = Eq(u[t + 1, Nx], 0.0) + +# Solve +op = Operator([update, bc_left, bc_right]) +op(time=Nt, dt=dt) + +# Used by tests +RESULT = float(max(abs(u.data[0, 0]), abs(u.data[0, -1]))) diff --git a/src/book_snippets/burgers_equations_bc.py b/src/book_snippets/burgers_equations_bc.py new file mode 100644 index 00000000..7439fb8a --- /dev/null +++ b/src/book_snippets/burgers_equations_bc.py @@ -0,0 +1,65 @@ +from devito import ( + Constant, + Eq, + Grid, + Operator, + TimeFunction, + first_derivative, + left, + solve, +) + +# Create grid and velocity fields +Nx, Ny = 41, 41 +Lx, Ly = 2.0, 2.0 + +grid = Grid(shape=(Nx, Ny), extent=(Lx, Ly)) +x, y = grid.dimensions + +u = TimeFunction(name="u", grid=grid, time_order=1, space_order=2) +v = TimeFunction(name="v", grid=grid, time_order=1, space_order=2) + +# First-order backward differences for advection +u_dx = first_derivative(u, dim=x, side=left, fd_order=1) +u_dy = first_derivative(u, dim=y, side=left, fd_order=1) +v_dx = first_derivative(v, dim=x, side=left, fd_order=1) +v_dy = first_derivative(v, dim=y, side=left, fd_order=1) + +# Viscosity as symbolic constant +nu = Constant(name="nu") + +# Burgers equations with backward advection and centered diffusion +# u_t + u*u_x + v*u_y = nu * laplace(u) +eq_u = Eq(u.dt + u * u_dx + v * u_dy, nu * u.laplace, subdomain=grid.interior) +eq_v = Eq(v.dt + u * v_dx + v * v_dy, nu * v.laplace, subdomain=grid.interior) + +# Solve for the update expressions +stencil_u = solve(eq_u, u.forward) +stencil_v = solve(eq_v, v.forward) + +update_u = Eq(u.forward, stencil_u) +update_v = Eq(v.forward, stencil_v) + +# Boundary conditions +t = grid.stepping_dim +bc_value = 1.0 # Boundary condition value + +# u boundary conditions +bc_u = [Eq(u[t + 1, 0, y], bc_value)] # left +bc_u += [Eq(u[t + 1, Nx - 1, y], bc_value)] # right +bc_u += [Eq(u[t + 1, x, 0], bc_value)] # bottom +bc_u += [Eq(u[t + 1, x, Ny - 1], bc_value)] # top + +# v boundary conditions (similar) +bc_v = [Eq(v[t + 1, 0, y], bc_value)] # left +bc_v += [Eq(v[t + 1, Nx - 1, y], bc_value)] # right +bc_v += [Eq(v[t + 1, x, 0], bc_value)] # bottom +bc_v += [Eq(v[t + 1, x, Ny - 1], bc_value)] # top + +# Create operator with updates and boundary conditions +op = Operator([update_u, update_v] + bc_u + bc_v) + +RESULT = { + "num_equations": len([update_u, update_v] + bc_u + bc_v), + "grid_shape": grid.shape, +} diff --git a/src/book_snippets/burgers_first_derivative.py b/src/book_snippets/burgers_first_derivative.py new file mode 100644 index 00000000..79e9319d --- /dev/null +++ b/src/book_snippets/burgers_first_derivative.py @@ -0,0 +1,25 @@ +from devito import Grid, TimeFunction, first_derivative, left + +# Create grid and velocity fields +Nx, Ny = 41, 41 +Lx, Ly = 2.0, 2.0 + +grid = Grid(shape=(Nx, Ny), extent=(Lx, Ly)) +x, y = grid.dimensions + +u = TimeFunction(name="u", grid=grid, time_order=1, space_order=2) +v = TimeFunction(name="v", grid=grid, time_order=1, space_order=2) + +# First-order backward differences for advection +# fd_order=1 gives first-order accuracy +# side=left gives backward difference: (u[x] - u[x-dx]) / dx +u_dx = first_derivative(u, dim=x, side=left, fd_order=1) +u_dy = first_derivative(u, dim=y, side=left, fd_order=1) +v_dx = first_derivative(v, dim=x, side=left, fd_order=1) +v_dy = first_derivative(v, dim=y, side=left, fd_order=1) + +# Verify the stencil structure +RESULT = { + "u_dx": str(u_dx), + "u_dy": str(u_dy), +} diff --git a/src/book_snippets/first_pde_wave1d.py b/src/book_snippets/first_pde_wave1d.py new file mode 100644 index 00000000..0c9e95d5 --- /dev/null +++ b/src/book_snippets/first_pde_wave1d.py @@ -0,0 +1,47 @@ +import numpy as np +from devito import Eq, Grid, Operator, TimeFunction + +# Problem parameters +L = 1.0 # Domain length +c = 1.0 # Wave speed +T = 1.0 # Final time +Nx = 100 # Number of grid points +C = 0.5 # Courant number (for stability) + +# Derived parameters +dx = L / Nx +dt = C * dx / c +Nt = int(T / dt) + +# Create the computational grid +grid = Grid(shape=(Nx + 1,), extent=(L,)) +t_dim = grid.stepping_dim + +# Create a time-varying field (2nd order in time, 2nd order in space) +u = TimeFunction(name="u", grid=grid, time_order=2, space_order=2) + +# Initial condition: Gaussian pulse +x_coords = np.linspace(0, L, Nx + 1) +x0 = 0.5 * L +sigma = 0.1 +u0 = np.exp(-((x_coords - x0) ** 2) / (2 * sigma**2)) +u.data[0, :] = u0 + +# First step for zero initial velocity (second-order accurate) +u_xx_0 = np.zeros_like(u0) +u_xx_0[1:-1] = (u0[2:] - 2 * u0[1:-1] + u0[:-2]) / dx**2 +u1 = u0 + 0.5 * dt**2 * c**2 * u_xx_0 +u1[0] = 0.0 +u1[-1] = 0.0 +u.data[1, :] = u1 + +# Update equation (interior) + Dirichlet boundaries +update = Eq(u.forward, 2 * u - u.backward + (c * dt) ** 2 * u.dx2, subdomain=grid.interior) +bc_left = Eq(u[t_dim + 1, 0], 0.0) +bc_right = Eq(u[t_dim + 1, Nx], 0.0) + +op = Operator([update, bc_left, bc_right]) +op(time=Nt, dt=dt) + +# Used by tests +RESULT = float(np.max(np.abs(u.data[0, :]))) diff --git a/src/book_snippets/mixed_bc_diffusion_1d.py b/src/book_snippets/mixed_bc_diffusion_1d.py new file mode 100644 index 00000000..a064163c --- /dev/null +++ b/src/book_snippets/mixed_bc_diffusion_1d.py @@ -0,0 +1,31 @@ +import numpy as np +from devito import Eq, Grid, Operator, TimeFunction + +# Mixed boundary conditions: Dirichlet on left, Neumann (copy) on right. +L = 1.0 +Nx = 80 +alpha = 1.0 +F = 0.4 + +dx = L / Nx +dt = F * dx**2 / alpha + +grid = Grid(shape=(Nx + 1,), extent=(L,)) +t = grid.stepping_dim +u = TimeFunction(name="u", grid=grid, time_order=1, space_order=2) + +x = np.linspace(0.0, L, Nx + 1) +u.data[0, :] = np.exp(-((x - 0.25) ** 2) / (2 * 0.05**2)) + +update = Eq(u.forward, u + alpha * dt * u.dx2, subdomain=grid.interior) + +bc_left = Eq(u[t + 1, 0], 0.0) +bc_right = Eq(u[t + 1, Nx], u[t + 1, Nx - 1]) # du/dx = 0 (copy trick) + +op = Operator([update, bc_left, bc_right]) +op.apply(time_m=0, time_M=0) + +RESULT = { + "left_boundary": float(u.data[1, 0]), + "right_copy_error": float(abs(u.data[1, -1] - u.data[1, -2])), +} diff --git a/src/book_snippets/neumann_bc_diffusion_1d.py b/src/book_snippets/neumann_bc_diffusion_1d.py new file mode 100644 index 00000000..8a2d9553 --- /dev/null +++ b/src/book_snippets/neumann_bc_diffusion_1d.py @@ -0,0 +1,42 @@ +import numpy as np +from devito import Eq, Grid, Operator, TimeFunction + +# Neumann boundary conditions: du/dx = 0 at both ends for diffusion. +L = 1.0 +Nx = 100 +alpha = 1.0 +F = 0.4 # stable for Forward Euler diffusion in 1D when F <= 0.5 + +dx = L / Nx +dt = F * dx**2 / alpha +Nt = 25 + +grid = Grid(shape=(Nx + 1,), extent=(L,)) +t = grid.stepping_dim +u = TimeFunction(name="u", grid=grid, time_order=1, space_order=2) + +x = np.linspace(0.0, L, Nx + 1) +u.data[0, :] = np.exp(-((x - 0.5) ** 2) / (2 * 0.05**2)) + +update = Eq(u.forward, u + alpha * dt * u.dx2, subdomain=grid.interior) + +dx_sym = grid.spacing[0] +bc_left = Eq( + u[t + 1, 0], + u[t, 0] + alpha * dt * 2.0 * (u[t, 1] - u[t, 0]) / dx_sym**2, +) +bc_right = Eq( + u[t + 1, Nx], + u[t, Nx] + alpha * dt * 2.0 * (u[t, Nx - 1] - u[t, Nx]) / dx_sym**2, +) + +op = Operator([update, bc_left, bc_right]) + +for _ in range(Nt): + op.apply(time_m=0, time_M=0) + u.data[0, :] = u.data[1, :] + +grad_left = float(abs(u.data[0, 1] - u.data[0, 0])) +grad_right = float(abs(u.data[0, -1] - u.data[0, -2])) + +RESULT = max(grad_left, grad_right) diff --git a/src/book_snippets/nonlin_logistic_be_solver.py b/src/book_snippets/nonlin_logistic_be_solver.py new file mode 100644 index 00000000..6490d608 --- /dev/null +++ b/src/book_snippets/nonlin_logistic_be_solver.py @@ -0,0 +1,124 @@ +"""Backward Euler solver for logistic equation with Picard/Newton iteration.""" + +import numpy as np + + +def quadratic_roots(a, b, c): + """Solve ax^2 + bx + c = 0.""" + discriminant = b**2 - 4 * a * c + if discriminant < 0: + return None, None + sqrt_disc = np.sqrt(discriminant) + return (-b - sqrt_disc) / (2 * a), (-b + sqrt_disc) / (2 * a) + + +def BE_logistic(u0, dt, Nt, choice="Picard", eps_r=1e-3, omega=1, max_iter=1000): + """Solve logistic equation u' = u(1-u) using Backward Euler. + + Parameters + ---------- + u0 : float + Initial condition + dt : float + Time step + Nt : int + Number of time steps + choice : str + Solution method: 'Picard', 'Picard1', 'Newton', 'r1', or 'r2' + eps_r : float + Residual tolerance for iteration + omega : float + Relaxation parameter (0 < omega <= 1) + max_iter : int + Maximum iterations per time step + + Returns + ------- + u : ndarray + Solution at all time levels + iterations : list + Number of iterations at each time level + """ + if choice == "Picard1": + choice = "Picard" + max_iter = 1 + + u = np.zeros(Nt + 1) + iterations = [] + u[0] = u0 + + for n in range(1, Nt + 1): + a = dt + b = 1 - dt + c = -u[n - 1] + + if choice in ("r1", "r2"): + # Use exact quadratic formula + r1, r2 = quadratic_roots(a, b, c) + u[n] = r1 if choice == "r1" else r2 + iterations.append(0) + + elif choice == "Picard": + + def F(u_val): + return a * u_val**2 + b * u_val + c + + u_ = u[n - 1] + k = 0 + while abs(F(u_)) > eps_r and k < max_iter: + u_ = omega * (-c / (a * u_ + b)) + (1 - omega) * u_ + k += 1 + u[n] = u_ + iterations.append(k) + + elif choice == "Newton": + + def F(u_val): + return a * u_val**2 + b * u_val + c + + def dF(u_val): + return 2 * a * u_val + b + + u_ = u[n - 1] + k = 0 + while abs(F(u_)) > eps_r and k < max_iter: + u_ = u_ - F(u_) / dF(u_) + k += 1 + u[n] = u_ + iterations.append(k) + + return u, iterations + + +def CN_logistic(u0, dt, Nt): + """Solve logistic equation using Crank-Nicolson with geometric mean. + + The geometric mean linearization avoids iteration entirely. + """ + u = np.zeros(Nt + 1) + u[0] = u0 + for n in range(0, Nt): + u[n + 1] = (1 + 0.5 * dt) / (1 + dt * u[n] - 0.5 * dt) * u[n] + return u + + +# Test the solvers +dt = 0.1 +Nt = 50 +u0 = 0.1 + +u_picard, iters_picard = BE_logistic(u0, dt, Nt, choice="Picard") +u_newton, iters_newton = BE_logistic(u0, dt, Nt, choice="Newton") +u_cn = CN_logistic(u0, dt, Nt) + +# Exact solution: u = 1 / (1 + 9*exp(-t)) +t = np.linspace(0, Nt * dt, Nt + 1) +u_exact = 1 / (1 + (1 / u0 - 1) * np.exp(-t)) + +RESULT = { + "picard_error": float(np.max(np.abs(u_picard - u_exact))), + "newton_error": float(np.max(np.abs(u_newton - u_exact))), + "cn_error": float(np.max(np.abs(u_cn - u_exact))), + "picard_avg_iters": float(np.mean(iters_picard)), + "newton_avg_iters": float(np.mean(iters_newton)), +} diff --git a/src/book_snippets/nonlin_split_logistic.py b/src/book_snippets/nonlin_split_logistic.py new file mode 100644 index 00000000..8baec687 --- /dev/null +++ b/src/book_snippets/nonlin_split_logistic.py @@ -0,0 +1,89 @@ +"""Operator splitting methods for the logistic equation. + +Demonstrates ordinary splitting, Strange splitting, and exact treatment +of the linear term f_0(u) = u. +""" + +import numpy as np + + +def solver(dt, T, f, f_0, f_1): + """Solve u'=f by Forward Euler and by splitting: f(u) = f_0(u) + f_1(u). + + Returns solutions from: + - Forward Euler on full equation + - Ordinary (1st order) splitting + - Strange (2nd order) splitting with FE substeps + - Strange splitting with exact treatment of f_0 + """ + Nt = int(round(T / float(dt))) + t = np.linspace(0, Nt * dt, Nt + 1) + u_FE = np.zeros(len(t)) + u_split1 = np.zeros(len(t)) # 1st-order splitting + u_split2 = np.zeros(len(t)) # 2nd-order splitting + u_split3 = np.zeros(len(t)) # 2nd-order splitting w/exact f_0 + + u_FE[0] = 0.1 + u_split1[0] = 0.1 + u_split2[0] = 0.1 + u_split3[0] = 0.1 + + for n in range(len(t) - 1): + # Forward Euler on full equation + u_FE[n + 1] = u_FE[n] + dt * f(u_FE[n]) + + # Ordinary splitting: f_0 step then f_1 step + u_s_n = u_split1[n] + u_s = u_s_n + dt * f_0(u_s_n) + u_ss_n = u_s + u_ss = u_ss_n + dt * f_1(u_ss_n) + u_split1[n + 1] = u_ss + + # Strange splitting: half f_0, full f_1, half f_0 + u_s_n = u_split2[n] + u_s = u_s_n + dt / 2.0 * f_0(u_s_n) + u_sss_n = u_s + u_sss = u_sss_n + dt * f_1(u_sss_n) + u_ss_n = u_sss + u_ss = u_ss_n + dt / 2.0 * f_0(u_ss_n) + u_split2[n + 1] = u_ss + + # Strange splitting with exact f_0 (u' = u has solution u*exp(t)) + u_s_n = u_split3[n] + u_s = u_s_n * np.exp(dt / 2.0) # exact + u_sss_n = u_s + u_sss = u_sss_n + dt * f_1(u_sss_n) + u_ss_n = u_sss + u_ss = u_ss_n * np.exp(dt / 2.0) # exact + u_split3[n + 1] = u_ss + + return u_FE, u_split1, u_split2, u_split3, t + + +# Define the logistic equation terms +def f(u): + return u * (1 - u) + + +def f_0(u): + return u + + +def f_1(u): + return -u**2 + + +# Run with dt=0.1 for reasonable accuracy +dt = 0.1 +T = 8.0 +u_FE, u_split1, u_split2, u_split3, t = solver(dt, T, f, f_0, f_1) + +# Exact solution +u_exact = 1 / (1 + 9 * np.exp(-t)) + +RESULT = { + "FE_error": float(np.max(np.abs(u_FE - u_exact))), + "ordinary_split_error": float(np.max(np.abs(u_split1 - u_exact))), + "strange_split_error": float(np.max(np.abs(u_split2 - u_exact))), + "strange_exact_error": float(np.max(np.abs(u_split3 - u_exact))), +} diff --git a/src/book_snippets/periodic_bc_advection_1d.py b/src/book_snippets/periodic_bc_advection_1d.py new file mode 100644 index 00000000..9850104e --- /dev/null +++ b/src/book_snippets/periodic_bc_advection_1d.py @@ -0,0 +1,32 @@ +import numpy as np +from devito import Constant, Eq, Grid, Operator, TimeFunction + +# Periodic boundary conditions using copy equations (1D advection). +L = 1.0 +Nx = 80 +c = 1.0 +C = 0.8 + +dx = L / Nx +dt = C * dx / c + +grid = Grid(shape=(Nx + 1,), extent=(L,)) +(x_dim,) = grid.dimensions +t = grid.stepping_dim + +u = TimeFunction(name="u", grid=grid, time_order=1, space_order=1) + +x = np.linspace(0.0, L, Nx + 1) +u.data[0, :] = np.exp(-0.5 * ((x - 0.25) / 0.05) ** 2) +u.data[1, :] = u.data[0, :] + +courant = Constant(name="C", value=C) +update = Eq(u.forward, u - courant * (u - u.subs(x_dim, x_dim - x_dim.spacing))) + +bc_left = Eq(u[t + 1, 0], u[t, Nx]) +bc_right = Eq(u[t + 1, Nx], u[t + 1, 0]) + +op = Operator([update, bc_left, bc_right]) +op.apply(time_m=0, time_M=0, dt=dt) + +RESULT = float(abs(u.data[1, 0] - u.data[1, -1])) diff --git a/src/book_snippets/time_dependent_bc_sine.py b/src/book_snippets/time_dependent_bc_sine.py new file mode 100644 index 00000000..bb48ede2 --- /dev/null +++ b/src/book_snippets/time_dependent_bc_sine.py @@ -0,0 +1,42 @@ +import numpy as np +from devito import Eq, Grid, Operator, TimeFunction + +# Time-dependent Dirichlet boundary condition: u(0,t) = A*sin(omega*t). +# For time-varying BCs, we loop manually and update the boundary each step. +L = 1.0 +Nx = 80 +c = 1.0 +C = 0.9 + +dx = L / Nx +dt = C * dx / c +Nt = 10 + +grid = Grid(shape=(Nx + 1,), extent=(L,)) +t_dim = grid.stepping_dim +u = TimeFunction(name="u", grid=grid, time_order=2, space_order=2) + +u.data[:] = 0.0 + +# Interior update (wave equation) +update = Eq(u.forward, 2 * u - u.backward + (c * dt) ** 2 * u.dx2, subdomain=grid.interior) + +# Time-independent right BC +bc_right = Eq(u[t_dim + 1, Nx], 0.0) + +# Create operator without the time-dependent left BC +op = Operator([update, bc_right]) + +# Amplitude and frequency +A = 1.0 +omega = 2 * np.pi + +# Time-stepping loop with manual BC update +for n in range(Nt): + t_val = n * dt + # Set time-dependent BC at left boundary + u.data[(n + 1) % 3, 0] = A * np.sin(omega * t_val) + op(time=1, dt=dt) + +# Check that the left boundary has non-zero values (was driven by sine) +RESULT = float(np.max(np.abs(u.data[:, 0]))) diff --git a/src/book_snippets/verification_convergence_wave.py b/src/book_snippets/verification_convergence_wave.py new file mode 100644 index 00000000..866c76f4 --- /dev/null +++ b/src/book_snippets/verification_convergence_wave.py @@ -0,0 +1,50 @@ +import numpy as np +from devito import Eq, Grid, Operator, TimeFunction + + +def solve_wave_equation(Nx, L=1.0, T=0.5, c=1.0, C=0.5): + dx = L / Nx + dt = C * dx / c + Nt = int(T / dt) + + grid = Grid(shape=(Nx + 1,), extent=(L,)) + t_dim = grid.stepping_dim + u = TimeFunction(name="u", grid=grid, time_order=2, space_order=2) + + x_vals = np.linspace(0, L, Nx + 1) + u.data[0, :] = np.sin(np.pi * x_vals) + u.data[1, :] = np.sin(np.pi * x_vals) * np.cos(np.pi * c * dt) + + update = Eq(u.forward, 2 * u - u.backward + (c * dt) ** 2 * u.dx2, subdomain=grid.interior) + bc_left = Eq(u[t_dim + 1, 0], 0.0) + bc_right = Eq(u[t_dim + 1, Nx], 0.0) + + op = Operator([update, bc_left, bc_right]) + op(time=Nt, dt=dt) + + t_final = Nt * dt + u_exact = np.sin(np.pi * x_vals) * np.cos(np.pi * c * t_final) + # For time_order=2, buffer has 3 slots; final solution is at Nt % 3 + final_idx = Nt % 3 + error = float(np.max(np.abs(u.data[final_idx, :] - u_exact))) + return error, dx + + +def convergence_test(grid_sizes): + errors = [] + dx_values = [] + + for Nx in grid_sizes: + error, dx = solve_wave_equation(Nx) + errors.append(error) + dx_values.append(dx) + + rates = [] + for i in range(len(errors) - 1): + rate = np.log(errors[i] / errors[i + 1]) / np.log(dx_values[i] / dx_values[i + 1]) + rates.append(float(rate)) + return rates + + +# Use grid sizes that avoid numerical resonance issues +RESULT = convergence_test([25, 50, 100, 200]) diff --git a/src/book_snippets/verification_mms_diffusion.py b/src/book_snippets/verification_mms_diffusion.py new file mode 100644 index 00000000..7e689fa6 --- /dev/null +++ b/src/book_snippets/verification_mms_diffusion.py @@ -0,0 +1,71 @@ +import numpy as np +from devito import Constant, Eq, Grid, Operator, TimeFunction, solve + + +def solve_diffusion_exact(Nx, alpha=1.0, T=0.1, F=0.4): + """Solve diffusion equation and compare with exact eigenfunction solution. + + Uses exact solution: u(x,t) = sin(pi*x) * exp(-alpha*pi^2*t) + which satisfies u_t = alpha * u_xx with u(0,t) = u(L,t) = 0. + """ + L = 1.0 + dx = L / Nx + dt = F * dx**2 / alpha + Nt = int(T / dt) + + grid = Grid(shape=(Nx + 1,), extent=(L,)) + u = TimeFunction(name="u", grid=grid, time_order=1, space_order=2) + t_dim = grid.stepping_dim + + x_vals = np.linspace(0, L, Nx + 1) + + # Exact solution: eigenfunction of diffusion operator + def u_exact(x, t): + return np.sin(np.pi * x) * np.exp(-alpha * np.pi**2 * t) + + # Initial condition from exact solution + u.data[0, :] = u_exact(x_vals, 0) + + # Diffusion equation: u_t = alpha * u_xx + a = Constant(name="a") + pde = u.dt - a * u.dx2 + update = Eq(u.forward, solve(pde, u.forward), subdomain=grid.interior) + + bc_left = Eq(u[t_dim + 1, 0], 0.0) + bc_right = Eq(u[t_dim + 1, Nx], 0.0) + + op = Operator([update, bc_left, bc_right]) + + # Run all time steps at once + op(time=Nt, dt=dt, a=alpha) + + # Compare to exact solution + t_final = Nt * dt + u_exact_final = u_exact(x_vals, t_final) + + # Determine which buffer has the final solution + final_idx = Nt % 2 + error = float(np.max(np.abs(u.data[final_idx, :] - u_exact_final))) + + return error, dx + + +def convergence_test_mms(grid_sizes): + """Run MMS convergence test for diffusion equation.""" + errors = [] + dx_vals = [] + + for Nx in grid_sizes: + error, dx = solve_diffusion_exact(Nx) + errors.append(error) + dx_vals.append(dx) + + # Compute rates + rates = [] + for i in range(len(errors) - 1): + rate = np.log(errors[i] / errors[i + 1]) / np.log(2) + rates.append(float(rate)) + return rates + + +RESULT = convergence_test_mms([20, 40, 80, 160]) diff --git a/src/book_snippets/verification_mms_symbolic.py b/src/book_snippets/verification_mms_symbolic.py new file mode 100644 index 00000000..2af47140 --- /dev/null +++ b/src/book_snippets/verification_mms_symbolic.py @@ -0,0 +1,19 @@ +import sympy as sp + +# Symbolic variables +x_sym, t_sym = sp.symbols("x t") +alpha_sym = sp.Symbol("alpha") + +# Manufactured solution (arbitrary smooth function) +u_mms = sp.sin(sp.pi * x_sym) * sp.exp(-t_sym) + +# Compute required source term: f = u_t - alpha * u_xx +u_t = sp.diff(u_mms, t_sym) +u_xx = sp.diff(u_mms, x_sym, 2) +f_mms = u_t - alpha_sym * u_xx + +# Verify the expressions +RESULT = { + "u_mms": str(u_mms), + "f_mms": str(sp.simplify(f_mms)), +} diff --git a/src/book_snippets/verification_quick_checks.py b/src/book_snippets/verification_quick_checks.py new file mode 100644 index 00000000..7990bd33 --- /dev/null +++ b/src/book_snippets/verification_quick_checks.py @@ -0,0 +1,67 @@ +import numpy as np +from devito import Eq, Grid, Operator, TimeFunction + + +def check_mass_conservation(Nx=50, alpha=1.0, T=0.1, F=0.4): + """Check mass conservation for diffusion with Neumann BCs (approximated).""" + L = 1.0 + dx = L / Nx + dt = F * dx**2 / alpha + Nt = int(T / dt) + + grid = Grid(shape=(Nx + 1,), extent=(L,)) + u = TimeFunction(name="u", grid=grid, time_order=1, space_order=2) + + # Symmetric initial condition + x_vals = np.linspace(0, L, Nx + 1) + u.data[0, :] = np.exp(-((x_vals - 0.5) ** 2) / 0.01) + + # Diffusion with zero-flux BCs (approximate via copying) + t_dim = grid.stepping_dim + update = Eq(u.forward, u + alpha * dt * u.dx2, subdomain=grid.interior) + bc_left = Eq(u[t_dim + 1, 0], u[t_dim + 1, 1]) + bc_right = Eq(u[t_dim + 1, Nx], u[t_dim + 1, Nx - 1]) + + op = Operator([update, bc_left, bc_right]) + + mass_initial = float(np.sum(u.data[0, :]) * dx) + op(time=Nt, dt=dt) + mass_final = float(np.sum(u.data[0, :]) * dx) + + return abs(mass_final - mass_initial) + + +def check_symmetry(Nx=50, alpha=1.0, T=0.1, F=0.4): + """Check symmetry preservation for symmetric initial conditions.""" + L = 1.0 + dx = L / Nx + dt = F * dx**2 / alpha + Nt = int(T / dt) + + grid = Grid(shape=(Nx + 1,), extent=(L,)) + u = TimeFunction(name="u", grid=grid, time_order=1, space_order=2) + + # Symmetric initial condition centered at L/2 + x_vals = np.linspace(0, L, Nx + 1) + u.data[0, :] = np.exp(-((x_vals - 0.5) ** 2) / 0.01) + + t_dim = grid.stepping_dim + update = Eq(u.forward, u + alpha * dt * u.dx2, subdomain=grid.interior) + bc_left = Eq(u[t_dim + 1, 0], 0.0) + bc_right = Eq(u[t_dim + 1, Nx], 0.0) + + op = Operator([update, bc_left, bc_right]) + op(time=Nt, dt=dt) + + # Check symmetry: left half vs reversed right half + u_left = u.data[0, : Nx // 2] + u_right = u.data[0, Nx // 2 + 1 :][::-1] + symmetry_error = float(np.max(np.abs(u_left - u_right))) + + return symmetry_error + + +RESULT = { + "mass_change": check_mass_conservation(), + "symmetry_error": check_symmetry(), +} diff --git a/src/book_snippets/what_is_devito_diffusion.py b/src/book_snippets/what_is_devito_diffusion.py new file mode 100644 index 00000000..96f0e7a5 --- /dev/null +++ b/src/book_snippets/what_is_devito_diffusion.py @@ -0,0 +1,33 @@ +import numpy as np +from devito import Constant, Eq, Grid, Operator, TimeFunction, solve + +# Problem parameters +Nx = 100 +L = 1.0 +alpha = 1.0 # diffusion coefficient +F = 0.5 # Fourier number (for stability, F <= 0.5) + +# Compute dt from stability condition: F = alpha * dt / dx^2 +dx = L / Nx +dt = F * dx**2 / alpha + +# Create computational grid +grid = Grid(shape=(Nx + 1,), extent=(L,)) + +# Define the unknown field +u = TimeFunction(name="u", grid=grid, time_order=1, space_order=2) + +# Set initial condition +u.data[0, Nx // 2] = 1.0 + +# Define the PDE symbolically and solve for u.forward +a = Constant(name="a") +pde = u.dt - a * u.dx2 +update = Eq(u.forward, solve(pde, u.forward)) + +# Create and run the operator +op = Operator([update]) +op(time=1000, dt=dt, a=alpha) + +# Used by tests +RESULT = float(np.max(u.data[0, :])) diff --git a/src/diffu/LeifRune/1dheat.py b/src/diffu/LeifRune/1dheat.py deleted file mode 100644 index 328dbe2b..00000000 --- a/src/diffu/LeifRune/1dheat.py +++ /dev/null @@ -1,176 +0,0 @@ -# The equation solved is the parabolic equaiton -# -# du d du -# -- = k -- -- -# dt dx dx -# -# along with boundary conditions - -import matplotlib -import matplotlib.pyplot as plt - -# Change some default values to make plots more readable on the screen -LNWDT = 2 -FNT = 15 -matplotlib.rcParams["lines.linewidth"] = LNWDT -matplotlib.rcParams["font.size"] = FNT - -import numpy as np -import scipy as sc -import scipy.sparse -import scipy.sparse.linalg - - -class Grid1d: - """A simple grid class for grid information and the solution.""" - - def __init__(self, nx=10, xmin=0.0, xmax=1.0): - self.xmin, self.xmax = xmin, xmax - self.dx = float(xmax - xmin) / (nx) - self.nx = nx # Number of dx - self.u = np.zeros((nx + 1, 1), "d") # Number of x-values is nx+1 - self.x = np.linspace(xmin, xmax, nx + 1) - - -class HeatSolver1d: - """A simple 1dheat equation solver that can use different schemes to solve the problem.""" - - def __init__(self, grid, scheme="explicit", k=1.0, r=0.5, theta=1.0): - self.grid = grid - self.setSolverScheme(scheme) - self.k = k - self.r = r - self.theta = theta # Used for implicit solver only - - def setSolverScheme(self, scheme="explicit"): - """Sets the scheme to be which should be one of ['slow', 'explicit', 'implicit'].""" - if scheme == "slow": - self.solver = self.pythonExplicit - self.name = "python" - elif scheme == "explicit": - self.solver = self.numpyExplicit - self.name = "explicit" - elif scheme == "implicit": - self.solver = self.numpyImplicit - self.name = "implicit" - else: - self.solver = self.numpyImplicit - self.name = "implicit" - - def numpyExplicit(self, tmin, tmax, nPlotInc): - """Solve equation for all t in time step using a NumPy expression.""" - g = self.grid - k = self.k # Diffusivity - r = self.r # Numerical Fourier number - u, x, dx = g.u, g.x, g.dx - xmin, xmax = g.xmin, g.xmax - - dt = r * dx**2 / k # Compute timestep based on Fourier number, dx and diffusivity - - m = round((tmax - tmin) / dt) # Number of temporal intervals - time = np.linspace(tmin, tmax, m) - - for t in time: - u[1:-1] = r * (u[0:-2] + u[2:]) + (1.0 - 2.0 * r) * u[1:-1] - - g.u = u - - def pythonExplicit(self, tmin, tmax, nPlotInc): - """Solve equation for all t in time step using a NumPy expression.""" - g = self.grid - k = self.k # Diffusivity - r = self.r # Numerical Fourier number - u, x, dx, n = g.u, g.x, g.dx, g.nx - xmin, xmax = g.xmin, g.xmax - - dt = r * dx**2 / k # Compute timestep based on Fourier number, dx and diffusivity - - m = round((tmax - tmin) / dt) # Number of temporal intervals - time = np.linspace(tmin, tmax, m) - - for t in time: - u0 = u - # u[1:-1] = r*(u[0:-2]+ u[2:]) + (1.0-2.0*r)*u[1:-1] - for i in range(1, n): - u[i] = r * (u[i - 1] + u[i + 1]) + (1.0 - 2.0 * r) * u[i] - - def numpyImplicit(self, tmin, tmax, nPlotInc): - g = self.grid - k = self.k # Diffusivity - r = self.r # Numerical Fourier number - theta = self.theta # Parameter for implicitness: theta=0.5 Crank-Nicholson, theta=1.0 fully implicit - u, x, dx = g.u, g.x, g.dx - xmin, xmax = g.xmin, g.xmax - - dt = r * dx**2 / k # Compute timestep based on Fourier number, dx and diffusivity - - m = round((tmax - tmin) / dt) # Number of temporal intervals - time = np.linspace(tmin, tmax, m) - - # Create matrix for sparse solver. Solve for interior values only (nx-1) - diagonals = np.zeros((3, g.nx - 1)) - diagonals[0, :] = -r * theta # all elts in first row is set to 1 - diagonals[1, :] = 1 + 2.0 * r * theta - diagonals[2, :] = -r * theta - As = sc.sparse.spdiags( - diagonals, [-1, 0, 1], g.nx - 1, g.nx - 1, format="csc" - ) # sparse matrix instance - - # Crete rhs array - d = np.zeros((g.nx - 1, 1), "d") - - # Advance in time an solve tridiagonal system for each t in time - for t in time: - d[:] = u[1:-1] + r * (1 - theta) * (u[0:-2] - 2 * u[1:-1] + u[2:]) - d[0] += r * theta * u[0] - w = sc.sparse.linalg.spsolve(As, d) # theta=sc.linalg.solve_triangular(A,d) - u[1:-1] = w[:, None] - - g.u = u - - def solve(self, tmin, tmax, nPlotInc=5): - return self.solver(tmin, tmax, nPlotInc) - - def initialize(self, U0=1.0): - self.grid.u[0] = U0 - - -## Main program - -## Make grids for the solvers -nx = 120 -L = 1.0 -# mg = Grid1d(nx,0,L) -# mg2 = Grid1d(nx,0,L) -# mg3 = Grid1d(nx,0,L) -# mg4 = Grid1d(nx,0,L) -# mg5 = Grid1d(nx,0,L) - -## Make various solvers. -solvers = [] -# solvers.append(HeatSolver1d(mg, scheme = 'slow', k=1.0, r=0.5)) -# solvers.append(HeatSolver1d(Grid1d(nx,0,L), scheme = 'slow', k=1.0, r=0.5)) -solvers.append(HeatSolver1d(Grid1d(nx, 0, L), scheme="explicit", k=1.0, r=0.5)) -solvers.append(HeatSolver1d(Grid1d(nx, 0, L), scheme="explicit", k=1.0, r=0.5)) -solvers.append(HeatSolver1d(Grid1d(nx, 0, L), scheme="implicit", k=1.0, r=3.0, theta=0.5)) - - -U0 = 1.0 -(tmin, tmax) = (0, 0.025) - -## Compute a solution for all solvers -for solver in solvers: - solver.initialize(U0=U0) - solver.solve(tmin, tmax, nPlotInc=2) -lstyle = ["r-", ":", ".", "-.", "--"] -mylegends = [] -i = 0 -for solver in solvers: - plt.plot(solver.grid.x, solver.grid.u, lstyle[i]) - mylegends.append(str("%s r = %3.1f" % (solver.name, solver.r))) - i += 1 - -plt.legend(mylegends) -plt.show() -# plt.pause(5) -# plt.close() diff --git a/src/diffu/LeifRune/laplace.py b/src/diffu/LeifRune/laplace.py deleted file mode 100644 index 3dbd9c61..00000000 --- a/src/diffu/LeifRune/laplace.py +++ /dev/null @@ -1,103 +0,0 @@ -import matplotlib.pylab as plt -import numpy as np -import scipy as sc -import scipy.linalg -import scipy.sparse -import scipy.sparse.linalg -from matplotlib import cm - -# discretizing geometry -width = 1.0 -height = 1.0 -Nx = 20 # number of points in x-direction -Ny = 20 # number of points in y-direction -N = Nx * Ny - -# BCs -bottom = 10.0 -left = 10.0 -top = 30.0 -right = 10.0 - -# diagonals -diag1, diag5 = np.zeros(N - 3), np.zeros(N - 3) -diag2, diag4 = np.zeros(N - 1), np.zeros(N - 1) -diag3 = np.zeros(N) -diag1[:], diag2[:], diag3[:], diag4[:], diag5[:] = -1.0, -1.0, 4.0, -1.0, -1.0 - -diagonals = np.zeros((5, N)) -diagonals[0, :] = -1.0 # all elts in first row is set to 1 -diagonals[1, :] = -1.0 -diagonals[2, :] = 4.0 -diagonals[3, :] = -1.0 -diagonals[4, :] = -1.0 - -# impose BCs on diag2 and diag4 -diag2[Nx - 1 :: Nx] = 0.0 -diag4[Nx - 1 :: Nx] = 0.0 - -# impose BCs for sparse solver -diagonals[1, Nx - 1 :: Nx] = 0.0 -diagonals[3, Nx::Nx] = 0.0 - -# assemble coefficient matrix A -A = np.zeros((N, N)) -for i in range(0, N): - A[i, i] = diag3[i] -for i in range(0, N - 1): - A[i + 1, i] = diag2[i] - A[i, i + 1] = diag4[i] -for i in range(0, N - Nx): - A[i, i + Nx] = diag5[i] - A[i + Nx, i] = diag1[i] - -# assemble the right hand side vector b -b = np.zeros(N) -b[:Nx] = b[:Nx] + bottom -b[-Nx:] = b[-Nx:] + top -b[::Nx] = b[::Nx] + left -b[Nx - 1 :: Nx] = b[Nx - 1 :: Nx] + right - - -# solve the equation system -As = sc.sparse.spdiags( - diagonals, [-Nx, -1, 0, 1, Nx], N, N, format="csc" -) # sparse matrix instance, -Tvector = sc.sparse.linalg.spsolve(As, b) # Compute the solution with a sparse solver. -Tvector2 = scipy.linalg.solve(A, b) - -print("max diff = ", np.max(Tvector - Tvector2)) -print("min diff = ", np.min(Tvector - Tvector2)) - -Tmatrix = np.reshape(Tvector, (Nx, Ny)) - -# adding the boundary points (for plotting) -T = np.zeros((Nx + 2, Ny + 2)) - -# Set the boundary values -T[:, 0] = left -T[:, Ny + 1] = right -T[0, :] = bottom -T[Nx + 1, :] = top - -# Assign the computed values to the field of the T -T[1 : Nx + 1, 1 : Ny + 1] = Tmatrix[0:Nx, 0:Ny] - -# plotting -x = np.linspace(0, width, Nx + 2) -y = np.linspace(0, height, Ny + 2) -Tmax = np.max(T) -Tmin = np.min(T) -fig = plt.figure() -ax = fig.add_subplot(111, projection="3d") -X, Y = np.meshgrid(x, y) -surf = ax.plot_surface(X, Y, T, rstride=1, cstride=1, cmap=cm.jet) -ax.set_zlim3d(Tmin, Tmax) -fig.colorbar(surf) -plt.title("Temperature field in beam cross section") -ax.set_xlabel("X axis") -ax.set_ylabel("Y axis") -ax.set_zlabel("Temperature") -ax.view_init(elev=10.0, azim=-140) -plt.show() -# plt.savefig('oppg1.pdf') diff --git a/src/diffu/LeifRune/laplace3.py b/src/diffu/LeifRune/laplace3.py deleted file mode 100644 index 1a2bc83d..00000000 --- a/src/diffu/LeifRune/laplace3.py +++ /dev/null @@ -1,174 +0,0 @@ -import matplotlib.pylab as plt -import numpy as np -import scipy as sc -import scipy.linalg -import scipy.sparse -import scipy.sparse.linalg -from matplotlib import cm - - -class Grid: - """A simple grid class that stores the details and solution of the - computational grid.""" - - def __init__(self, nx=10, ny=10, xmin=0.0, xmax=1.0, ymin=0.0, ymax=1.0): - self.xmin, self.xmax, self.ymin, self.ymax = xmin, xmax, ymin, ymax - self.dx = float(xmax - xmin) / (nx - 1) - self.dy = float(ymax - ymin) / (ny - 1) - self.T = np.zeros((nx + 2, ny + 2), "d") - self.nx = nx - self.ny = ny - - def setBC(self, top=30, bottom=10, left=10, right=10): - self.T[0, :] = bottom - self.bottom = bottom - self.T[-1, :] = top - self.top = top - self.T[:, 0] = left - self.left = left - self.T[:, -1] = right - self.right = right - - -class LaplaceSolver: - """Solvers for the laplacian equation. Scheme can be one of - ['direct','slow', 'numeric', 'blitz', 'inline', 'fastinline','fortran'].""" - - def __init__(self, grid, scheme="direct"): - self.grid = grid - - if scheme == "slow": - self.solver = self.slowTimeStep - elif scheme == "direct": - self.solver = self.directSolver - elif scheme == "blitz": - self.solver = self.blitzTimeStep - else: - self.solver = self.numericTimeStep - - def directSolver(self, dt=0): - g = self.grid - T = self.grid.T - - bottom, top, left, right = g.bottom, g.top, g.left, g.right - nx, ny = g.T.shape # number of grid pts excluding bndry - n = nx * ny - - dgs = np.zeros((5, N)) - dgs[0, :] = -1.0 - dgs[1, :] = -1.0 - dgs[2, :] = 4.0 - dgs[3, :] = -1.0 - dgs[4, :] = -1, 0 - - # impose BCs for sparse solver - dgs[1, nx - 1 :: nx] = 0.0 - dgs[3, nx::nx] = 0.0 - - # assemble the right hand side vector b - b = np.zeros(n) - b[:nx] = b[:nx] + bottom - b[-nx:] = b[-nx:] + top - b[::nx] = b[::nx] + left - b[nx - 1 :: nx] = b[nx - 1 :: nx] + right - - Asp = sc.sparse.spdiags( - dgs, [-nx, -1, 0, 1, nx], n, n, format="csc" - ) # sparse matrix instance, - Tv = sc.sparse.linalg.spsolve(Asp, b) # Compute the solution with sparse solver - Tmatrix = np.reshape(Tv, (nx, ny)) - - # Assign the computed values to the field of the T - T[1 : nx + 1, 1 : ny + 1] = Tmatrix[0 : nx - 1, 0 : ny - 1] - T[5:8, 5:8] = 30 - # T[1:nx,1:ny]=Tmatrix[:,:] - - g.T = T - return "Ok" - - def solve(self): - return self.solver() - - -# discretizing geometry -width = 1.0 -height = 1.0 -Nx = 20 # number of unknowns in the x-direction -Ny = 20 # number of unknowns in the y-direction -N = Nx * Ny - - -# BCs -bottom = 10.0 -left = 10.0 -top = 30.0 -right = 10.0 - - -diagonals = np.zeros((5, N)) -diagonals[0, :] = -1.0 # all elts in first row is set to 1 -diagonals[1, :] = -1.0 -diagonals[2, :] = 4.0 -diagonals[3, :] = -1.0 -diagonals[4, :] = -1.0 - -# impose BCs for sparse solver -diagonals[1, Nx - 1 :: Nx] = 0.0 -diagonals[3, Nx::Nx] = 0.0 - -# assemble the right hand side vector b -b = np.zeros(N) -b[:Nx] = b[:Nx] + bottom -b[-Nx:] = b[-Nx:] + top # assemble coefficient matrix A -b[::Nx] = b[::Nx] + left -b[Nx - 1 :: Nx] = b[Nx - 1 :: Nx] + right - - -# solve the equation system -As = sc.sparse.spdiags( - diagonals, [-Nx, -1, 0, 1, Nx], N, N, format="csc" -) # sparse matrix instance, -Tvector = sc.sparse.linalg.spsolve(As, b) # Compute the solution with a sparse solver. - -Tmatrix = np.reshape(Tvector, (Nx, Ny)) - -# adding the boundary points (for plotting) -T = np.zeros((Nx + 2, Ny + 2)) - -myGrid = Grid(nx=Nx, ny=Nx) -myGrid.setBC(top=top, bottom=bottom, left=left, right=right) -# mySolver=LaplaceSolver(myGrid,scheme='direct') -mySolver = LaplaceSolver(Grid(nx=Nx, ny=Nx), scheme="direct") -mySolver.grid.setBC(top=top, bottom=bottom, left=left, right=right) -mySolver.solve() - -# Set the boundary values -T[:, 0] = left -T[:, Ny + 1] = right -T[0, :] = bottom -T[Nx + 1, :] = top - -# Assign the computed values to the field of the T -T[1 : Nx + 1, 1 : Ny + 1] = Tmatrix[0:Nx, 0:Ny] - - -mySolver.grid.T[5:5, 5:5] = 30.0 -T = mySolver.grid.T[:, :] -# plotting -x = np.linspace(0, width, Nx + 2) -y = np.linspace(0, height, Ny + 2) -Tmax = np.max(T) -Tmin = np.min(T) -fig = plt.figure() -ax = fig.add_subplot(111, projection="3d") -X, Y = np.meshgrid(x, y) -surf = ax.plot_surface(X, Y, T, rstride=1, cstride=1, cmap=cm.jet) -ax.set_zlim3d(Tmin, Tmax) -fig.colorbar(surf) -plt.title("Temperature field in beam cross section") -ax.set_xlabel("X axis") -ax.set_ylabel("Y axis") -ax.set_zlabel("Temperature") -ax.view_init(elev=10.0, azim=-140) -plt.show() -# #plt.savefig('oppg1.pdf') diff --git a/src/diffu/diffu1D_compare.py b/src/diffu/diffu1D_compare.py deleted file mode 100644 index cd1cbb39..00000000 --- a/src/diffu/diffu1D_compare.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Compare FE, BE, and CN.""" - - -F = float(sys.argv[1]) -dt = 0.0002 -u = {} # solutions for all schemes -for name in "FE", "BE", "theta": - u[name] = plug(solver="solver_" + name, F=F, dt=dt) -# Note that all schemes employ the same time mesh regardless of F -x = u["FE"][0] -t = u["FE"][1] -for n in range(len(t)): - plot( - x, - u["FE"][2 + n], - "r-", - x, - u["BE"][2 + n], - "b-", - x, - u["theta"][2 + n], - "g-", - legend=["FE", "BE", "CN"], - xlabel="x", - ylabel="u", - title="t=%f" % t[n], - savefig="tmp_frame%04d.png" % n, - ) diff --git a/src/diffu/diffu1D_exam11.py b/src/diffu/diffu1D_exam11.py deleted file mode 100644 index cc8f7e07..00000000 --- a/src/diffu/diffu1D_exam11.py +++ /dev/null @@ -1,142 +0,0 @@ -import time - -from numpy import linspace, zeros -from scipy.sparse import spdiags -from scipy.sparse.linalg import spsolve - - -def solver(I, a, L, Nx, F, T, theta=0.5, u_L=0, u_R=0, user_action=None): - """ - Solve the diffusion equation u_t = a*u_xx on (0,L) with - boundary conditions u(0,t) = u_L and u(L,t) = u_R, - for t in (0,T]. Initial condition: u(x,0) = I(x). - - Method: (implicit) theta-rule in time. - - Nx is the total number of mesh cells; mesh points are numbered - from 0 to Nx. - F is the dimensionless number a*dt/dx**2 and implicitly specifies the - time step. No restriction on F. - T is the stop time for the simulation. - I is a function of x. - - user_action is a function of (u, x, t, n) where the calling code - can add visualization, error computations, data analysis, - store solutions, etc. - - The coefficient matrix is stored in a scipy data structure for - sparse matrices. Input to the storage scheme is a set of - diagonals with nonzero entries in the matrix. - """ - import time - - t0 = time.perf_counter() - - x = linspace(0, L, Nx + 1) # mesh points in space - dx = x[1] - x[0] - dt = F * dx**2 / a - Nt = int(round(T / float(dt))) - print("Nt:", Nt) - t = linspace(0, T, Nt + 1) # mesh points in time - - u = zeros(Nx + 1) # solution array at t[n+1] - u_n = zeros(Nx + 1) # solution at t[n] - - # Representation of sparse matrix and right-hand side - diagonal = zeros(Nx + 1) - lower = zeros(Nx + 1) - upper = zeros(Nx + 1) - b = zeros(Nx + 1) - - # Precompute sparse matrix (scipy format) - Fl = F * theta - Fr = F * (1 - theta) - diagonal[:] = 1 + 2 * Fl - lower[:] = -Fl # 1 - upper[:] = -Fl # 1 - # Insert boundary conditions - # (upper[1:] and lower[:-1] are the active alues) - upper[0:2] = 0 - lower[-2:] = 0 - diagonal[0] = 1 - diagonal[Nx] = 1 - - diags = [0, -1, 1] - A = spdiags([diagonal, lower, upper], diags, Nx + 1, Nx + 1) - # print A.todense() - - # Set initial condition - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - - if user_action is not None: - user_action(u_n, x, t, 0) - - # Time loop - for n in range(0, Nt): - b[1:-1] = u_n[1:-1] + Fr * (u_n[:-2] - 2 * u_n[1:-1] + u_n[2:]) - b[0] = u_L - b[-1] = u_R # boundary conditions - u[:] = spsolve(A, b) - - if user_action is not None: - user_action(u, x, t, n + 1) - - # Switch variables before next step - u_n, u = u, u_n - - t1 = time.perf_counter() - return u, x, t, t1 - t0 - - -# Case: initial discontinuity - - -def plot_u(u, x, t, n): - import matplotlib.pyplot as plt - - umin = -0.1 - umax = 1.1 # axis limits for plotting - plt.clf() - plt.plot(x, u, "r-") - plt.axis([0, L, umin, umax]) - plt.title("t=%f" % t[n]) - plt.draw() - plt.pause(0.001) - - # Pause the animation initially, otherwise 0.2 s between frames - if t[n] == 0: - time.sleep(2) - else: - time.sleep(0.2) - - -L = 1 -a = 1 - - -def I(x): - return 0 if x > L / 2.0 else 1 - - -# Command-line arguments: Nx F theta - -Nx = 15 -F = 0.5 -theta = 0 -T = 3 -# theta = 1 -# Nx = int(sys.argv[1]) -# F = float(sys.argv[2]) -# theta = float(sys.argv[3]) - -cases = [ - (7, 5, 0.5, 3), - (15, 0.5, 0, 0.5), -] -for Nx, F, theta, T in cases: - print("theta=%g, F=%g, Nx=%d" % (theta, F, Nx)) - u, x, t, cpu = solver( - I, a, L, Nx, F, T, theta=theta, u_L=1, u_R=0, user_action=plot_u - ) - input("CR: ") diff --git a/src/diffu/diffu1D_u0.py b/src/diffu/diffu1D_u0.py deleted file mode 100644 index ae5513f8..00000000 --- a/src/diffu/diffu1D_u0.py +++ /dev/null @@ -1,553 +0,0 @@ -#!/usr/bin/env python -# As v1, but using scipy.sparse.diags instead of spdiags -""" -Functions for solving a 1D diffusion equations of simplest types -(constant coefficient, no source term): - - u_t = a*u_xx on (0,L) - -with boundary conditions u=0 on x=0,L, for t in (0,T]. -Initial condition: u(x,0)=I(x). - -The following naming convention of variables are used. - -===== ========================================================== -Name Description -===== ========================================================== -Nx The total number of mesh cells; mesh points are numbered - from 0 to Nx. -F The dimensionless number a*dt/dx**2, which implicitly - specifies the time step. -T The stop time for the simulation. -I Initial condition (Python function of x). -a Variable coefficient (constant). -L Length of the domain ([0,L]). -x Mesh points in space. -t Mesh points in time. -n Index counter in time. -u Unknown at current/new time level. -u_n u at the previous time level. -dx Constant mesh spacing in x. -dt Constant mesh spacing in t. -===== ========================================================== - -user_action is a function of (u, x, t, n), u[i] is the solution at -spatial mesh point x[i] at time t[n], where the calling code -can add visualization, error computations, data analysis, -store solutions, etc. -""" - -import sys -import time - -import matplotlib.pyplot as plt -import numpy as np -import scipy.sparse -import scipy.sparse.linalg - - -def solver_FE_simple(I, a, f, L, dt, F, T): - """ - Simplest expression of the computational algorithm - using the Forward Euler method and explicit Python loops. - For this method F <= 0.5 for stability. - """ - import time - - t0 = time.perf_counter() # For measuring the CPU time - - Nt = int(round(T / float(dt))) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points in time - dx = np.sqrt(a * dt / F) - Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - # Make sure dx and dt are compatible with x and t - dx = x[1] - x[0] - dt = t[1] - t[0] - - u = np.zeros(Nx + 1) - u_n = np.zeros(Nx + 1) - - # Set initial condition u(x,0) = I(x) - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - - for n in range(0, Nt): - # Compute u at inner mesh points - for i in range(1, Nx): - u[i] = ( - u_n[i] + F * (u_n[i - 1] - 2 * u_n[i] + u_n[i + 1]) + dt * f(x[i], t[n]) - ) - - # Insert boundary conditions - u[0] = 0 - u[Nx] = 0 - - # Switch variables before next step - # u_n[:] = u # safe, but slow - u_n, u = u, u_n - - t1 = time.perf_counter() - return u_n, x, t, t1 - t0 # u_n holds latest u - - -def solver_FE(I, a, f, L, dt, F, T, user_action=None, version="scalar"): - """ - Vectorized implementation of solver_FE_simple. - """ - import time - - t0 = time.perf_counter() # for measuring the CPU time - - Nt = int(round(T / float(dt))) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points in time - dx = np.sqrt(a * dt / F) - Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - # Make sure dx and dt are compatible with x and t - dx = x[1] - x[0] - dt = t[1] - t[0] - - u = np.zeros(Nx + 1) # solution array - u_n = np.zeros(Nx + 1) # solution at t-dt - - # Set initial condition - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - - if user_action is not None: - user_action(u_n, x, t, 0) - - for n in range(0, Nt): - # Update all inner points - if version == "scalar": - for i in range(1, Nx): - u[i] = ( - u_n[i] - + F * (u_n[i - 1] - 2 * u_n[i] + u_n[i + 1]) - + dt * f(x[i], t[n]) - ) - - elif version == "vectorized": - u[1:Nx] = ( - u_n[1:Nx] - + F * (u_n[0 : Nx - 1] - 2 * u_n[1:Nx] + u_n[2 : Nx + 1]) - + dt * f(x[1:Nx], t[n]) - ) - else: - raise ValueError("version=%s" % version) - - # Insert boundary conditions - u[0] = 0 - u[Nx] = 0 - if user_action is not None: - user_action(u, x, t, n + 1) - - # Switch variables before next step - u_n, u = u, u_n - - t1 = time.perf_counter() - return t1 - t0 - - -def solver_BE_simple(I, a, f, L, dt, F, T, user_action=None): - """ - Simplest expression of the computational algorithm - for the Backward Euler method, using explicit Python loops - and a dense matrix format for the coefficient matrix. - """ - import time - - t0 = time.perf_counter() # for measuring the CPU time - - Nt = int(round(T / float(dt))) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points in time - dx = np.sqrt(a * dt / F) - Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - # Make sure dx and dt are compatible with x and t - dx = x[1] - x[0] - dt = t[1] - t[0] - - u = np.zeros(Nx + 1) - u_n = np.zeros(Nx + 1) - - # Data structures for the linear system - A = np.zeros((Nx + 1, Nx + 1)) - b = np.zeros(Nx + 1) - - for i in range(1, Nx): - A[i, i - 1] = -F - A[i, i + 1] = -F - A[i, i] = 1 + 2 * F - A[0, 0] = A[Nx, Nx] = 1 - - # Set initial condition u(x,0) = I(x) - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - if user_action is not None: - user_action(u_n, x, t, 0) - - for n in range(0, Nt): - # Compute b and solve linear system - for i in range(1, Nx): - b[i] = u_n[i] + dt * f(x[i], t[n + 1]) - b[0] = b[Nx] = 0 - u[:] = np.linalg.solve(A, b) - - if user_action is not None: - user_action(u, x, t, n + 1) - - # Update u_n before next step - u_n, u = u, u_n - - t1 = time.perf_counter() - return t1 - t0 - - -def solver_BE(I, a, f, L, dt, F, T, user_action=None): - """ - Vectorized implementation of solver_BE_simple using also - a sparse (tridiagonal) matrix for efficiency. - """ - import time - - t0 = time.perf_counter() # for measuring the CPU time - - Nt = int(round(T / float(dt))) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points in time - dx = np.sqrt(a * dt / F) - Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - # Make sure dx and dt are compatible with x and t - dx = x[1] - x[0] - dt = t[1] - t[0] - - u = np.zeros(Nx + 1) # solution array at t[n+1] - u_n = np.zeros(Nx + 1) # solution at t[n] - - # Representation of sparse matrix and right-hand side - diagonal = np.zeros(Nx + 1) - lower = np.zeros(Nx) - upper = np.zeros(Nx) - b = np.zeros(Nx + 1) - - # Precompute sparse matrix - diagonal[:] = 1 + 2 * F - lower[:] = -F # 1 - upper[:] = -F # 1 - # Insert boundary conditions - diagonal[0] = 1 - upper[0] = 0 - diagonal[Nx] = 1 - lower[-1] = 0 - - A = scipy.sparse.diags( - diagonals=[diagonal, lower, upper], - offsets=[0, -1, 1], - shape=(Nx + 1, Nx + 1), - format="csr", - ) - print(A.todense()) - - # Set initial condition - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - - if user_action is not None: - user_action(u_n, x, t, 0) - - for n in range(0, Nt): - b = u_n + dt * f(x[:], t[n + 1]) - b[0] = b[-1] = 0.0 # boundary conditions - u[:] = scipy.sparse.linalg.spsolve(A, b) - - if user_action is not None: - user_action(u, x, t, n + 1) - - # Update u_n before next step - # u_n[:] = u - u_n, u = u, u_n - - t1 = time.perf_counter() - return t1 - t0 - - -def solver_theta(I, a, f, L, dt, F, T, theta=0.5, u_L=0, u_R=0, user_action=None): - """ - Full solver for the model problem using the theta-rule - difference approximation in time (no restriction on F, - i.e., the time step when theta >= 0.5). - Vectorized implementation and sparse (tridiagonal) - coefficient matrix. - """ - import time - - t0 = time.perf_counter() # for measuring the CPU time - - Nt = int(round(T / float(dt))) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points in time - dx = np.sqrt(a * dt / F) - Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - # Make sure dx and dt are compatible with x and t - dx = x[1] - x[0] - dt = t[1] - t[0] - - u = np.zeros(Nx + 1) # solution array at t[n+1] - u_n = np.zeros(Nx + 1) # solution at t[n] - - # Representation of sparse matrix and right-hand side - diagonal = np.zeros(Nx + 1) - lower = np.zeros(Nx) - upper = np.zeros(Nx) - b = np.zeros(Nx + 1) - - # Precompute sparse matrix (scipy format) - Fl = F * theta - Fr = F * (1 - theta) - diagonal[:] = 1 + 2 * Fl - lower[:] = -Fl # 1 - upper[:] = -Fl # 1 - # Insert boundary conditions - diagonal[0] = 1 - upper[0] = 0 - diagonal[Nx] = 1 - lower[-1] = 0 - - diags = [0, -1, 1] - A = scipy.sparse.diags( - diagonals=[diagonal, lower, upper], - offsets=[0, -1, 1], - shape=(Nx + 1, Nx + 1), - format="csr", - ) - # print A.todense() - - # Set initial condition - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - - if user_action is not None: - user_action(u_n, x, t, 0) - - # Time loop - for n in range(0, Nt): - b[1:-1] = ( - u_n[1:-1] - + Fr * (u_n[:-2] - 2 * u_n[1:-1] + u_n[2:]) - + dt * theta * f(x[1:-1], t[n + 1]) - + dt * (1 - theta) * f(x[1:-1], t[n]) - ) - b[0] = u_L - b[-1] = u_R # boundary conditions - u[:] = scipy.sparse.linalg.spsolve(A, b) - - if user_action is not None: - user_action(u, x, t, n + 1) - - # Update u_n before next step - u_n, u = u, u_n - - t1 = time.perf_counter() - return t1 - t0 - - -def viz(I, a, L, dt, F, T, umin, umax, scheme="FE", animate=True, framefiles=True): - def plot_u(u, x, t, n): - plt.plot(x, u, "r-", axis=[0, L, umin, umax], title="t=%f" % t[n]) - if framefiles: - plt.savefig("tmp_frame%04d.png" % n) - if t[n] == 0: - time.sleep(2) - elif not framefiles: - # It takes time to write files so pause is needed - # for screen only animation - time.sleep(0.2) - - user_action = plot_u if animate else lambda u, x, t, n: None - - cpu = eval("solver_" + scheme)(I, a, L, dt, F, T, user_action=user_action) - return cpu - - -def plug(scheme="FE", F=0.5, Nx=50): - L = 1.0 - a = 1.0 - T = 0.1 - # Compute dt from Nx and F - dx = L / Nx - dt = F / a * dx**2 - - def I(x): - """Plug profile as initial condition.""" - if abs(x - L / 2.0) > 0.1: - return 0 - else: - return 1 - - cpu = viz( - I, - a, - L, - dt, - F, - T, - umin=-0.1, - umax=1.1, - scheme=scheme, - animate=True, - framefiles=True, - ) - print("CPU time:", cpu) - - -def gaussian(scheme="FE", F=0.5, Nx=50, sigma=0.05): - L = 1.0 - a = 1.0 - T = 0.1 - # Compute dt from Nx and F - dx = L / Nx - dt = F / a * dx**2 - - def I(x): - """Gaussian profile as initial condition.""" - return exp(-0.5 * ((x - L / 2.0) ** 2) / sigma**2) - - u, cpu = viz( - I, - a, - L, - dt, - F, - T, - umin=-0.1, - umax=1.1, - scheme=scheme, - animate=True, - framefiles=True, - ) - print("CPU time:", cpu) - - -def expsin(scheme="FE", F=0.5, m=3): - L = 10.0 - a = 1 - T = 1.2 - - def exact(x, t): - return exp(-(m**2) * pi**2 * a / L**2 * t) * sin(m * pi / L * x) - - def I(x): - return exact(x, 0) - - Nx = 80 - # Compute dt from Nx and F - dx = L / Nx - dt = F / a * dx**2 - viz(I, a, L, dt, F, T, -1, 1, scheme=scheme, animate=True, framefiles=True) - - # Convergence study - def action(u, x, t, n): - e = abs(u - exact(x, t[n])).max() - errors.append(e) - - errors = [] - Nx_values = [10, 20, 40, 80, 160] - for Nx in Nx_values: - eval("solver_" + scheme)(I, a, L, Nx, F, T, user_action=action) - dt = F * (L / Nx) ** 2 / a - print(dt, errors[-1]) - - -def test_solvers(): - def u_exact(x, t): - return x * (L - x) * 5 * t # fulfills BC at x=0 and x=L - - def I(x): - return u_exact(x, 0) - - def f(x, t): - return 5 * x * (L - x) + 10 * a * t - - a = 3.5 - L = 1.5 - Nx = 4 - F = 0.5 - # Compute dt from Nx and F - dx = L / Nx - dt = F / a * dx**2 - - def compare(u, x, t, n): # user_action function - """Compare exact and computed solution.""" - u_e = u_exact(x, t[n]) - diff = abs(u_e - u).max() - tol = 1e-14 - assert diff < tol, "max diff: %g" % diff - - import functools - - s = functools.partial # object for calling a function w/args - solvers = [ - s(solver_FE_simple, I=I, a=a, f=f, L=L, dt=dt, F=F, T=0.2), - s( - solver_FE, - I=I, - a=a, - f=f, - L=L, - dt=dt, - F=F, - T=2, - user_action=compare, - version="scalar", - ), - s( - solver_FE, - I=I, - a=a, - f=f, - L=L, - dt=dt, - F=F, - T=2, - user_action=compare, - version="vectorized", - ), - s(solver_BE_simple, I=I, a=a, f=f, L=L, dt=dt, F=F, T=2, user_action=compare), - s(solver_BE, I=I, a=a, f=f, L=L, dt=dt, F=F, T=2, user_action=compare), - s( - solver_theta, - I=I, - a=a, - f=f, - L=L, - dt=dt, - F=F, - T=2, - theta=0, - u_L=0, - u_R=0, - user_action=compare, - ), - ] - # solver_FE_simple has different return from the others - u, x, t, cpu = solvers[0]() - u_e = u_exact(x, t[-1]) - diff = abs(u_e - u).max() - tol = 1e-14 - print(u_e) - print(u) - assert diff < tol, "max diff solver_FE_simple: %g" % diff - - for solver in solvers: - solver() - - -if __name__ == "__main__": - if len(sys.argv) < 2: - print("""Usage %s function arg1 arg2 arg3 ...""" % sys.argv[0]) - sys.exit(0) - cmd = "%s(%s)" % (sys.argv[1], ", ".join(sys.argv[2:])) - print(cmd) - eval(cmd) diff --git a/src/diffu/diffu1D_v1.py b/src/diffu/diffu1D_v1.py deleted file mode 100644 index 3618addb..00000000 --- a/src/diffu/diffu1D_v1.py +++ /dev/null @@ -1,368 +0,0 @@ -#!/usr/bin/env python -""" -Functions for solving a 1D diffusion equations of simplest types -(constant coefficient, no source term): - - u_t = a*u_xx on (0,L) - -with boundary conditions u=0 on x=0,L, for t in (0,T]. -Initial condition: u(x,0)=I(x). - -The following naming convention of variables are used. - -===== ========================================================== -Name Description -===== ========================================================== -Nx The total number of mesh cells; mesh points are numbered - from 0 to Nx. -F The dimensionless number a*dt/dx**2, which implicitly - specifies the time step. -T The stop time for the simulation. -I Initial condition (Python function of x). -a Variable coefficient (constant). -L Length of the domain ([0,L]). -x Mesh points in space. -t Mesh points in time. -n Index counter in time. -u Unknown at current/new time level. -u_n u at the previous time level. -dx Constant mesh spacing in x. -dt Constant mesh spacing in t. -===== ========================================================== - -user_action is a function of (u, x, t, n), u[i] is the solution at -spatial mesh point x[i] at time t[n], where the calling code -can add visualization, error computations, data analysis, -store solutions, etc. -""" - -from scipy.sparse import spdiags -from scipy.sparse.linalg import spsolve - - -def solver_FE_simple(I, a, L, Nx, F, T): - """ - Simplest expression of the computational algorithm - using the Forward Euler method and explicit Python loops. - For this method F <= 0.5 for stability. - """ - x = linspace(0, L, Nx + 1) # mesh points in space - dx = x[1] - x[0] - dt = F * dx**2 / a - Nt = int(round(T / float(dt))) - t = linspace(0, T, Nt + 1) # mesh points in time - u = zeros(Nx + 1) - u_n = zeros(Nx + 1) - - # Set initial condition u(x,0) = I(x) - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - - for n in range(0, Nt): - # Compute u at inner mesh points - for i in range(1, Nx): - u[i] = u_n[i] + F * (u_n[i - 1] - 2 * u_n[i] + u_n[i + 1]) - - # Insert boundary conditions - u[0] = 0 - u[Nx] = 0 - - # Switch variables before next step - u_n, u = u, u_n - return u - - -def solver_FE(I, a, L, Nx, F, T, user_action=None, version="scalar"): - """ - Vectorized implementation of solver_FE_simple. - """ - import time - - t0 = time.perf_counter() - - x = linspace(0, L, Nx + 1) # mesh points in space - dx = x[1] - x[0] - dt = F * dx**2 / a - Nt = int(round(T / float(dt))) - t = linspace(0, T, Nt + 1) # mesh points in time - - u = zeros(Nx + 1) # solution array - u_n = zeros(Nx + 1) # solution at t-dt - u_2 = zeros(Nx + 1) # solution at t-2*dt - - # Set initial condition - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - - if user_action is not None: - user_action(u_n, x, t, 0) - - for n in range(0, Nt): - # Update all inner points - if version == "scalar": - for i in range(1, Nx): - u[i] = u_n[i] + F * (u_n[i - 1] - 2 * u_n[i] + u_n[i + 1]) - - elif version == "vectorized": - u[1:Nx] = u_n[1:Nx] + F * (u_n[0 : Nx - 1] - 2 * u_n[1:Nx] + u_n[2 : Nx + 1]) - else: - raise ValueError("version=%s" % version) - - # Insert boundary conditions - u[0] = 0 - u[Nx] = 0 - if user_action is not None: - user_action(u, x, t, n + 1) - - # Switch variables before next step - # u_n[:] = u # slow - u_n, u = u, u_n - - t1 = time.perf_counter() - return u, x, t, t1 - t0 - - -def solver_BE_simple(I, a, L, Nx, F, T): - """ - Simplest expression of the computational algorithm - for the Backward Euler method, using explicit Python loops - and a dense matrix format for the coefficient matrix. - """ - x = linspace(0, L, Nx + 1) # mesh points in space - dx = x[1] - x[0] - dt = F * dx**2 / a - Nt = int(round(T / float(dt))) - t = linspace(0, T, Nt + 1) # mesh points in time - u = zeros(Nx + 1) - u_n = zeros(Nx + 1) - - # Data structures for the linear system - A = zeros((Nx + 1, Nx + 1)) - b = zeros(Nx + 1) - - for i in range(1, Nx): - A[i, i - 1] = -F - A[i, i + 1] = -F - A[i, i] = 1 + 2 * F - A[0, 0] = A[Nx, Nx] = 1 - - # Set initial condition u(x,0) = I(x) - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - - for n in range(0, Nt): - # Compute b and solve linear system - for i in range(1, Nx): - b[i] = -u_n[i] - b[0] = b[Nx] = 0 - u[:] = linalg.solve(A, b) - - # Switch variables before next step - u_n, u = u, u_n - return u - - -def solver_BE(I, a, L, Nx, F, T, user_action=None): - """ - Vectorized implementation of solver_BE_simple using also - a sparse (tridiagonal) matrix for efficiency. - """ - import time - - t0 = time.perf_counter() - - x = linspace(0, L, Nx + 1) # mesh points in space - dx = x[1] - x[0] - dt = F * dx**2 / a - Nt = int(round(T / float(dt))) - t = linspace(0, T, Nt + 1) # mesh points in time - - u = zeros(Nx + 1) # solution array at t[n+1] - u_n = zeros(Nx + 1) # solution at t[n] - - # Representation of sparse matrix and right-hand side - diagonal = zeros(Nx + 1) - lower = zeros(Nx + 1) - upper = zeros(Nx + 1) - b = zeros(Nx + 1) - # "Active" values: diagonal[:], upper[1:], lower[:-1] - - # Precompute sparse matrix - diagonal[:] = 1 + 2 * F - lower[:] = -F # 1 - upper[:] = -F # 1 - # Insert boundary conditions - diagonal[0] = 1 - diagonal[Nx] = 1 - # Remove unused/inactive values - upper[0:2] = 0 - lower[-2:] = 0 - - diags = [0, -1, 1] - A = spdiags([diagonal, lower, upper], diags, Nx + 1, Nx + 1) - print(A.todense()) - - # Set initial condition - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - - if user_action is not None: - user_action(u_n, x, t, 0) - - for n in range(0, Nt): - b = u_n - b[0] = b[-1] = 0.0 # boundary conditions - u[:] = spsolve(A, b) - - if user_action is not None: - user_action(u, x, t, n + 1) - - # Switch variables before next step - u_n, u = u, u_n - - t1 = time.perf_counter() - return u, x, t, t1 - t0 - - -def solver_theta(I, a, L, Nx, F, T, theta=0.5, u_L=0, u_R=0, user_action=None): - """ - Full solver for the model problem using the theta-rule - difference approximation in time (no restriction on F, - i.e., the time step when theta >= 0.5). - Vectorized implementation and sparse (tridiagonal) - coefficient matrix. - """ - import time - - t0 = time.perf_counter() - - x = linspace(0, L, Nx + 1) # mesh points in space - dx = x[1] - x[0] - dt = F * dx**2 / a - Nt = int(round(T / float(dt))) - t = linspace(0, T, Nt + 1) # mesh points in time - - u = zeros(Nx + 1) # solution array at t[n+1] - u_n = zeros(Nx + 1) # solution at t[n] - - # Representation of sparse matrix and right-hand side - diagonal = zeros(Nx + 1) - lower = zeros(Nx + 1) - upper = zeros(Nx + 1) - b = zeros(Nx + 1) - # "Active" values: diagonal[:], upper[1:], lower[:-1] - - # Precompute sparse matrix (scipy format) - diagonal[:] = 1 + 2 * Fl - lower[:] = -Fl # 1 - upper[:] = -Fl # 1 - # Insert boundary conditions - diagonal[0] = 1 - diagonal[Nx] = 1 - # Remove unused/inactive values - upper[0:2] = 0 - lower[-2:] = 0 - - diags = [0, -1, 1] - A = spdiags([diagonal, lower, upper], diags, Nx + 1, Nx + 1) - # print A.todense() - - # Set initial condition - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - - if user_action is not None: - user_action(u_n, x, t, 0) - - # Time loop - for n in range(0, Nt): - b[1:-1] = u_n[1:-1] + Fr * (u_n[:-2] - 2 * u_n[1:-1] + u_n[2:]) - b[0] = u_L - b[-1] = u_R # boundary conditions - u[:] = spsolve(A, b) - - if user_action is not None: - user_action(u, x, t, n + 1) - - # Switch variables before next step - u_n, u = u, u_n - - t1 = time.perf_counter() - return u, x, t, t1 - t0 - - -def viz(I, a, L, Nx, F, T, umin, umax, scheme="FE", animate=True): - def plot_u(u, x, t, n): - plot(x, u, "r-", axis=[0, L, umin, umax], title="t=%f" % t[n]) - if t[n] == 0: - time.sleep(2) - else: - time.sleep(0.2) - - user_action = plot_u if animate else lambda u, x, t, n: None - - u, x, t, cpu = eval("solver_" + scheme)(I, a, L, Nx, F, T, user_action=user_action) - return u, cpu - - -def plug(scheme="FE", F=0.5, Nx=50): - L = 1.0 - a = 1 - T = 0.1 - - def I(x): - """Plug profile as initial condition.""" - if abs(x - L / 2.0) > 0.1: - return 0 - else: - return 1 - - u, cpu = viz(I, a, L, Nx, F, T, umin=-0.1, umax=1.1, scheme=scheme, animate=True) - print("CPU time:", cpu) - - """ - if not allclose(solutions[0], solutions[-1], - atol=1.0E-10, rtol=1.0E-12): - print('error in computations') - else: - print('correct solution') - """ - - -def expsin(scheme="FE", F=0.5, m=3): - L = 10.0 - a = 1 - T = 1.2 - - def exact(x, t): - return exp(-(m**2) * pi**2 * a / L**2 * t) * sin(m * pi / L * x) - - def I(x): - return exact(x, 0) - - Nx = 80 - viz(I, a, L, Nx, F, T, -1, 1, scheme=scheme, animate=True) - - # Convergence study - def action(u, x, t, n): - e = abs(u - exact(x, t[n])).max() - errors.append(e) - - errors = [] - Nx_values = [10, 20, 40, 80, 160] - for Nx in Nx_values: - eval("solver_" + scheme)(I, a, L, Nx, F, T, user_action=action) - dt = F * (L / Nx) ** 2 / a - print(dt, errors[-1]) - - -if __name__ == "__main__": - import sys - import time - - if len(sys.argv) < 2: - print("""Usage %s function arg1 arg2 arg3 ...""" % sys.argv[0]) - sys.exit(0) - cmd = "%s(%s)" % (sys.argv[1], ", ".join(sys.argv[2:])) - print(cmd) - eval(cmd) diff --git a/src/diffu/diffu1D_vc.py b/src/diffu/diffu1D_vc.py deleted file mode 100644 index 21157f13..00000000 --- a/src/diffu/diffu1D_vc.py +++ /dev/null @@ -1,223 +0,0 @@ -""" -Solve the diffusion equation - - u_t = (a(x)*u_x)_x + f(x,t) - -on (0,L) with boundary conditions u(0,t) = u_L and u(L,t) = u_R, -for t in (0,T]. Initial condition: u(x,0) = I(x). - -The following naming convention of variables are used. - -===== ========================================================== -Name Description -===== ========================================================== -Nx The total number of mesh cells; mesh points are numbered - from 0 to Nx. -T The stop time for the simulation. -I Initial condition (Python function of x). -a Variable coefficient (constant). -L Length of the domain ([0,L]). -x Mesh points in space. -t Mesh points in time. -n Index counter in time. -u Unknown at current/new time level. -u_n u at the previous time level. -dx Constant mesh spacing in x. -dt Constant mesh spacing in t. -===== ========================================================== - -``user_action`` is a function of ``(u, x, t, n)``, ``u[i]`` is the -solution at spatial mesh point ``x[i]`` at time ``t[n]``, where the -calling code can add visualization, error computations, data analysis, -store solutions, etc. -""" - -import time - -import scipy.sparse -import scipy.sparse.linalg -from numpy import array, linspace, zeros - - -def solver(I, a, f, L, Nx, D, T, theta=0.5, u_L=1, u_R=0, user_action=None): - """ - The a variable is an array of length Nx+1 holding the values of - a(x) at the mesh points. - - Method: (implicit) theta-rule in time. - - Nx is the total number of mesh cells; mesh points are numbered - from 0 to Nx. - D = dt/dx**2 and implicitly specifies the time step. - T is the stop time for the simulation. - I is a function of x. - - user_action is a function of (u, x, t, n) where the calling code - can add visualization, error computations, data analysis, - store solutions, etc. - """ - import time - - t0 = time.perf_counter() - - x = linspace(0, L, Nx + 1) # mesh points in space - dx = x[1] - x[0] - dt = D * dx**2 - # print 'dt=%g' % dt - Nt = int(round(T / float(dt))) - t = linspace(0, T, Nt + 1) # mesh points in time - - if isinstance(a, (float, int)): - a = zeros(Nx + 1) + a - if isinstance(u_L, (float, int)): - u_L_ = float(u_L) # must take copy of u_L number - u_L = lambda t: u_L_ - if isinstance(u_R, (float, int)): - u_R_ = float(u_R) # must take copy of u_R number - u_R = lambda t: u_R_ - - u = zeros(Nx + 1) # solution array at t[n+1] - u_n = zeros(Nx + 1) # solution at t[n] - - """ - Basic formula in the scheme: - - 0.5*(a[i+1] + a[i])*(u[i+1] - u[i]) - - 0.5*(a[i] + a[i-1])*(u[i] - u[i-1]) - - 0.5*(a[i+1] + a[i])*u[i+1] - 0.5*(a[i] + a[i-1])*u[i-1] - -0.5*(a[i+1] + 2*a[i] + a[i-1])*u[i] - """ - - Dl = 0.5 * D * theta - Dr = 0.5 * D * (1 - theta) - - # Representation of sparse matrix and right-hand side - diagonal = zeros(Nx + 1) - lower = zeros(Nx) - upper = zeros(Nx) - b = zeros(Nx + 1) - - # Precompute sparse matrix (scipy format) - diagonal[1:-1] = 1 + Dl * (a[2:] + 2 * a[1:-1] + a[:-2]) - lower[:-1] = -Dl * (a[1:-1] + a[:-2]) - upper[1:] = -Dl * (a[2:] + a[1:-1]) - # Insert boundary conditions - diagonal[0] = 1 - upper[0] = 0 - diagonal[Nx] = 1 - lower[-1] = 0 - - A = scipy.sparse.diags( - diagonals=[diagonal, lower, upper], - offsets=[0, -1, 1], - shape=(Nx + 1, Nx + 1), - format="csr", - ) - # print A.todense() - - # Set initial condition - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - - if user_action is not None: - user_action(u_n, x, t, 0) - - # Time loop - for n in range(0, Nt): - b[1:-1] = ( - u_n[1:-1] - + Dr - * ( - (a[2:] + a[1:-1]) * (u_n[2:] - u_n[1:-1]) - - (a[1:-1] + a[0:-2]) * (u_n[1:-1] - u_n[:-2]) - ) - + dt * theta * f(x[1:-1], t[n + 1]) - + dt * (1 - theta) * f(x[1:-1], t[n]) - ) - # Boundary conditions - b[0] = u_L(t[n + 1]) - b[-1] = u_R(t[n + 1]) - # Solve - u[:] = scipy.sparse.linalg.spsolve(A, b) - - if user_action is not None: - user_action(u, x, t, n + 1) - - # Switch variables before next step - u_n, u = u, u_n - - t1 = time.perf_counter() - return t1 - t0 - - -def viz(I, a, f, L, Nx, D, T, umin, umax, theta, u_L, u_R, animate=True, store_u=False): - import matplotlib.pyplot as plt - - solutions = [] - - def process_u(u, x, t, n): - if animate: - plt.clf() - plt.plot(x, u, "r-") - plt.axis([0, L, umin, umax]) - plt.title("t=%f" % t[n]) - plt.draw() - plt.pause(0.001) - if t[n] == 0: - if store_u: - solutions.append(x) - solutions.append(t) - solutions.append(u.copy()) - time.sleep(3) - else: - if store_u: - solutions.append(u.copy()) - # time.sleep(0.1) - - cpu = solver(I, a, f, L, Nx, D, T, theta, u_L, u_R, user_action=process_u) - return cpu, array(solutions) - - -def fill_a(a_consts, L, Nx): - """ - *a_consts*: ``[[x0, a0], [x1, a1], ...]`` is a - piecewise constant function taking the value ``a0`` in ``[x0,x1]``, - ``a1`` in ``[x1,x2]``, and so forth. - - Return a finite difference function ``a`` on a uniform mesh with - Nx+1 points in [0, L] where the function takes on the piecewise - constant values of *a_const*. That is, - - ``a[i] = a_consts[s][1]`` if ``x[i]`` is in subdomain - ``[a_consts[s][0], a_consts[s+1][0]]``. - """ - a = zeros(Nx + 1) - x = linspace(0, L, Nx + 1) - s = 0 # subdomain counter - for i in range(len(x)): - if s < len(a_consts) - 1 and x[i] > a_consts[s + 1][0]: - s += 1 - a[i] = a_consts[s][1] - return a - - -def u_exact_stationary(x, a, u_L, u_R): - """ - Return stationary solution of a 1D variable coefficient - Laplace equation: (a(x)*v'(x))'=0, v(0)=u_L, v(L)=u_R. - - v(x) = u_L + (u_R-u_L)*(int_0^x 1/a(c)dc / int_0^L 1/a(c)dc) - """ - Nx = x.size - 1 - g = zeros(Nx + 1) # integral of 1/a from 0 to x - dx = x[1] - x[0] # assumed constant - i = 0 - g[i] = 0.5 * dx / a[i] - for i in range(1, Nx): - g[i] = g[i - 1] + dx / a[i] - i = Nx - g[i] = g[i - 1] + 0.5 * dx / a[i] - v = u_L + (u_R - u_L) * g / g[-1] - return v diff --git a/src/diffu/diffu2D_u0.py b/src/diffu/diffu2D_u0.py deleted file mode 100644 index e5b91650..00000000 --- a/src/diffu/diffu2D_u0.py +++ /dev/null @@ -1,1018 +0,0 @@ -#!/usr/bin/env python -""" -Functions for solving 2D diffusion equations of a simple type -(constant coefficient): - - u_t = a*(u_xx + u_yy) + f(x,t) on (0,Lx)x(0,Ly) - -with boundary conditions u=0 on x=0,Lx and y=0,Ly for t in (0,T]. -Initial condition: u(x,y,0)=I(x,y). - -The following naming convention of variables are used. - -===== ========================================================== -Name Description -===== ========================================================== -Fx The dimensionless number a*dt/dx**2, which implicitly - together with dt specifies the mesh in x. -Fy The dimensionless number a*dt/dy**2, which implicitly - together with dt specifies the mesh in y. -Nx Number of mesh cells in x direction. -Ny Number of mesh cells in y direction. -dt Desired time step. dx is computed from dt and F. -T The stop time for the simulation. -I Initial condition (Python function of x and y). -a Variable coefficient (constant). -Lx Length of the domain ([0,Lx]). -Ly Length of the domain ([0,Ly]). -x Mesh points in x. -y Mesh points in y. -t Mesh points in time. -n Index counter in time. -u Unknown at current/new time level. -u_n u at the previous time level. -dx Constant mesh spacing in x. -dy Constant mesh spacing in y. -dt Constant mesh spacing in t. -===== ========================================================== - -The mesh points are numbered as (0,0), (1,0), (2,0), -..., (Nx,0), (0,1), (1,1), ..., (Nx,1), ..., (0,Ny), (1,Ny), ...(Nx,Ny). -2D-index i,j maps to a single index k = j*(Nx+1) + i, where i,j is the -node ID and k is the corresponding location in the solution array u (or u1). - -f can be specified as None or 0, resulting in f=0. - -user_action: function of (u, x, y, t, n) called at each time -level (x and y are one-dimensional coordinate vectors). -This function allows the calling code to plot the solution, -compute errors, etc. -""" -import numpy as np - - -def solver_dense( - I, a, f, Lx, Ly, Nx, Ny, dt, T, theta=0.5, - U_0x=0, U_0y=0, U_Lx=0, U_Ly=0, user_action=None): - """ - Full solver for the model problem using the theta-rule - difference approximation in time. Dense matrix. Gaussian solve. - """ - import time; t0 = time.perf_counter() # for measuring CPU time - - x = np.linspace(0, Lx, Nx+1) # mesh points in x dir - y = np.linspace(0, Ly, Ny+1) # mesh points in y dir - dx = x[1] - x[0] - dy = y[1] - y[0] - - dt = float(dt) # avoid integer division - Nt = int(round(T/float(dt))) - t = np.linspace(0, Nt*dt, Nt+1) # mesh points in time - - # Mesh Fourier numbers in each direction - Fx = a*dt/dx**2 - Fy = a*dt/dy**2 - - # Allow f to be None or 0 - if f is None or f == 0: - f = lambda x, y, t: np.zeros((x.size, y.size)) \ - if isinstance(x, np.ndarray) else 0 - - u = np.zeros((Nx+1, Ny+1)) # unknown u at new time level - u_n = np.zeros((Nx+1, Ny+1)) # u at the previous time level - - Ix = range(0, Nx+1) - It = range(0, Ny+1) - It = range(0, Nt+1) - - # Make U_0x, U_0y, U_Lx and U_Ly functions if they are float/int - if isinstance(U_0x, (float,int)): - _U_0x = float(U_0x) # Make copy of U_0x - U_0x = lambda t: _U_0x - if isinstance(U_0y, (float,int)): - _U_0y = float(U_0y) # Make copy of U_0y - U_0y = lambda t: _U_0y - if isinstance(U_Lx, (float,int)): - _U_Lx = float(U_Lx) # Make copy of U_Lx - U_Lx = lambda t: _U_Lx - if isinstance(U_Ly, (float,int)): - _U_Ly = float(U_Ly) # Make copy of U_Ly - U_Ly = lambda t: _U_Ly - - # Load initial condition into u_n - for i in Ix: - for j in It: - u_n[i,j] = I(x[i], y[j]) - - # Two-dim coordinate arrays for vectorized function evaluations - # in the user_action function - xv = x[:,np.newaxis] - yv = y[np.newaxis,:] - - if user_action is not None: - user_action(u_n, x, xv, y, yv, t, 0) - - # Data structures for the linear system - N = (Nx+1)*(Ny+1) # no of unknowns - A = np.zeros((N, N)) - b = np.zeros(N) - - # Fill in dense matrix A, mesh line by line - m = lambda i, j: j*(Nx+1) + i - - # Equation corresponding to mesh point (i,j) has number - # j*(Nx+1)+i and will contribute to row j*(Nx+1)+i - # in the matrix. - - # Equations corresponding to j=0, i=0,1,... (u known) - j = 0 - for i in Ix: - p = m(i,j); A[p, p] = 1 - # Loop over all internal mesh points in y diretion - # and all mesh points in x direction - for j in It[1:-1]: - i = 0; p = m(i,j); A[p, p] = 1 # boundary - for i in Ix[1:-1]: # interior points - p = m(i,j) - A[p, m(i,j-1)] = - theta*Fy - A[p, m(i-1,j)] = - theta*Fx - A[p, p] = 1 + 2*theta*(Fx+Fy) - A[p, m(i+1,j)] = - theta*Fx - A[p, m(i,j+1)] = - theta*Fy - i = Nx; p = m(i,j); A[p, p] = 1 # boundary - # Equations corresponding to j=Ny, i=0,1,... (u known) - j = Ny - for i in Ix: - p = m(i,j); A[p, p] = 1 - - # Time loop - import scipy.linalg - for n in It[0:-1]: - # Compute b - j = 0 - for i in Ix: - p = m(i,j); b[p] = U_0y(t[n+1]) # boundary - for j in It[1:-1]: - i = 0; p = p = m(i,j); b[p] = U_0x(t[n+1]) # boundary - for i in Ix[1:-1]: - p = m(i,j) # interior - b[p] = u_n[i,j] + \ - (1-theta)*( - Fx*(u_n[i+1,j] - 2*u_n[i,j] + u_n[i-1,j]) +\ - Fy*(u_n[i,j+1] - 2*u_n[i,j] + u_n[i,j-1]))\ - + theta*dt*f(i*dx,j*dy,(n+1)*dt) + \ - (1-theta)*dt*f(i*dx,j*dy,n*dt) - i = Nx; p = m(i,j); b[p] = U_Lx(t[n+1]) # boundary - j = Ny - for i in Ix: - p = m(i,j); b[p] = U_Ly(t[n+1]) # boundary - #print b - - # Solve matrix system A*c = b - # (the solve function always returns a new object so we - # do not bother with inserting the solution in-place - # with c[:] = ...) - c = scipy.linalg.solve(A, b) - - # Fill u with vector c - for i in Ix: - for j in It: - u[i,j] = c[m(i,j)] - - if user_action is not None: - user_action(u, x, xv, y, yv, t, n+1) - - # Update u_n before next step - u_n, u = u, u_n - - t1 = time.perf_counter() - - return t, t1-t0 - -import scipy.sparse -import scipy.sparse.linalg - - -def solver_sparse( - I, a, f, Lx, Ly, Nx, Ny, dt, T, theta=0.5, - U_0x=0, U_0y=0, U_Lx=0, U_Ly=0, user_action=None, - method='direct', CG_prec='ILU', CG_tol=1E-5): - """ - Full solver for the model problem using the theta-rule - difference approximation in time. Sparse matrix with - dedicated Gaussian elimination algorithm (method='direct') - or ILU preconditioned Conjugate Gradients (method='CG' with - tolerance CG_tol and preconditioner CG_prec ('ILU' or None)). - """ - import time; t0 = time.perf_counter() # for measuring CPU time - - x = np.linspace(0, Lx, Nx+1) # mesh points in x dir - y = np.linspace(0, Ly, Ny+1) # mesh points in y dir - dx = x[1] - x[0] - dy = y[1] - y[0] - - dt = float(dt) # avoid integer division - Nt = int(round(T/float(dt))) - t = np.linspace(0, Nt*dt, Nt+1) # mesh points in time - - # Mesh Fourier numbers in each direction - Fx = a*dt/dx**2 - Fy = a*dt/dy**2 - - # Allow f to be None or 0 - if f is None or f == 0: - f = lambda x, y, t: np.zeros((x.size, y.size)) \ - if isinstance(x, np.ndarray) else 0 - - u = np.zeros((Nx+1, Ny+1)) # unknown u at new time level - u_n = np.zeros((Nx+1, Ny+1)) # u at the previous time level - - Ix = range(0, Nx+1) - It = range(0, Ny+1) - It = range(0, Nt+1) - - # Make U_0x, U_0y, U_Lx and U_Ly functions if they are float/int - if isinstance(U_0x, (float,int)): - _U_0x = float(U_0x) # Make copy of U_0x - U_0x = lambda t: _U_0x - if isinstance(U_0y, (float,int)): - _U_0y = float(U_0y) # Make copy of U_0y - U_0y = lambda t: _U_0y - if isinstance(U_Lx, (float,int)): - _U_Lx = float(U_Lx) # Make copy of U_Lx - U_Lx = lambda t: _U_Lx - if isinstance(U_Ly, (float,int)): - _U_Ly = float(U_Ly) # Make copy of U_Ly - U_Ly = lambda t: _U_Ly - - # Load initial condition into u_n - for i in Ix: - for j in It: - u_n[i,j] = I(x[i], y[j]) - - # Two-dim coordinate arrays for vectorized function evaluations - xv = x[:,np.newaxis] - yv = y[np.newaxis,:] - - if user_action is not None: - user_action(u_n, x, xv, y, yv, t, 0) - - N = (Nx+1)*(Ny+1) - main = np.zeros(N) # diagonal - lower = np.zeros(N-1) # subdiagonal - upper = np.zeros(N-1) # superdiagonal - lower2 = np.zeros(N-(Nx+1)) # lower diagonal - upper2 = np.zeros(N-(Nx+1)) # upper diagonal - b = np.zeros(N) # right-hand side - - # Precompute sparse matrix - lower_offset = 1 - lower2_offset = Nx+1 - - m = lambda i, j: j*(Nx+1) + i - j = 0; main[m(0,j):m(Nx+1,j)] = 1 # j=0 boundary line - for j in It[1:-1]: # Interior mesh lines j=1,...,Ny-1 - i = 0; main[m(i,j)] = 1 # Boundary - i = Nx; main[m(i,j)] = 1 # Boundary - # Interior i points: i=1,...,N_x-1 - lower2[m(1,j)-lower2_offset:m(Nx,j)-lower2_offset] = - theta*Fy - lower[m(1,j)-lower_offset:m(Nx,j)-lower_offset] = - theta*Fx - main[m(1,j):m(Nx,j)] = 1 + 2*theta*(Fx+Fy) - upper[m(1,j):m(Nx,j)] = - theta*Fx - upper2[m(1,j):m(Nx,j)] = - theta*Fy - j = Ny; main[m(0,j):m(Nx+1,j)] = 1 # Boundary line - - A = scipy.sparse.diags( - diagonals=[main, lower, upper, lower2, upper2], - offsets=[0, -lower_offset, lower_offset, - -lower2_offset, lower2_offset], - shape=(N, N), format='csc') - #print A.todense() # Check that A is correct - - if method == 'CG': - if CG_prec == 'ILU': - # Find ILU preconditioner (constant in time) - A_ilu = scipy.sparse.linalg.spilu(A) # SuperLU defaults - M = scipy.sparse.linalg.LinearOperator( - shape=(N, N), matvec=A_ilu.solve) - else: - M = None - CG_iter = [] # No of CG iterations at time level n - - # Time loop - for n in It[0:-1]: - """ - # Compute b, scalar version - j = 0 - for i in Ix: - p = m(i,j); b[p] = U_0y(t[n+1]) # Boundary - for j in It[1:-1]: - i = 0; p = m(i,j); b[p] = U_0x(t[n+1]) # Boundary - for i in Ix[1:-1]: - p = m(i,j) # Interior - b[p] = u_n[i,j] + \ - (1-theta)*( - Fx*(u_n[i+1,j] - 2*u_n[i,j] + u_n[i-1,j]) +\ - Fy*(u_n[i,j+1] - 2*u_n[i,j] + u_n[i,j-1]))\ - + theta*dt*f(i*dx,j*dy,(n+1)*dt) + \ - (1-theta)*dt*f(i*dx,j*dy,n*dt) - i = Nx; p = m(i,j); b[p] = U_Lx(t[n+1]) # Boundary - j = Ny - for i in Ix: - p = m(i,j); b[p] = U_Ly(t[n+1]) # Boundary - #print b - """ - # Compute b, vectorized version - - # Precompute f in array so we can make slices - f_a_np1 = f(xv, yv, t[n+1]) - f_a_n = f(xv, yv, t[n]) - - j = 0; b[m(0,j):m(Nx+1,j)] = U_0y(t[n+1]) # Boundary - for j in It[1:-1]: - i = 0; p = m(i,j); b[p] = U_0x(t[n+1]) # Boundary - i = Nx; p = m(i,j); b[p] = U_Lx(t[n+1]) # Boundary - imin = Ix[1] - imax = Ix[-1] # for slice, max i index is Ix[-1]-1 - b[m(imin,j):m(imax,j)] = u_n[imin:imax,j] + \ - (1-theta)*(Fx*( - u_n[imin+1:imax+1,j] - - 2*u_n[imin:imax,j] + - u_n[imin-1:imax-1,j]) + - Fy*( - u_n[imin:imax,j+1] - - 2*u_n[imin:imax,j] + - u_n[imin:imax,j-1])) + \ - theta*dt*f_a_np1[imin:imax,j] + \ - (1-theta)*dt*f_a_n[imin:imax,j] - j = Ny; b[m(0,j):m(Nx+1,j)] = U_Ly(t[n+1]) # Boundary - - # Solve matrix system A*c = b - if method == 'direct': - c = scipy.sparse.linalg.spsolve(A, b) - elif method == 'CG': - x0 = u_n.T.reshape(N) # Start vector is u_n - CG_iter.append(0) - - def CG_callback(c_k): - """Trick to count the no of iterations in CG.""" - CG_iter[-1] += 1 - - c, info = scipy.sparse.linalg.cg( - A, b, x0=x0, tol=CG_tol, maxiter=N, M=M, - callback=CG_callback) - ''' - if info > 0: - print('CG: tolerance %g not achieved within %d iterations' - % (CG_tol, info)) - elif info < 0: - print('CG breakdown') - else: - print('CG converged in %d iterations (tol=%g)' - % (CG_iter[-1], CG_tol)) - ''' - # Fill u with vector c - #for j in It: # vectorize y lines - # u[0:Nx+1,j] = c[m(0,j):m(Nx+1,j)] - u[:,:] = c.reshape(Ny+1,Nx+1).T - - if user_action is not None: - user_action(u, x, xv, y, yv, t, n+1) - - # Update u_n before next step - u_n, u = u, u_n - - t1 = time.perf_counter() - - return t, t1-t0 - - -def omega_optimal(Lx, Ly, Nx, Ny): - """Return the optimal omega for SOR according to 2D formula.""" - dx = Lx/float(Nx) - dy = Ly/float(Ny) - rho = (np.cos(np.pi/Nx) + (dx/dy)**2*np.cos(np.pi/Ny))/\ - (1 + (dx/dy)**2) - omega = 2.0/(1 + np.sqrt(1-rho**2)) - return omega - - -def solver_classic_iterative( - I, a, f, Lx, Ly, Nx, Ny, dt, T, theta=0.5, - U_0x=0, U_0y=0, U_Lx=0, U_Ly=0, user_action=None, - version='vectorized', iteration='Jacobi', - omega=1.0, max_iter=100, tol=1E-4): - """ - Full solver for the model problem using the theta-rule - difference approximation in time. Jacobi or SOR iteration. - (omega='optimal' applies an omega according a formula.) - """ - import time; t0 = time.perf_counter() # for measuring CPU time - - x = np.linspace(0, Lx, Nx+1) # mesh points in x dir - y = np.linspace(0, Ly, Ny+1) # mesh points in y dir - dx = x[1] - x[0] - dy = y[1] - y[0] - - dt = float(dt) # avoid integer division - Nt = int(round(T/float(dt))) - t = np.linspace(0, Nt*dt, Nt+1) # mesh points in time - - # Mesh Fourier numbers in each direction - Fx = a*dt/dx**2 - Fy = a*dt/dy**2 - - # Allow f to be None or 0 - if f is None or f == 0: - f = lambda x, y, t: np.zeros((x.size, y.size)) \ - if isinstance(x, np.ndarray) else 0 - - if version == 'vectorized' and iteration == 'SOR': - if (Nx % 2) != 0 or (Ny % 2) != 0: - raise ValueError( - 'Vectorized SOR requires even Nx and Ny (%dx%d)' - % (Nx, Ny)) - if version == 'SOR': - if omega == 'optimal': - omega = omega_optimal(Lx, Ly, Nx, Ny) - - u = np.zeros((Nx+1, Ny+1)) # unknown u at new time level - u_n = np.zeros((Nx+1, Ny+1)) # u at the previous time level - u_ = np.zeros((Nx+1, Ny+1)) # most recent approx to u - if version == 'vectorized': - u_new = np.zeros((Nx+1, Ny+1)) # help array - - Ix = range(0, Nx+1) - It = range(0, Ny+1) - It = range(0, Nt+1) - - # Make U_0x, U_0y, U_Lx and U_Ly functions if they are float/int - if isinstance(U_0x, (float,int)): - _U_0x = float(U_0x) # Make copy of U_0x - U_0x = lambda t: _U_0x - if isinstance(U_0y, (float,int)): - _U_0y = float(U_0y) # Make copy of U_0y - U_0y = lambda t: _U_0y - if isinstance(U_Lx, (float,int)): - _U_Lx = float(U_Lx) # Make copy of U_Lx - U_Lx = lambda t: _U_Lx - if isinstance(U_Ly, (float,int)): - _U_Ly = float(U_Ly) # Make copy of U_Ly - U_Ly = lambda t: _U_Ly - - # Load initial condition into u_n - for i in Ix: - for j in It: - u_n[i,j] = I(x[i], y[j]) - - # Two-dim coordinate arrays for vectorized function evaluations - # in the user_action function - xv = x[:,np.newaxis] - yv = y[np.newaxis,:] - - if user_action is not None: - user_action(u_n, x, xv, y, yv, t, 0) - - # Time loop - for n in It[0:-1]: - # Solve linear system by Jacobi or SOR iteration at time level n+1 - u_[:,:] = u_n # Start value - converged = False - r = 0 - while not converged: - if version == 'scalar': - if iteration == 'Jacobi': - u__ = u_ - elif iteration == 'SOR': - u__ = u - j = 0 - for i in Ix: - u[i,j] = U_0y(t[n+1]) # Boundary - for j in It[1:-1]: - i = 0; u[i,j] = U_0x(t[n+1]) # Boundary - i = Nx; u[i,j] = U_Lx(t[n+1]) # Boundary - for i in Ix[1:-1]: - u_new = 1.0/(1.0 + 2*theta*(Fx + Fy))*(theta*( - Fx*(u_[i+1,j] + u__[i-1,j]) + - Fy*(u_[i,j+1] + u__[i,j-1])) + \ - u_n[i,j] + (1-theta)*( - Fx*( - u_n[i+1,j] - 2*u_n[i,j] + u_n[i-1,j]) + - Fy*( - u_n[i,j+1] - 2*u_n[i,j] + u_n[i,j-1]))\ - + theta*dt*f(i*dx,j*dy,(n+1)*dt) + \ - (1-theta)*dt*f(i*dx,j*dy,n*dt)) - u[i,j] = omega*u_new + (1-omega)*u_[i,j] - j = Ny - for i in Ix: - u[i,j] = U_Ly(t[n+1]) # boundary - elif version == 'vectorized': - j = 0; u[:,j] = U_0y(t[n+1]) # boundary - i = 0; u[i,:] = U_0x(t[n+1]) # boundary - i = Nx; u[i,:] = U_Lx(t[n+1]) # boundary - j = Ny; u[:,j] = U_Ly(t[n+1]) # boundary - # Internal points - f_a_np1 = f(xv, yv, t[n+1]) - f_a_n = f(xv, yv, t[n]) - def update(u_, u_n, ic, im1, ip1, jc, jm1, jp1): - #print ''' -#ic: %s -#im1: %s -#ip1: %s -#jc: %s -#jm1: %s -#jp1: %s -#''' % (range(u_.shape[0])[ic],range(u_.shape[0])[im1],range(u_.shape[0])[ip1], -# range(u_.shape[1])[ic],range(u_.shape[1])[im1],range(u_.shape[1])[ip1]) - return \ - 1.0/(1.0 + 2*theta*(Fx + Fy))*(theta*( - Fx*(u_[ip1,jc] + u_[im1,jc]) + - Fy*(u_[ic,jp1] + u_[ic,jm1])) +\ - u_n[ic,jc] + (1-theta)*( - Fx*(u_n[ip1,jc] - 2*u_n[ic,jc] + u_n[im1,jc]) +\ - Fy*(u_n[ic,jp1] - 2*u_n[ic,jc] + u_n[ic,jm1]))+\ - theta*dt*f_a_np1[ic,jc] + \ - (1-theta)*dt*f_a_n[ic,jc]) - - if iteration == 'Jacobi': - ic = jc = slice(1,-1) - im1 = jm1 = slice(0,-2) - ip1 = jp1 = slice(2,None) - u_new[ic,jc] = update( - u_, u_n, ic, im1, ip1, jc, jm1, jp1) - u[ic,jc] = omega*u_new[ic,jc] + (1-omega)*u_[ic,jc] - elif iteration == 'SOR': - u_new[:,:] = u_ - # Red points - ic = slice(1,-1,2) - im1 = slice(0,-2,2) - ip1 = slice(2,None,2) - jc = slice(1,-1,2) - jm1 = slice(0,-2,2) - jp1 = slice(2,None,2) - u_new[ic,jc] = update( - u_new, u_n, ic, im1, ip1, jc, jm1, jp1) - - ic = slice(2,-1,2) - im1 = slice(1,-2,2) - ip1 = slice(3,None,2) - jc = slice(2,-1,2) - jm1 = slice(1,-2,2) - jp1 = slice(3,None,2) - u_new[ic,jc] = update( - u_new, u_n, ic, im1, ip1, jc, jm1, jp1) - - # Black points - ic = slice(2,-1,2) - im1 = slice(1,-2,2) - ip1 = slice(3,None,2) - jc = slice(1,-1,2) - jm1 = slice(0,-2,2) - jp1 = slice(2,None,2) - u_new[ic,jc] = update( - u_new, u_n, ic, im1, ip1, jc, jm1, jp1) - - ic = slice(1,-1,2) - im1 = slice(0,-2,2) - ip1 = slice(2,None,2) - jc = slice(2,-1,2) - jm1 = slice(1,-2,2) - jp1 = slice(3,None,2) - u_new[ic,jc] = update( - u_new, u_n, ic, im1, ip1, jc, jm1, jp1) - - # Relax - c = slice(1,-1) - u[c,c] = omega*u_new[c,c] + (1-omega)*u_[c,c] - - r += 1 - converged = np.abs(u-u_).max() < tol or r >= max_iter - #print r, np.abs(u-u_).max(), np.sqrt(dx*dy*np.sum((u-u_)**2)) - u_[:,:] = u - - print('t=%.2f: %s %s (omega=%g) finished in %d iterations' % - (t[n+1], version, iteration, omega, r)) - - if user_action is not None: - user_action(u, x, xv, y, yv, t, n+1) - - # Update u_n before next step - u_n, u = u, u_n - - t1 = time.perf_counter() - - return t, t1-t0 - -def quadratic(theta, Nx, Ny): - """Exact discrete solution of the scheme.""" - - def u_exact(x, y, t): - return 5*t*x*(Lx-x)*y*(Ly-y) - def I(x, y): - return u_exact(x, y, 0) - def f(x, y, t): - return 5*x*(Lx-x)*y*(Ly-y) + 10*a*t*(y*(Ly-y)+x*(Lx-x)) - - # Use rectangle to detect errors in switching i and j in scheme - Lx = 0.75 - Ly = 1.5 - a = 3.5 - dt = 0.5 - T = 2 - - def assert_no_error(u, x, xv, y, yv, t, n): - """Assert zero error at all mesh points.""" - u_e = u_exact(xv, yv, t[n]) - diff = abs(u - u_e).max() - tol = 1E-12 - msg = 'diff=%g, step %d, time=%g' % (diff, n, t[n]) - print(msg) - assert diff < tol, msg - - print('\ntesting dense matrix') - t, cpu = solver_dense( - I, a, f, Lx, Ly, Nx, Ny, - dt, T, theta, user_action=assert_no_error) - - print('\ntesting sparse matrix') - t, cpu = solver_sparse( - I, a, f, Lx, Ly, Nx, Ny, - dt, T, theta, user_action=assert_no_error, - method='direct') - - def assert_small_error(u, x, xv, y, yv, t, n): - """Assert small error at all mesh points for iterative methods.""" - u_e = u_exact(xv, yv, t[n]) - diff = abs(u - u_e).max() - tol = 1E-12 - tol = 1E-4 - msg = 'diff=%g, step %d, time=%g' % (diff, n, t[n]) - print(msg) - assert diff < tol, msg - - tol = 1E-5 # Tolerance in iterative methods - for iteration in 'Jacobi', 'SOR': - for version in 'scalar', 'vectorized': - for theta in 1, 0.5: - print('\ntesting %s, %s version, theta=%g, tol=%g' - % (iteration, version, theta, tol)) - t, cpu = solver_classic_iterative( - I=I, a=a, f=f, Lx=Lx, Ly=Ly, Nx=Nx, Ny=Ny, - dt=dt, T=T, theta=theta, - U_0x=0, U_0y=0, U_Lx=0, U_Ly=0, - user_action=assert_small_error, - version=version, iteration=iteration, - omega=1.0, max_iter=100, tol=tol) - - print('\ntesting CG+ILU, theta=%g, tol=%g' % (theta, tol)) - solver_sparse( - I, a, f, Lx, Ly, Nx, Ny, dt, T, theta=0.5, - user_action=assert_small_error, - method='CG', CG_prec='ILU', CG_tol=tol) - - print('\ntesting CG, theta=%g, tol=%g' % (theta, tol)) - solver_sparse( - I, a, f, Lx, Ly, Nx, Ny, dt, T, theta=0.5, - user_action=assert_small_error, - method='CG', CG_prec=None, CG_tol=tol) - - return t, cpu - -def test_quadratic(): - # For each of the three schemes (theta = 1, 0.5, 0), a series of - # meshes are tested (Nx > Ny and Nx < Ny) - for theta in [1, 0.5, 0]: - for Nx in range(2, 6, 2): - for Ny in range(2, 6, 2): - print('\n*** testing for %dx%d mesh' % (Nx, Ny)) - quadratic(theta, Nx, Ny) - -def demo_classic_iterative( - tol=1E-4, iteration='Jacobi', - version='vectorized', theta=0.5, - Nx=10, Ny=10): - Lx = 2.0 - Ly = 1.0 - a = 1.5 - - u_exact = lambda x, y, t: \ - np.exp(-a*np.pi**2*(Lx**(-2) + Ly**(-2))*t)*\ - np.sin(np.pi*x/Lx)*np.sin(np.pi*y/Ly) - I = lambda x, y: u_exact(x, y, 0) - f = lambda x, y, t: 0 if isinstance(x, (float,int)) else \ - np.zeros((Nx+1,Ny+1)) - dt = 0.2 - dt = 0.05 - T = 0.5 - - def examine(u, x, xv, y, yv, t, n): - # Expected error in amplitude - dx = x[1] - x[0]; dy = y[1] - y[0]; dt = t[1] - t[0] - Fx = a*dt/dx**2; Fy = a*dt/dy**2 - kx = np.pi/Lx; ky = np.pi/Ly - px = kx*dx/2; py = ky*dy/2 - if theta == 1: - A_d = (1 + 4*Fx*np.sin(px)**2 + 4*Fy*np.sin(py)**2)**(-n) - else: - A_d = ((1 - 2*Fx*np.sin(px)**2 - 2*Fy*np.sin(py)**2)/\ - (1 + 2*Fx*np.sin(px)**2 + 2*Fy*np.sin(py)**2))**n - A_e = np.exp(-a*np.pi**2*(Lx**(-2) + Ly**(-2))*t[n]) - A_diff = abs(A_e - A_d) - u_diff = abs(u_exact(xv, yv, t[n]).max() - u.max()) - print('Max u: %.2E' % u.max(), - 'error in u: %.2E' % u_diff, 'ampl.: %.2E' % A_diff, - 'iter: %.2E' % abs(u_diff - A_diff)) - - solver_classic_iterative( - I=I, a=a, f=f, Lx=Lx, Ly=Ly, Nx=Nx, Ny=Ny, - dt=dt, T=T, theta=theta, - U_0x=0, U_0y=0, U_Lx=0, U_Ly=0, user_action=examine, - #version='vectorized', iteration='Jacobi', - version=version, iteration=iteration, - omega=1.0, max_iter=300, tol=tol) - - -def convergence_rates(theta, num_experiments=6): - """ - Compute convergence rates. The error measure is the L2 norm - of the error mesh function in space and time: - E^2 = dt*sum(dx*dy*sum((u_e - u)**2)). - """ - # ...for FE and BE, error truncation analysis suggests - # that the error E goes like C*h**1, where h = dt = dx**2 - # (note that dx = dy is chosen). - # ...for CN, we similarly have that E goes like - # C*h**2, where h = dt**2 = dx**2 in this case. - - r_FE_BE_expected = 1 - r_CN_expected = 2 - E_values = [] - h_values = [] - Lx = 2.0; Ly = 2.0 # Use square domain - a = 3.5 - T = 1 - kx = np.pi/Lx - ky = np.pi/Ly - #p = a*(kx**2 + ky**2) # f=0, slower approach to asymptotic range - p = 1 - - def u_exact(x, y, t): - return np.exp(-p*t)*np.sin(kx*x)*np.sin(ky*y) - def I(x, y): - return u_exact(x, y, 0) - def f(x, y, t): - return (-p + a*(kx**2 + ky**2))*np.exp(-p*t)*np.sin(kx*x)*np.sin(ky*y) - - def add_error_contribution(u, x, xv, y, yv, t, n): - u_e = u_exact(xv, yv, t[n]) - E2_sum['err'] += np.sum((u_e - u)**2) - if n == 0: - # Store away dx, dy, dt in the dict for later use - E2_sum['dx'] = x[1] - x[0] - E2_sum['dy'] = y[1] - y[0] - E2_sum['dt'] = t[1] - t[0] - - def compute_E(h): - dx = E2_sum['dx'] - dy = E2_sum['dy'] - dt = E2_sum['dt'] - sum_xyt = E2_sum['err'] - E = np.sqrt(dt*dx*dy*sum_xyt) # L2 norm of error mesh func - E_values.append(E) - h_values.append(h) - - def assert_conv_rates(): - r = [np.log(E_values[i+1]/E_values[i])/ - np.log(h_values[i+1]/h_values[i]) - for i in range(0, num_experiments-2, 1)] - tol = 0.5 - if theta == 0: # i.e., FE - diff = abs(r_FE_BE_expected - r[-1]) - msg = 'Forward Euler. r = 1 expected, got=%g' % r[-1] - elif theta == 1: # i.e., BE - diff = abs(r_FE_BE_expected - r[-1]) - msg = 'Backward Euler. r = 1 expected, got=%g' % r[-1] - else: # theta == 0.5, i.e, CN - diff = abs(r_CN_expected - r[-1]) - msg = 'Crank-Nicolson. r = 2 expected, got=%g' % r[-1] - #print msg - print('theta: %g' % theta) - print('r: ', r) - assert diff < tol, msg - - tol = 1E-5 # Tolerance in iterative methods - for method in 'direct', 'CG': - print('\ntesting convergence rate, theta=%g, method=%s' % (theta, method)) - for i in range(num_experiments): - # Want to do E2_sum += ... in local functions (closures), but - # a standard variable E2_sum is reported undefined. Trick: use - # a dictionary or list instead. - E2_sum = {'err' : 0} - - N = 2**(i+1) - Nx = N; Ny = N # We want dx=dy, so Nx=Ny if Lx=Ly - dx = float(Lx)/N - # Find single discretization parameter h = dt and its relation - # to dt, dx and dy (E=C*h^r) - if theta == 1: - dt = h = dx**2 - elif theta == 0: - h = dx**2 - K = 1./(4*a) - dt = K*h - elif theta == 0.5: - dt = h = dx - if method == 'direct': - solver_sparse( - I, a, f, Lx, Ly, Nx, Ny, dt, T, theta=0.5, - user_action=add_error_contribution, - method=method) - else: - solver_sparse( - I, a, f, Lx, Ly, Nx, Ny, dt, T, theta=0.5, - user_action=add_error_contribution, - method=method, CG_prec='ILU', CG_tol=tol) - compute_E(h) - print('Experiment no:%d, %d unknowns' % (i+1, (N+1)**2), E_values[-1]) - assert_conv_rates() - - -def convergence_rates0(theta, num_experiments=10): - # Sveins version - """ - Compute convergence rates. The error measure is the L2 norm - of the error mesh function in space and time: - E^2 = dt*sum(dx*dy*sum((u_e - u)**2)). - """ - # ...for FE and BE, error truncation analysis suggests - # that the error E goes like C*h**1, where h = dt = dx**2 - # (note that dx = dy is chosen). - # ...for CN, we similarly have that E goes like - # C*h**2, where h = dt**2 = dx**2 in this case. - - r_FE_BE_expected = 1 - r_CN_expected = 2 - E_values = [] - dt_values = [] - Lx = 2.0; Ly = 2.0 # Use square domain - a = 3.5 - T = 2 - p = 1 # hpl: easier to let p match q and s, so f=0 - q = 2*np.pi/Lx - s = 2*np.pi/Ly # parameters for exact solution, loop later...? - - # hpl: easier to have p,q,s as "global" variables in the function, - # they are in I and f anyway... - def u_exact(p, q, s, x, y, t): - return np.exp(-p*t)*np.sin(q*x)*np.sin(s*y) - def I(x, y): - return u_exact(p, q, s, x, y, 0) - def f(x, y, t): - return np.exp(-p*t)*(-p + a*(q**2 + s**2))*np.sin(s*y)*np.sin(q*x) - - # Define boundary conditions (functions of space and time) - def U_0x(t): - return 0 - def U_0y(t): - return 0 - def U_Lx(t): - return 0 - def U_Ly(t): - return 0 - - def assert_correct_convergence_rate(u, x, xv, y, yv, t, n): - u_e = u_exact(p, q, s, xv, yv, t[n]) - E2_sum['err'] += np.sum((u_e - u)**2) - - if t[n] == T: # hpl: dangerous comparison... - dx = x[1] - x[0] - dt = t[1] - t[0] - E = np.sqrt(dt*dx*E2_sum['err']) # error, 1 simulation, t = [0,T] - E_values.append(E) - dt_values.append(dt) - if counter['i'] == num_experiments: # i.e., all num. exp. finished - print('...all experiments finished') - r = [np.log(E_values[i+1]/E_values[i])/ - np.log(dt_values[i+1]/dt_values[i]) - for i in range(0, num_experiments-2, 1)] - tol = 0.5 - if theta == 0: # i.e., FE - diff = abs(r_FE_BE_expected - r[-1]) - msg = 'Forward Euler. r = 1 expected, got=%g' % r[-1] - elif theta == 1: # i.e., BE - diff = abs(r_FE_BE_expected - r[-1]) - msg = 'Backward Euler. r = 1 expected, got=%g' % r[-1] - else: # theta == 0.5, i.e, CN - diff = abs(r_CN_expected - r[-1]) - msg = 'Crank-Nicolson. r = 2 expected, got=%g' % r[-1] - #print msg - print('theta: %g' % theta) - print('r: ', r) - assert diff < tol, msg - - print('\ntesting convergence rate, sparse matrix, CG, ILU') - tol = 1E-5 # Tolerance in iterative methods - counter = {'i' : 0} # initialize - for i in range(num_experiments): - # Want to do E2_sum += ... in local functions (closures), but - # a standard variable E2_sum is reported undefined. Trick: use - # a dictionary or list instead. - E2_sum = {'err' : 0} - counter['i'] += 1 - N = 2**(i+1) - Nx = N; Ny = N - if theta == 0 or theta == 1: # i.e., FE or BE - dt = (float(Lx)/N)**2 # i.e., choose dt = dx**2 - else: # theta == 0.5, i.e., CN - dt = float(Lx)/N # i.e., choose dt = dx - solver_sparse( - I, a, f, Lx, Ly, Nx, Ny, dt, T, theta=0.5, - user_action=assert_correct_convergence_rate, - method='CG', CG_prec='ILU', CG_tol=tol) - print('Experiment no:%d' % (i+1)) - - -def test_convergence_rate(): - # For each solver, we find the conv rate on a square domain: - # For each of the three schemes (theta = 1, 0.5, 0), a series of - # finer and finer meshes are tested, computing the conv.rate r - # in each case to find the limiting value when dt and dx --> 0. - - for theta in [1, 0.5, 0]: - convergence_rates(theta, num_experiments=6) - - -def efficiency(): - """Measure the efficiency of iterative methods.""" - # JUST A SKETCH! - cpu = {} - - # Find a more advanced example and use large Nx, Ny - def u_exact(x, y, t): - return 5*t*x*(Lx-x)*y*(Ly-y) - def I(x, y): - return u_exact(x, y, 0) - def f(x, y, t): - return 5*x*(Lx-x)*y*(Ly-y) + 10*a*t*(y*(Ly-y)+x*(Lx-x)) - - # Use rectangle to detect errors in switching i and j in scheme - Lx = 0.75 - Ly = 1.5 - a = 3.5 - dt = 0.5 - T = 2 - - print('\ntesting sparse matrix LU solver') - t, cpu = solver_sparse( - I, a, f, Lx, Ly, Nx, Ny, - dt, T, theta, user_action=None, - method='direct') - - theta = 0.5 - tol = 1E-5 # Tolerance in iterative methods - # Testing Jacobi and Gauss-Seidel - for iteration in 'Jacobi', 'SOR': - for version in 'scalar', 'vectorized': - print('\ntesting %s, %s version, theta=%g, tol=%g' - % (iteration, version, theta, tol)) - t, cpu_ = solver_classic_iterative( - I=I, a=a, f=f, Lx=Lx, Ly=Ly, Nx=Nx, Ny=Ny, - dt=dt, T=T, theta=theta, - U_0x=0, U_0y=0, U_Lx=0, U_Ly=0, - user_action=None, - version=version, iteration=iteration, - omega=1.0, max_iter=100, tol=tol) - cpu[iteration+'_'+version] = cpu_ - - for omega in 'optimal', 1.2, 1.5: - print('\ntesting SOR, omega:', omega) - t, cpu_ = solver_classic_iterative( - I=I, a=a, f=f, Lx=Lx, Ly=Ly, Nx=Nx, Ny=Ny, - dt=dt, T=T, theta=theta, - U_0x=0, U_0y=0, U_Lx=0, U_Ly=0, - user_action=None, - version=version, iteration=iteration, - omega=1.0, max_iter=100, tol=tol) - cpu['SOR(omega=%g)' % omega] = cpu_ - - print('\ntesting CG+ILU, theta=%g, tol=%g' % (theta, tol)) - t, cpu_ = solver_sparse( - I, a, f, Lx, Ly, Nx, Ny, dt, T, theta=0.5, - user_action=None, - method='CG', CG_prec='ILU', CG_tol=tol) - cpu['CG+ILU'] = cpu_ - - print('\ntesting CG, theta=%g, tol=%g' % (theta, tol)) - t, cpu_ = solver_sparse( - I, a, f, Lx, Ly, Nx, Ny, dt, T, theta=0.5, - user_action=None, - method='CG', CG_prec=None, CG_tol=tol) - cpu['CG'] = cpu_ - - return t, cpu - -if __name__ == '__main__': - #test_quadratic() - #demo_classic_iterative( - # iteration='Jacobi', theta=0.5, tol=1E-4, Nx=20, Ny=20) - test_convergence_rate() diff --git a/src/diffu/diffu3D_u0.py b/src/diffu/diffu3D_u0.py deleted file mode 100644 index 70289990..00000000 --- a/src/diffu/diffu3D_u0.py +++ /dev/null @@ -1,394 +0,0 @@ -#!/usr/bin/env python -""" -Function for solving 3D diffusion equations of a simple type -(constant coefficient) with incomplete LU factorization and -Conjug. grad. method. - - u_t = a*(u_xx + u_yy + u_zz) + f(x,y,z,t) on (0,Lx)x(0,Ly)x(0,Lz) - -with boundary conditions u=0 on x=0,Lx and y=0,Ly and z=0,Lz for t in (0,T]. -Initial condition: u(x,y,z,0)=I(x,y,z). - -The following naming convention of variables are used. - -===== ========================================================== -Name Description -===== ========================================================== -Fx The dimensionless number a*dt/dx**2, which implicitly - together with dt specifies the mesh in x. -Fy The dimensionless number a*dt/dy**2, which implicitly - together with dt specifies the mesh in y. -Fz The dimensionless number a*dt/dz**2, which implicitly - together with dt specifies the mesh in z. -Nx Number of mesh cells in x direction. -Ny Number of mesh cells in y direction. -Nz Number of mesh cells in z direction. -dt Desired time step. dx is computed from dt and F. -T The stop time for the simulation. -I Initial condition (Python function of x and y). -a Variable coefficient (constant). -Lx Length of the domain ([0,Lx]). -Ly Length of the domain ([0,Ly]). -Lz Length of the domain ([0,Lz]). -x Mesh points in x. -y Mesh points in y. -z Mesh points in z. -t Mesh points in time. -n Index counter in time. -u Unknown at current/new time level. -u_n u at the previous time level. -dx Constant mesh spacing in x. -dy Constant mesh spacing in y. -dz Constant mesh spacing in z. -dt Constant mesh spacing in t. -===== ========================================================== - -The mesh points are numbered as (0,0,0), (1,0,0), (2,0,0), -..., (Nx,0,0), (0,1,0), (1,1,0), ..., (Nx,1,0), ..., (0,Ny,0), -(1,Ny,0), ...(Nx,Ny,0), (0,0,1), (1,0,1), ...(Nx,0,1), (0,1,1), -(1,1,1), ...(Nx, Ny, Nz). 3D-index i,j,k maps to a single index -s = k*(Nx+1)*(Ny+1) + j*(Nx+1) + i, where i,j,k is the node ID -and s is the corresponding location in the solution array c when -solving Ac = b. - -f can be specified as None or 0, resulting in f=0. - -user_action: function of (u, x, y, z, t, n) called at each time -level (x, y and z are one-dimensional coordinate vectors). -This function allows the calling code to plot the solution, -compute errors, etc. -""" - -import numpy as np -import scipy.linalg -import scipy.sparse -import scipy.sparse.linalg - - -def solver_sparse_CG( - I, - a, - f, - Lx, - Ly, - Lz, - Nx, - Ny, - Nz, - dt, - T, - theta=0.5, - U_0x=0, - U_0y=0, - U_0z=0, - U_Lx=0, - U_Ly=0, - U_Lz=0, - user_action=None, -): - """ - Full solver for the model problem using the theta-rule - difference approximation in time. Sparse matrix with ILU - preconditioning and CG solve. - """ - import time - - t0 = time.perf_counter() # for measuring CPU time - - x = np.linspace(0, Lx, Nx + 1) # mesh points in x dir - y = np.linspace(0, Ly, Ny + 1) # mesh points in y dir - z = np.linspace(0, Lz, Nz + 1) # mesh points in z dir - dx = x[1] - x[0] - dy = y[1] - y[0] - dz = z[1] - z[0] - - dt = float(dt) # avoid integer division - Nt = int(round(T / float(dt))) - t = np.linspace(0, Nt * dt, Nt + 1) # mesh points in time - - # Mesh Fourier numbers in each direction - Fx = a * dt / dx**2 - Fy = a * dt / dy**2 - Fz = a * dt / dz**2 - - # Allow f to be None or 0 - if f is None or f == 0: - f = lambda x, y, z, t: 0 - - # unknown u at new time level - u = np.zeros((Nx + 1, Ny + 1, Nz + 1)) - # u at the previous time level - u_n = np.zeros((Nx + 1, Ny + 1, Nz + 1)) - - Ix = range(0, Nx + 1) - It = range(0, Ny + 1) - Iz = range(0, Nz + 1) - It = range(0, Nt + 1) - - # Make U_0x, U_0y, U_0z, U_Lx, U_Ly, U_Lz - # functions if they are float/int - if isinstance(U_0x, (float, int)): - _U_0x = float(U_0x) # make copy of U_0x - U_0x = lambda t: _U_0x - if isinstance(U_0y, (float, int)): - _U_0y = float(U_0y) # make copy of U_0y - U_0y = lambda t: _U_0y - if isinstance(U_0z, (float, int)): - _U_0z = float(U_0z) # make copy of U_0z - U_0z = lambda t: _U_0z - if isinstance(U_Lx, (float, int)): - _U_Lx = float(U_Lx) # make copy of U_Lx - U_Lx = lambda t: _U_Lx - if isinstance(U_Ly, (float, int)): - _U_Ly = float(U_Ly) # make copy of U_Ly - U_Ly = lambda t: _U_Ly - if isinstance(U_Lz, (float, int)): - _U_Lz = float(U_Lz) # make copy of U_Lz - U_Lz = lambda t: _U_Lz - - # Load initial condition into u_n - for i in Ix: - for j in It: - for k in Iz: - u_n[i, j, k] = I(x[i], y[j], z[k]) - - # 3D coordinate arrays for vectorized function evaluations - xv = x[:, np.newaxis, np.newaxis] - yv = y[np.newaxis, :, np.newaxis] - zv = z[np.newaxis, np.newaxis, :] - - if user_action is not None: - user_action(u_n, x, xv, y, yv, z, zv, t, 0) - - N = (Nx + 1) * (Ny + 1) * (Nz + 1) - main = np.zeros(N) # diagonal - lower = np.zeros(N - 1) # subdiagonal - upper = np.zeros(N - 1) # superdiagonal - lower2 = np.zeros(N - (Nx + 1)) # lower diagonal - upper2 = np.zeros(N - (Nx + 1)) # upper diagonal - lower3 = np.zeros(N - (Nx + 1) * (Ny + 1)) # lower diagonal - upper3 = np.zeros(N - (Nx + 1) * (Ny + 1)) # upper diagonal - b = np.zeros(N) # right-hand side - - # Precompute sparse matrix - lower_offset = 1 - lower2_offset = Nx + 1 - lower3_offset = (Nx + 1) * (Ny + 1) - - m = lambda i, j, k: k * (Nx + 1) * (Ny + 1) + j * (Nx + 1) + i - k = 0 - main[m(0, 0, k) : m(Nx + 1, Ny + 1, k)] = 1 # k=0 boundary layer - for k in Iz[1:-1]: # interior mesh layers k=1,...,Nz-1 - j = 0 - main[m(0, j, k) : m(Nx + 1, j, k)] = 1 # j=0 boundary line - for j in It[1:-1]: # interior mesh lines j=1,...,Ny-1 - i = 0 - main[m(i, j, k)] = 1 # boundary node - i = Nx - main[m(i, j, k)] = 1 # boundary node - # Interior i points: i=1,...,N_x-1 - lower3[m(1, j, k) - lower3_offset : m(Nx, j, k) - lower3_offset] = -theta * Fz - lower2[m(1, j, k) - lower2_offset : m(Nx, j, k) - lower2_offset] = -theta * Fy - lower[m(1, j, k) - lower_offset : m(Nx, j, k) - lower_offset] = -theta * Fx - main[m(1, j, k) : m(Nx, j, k)] = 1 + 2 * theta * (Fx + Fy + Fz) - upper[m(1, j, k) : m(Nx, j, k)] = -theta * Fx - upper2[m(1, j, k) : m(Nx, j, k)] = -theta * Fy - upper3[m(1, j, k) : m(Nx, j, k)] = -theta * Fz - j = Ny - main[m(0, j, k) : m(Nx + 1, j, k)] = 1 # boundary line - k = Nz - main[m(0, 0, k) : m(Nx + 1, Ny + 1, k)] = 1 # boundary layer - - A = scipy.sparse.diags( - diagonals=[main, lower, upper, lower2, upper2, lower3, upper3], - offsets=[ - 0, - -lower_offset, - lower_offset, - -lower2_offset, - lower2_offset, - -lower3_offset, - lower3_offset, - ], - shape=(N, N), - format="csc", - ) - # print A.todense() # Check that A is correct - - # Find preconditioner for A (stays constant the whole time interval) - A_ilu = scipy.sparse.linalg.spilu(A) - M = scipy.sparse.linalg.LinearOperator(shape=(N, N), matvec=A_ilu.solve) - - # Time loop - c = None # initialize solution vector (Ac = b) - for n in It[0:-1]: - # Compute b, scalar version - """ - k = 0 # k=0 boundary layer - for j in It: - for i in Ix: - p = m(i,j,k); b[p] = U_0z(t[n+1]) - - for k in Iz[1:-1]: # interior mesh layers k=1,...,Nz-1 - j = 0 # boundary mesh line - for i in Ix: - p = m(i,j,k); b[p] = U_0y(t[n+1]) - - for j in It[1:-1]: # interior mesh lines j=1,...,Ny-1 - i = 0; p = m(i,j,k); b[p] = U_0x(t[n+1]) # boundary node - - for i in Ix[1:-1]: # interior nodes - p = m(i,j,k) - b[p] = u_n[i,j,k] + \ - (1-theta)*( - Fx*(u_n[i+1,j,k] - 2*u_n[i,j,k] + u_n[i-1,j,k]) +\ - Fy*(u_n[i,j+1,k] - 2*u_n[i,j,k] + u_n[i,j-1,k]) + - Fz*(u_n[i,j,k+1] - 2*u_n[i,j,k] + u_n[i,j,k-1]))\ - + theta*dt*f(i*dx,j*dy,k*dz,(n+1)*dt) + \ - (1-theta)*dt*f(i*dx,j*dy,k*dz,n*dt) - i = Nx; p = m(i,j,k); b[p] = U_Lx(t[n+1]) # boundary node - - j = Ny # boundary mesh line - for i in Ix: - p = m(i,j,k); b[p] = U_Ly(t[n+1]) - - k = Nz # k=Nz boundary layer - for j in It: - for i in Ix: - p = m(i,j,k); b[p] = U_Lz(t[n+1]) - - #print b - """ - # Compute b, vectorized version - - # Precompute f in array so we can make slices - f_a_np1 = f(xv, yv, zv, t[n + 1]) - f_a_n = f(xv, yv, zv, t[n]) - - k = 0 - b[m(0, 0, k) : m(Nx + 1, Ny + 1, k)] = U_0z(t[n + 1]) # k=0 boundary layer - for k in Iz[1:-1]: # interior mesh layers k=1,...,Nz-1 - j = 0 - b[m(0, j, k) : m(Nx + 1, j, k)] = U_0y(t[n + 1]) # j=0, boundary mesh line - for j in It[1:-1]: # interior mesh lines j=1,...,Ny-1 - i = 0 - p = m(i, j, k) - b[p] = U_0x(t[n + 1]) # boundary node - i = Nx - p = m(i, j, k) - b[p] = U_Lx(t[n + 1]) # boundary node - # Interior i points: i=1,...,N_x-1 - imin = Ix[1] - imax = Ix[-1] # for slice, max i index is Ix[-1]-1 - b[m(imin, j, k) : m(imax, j, k)] = ( - u_n[imin:imax, j, k] - + (1 - theta) - * ( - Fx - * ( - u_n[imin + 1 : imax + 1, j, k] - - 2 * u_n[imin:imax, j, k] - + u_n[imin - 1 : imax - 1, j, k] - ) - + Fy - * ( - u_n[imin:imax, j + 1, k] - - 2 * u_n[imin:imax, j, k] - + u_n[imin:imax, j - 1, k] - ) - + Fz - * ( - u_n[imin:imax, j, k + 1] - - 2 * u_n[imin:imax, j, k] - + u_n[imin:imax, j, k - 1] - ) - ) - + theta * dt * f_a_np1[imin:imax, j, k] - + (1 - theta) * dt * f_a_n[imin:imax, j, k] - ) - j = Ny - b[m(0, j, k) : m(Nx + 1, j, k)] = U_Ly(t[n + 1]) # j=Ny, boundary mesh line - k = Nz - b[m(0, 0, k) : m(Nx + 1, Ny + 1, k)] = U_Lz(t[n + 1]) # k=Nz boundary layer - - # Solve matrix system A*c = b (use previous sol as start vector x0) - c, info = scipy.sparse.linalg.cg(A, b, x0=c, tol=1e-14, maxiter=N, M=M) - - if info > 0: - print("CG: tolerance not achieved within %d iterations" % info) - elif info < 0: - print("CG breakdown") - - # Fill u with vector c - # for k in Iz: - # for j in It: - # u[0:Nx+1,j,k] = c[m(0,j,k):m(Nx+1,j,k)] - u[:, :, :] = c.reshape(Nz + 1, Ny + 1, Nx + 1).T - - if user_action is not None: - user_action(u, x, xv, y, yv, z, zv, t, n + 1) - - # Update u_n before next step - u_n, u = u, u_n - - t1 = time.perf_counter() - - return t, t1 - t0 - - -def quadratic(theta, Nx, Ny, Nz): - """Exact discrete solution of the scheme.""" - - def u_exact(x, y, z, t): - return 5 * t * x * (Lx - x) * y * (Ly - y) * z * (Lz - z) - - def I(x, y, z): - return u_exact(x, y, z, 0) - - def f(x, y, z, t): - return 5 * x * (Lx - x) * y * (Ly - y) * z * (Lz - z) + 10 * a * t * ( - y * (Ly - y) * z * (Lz - z) - + x * (Lx - x) * z * (Lz - z) - + x * (Lx - x) * y * (Ly - y) - ) - - # Use rectangular box (cuboid) to detect errors in switching - # i, j and k in scheme - Lx = 0.75 - Ly = 1.5 - Lz = 2.0 - a = 3.5 - dt = 0.5 - T = 2 - - def assert_no_error(u, x, xv, y, yv, z, zv, t, n): - """Assert zero error at all mesh points.""" - u_e = u_exact(xv, yv, zv, t[n]) - diff = abs(u - u_e).max() - tol = 1e-12 - msg = "diff=%g, step %d, time=%g" % (diff, n, t[n]) - print(msg) - assert diff < tol, msg - - print("testing sparse matrix, ILU and CG, theta=%g" % theta) - t, cpu = solver_sparse_CG( - I, a, f, Lx, Ly, Lz, Nx, Ny, Nz, dt, T, theta, user_action=assert_no_error - ) - - return t, cpu - - -def test_quadratic(): - # For each of the three schemes (theta = 1, 0.5, 0), a series of - # meshes are tested, where Nc, c = x,y,z, is successively - # the largest and smallest among the three Nc values. - for theta in [1, 0.5, 0]: - for Nx in range(2, 6, 2): - for Ny in range(2, 6, 2): - for Nz in range(2, 6, 2): - print("testing for %dx%dx%d mesh" % (Nx, Ny, Nz)) - quadratic(theta, Nx, Ny, Nz) - - -if __name__ == "__main__": - test_quadratic() diff --git a/src/diffu/test1_diffu1D_vc.py b/src/diffu/test1_diffu1D_vc.py deleted file mode 100644 index 9df9ea9f..00000000 --- a/src/diffu/test1_diffu1D_vc.py +++ /dev/null @@ -1,50 +0,0 @@ -from diffusion1D_vc import * - -# Test problem: start with u=u_L in left part and u=u_R in right part, -# let diffusion work and make a linear function from u_L to u_R as -# time goes to infinity. For a=1, u=1-x when u_L=1, u_R=0. - - -def I(x): - return u_L if x <= L / 2.0 else u_R - - -theta = 1 -L = 1 -Nx = 20 -# Nx = 400 -a = zeros(Nx + 1) + 1 -u_L = 1 -u_R = 0 -dx = L / float(Nx) -D = 500 -dt = dx**2 * D -dt = 1.25 -D = dt / dx**2 -T = 2.5 -umin = u_R -umax = u_L - -a_consts = [[0, 1]] -a_consts = [[0, 1], [0.5, 8]] -a_consts = [[0, 1], [0.5, 8], [0.75, 0.1]] -a = fill_a(a_consts, L, Nx) -# a = random.uniform(0, 10, Nx+1) - -import matplotlib.pyplot as plt - -plt.figure() -plt.subplot(2, 1, 1) -u, x, cpu = viz(I, a, L, Nx, D, T, umin, umax, theta, u_L, u_R) - -v = u_exact_stationary(x, a, u_L, u_R) -print("v", v) -print("u", u) -symbol = "bo" if Nx < 32 else "b-" -plt.plot(x, v, symbol, label="exact stationary") -plt.legend() - -plt.subplot(2, 1, 2) -plt.plot(x, a, label="a") -plt.legend() -plt.show() diff --git a/src/diffu/test2_diffu1D_vc.py b/src/diffu/test2_diffu1D_vc.py deleted file mode 100644 index c6e198d4..00000000 --- a/src/diffu/test2_diffu1D_vc.py +++ /dev/null @@ -1,61 +0,0 @@ -from diffusion1D_vc import * - -# Test problem: start with u=u_L in left part and u=u_R in right part, -# let diffusion work and make a linear function from u_L to u_R as -# time goes to infinity. For a=1, u=1-x when u_L=1, u_R=0. - -theta = 1 -L = 1 -Nx = 41 -a = zeros(Nx + 1) + 1 -u_L = 1 -u_R = 0 -D = 50 -T = 0.4 # "infinite time" -umin = u_R -umax = u_L - - -def I(x): - return u_L if x <= L / 2.0 else u_R - - -def test2(p): - """ - Given the values ``p=(a1,a2)`` of the diffusion coefficient - in two subdomains [0, 0.5] and [0.5, 1], return u(0.5, inf). - """ - assert len(p) == 2 - a1, a2 = p - a_consts = [[0, a1], [0.5, a2]] - a = fill_a(a_consts, L, Nx) - - u, x, t, cpu = solver_theta(I, a, L, Nx, D, T, theta=theta, u_L=u_L, u_R=u_R) - return u[Nx / 2] - - -def test2_fast(p): - """Fast version of test2 using the analytical solution directly.""" - a1, a2 = p - a_consts = [[0, a1], [0.5, a2]] - a = fill_a(a_consts, L, Nx) - x = linspace(0, L, Nx + 1) - v = u_exact_stationary(x, a, u_L, u_R) - - return v[Nx / 2] - - -def visualize(p): - a1, a2 = p - a_consts = [[0, a1], [0.5, a2]] - a = fill_a(a_consts, L, Nx) - # Choose smaller time step to better see the evolution - global D - D = 50.0 - - u, x, cpu = viz(I, a, L, Nx, D, T, umin, umax, theta, u_L, u_R) - - -if __name__ == "__main__": - # visualize((8, 1)) - print(test2((8, 1))) diff --git a/src/elliptic/__init__.py b/src/elliptic/__init__.py new file mode 100644 index 00000000..2a4fe9d9 --- /dev/null +++ b/src/elliptic/__init__.py @@ -0,0 +1,79 @@ +"""Elliptic PDE solvers using Devito DSL. + +This module provides solvers for steady-state elliptic PDEs +using Devito's symbolic finite difference framework. + +Elliptic equations have no time derivatives and describe +equilibrium or steady-state problems. The two main equations are: + +1. Laplace equation: laplace(p) = 0 + - Describes steady-state potential problems + - Solution determined entirely by boundary conditions + +2. Poisson equation: laplace(p) = b + - Laplace equation with source term + - Common in electrostatics, gravity, heat conduction + +Both solvers use iterative methods (Jacobi iteration) with +pseudo-timestepping to converge to the steady-state solution. + +Examples +-------- +Solve the Laplace equation on [0, 2] x [0, 1]: + + >>> from src.elliptic import solve_laplace_2d + >>> result = solve_laplace_2d( + ... Lx=2.0, Ly=1.0, + ... Nx=31, Ny=31, + ... bc_left=0.0, + ... bc_right=lambda y: y, + ... bc_bottom='neumann', + ... bc_top='neumann', + ... tol=1e-4, + ... ) + >>> print(f"Converged in {result.iterations} iterations") + +Solve the Poisson equation with point sources: + + >>> from src.elliptic import solve_poisson_2d + >>> result = solve_poisson_2d( + ... Lx=2.0, Ly=1.0, + ... Nx=50, Ny=50, + ... source_points=[(0.5, 0.25, 100), (1.5, 0.75, -100)], + ... n_iterations=100, + ... ) +""" + +from src.elliptic.laplace_devito import ( + LaplaceResult, + convergence_test_laplace_2d, + exact_laplace_linear, + solve_laplace_2d, + solve_laplace_2d_with_copy, +) +from src.elliptic.poisson_devito import ( + PoissonResult, + convergence_test_poisson_2d, + create_gaussian_source, + create_point_source, + exact_poisson_point_source, + solve_poisson_2d, + solve_poisson_2d_timefunction, + solve_poisson_2d_with_copy, +) + +__all__ = [ + "LaplaceResult", + "PoissonResult", + "convergence_test_laplace_2d", + "convergence_test_poisson_2d", + "create_gaussian_source", + "create_point_source", + "exact_laplace_linear", + "exact_poisson_point_source", + "solve_laplace_2d", + "solve_laplace_2d_with_copy", + "solve_poisson_2d", + "solve_poisson_2d_timefunction", + "solve_poisson_2d_with_copy", +] diff --git a/src/elliptic/laplace_devito.py b/src/elliptic/laplace_devito.py new file mode 100644 index 00000000..4c1ad1db --- /dev/null +++ b/src/elliptic/laplace_devito.py @@ -0,0 +1,618 @@ +"""2D Laplace Equation Solver using Devito DSL. + +Solves the steady-state Laplace equation: + laplace(p) = p_xx + p_yy = 0 + +on domain [0, Lx] x [0, Ly] with: + - Dirichlet boundary conditions: prescribed values on boundaries + - Neumann boundary conditions: prescribed derivatives on boundaries + +The discretization uses central differences for the Laplacian: + p_{i,j} = (dx^2*(p_{i,j+1} + p_{i,j-1}) + dy^2*(p_{i+1,j} + p_{i-1,j})) + / (2*(dx^2 + dy^2)) + +This is an iterative (pseudo-timestepping) solver that converges to +the steady-state solution. Convergence is measured using the L1 norm. + +The solver uses a dual-buffer approach with two Function objects, +alternating between them to avoid data copies during iteration. + +Usage: + from src.elliptic import solve_laplace_2d + + result = solve_laplace_2d( + Lx=2.0, Ly=1.0, # Domain size + Nx=31, Ny=31, # Grid points + bc_left=0.0, # p = 0 at x = 0 + bc_right=lambda y: y, # p = y at x = Lx + bc_bottom='neumann', # dp/dy = 0 at y = 0 + bc_top='neumann', # dp/dy = 0 at y = Ly + tol=1e-4, # Convergence tolerance + ) +""" + +from collections.abc import Callable +from dataclasses import dataclass + +import numpy as np + +try: + from devito import Eq, Function, Grid, Operator, solve + DEVITO_AVAILABLE = True +except ImportError: + DEVITO_AVAILABLE = False + + +@dataclass +class LaplaceResult: + """Results from the 2D Laplace equation solver. + + Attributes + ---------- + p : np.ndarray + Solution at convergence, shape (Nx+1, Ny+1) + x : np.ndarray + x-coordinate grid points + y : np.ndarray + y-coordinate grid points + iterations : int + Number of iterations to convergence + final_l1norm : float + Final L1 norm (convergence measure) + converged : bool + Whether the solver converged within max_iterations + p_history : list, optional + Solution history at specified intervals + """ + p: np.ndarray + x: np.ndarray + y: np.ndarray + iterations: int + final_l1norm: float + converged: bool + p_history: list | None = None + + +def solve_laplace_2d( + Lx: float = 2.0, + Ly: float = 1.0, + Nx: int = 31, + Ny: int = 31, + bc_left: float | Callable[[np.ndarray], np.ndarray] | str = 0.0, + bc_right: float | Callable[[np.ndarray], np.ndarray] | str = "neumann", + bc_bottom: float | Callable[[np.ndarray], np.ndarray] | str = "neumann", + bc_top: float | Callable[[np.ndarray], np.ndarray] | str = "neumann", + tol: float = 1e-4, + max_iterations: int = 10000, + save_interval: int | None = None, +) -> LaplaceResult: + """Solve the 2D Laplace equation using Devito (iterative method). + + Solves: laplace(p) = p_xx + p_yy = 0 + using an iterative pseudo-timestepping approach with dual buffers. + + Parameters + ---------- + Lx : float + Domain length in x direction [0, Lx] + Ly : float + Domain length in y direction [0, Ly] + Nx : int + Number of grid points in x (including boundaries) + Ny : int + Number of grid points in y (including boundaries) + bc_left : float, callable, or 'neumann' + Boundary condition at x=0: + - float: Dirichlet with constant value + - callable: Dirichlet with f(y) profile + - 'neumann': Zero-gradient (dp/dx = 0) + bc_right : float, callable, or 'neumann' + Boundary condition at x=Lx (same options as bc_left) + bc_bottom : float, callable, or 'neumann' + Boundary condition at y=0: + - float: Dirichlet with constant value + - callable: Dirichlet with f(x) profile + - 'neumann': Zero-gradient (dp/dy = 0) + bc_top : float, callable, or 'neumann' + Boundary condition at y=Ly (same options as bc_bottom) + tol : float + Convergence tolerance for L1 norm + max_iterations : int + Maximum number of iterations + save_interval : int, optional + If specified, save solution every save_interval iterations + + Returns + ------- + LaplaceResult + Solution data including converged solution, grids, and iteration info + + Raises + ------ + ImportError + If Devito is not installed + + Notes + ----- + The solver uses a dual-buffer approach where two Function objects + alternate roles as source and target. This avoids data copies and + provides good performance. + + Neumann boundary conditions are implemented by copying the + second-to-last row/column to the boundary (numerical approximation + of zero gradient). + """ + if not DEVITO_AVAILABLE: + raise ImportError( + "Devito is required for this solver. " + "Install with: pip install devito" + ) + + # Create Devito 2D grid + grid = Grid(shape=(Nx, Ny), extent=(Lx, Ly)) + x_dim, y_dim = grid.dimensions + + # Create two explicit buffers for pseudo-timestepping + p = Function(name='p', grid=grid, space_order=2) + pn = Function(name='pn', grid=grid, space_order=2) + + # Get coordinate arrays + dx = Lx / (Nx - 1) + dy = Ly / (Ny - 1) + x_coords = np.linspace(0, Lx, Nx) + y_coords = np.linspace(0, Ly, Ny) + + # Create boundary condition profiles + bc_left_vals = _process_bc(bc_left, y_coords, "left") + bc_right_vals = _process_bc(bc_right, y_coords, "right") + bc_bottom_vals = _process_bc(bc_bottom, x_coords, "bottom") + bc_top_vals = _process_bc(bc_top, x_coords, "top") + + # Create boundary condition functions for prescribed profiles + if isinstance(bc_right_vals, np.ndarray): + bc_right_func = Function(name='bc_right', shape=(Ny,), dimensions=(y_dim,)) + bc_right_func.data[:] = bc_right_vals + + if isinstance(bc_left_vals, np.ndarray): + bc_left_func = Function(name='bc_left', shape=(Ny,), dimensions=(y_dim,)) + bc_left_func.data[:] = bc_left_vals + + if isinstance(bc_bottom_vals, np.ndarray): + bc_bottom_func = Function(name='bc_bottom', shape=(Nx,), dimensions=(x_dim,)) + bc_bottom_func.data[:] = bc_bottom_vals + + if isinstance(bc_top_vals, np.ndarray): + bc_top_func = Function(name='bc_top', shape=(Nx,), dimensions=(x_dim,)) + bc_top_func.data[:] = bc_top_vals + + # Create Laplace equation based on pn + # laplace(pn) = 0, solve for central point + eqn = Eq(pn.laplace, subdomain=grid.interior) + stencil = solve(eqn, pn) + + # Create update expression: p gets the stencil from pn + eq_stencil = Eq(p, stencil) + + # Create boundary condition expressions + bc_exprs = [] + + # Left boundary (x = 0) + if isinstance(bc_left_vals, str) and bc_left_vals == "neumann": + # dp/dx = 0: copy second column to first + bc_exprs.append(Eq(p[0, y_dim], p[1, y_dim])) + elif isinstance(bc_left_vals, np.ndarray): + bc_exprs.append(Eq(p[0, y_dim], bc_left_func[y_dim])) + else: + bc_exprs.append(Eq(p[0, y_dim], float(bc_left_vals))) + + # Right boundary (x = Lx) + if isinstance(bc_right_vals, str) and bc_right_vals == "neumann": + # dp/dx = 0: copy second-to-last column to last + bc_exprs.append(Eq(p[Nx - 1, y_dim], p[Nx - 2, y_dim])) + elif isinstance(bc_right_vals, np.ndarray): + bc_exprs.append(Eq(p[Nx - 1, y_dim], bc_right_func[y_dim])) + else: + bc_exprs.append(Eq(p[Nx - 1, y_dim], float(bc_right_vals))) + + # Bottom boundary (y = 0) + if isinstance(bc_bottom_vals, str) and bc_bottom_vals == "neumann": + # dp/dy = 0: copy second row to first + bc_exprs.append(Eq(p[x_dim, 0], p[x_dim, 1])) + elif isinstance(bc_bottom_vals, np.ndarray): + bc_exprs.append(Eq(p[x_dim, 0], bc_bottom_func[x_dim])) + else: + bc_exprs.append(Eq(p[x_dim, 0], float(bc_bottom_vals))) + + # Top boundary (y = Ly) + if isinstance(bc_top_vals, str) and bc_top_vals == "neumann": + # dp/dy = 0: copy second-to-last row to last + bc_exprs.append(Eq(p[x_dim, Ny - 1], p[x_dim, Ny - 2])) + elif isinstance(bc_top_vals, np.ndarray): + bc_exprs.append(Eq(p[x_dim, Ny - 1], bc_top_func[x_dim])) + else: + bc_exprs.append(Eq(p[x_dim, Ny - 1], float(bc_top_vals))) + + # Create operator + op = Operator([eq_stencil] + bc_exprs) + + # Initialize both buffers + p.data[:] = 0.0 + pn.data[:] = 0.0 + + # Apply initial boundary conditions to both buffers + _apply_initial_bc(p.data, bc_left_vals, bc_right_vals, + bc_bottom_vals, bc_top_vals, Nx, Ny) + _apply_initial_bc(pn.data, bc_left_vals, bc_right_vals, + bc_bottom_vals, bc_top_vals, Nx, Ny) + + # Storage for history + p_history = [] if save_interval is not None else None + if save_interval is not None: + p_history.append(p.data[:].copy()) + + # Run convergence loop by explicitly flipping buffers + l1norm = 1.0 + iteration = 0 + + while l1norm > tol and iteration < max_iterations: + # Determine buffer order based on iteration parity + if iteration % 2 == 0: + _p = p + _pn = pn + else: + _p = pn + _pn = p + + # Apply operator + op(p=_p, pn=_pn) + + # Compute L1 norm for convergence check + denom = np.sum(np.abs(_pn.data[:])) + if denom > 1e-15: + l1norm = np.sum(np.abs(_p.data[:]) - np.abs(_pn.data[:])) / denom + else: + l1norm = np.sum(np.abs(_p.data[:]) - np.abs(_pn.data[:])) + + l1norm = abs(l1norm) + iteration += 1 + + # Save history if requested + if save_interval is not None and iteration % save_interval == 0: + p_history.append(_p.data[:].copy()) + + # Get the final result from the correct buffer + if iteration % 2 == 1: + p_final = p.data[:].copy() + else: + p_final = pn.data[:].copy() + + converged = l1norm <= tol + + return LaplaceResult( + p=p_final, + x=x_coords, + y=y_coords, + iterations=iteration, + final_l1norm=l1norm, + converged=converged, + p_history=p_history, + ) + + +def _process_bc(bc, coords, name): + """Process boundary condition specification. + + Parameters + ---------- + bc : float, callable, or 'neumann' + Boundary condition specification + coords : np.ndarray + Coordinate array along the boundary + name : str + Name of the boundary for error messages + + Returns + ------- + float, np.ndarray, or 'neumann' + Processed boundary condition value(s) + """ + if isinstance(bc, str): + if bc.lower() == "neumann": + return "neumann" + else: + raise ValueError(f"Unknown boundary condition type for {name}: {bc}") + elif callable(bc): + return bc(coords) + else: + return float(bc) + + +def _apply_initial_bc(data, bc_left, bc_right, bc_bottom, bc_top, Nx, Ny): + """Apply initial boundary conditions to a data array. + + Parameters + ---------- + data : np.ndarray + Data array to modify (shape Nx x Ny) + bc_left, bc_right, bc_bottom, bc_top : various + Boundary condition specifications + Nx, Ny : int + Grid dimensions + """ + def _is_neumann(bc): + return isinstance(bc, str) and bc == "neumann" + + # Left (x = 0) + if isinstance(bc_left, np.ndarray): + data[0, :] = bc_left + elif not _is_neumann(bc_left): + data[0, :] = float(bc_left) + + # Right (x = Lx) + if isinstance(bc_right, np.ndarray): + data[Nx - 1, :] = bc_right + elif not _is_neumann(bc_right): + data[Nx - 1, :] = float(bc_right) + + # Bottom (y = 0) + if isinstance(bc_bottom, np.ndarray): + data[:, 0] = bc_bottom + elif not _is_neumann(bc_bottom): + data[:, 0] = float(bc_bottom) + + # Top (y = Ly) + if isinstance(bc_top, np.ndarray): + data[:, Ny - 1] = bc_top + elif not _is_neumann(bc_top): + data[:, Ny - 1] = float(bc_top) + + # Handle Neumann BCs by copying adjacent values + if _is_neumann(bc_left): + data[0, :] = data[1, :] + if _is_neumann(bc_right): + data[Nx - 1, :] = data[Nx - 2, :] + if _is_neumann(bc_bottom): + data[:, 0] = data[:, 1] + if _is_neumann(bc_top): + data[:, Ny - 1] = data[:, Ny - 2] + + +def solve_laplace_2d_with_copy( + Lx: float = 2.0, + Ly: float = 1.0, + Nx: int = 31, + Ny: int = 31, + bc_left: float | Callable[[np.ndarray], np.ndarray] | str = 0.0, + bc_right: float | Callable[[np.ndarray], np.ndarray] | str = "neumann", + bc_bottom: float | Callable[[np.ndarray], np.ndarray] | str = "neumann", + bc_top: float | Callable[[np.ndarray], np.ndarray] | str = "neumann", + tol: float = 1e-4, + max_iterations: int = 10000, +) -> LaplaceResult: + """Solve 2D Laplace equation using data copies (for comparison). + + This is the straightforward implementation that copies data between + buffers on each iteration. The buffer-swapping version + (solve_laplace_2d) is more efficient for large grids. + + Parameters are identical to solve_laplace_2d. + """ + if not DEVITO_AVAILABLE: + raise ImportError( + "Devito is required for this solver. " + "Install with: pip install devito" + ) + + # Create Devito 2D grid + grid = Grid(shape=(Nx, Ny), extent=(Lx, Ly)) + x_dim, y_dim = grid.dimensions + + # Create two explicit buffers for pseudo-timestepping + p = Function(name='p', grid=grid, space_order=2) + pn = Function(name='pn', grid=grid, space_order=2) + + # Get coordinate arrays + x_coords = np.linspace(0, Lx, Nx) + y_coords = np.linspace(0, Ly, Ny) + + # Create boundary condition profiles + bc_left_vals = _process_bc(bc_left, y_coords, "left") + bc_right_vals = _process_bc(bc_right, y_coords, "right") + bc_bottom_vals = _process_bc(bc_bottom, x_coords, "bottom") + bc_top_vals = _process_bc(bc_top, x_coords, "top") + + # Create boundary condition functions for prescribed profiles + if isinstance(bc_right_vals, np.ndarray): + bc_right_func = Function(name='bc_right', shape=(Ny,), dimensions=(y_dim,)) + bc_right_func.data[:] = bc_right_vals + + if isinstance(bc_left_vals, np.ndarray): + bc_left_func = Function(name='bc_left', shape=(Ny,), dimensions=(y_dim,)) + bc_left_func.data[:] = bc_left_vals + + if isinstance(bc_bottom_vals, np.ndarray): + bc_bottom_func = Function(name='bc_bottom', shape=(Nx,), dimensions=(x_dim,)) + bc_bottom_func.data[:] = bc_bottom_vals + + if isinstance(bc_top_vals, np.ndarray): + bc_top_func = Function(name='bc_top', shape=(Nx,), dimensions=(x_dim,)) + bc_top_func.data[:] = bc_top_vals + + # Create Laplace equation based on pn + eqn = Eq(pn.laplace, subdomain=grid.interior) + stencil = solve(eqn, pn) + eq_stencil = Eq(p, stencil) + + # Create boundary condition expressions + bc_exprs = [] + + # Left boundary + if isinstance(bc_left_vals, str) and bc_left_vals == "neumann": + bc_exprs.append(Eq(p[0, y_dim], p[1, y_dim])) + elif isinstance(bc_left_vals, np.ndarray): + bc_exprs.append(Eq(p[0, y_dim], bc_left_func[y_dim])) + else: + bc_exprs.append(Eq(p[0, y_dim], float(bc_left_vals))) + + # Right boundary + if isinstance(bc_right_vals, str) and bc_right_vals == "neumann": + bc_exprs.append(Eq(p[Nx - 1, y_dim], p[Nx - 2, y_dim])) + elif isinstance(bc_right_vals, np.ndarray): + bc_exprs.append(Eq(p[Nx - 1, y_dim], bc_right_func[y_dim])) + else: + bc_exprs.append(Eq(p[Nx - 1, y_dim], float(bc_right_vals))) + + # Bottom boundary + if isinstance(bc_bottom_vals, str) and bc_bottom_vals == "neumann": + bc_exprs.append(Eq(p[x_dim, 0], p[x_dim, 1])) + elif isinstance(bc_bottom_vals, np.ndarray): + bc_exprs.append(Eq(p[x_dim, 0], bc_bottom_func[x_dim])) + else: + bc_exprs.append(Eq(p[x_dim, 0], float(bc_bottom_vals))) + + # Top boundary + if isinstance(bc_top_vals, str) and bc_top_vals == "neumann": + bc_exprs.append(Eq(p[x_dim, Ny - 1], p[x_dim, Ny - 2])) + elif isinstance(bc_top_vals, np.ndarray): + bc_exprs.append(Eq(p[x_dim, Ny - 1], bc_top_func[x_dim])) + else: + bc_exprs.append(Eq(p[x_dim, Ny - 1], float(bc_top_vals))) + + # Create operator + op = Operator([eq_stencil] + bc_exprs) + + # Initialize both buffers + p.data[:] = 0.0 + pn.data[:] = 0.0 + + # Apply initial boundary conditions + _apply_initial_bc(p.data, bc_left_vals, bc_right_vals, + bc_bottom_vals, bc_top_vals, Nx, Ny) + _apply_initial_bc(pn.data, bc_left_vals, bc_right_vals, + bc_bottom_vals, bc_top_vals, Nx, Ny) + + # Run convergence loop with deep data copies + l1norm = 1.0 + iteration = 0 + + while l1norm > tol and iteration < max_iterations: + # Deep copy (this is what we want to avoid in production) + pn.data[:] = p.data[:] + + # Apply operator + op(p=p, pn=pn) + + # Compute L1 norm + denom = np.sum(np.abs(pn.data[:])) + if denom > 1e-15: + l1norm = np.sum(np.abs(p.data[:]) - np.abs(pn.data[:])) / denom + else: + l1norm = np.sum(np.abs(p.data[:]) - np.abs(pn.data[:])) + + l1norm = abs(l1norm) + iteration += 1 + + converged = l1norm <= tol + + return LaplaceResult( + p=p.data[:].copy(), + x=x_coords, + y=y_coords, + iterations=iteration, + final_l1norm=l1norm, + converged=converged, + ) + + +def exact_laplace_linear( + X: np.ndarray, + Y: np.ndarray, + Lx: float = 2.0, + Ly: float = 1.0, +) -> np.ndarray: + """Exact solution for Laplace equation with linear boundary conditions. + + For the boundary conditions: + p = 0 at x = 0 + p = y at x = Lx + dp/dy = 0 at y = 0 and y = Ly + + The exact solution is p(x, y) = x * y / Lx + + Parameters + ---------- + X : np.ndarray + x-coordinates (meshgrid) + Y : np.ndarray + y-coordinates (meshgrid) + Lx : float + Domain length in x + Ly : float + Domain length in y + + Returns + ------- + np.ndarray + Exact solution at (x, y) + """ + return X * Y / Lx + + +def convergence_test_laplace_2d( + grid_sizes: list | None = None, + tol: float = 1e-8, +) -> tuple[np.ndarray, np.ndarray, float]: + """Run convergence test for 2D Laplace solver. + + Uses the linear solution test case for error computation. + + Parameters + ---------- + grid_sizes : list, optional + List of N values to test (same for Nx and Ny). + Default: [11, 21, 41, 81] + tol : float + Convergence tolerance for the solver + + Returns + ------- + tuple + (grid_sizes, errors, observed_order) + """ + if grid_sizes is None: + grid_sizes = [11, 21, 41, 81] + + errors = [] + Lx = 2.0 + Ly = 1.0 + + for N in grid_sizes: + result = solve_laplace_2d( + Lx=Lx, Ly=Ly, + Nx=N, Ny=N, + bc_left=0.0, + bc_right=lambda y: y, + bc_bottom="neumann", + bc_top="neumann", + tol=tol, + ) + + # Create meshgrid for exact solution + X, Y = np.meshgrid(result.x, result.y, indexing='ij') + + # Exact solution + p_exact = exact_laplace_linear(X, Y, Lx, Ly) + + # L2 error + error = np.sqrt(np.mean((result.p - p_exact) ** 2)) + errors.append(error) + + errors = np.array(errors) + grid_sizes = np.array(grid_sizes) + + # Compute observed order + log_h = np.log(1.0 / grid_sizes) + log_err = np.log(errors + 1e-15) # Avoid log(0) + observed_order = np.polyfit(log_h, log_err, 1)[0] + + return grid_sizes, errors, observed_order diff --git a/src/elliptic/poisson_devito.py b/src/elliptic/poisson_devito.py new file mode 100644 index 00000000..633be4b3 --- /dev/null +++ b/src/elliptic/poisson_devito.py @@ -0,0 +1,632 @@ +"""2D Poisson Equation Solver using Devito DSL. + +Solves the Poisson equation with source term: + laplace(p) = p_xx + p_yy = b + +on domain [0, Lx] x [0, Ly] with: + - Dirichlet boundary conditions (default: p = 0 on all boundaries) + - Source term b(x, y) + +The discretization uses central differences: + p_{i,j} = (dy^2*(p_{i+1,j} + p_{i-1,j}) + dx^2*(p_{i,j+1} + p_{i,j-1}) + - b_{i,j}*dx^2*dy^2) / (2*(dx^2 + dy^2)) + +Two solver approaches are provided: +1. Dual-buffer (manual loop): Uses two Function objects with explicit + buffer swapping and Python convergence loop. Good for understanding + the algorithm and adding custom convergence criteria. + +2. TimeFunction (internal loop): Uses Devito's TimeFunction with + internal time stepping. More efficient for many iterations. + +Usage: + from src.elliptic import solve_poisson_2d + + # Define source term with point sources + result = solve_poisson_2d( + Lx=2.0, Ly=1.0, + Nx=50, Ny=50, + source_points=[(0.5, 0.25, 100), (1.5, 0.75, -100)], + n_iterations=100, + ) +""" + +from collections.abc import Callable +from dataclasses import dataclass + +import numpy as np + +try: + from devito import Eq, Function, Grid, Operator, TimeFunction, solve + DEVITO_AVAILABLE = True +except ImportError: + DEVITO_AVAILABLE = False + + +@dataclass +class PoissonResult: + """Results from the 2D Poisson equation solver. + + Attributes + ---------- + p : np.ndarray + Solution at final iteration, shape (Nx, Ny) + x : np.ndarray + x-coordinate grid points + y : np.ndarray + y-coordinate grid points + b : np.ndarray + Source term used + iterations : int + Number of iterations performed + p_history : list, optional + Solution history at specified intervals + """ + p: np.ndarray + x: np.ndarray + y: np.ndarray + b: np.ndarray + iterations: int + p_history: list | None = None + + +def solve_poisson_2d( + Lx: float = 2.0, + Ly: float = 1.0, + Nx: int = 50, + Ny: int = 50, + b: Callable[[np.ndarray, np.ndarray], np.ndarray] | np.ndarray | None = None, + source_points: list[tuple[float, float, float]] | None = None, + n_iterations: int = 100, + bc_value: float = 0.0, + save_interval: int | None = None, +) -> PoissonResult: + """Solve the 2D Poisson equation using Devito (dual-buffer approach). + + Solves: laplace(p) = p_xx + p_yy = b + with p = bc_value on all boundaries (Dirichlet). + + Uses a dual-buffer approach with two Function objects and explicit + buffer swapping for efficiency. The Python loop allows custom + convergence criteria if needed. + + Parameters + ---------- + Lx : float + Domain length in x direction [0, Lx] + Ly : float + Domain length in y direction [0, Ly] + Nx : int + Number of grid points in x (including boundaries) + Ny : int + Number of grid points in y (including boundaries) + b : callable, np.ndarray, or None + Source term specification: + - callable: b(X, Y) where X, Y are meshgrid arrays + - np.ndarray: explicit source array of shape (Nx, Ny) + - None: use source_points or default to zero + source_points : list of tuples, optional + List of (x, y, value) tuples for point sources. + Each tuple places a source of given value at (x, y). + n_iterations : int + Number of pseudo-timestep iterations + bc_value : float + Dirichlet boundary condition value (same on all boundaries) + save_interval : int, optional + If specified, save solution every save_interval iterations + + Returns + ------- + PoissonResult + Solution data including final solution, grids, and source term + + Raises + ------ + ImportError + If Devito is not installed + + Notes + ----- + The dual-buffer approach alternates between two Function objects + to avoid data copies. On even iterations, pn -> p; on odd + iterations, p -> pn. The operator is called with swapped arguments. + + This is more efficient than copying data on each iteration, + especially for large grids. + """ + if not DEVITO_AVAILABLE: + raise ImportError( + "Devito is required for this solver. " + "Install with: pip install devito" + ) + + # Create Devito 2D grid + grid = Grid(shape=(Nx, Ny), extent=(Lx, Ly)) + x_dim, y_dim = grid.dimensions + + # Create two explicit buffers for pseudo-timestepping + p = Function(name='p', grid=grid, space_order=2) + pd = Function(name='pd', grid=grid, space_order=2) + + # Initialize source term function + b_func = Function(name='b', grid=grid) + + # Get coordinate arrays + dx = Lx / (Nx - 1) + dy = Ly / (Ny - 1) + x_coords = np.linspace(0, Lx, Nx) + y_coords = np.linspace(0, Ly, Ny) + X, Y = np.meshgrid(x_coords, y_coords, indexing='ij') + + # Set source term + b_func.data[:] = 0.0 + + if b is not None: + if callable(b): + b_func.data[:] = b(X, Y) + elif isinstance(b, np.ndarray): + if b.shape != (Nx, Ny): + raise ValueError( + f"Source array shape {b.shape} does not match grid ({Nx}, {Ny})" + ) + b_func.data[:] = b + elif source_points is not None: + # Add point sources + for x_src, y_src, value in source_points: + # Find nearest grid indices + i = int(round(x_src * (Nx - 1) / Lx)) + j = int(round(y_src * (Ny - 1) / Ly)) + i = max(0, min(Nx - 1, i)) + j = max(0, min(Ny - 1, j)) + b_func.data[i, j] = value + + # Create Poisson equation based on pd: laplace(pd) = b + eq = Eq(pd.laplace, b_func, subdomain=grid.interior) + stencil = solve(eq, pd) + + # Create update expression: p gets the stencil from pd + eq_stencil = Eq(p, stencil) + + # Boundary condition expressions (Dirichlet: p = bc_value) + bc_exprs = [ + Eq(p[x_dim, 0], bc_value), # Bottom (y = 0) + Eq(p[x_dim, Ny - 1], bc_value), # Top (y = Ly) + Eq(p[0, y_dim], bc_value), # Left (x = 0) + Eq(p[Nx - 1, y_dim], bc_value), # Right (x = Lx) + ] + + # Create operator + op = Operator([eq_stencil] + bc_exprs) + + # Initialize buffers + p.data[:] = 0.0 + pd.data[:] = 0.0 + + # Storage for history + p_history = [] if save_interval is not None else None + if save_interval is not None: + p_history.append(p.data[:].copy()) + + # Run the outer loop with buffer swapping + for i in range(n_iterations): + # Determine buffer order based on iteration parity + if i % 2 == 0: + _p = p + _pd = pd + else: + _p = pd + _pd = p + + # Apply operator + op(p=_p, pd=_pd) + + # Save history if requested + if save_interval is not None and (i + 1) % save_interval == 0: + p_history.append(_p.data[:].copy()) + + # Get the final result from the correct buffer + if n_iterations % 2 == 1: + p_final = p.data[:].copy() + else: + p_final = pd.data[:].copy() + + return PoissonResult( + p=p_final, + x=x_coords, + y=y_coords, + b=b_func.data[:].copy(), + iterations=n_iterations, + p_history=p_history, + ) + + +def solve_poisson_2d_timefunction( + Lx: float = 2.0, + Ly: float = 1.0, + Nx: int = 50, + Ny: int = 50, + b: Callable[[np.ndarray, np.ndarray], np.ndarray] | np.ndarray | None = None, + source_points: list[tuple[float, float, float]] | None = None, + n_iterations: int = 100, + bc_value: float = 0.0, +) -> PoissonResult: + """Solve 2D Poisson equation using TimeFunction (internal loop). + + This version uses Devito's TimeFunction to internalize the + pseudo-timestepping loop, which is more efficient for large + numbers of iterations. + + Parameters are identical to solve_poisson_2d. + + Notes + ----- + The TimeFunction approach lets Devito handle buffer management + internally. This results in a compiled kernel with an internal + time loop, avoiding Python overhead for each iteration. + + The tradeoff is less flexibility for custom convergence criteria + during iteration. + """ + if not DEVITO_AVAILABLE: + raise ImportError( + "Devito is required for this solver. " + "Install with: pip install devito" + ) + + # Create Devito 2D grid + grid = Grid(shape=(Nx, Ny), extent=(Lx, Ly)) + x_dim, y_dim = grid.dimensions + t_dim = grid.stepping_dim + + # Create TimeFunction for implicit buffer management + p = TimeFunction(name='p', grid=grid, space_order=2) + + # Initialize source term function + b_func = Function(name='b', grid=grid) + + # Get coordinate arrays + x_coords = np.linspace(0, Lx, Nx) + y_coords = np.linspace(0, Ly, Ny) + X, Y = np.meshgrid(x_coords, y_coords, indexing='ij') + + # Set source term + b_func.data[:] = 0.0 + + if b is not None: + if callable(b): + b_func.data[:] = b(X, Y) + elif isinstance(b, np.ndarray): + if b.shape != (Nx, Ny): + raise ValueError( + f"Source array shape {b.shape} does not match grid ({Nx}, {Ny})" + ) + b_func.data[:] = b + elif source_points is not None: + # Add point sources + for x_src, y_src, value in source_points: + # Find nearest grid indices + i = int(round(x_src * (Nx - 1) / Lx)) + j = int(round(y_src * (Ny - 1) / Ly)) + i = max(0, min(Nx - 1, i)) + j = max(0, min(Ny - 1, j)) + b_func.data[i, j] = value + + # Create Poisson equation: laplace(p) = b + # Let SymPy solve for the central stencil point + eq = Eq(p.laplace, b_func) + stencil = solve(eq, p) + + # Create update to populate p.forward + eq_stencil = Eq(p.forward, stencil) + + # Boundary condition expressions + # Note: with TimeFunction we need explicit time index t + 1 + bc_exprs = [ + Eq(p[t_dim + 1, x_dim, 0], bc_value), # Bottom + Eq(p[t_dim + 1, x_dim, Ny - 1], bc_value), # Top + Eq(p[t_dim + 1, 0, y_dim], bc_value), # Left + Eq(p[t_dim + 1, Nx - 1, y_dim], bc_value), # Right + ] + + # Create operator + op = Operator([eq_stencil] + bc_exprs) + + # Initialize + p.data[:] = 0.0 + + # Execute operator with internal time loop + op(time=n_iterations) + + # Get final solution (from buffer 0 due to modular indexing) + p_final = p.data[0, :, :].copy() + + return PoissonResult( + p=p_final, + x=x_coords, + y=y_coords, + b=b_func.data[:].copy(), + iterations=n_iterations, + ) + + +def solve_poisson_2d_with_copy( + Lx: float = 2.0, + Ly: float = 1.0, + Nx: int = 50, + Ny: int = 50, + b: Callable[[np.ndarray, np.ndarray], np.ndarray] | np.ndarray | None = None, + source_points: list[tuple[float, float, float]] | None = None, + n_iterations: int = 100, + bc_value: float = 0.0, +) -> PoissonResult: + """Solve 2D Poisson equation using data copies (for comparison). + + This is the straightforward implementation that copies data between + buffers on each iteration. The buffer-swapping version + (solve_poisson_2d) is more efficient for large grids. + + Parameters are identical to solve_poisson_2d. + + Notes + ----- + This function is provided for educational purposes to demonstrate + the performance difference between copying data and swapping buffers. + For production use, prefer solve_poisson_2d or + solve_poisson_2d_timefunction. + """ + if not DEVITO_AVAILABLE: + raise ImportError( + "Devito is required for this solver. " + "Install with: pip install devito" + ) + + # Create Devito 2D grid + grid = Grid(shape=(Nx, Ny), extent=(Lx, Ly)) + x_dim, y_dim = grid.dimensions + + # Create two explicit buffers + p = Function(name='p', grid=grid, space_order=2) + pd = Function(name='pd', grid=grid, space_order=2) + + # Initialize source term function + b_func = Function(name='b', grid=grid) + + # Get coordinate arrays + x_coords = np.linspace(0, Lx, Nx) + y_coords = np.linspace(0, Ly, Ny) + X, Y = np.meshgrid(x_coords, y_coords, indexing='ij') + + # Set source term + b_func.data[:] = 0.0 + + if b is not None: + if callable(b): + b_func.data[:] = b(X, Y) + elif isinstance(b, np.ndarray): + b_func.data[:] = b + elif source_points is not None: + for x_src, y_src, value in source_points: + i = int(round(x_src * (Nx - 1) / Lx)) + j = int(round(y_src * (Ny - 1) / Ly)) + i = max(0, min(Nx - 1, i)) + j = max(0, min(Ny - 1, j)) + b_func.data[i, j] = value + + # Create Poisson equation + eq = Eq(pd.laplace, b_func, subdomain=grid.interior) + stencil = solve(eq, pd) + eq_stencil = Eq(p, stencil) + + # Boundary conditions + bc_exprs = [ + Eq(p[x_dim, 0], bc_value), + Eq(p[x_dim, Ny - 1], bc_value), + Eq(p[0, y_dim], bc_value), + Eq(p[Nx - 1, y_dim], bc_value), + ] + + # Create operator + op = Operator([eq_stencil] + bc_exprs) + + # Initialize + p.data[:] = 0.0 + pd.data[:] = 0.0 + + # Run with data copies (less efficient) + for _ in range(n_iterations): + pd.data[:] = p.data[:] # Deep copy + op(p=p, pd=pd) + + return PoissonResult( + p=p.data[:].copy(), + x=x_coords, + y=y_coords, + b=b_func.data[:].copy(), + iterations=n_iterations, + ) + + +def create_point_source( + Nx: int, + Ny: int, + Lx: float, + Ly: float, + x_src: float, + y_src: float, + value: float, +) -> np.ndarray: + """Create a point source array for the Poisson equation. + + Parameters + ---------- + Nx, Ny : int + Grid dimensions + Lx, Ly : float + Domain extents + x_src, y_src : float + Source location + value : float + Source strength + + Returns + ------- + np.ndarray + Source array with single point source + """ + b = np.zeros((Nx, Ny)) + i = int(round(x_src * (Nx - 1) / Lx)) + j = int(round(y_src * (Ny - 1) / Ly)) + i = max(0, min(Nx - 1, i)) + j = max(0, min(Ny - 1, j)) + b[i, j] = value + return b + + +def create_gaussian_source( + X: np.ndarray, + Y: np.ndarray, + x0: float, + y0: float, + sigma: float = 0.1, + amplitude: float = 1.0, +) -> np.ndarray: + """Create a Gaussian source term for the Poisson equation. + + Parameters + ---------- + X, Y : np.ndarray + Meshgrid coordinate arrays + x0, y0 : float + Center of the Gaussian + sigma : float + Width of the Gaussian + amplitude : float + Peak amplitude + + Returns + ------- + np.ndarray + Gaussian source distribution + """ + r2 = (X - x0)**2 + (Y - y0)**2 + return amplitude * np.exp(-r2 / (2 * sigma**2)) + + +def exact_poisson_point_source( + X: np.ndarray, + Y: np.ndarray, + Lx: float, + Ly: float, + x_src: float, + y_src: float, + strength: float, + n_terms: int = 20, +) -> np.ndarray: + """Analytical solution for Poisson equation with point source. + + Uses Fourier series solution for a point source in a rectangular + domain with homogeneous Dirichlet boundary conditions. + + The solution is: + p(x, y) = sum_{m,n} A_{mn} * sin(m*pi*x/Lx) * sin(n*pi*y/Ly) + + where the coefficients A_{mn} are determined by the point source. + + Parameters + ---------- + X, Y : np.ndarray + Meshgrid coordinate arrays + Lx, Ly : float + Domain dimensions + x_src, y_src : float + Source location + strength : float + Source strength + n_terms : int + Number of terms in Fourier series + + Returns + ------- + np.ndarray + Analytical solution + """ + p = np.zeros_like(X) + + for m in range(1, n_terms + 1): + for n in range(1, n_terms + 1): + # Eigenvalue + lambda_mn = (m * np.pi / Lx)**2 + (n * np.pi / Ly)**2 + + # Source coefficient + f_mn = (4 / (Lx * Ly)) * strength * \ + np.sin(m * np.pi * x_src / Lx) * \ + np.sin(n * np.pi * y_src / Ly) + + # Solution coefficient + A_mn = f_mn / lambda_mn + + # Add term + p += A_mn * np.sin(m * np.pi * X / Lx) * np.sin(n * np.pi * Y / Ly) + + return p + + +def convergence_test_poisson_2d( + grid_sizes: list | None = None, + n_iterations: int = 1000, +) -> tuple[np.ndarray, np.ndarray]: + """Run convergence test for 2D Poisson solver. + + Uses a manufactured solution to test convergence. + + Parameters + ---------- + grid_sizes : list, optional + List of N values to test (same for Nx and Ny). + Default: [20, 40, 80] + n_iterations : int + Number of iterations for each grid size + + Returns + ------- + tuple + (grid_sizes, errors) + + Notes + ----- + Uses manufactured solution: + p_exact(x, y) = sin(pi*x) * sin(pi*y) + which satisfies: + laplace(p) = -2*pi^2 * sin(pi*x) * sin(pi*y) + with p = 0 on all boundaries of [0, 1] x [0, 1]. + """ + if grid_sizes is None: + grid_sizes = [20, 40, 80] + + errors = [] + Lx = Ly = 1.0 + + # Source term for manufactured solution + def b_mms(X, Y): + return -2 * np.pi**2 * np.sin(np.pi * X) * np.sin(np.pi * Y) + + for N in grid_sizes: + result = solve_poisson_2d( + Lx=Lx, Ly=Ly, + Nx=N, Ny=N, + b=b_mms, + n_iterations=n_iterations, + bc_value=0.0, + ) + + # Create meshgrid for exact solution + X, Y = np.meshgrid(result.x, result.y, indexing='ij') + + # Exact solution + p_exact = np.sin(np.pi * X) * np.sin(np.pi * Y) + + # L2 error + error = np.sqrt(np.mean((result.p - p_exact) ** 2)) + errors.append(error) + + return np.array(grid_sizes), np.array(errors) diff --git a/src/nonlin/Newton_demo.py b/src/nonlin/Newton_demo.py deleted file mode 100644 index 71428a0d..00000000 --- a/src/nonlin/Newton_demo.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -This is a program for illustrating the convergence of Newton's method -for solving nonlinear algebraic equations of the form f(x) = 0. - -Usage: -python Newton_movie.py f_formula df_formula x0 xmin xmax - -where f_formula is a string formula for f(x); df_formula is -a string formula for the derivative f'(x), or df_formula can -be the string 'numeric', which implies that f'(x) is computed -numerically; x0 is the initial guess of the root; and the -x axis in the plot has extent [xmin, xmax]. -""" - -import sys - -import matplotlib.pyplot as plt -from Newton import Newton -from numpy import linspace -from sympy import lambdify, symbols, sympify - -plt.xkcd() # cartoon style - - -def line(x0, y0, dydx): - """ - Find a and b for a line a*x+b that goes through (x0,y0) - and has the derivative dydx at this point. - - Formula: y = y0 + dydx*(x - x0) - """ - return dydx, y0 - dydx * x0 - - -def illustrate_Newton(info, f, df, xmin, xmax): - # First make a plot f for the x values that are in info - xvalues = linspace(xmin, xmax, 401) - fvalues = f(xvalues) - ymin = fvalues.min() - ymax = fvalues.max() - frame_counter = 0 - - # Go through all x points (roots) and corresponding values - # for each iteration and plot a green line from the x axis up - # to the point (root,value), construct and plot the tangent at - # this point, then plot the function curve, the tangent, - # and the green line, - # repeat this for all iterations and store hardcopies for making - # a movie. - - for root, value in info: - a, b = line(root, value, df(root)) - y = a * xvalues + b - input("Type CR to continue: ") - plt.figure() - plt.plot( - xvalues, - fvalues, - "r-", - [root, root], - [ymin, value], - "g-", - [xvalues[0], xvalues[-1]], - [0, 0], - "k--", - xvalues, - y, - "b-", - ) - plt.legend(["f(x)", "approx. root", "y=0", "approx. line"]) - plt.axis([xmin, xmax, ymin, ymax]) - plt.title( - "Newton's method, iter. %d: x=%g; f(%g)=%.3E" - % (frame_counter + 1, root, root, value) - ) - plt.savefig("tmp_root_%04d.pdf" % frame_counter) - plt.savefig("tmp_root_%04d.png" % frame_counter) - frame_counter += 1 - - -try: - f_formula = sys.argv[1] - df_formula = sys.argv[2] - x0 = float(sys.argv[3]) - xmin = float(sys.argv[4]) - xmax = float(sys.argv[5]) -except IndexError: - print("f_formula df_formula x0 xmin max") - sys.exit(1) - -# Clean up all plot files -import glob -import os - -for filename in glob.glob("tmp_*.pdf"): - os.remove(filename) - -# Parse string formula to callable function using sympy -x_sym = symbols("x") -f = lambdify(x_sym, sympify(f_formula), modules=["numpy"]) - -if df_formula == "numeric": - # Make a numerical differentiation formula - h = 1.0e-7 - - def df(x): - return (f(x + h) - f(x - h)) / (2 * h) -else: - df = lambdify(x_sym, sympify(df_formula), modules=["numpy"]) -x, info = Newton(f, x0, df, store=True) -illustrate_Newton(info, f, df, xmin, xmax) -plt.show() diff --git a/src/nonlin/Newton_demo.sh b/src/nonlin/Newton_demo.sh deleted file mode 100644 index 6a6460fe..00000000 --- a/src/nonlin/Newton_demo.sh +++ /dev/null @@ -1 +0,0 @@ -python Newton_demo.py "x*(1-x)*(x-2)" "2*x - 3*x**2 - 2 + 4*x" -0.6 -1 3 diff --git a/src/nonlin/ODE_Picard_tricks.py b/src/nonlin/ODE_Picard_tricks.py deleted file mode 100644 index 1e66e8de..00000000 --- a/src/nonlin/ODE_Picard_tricks.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -Solve u' = f(u, t). Test if a trick in linearization in a Picard -iteration is done like f(u_,t)*u_/u, if u_ is the most recent -approximation to u (called Picard2 in the Odespy software). -""" - -from numpy import linspace, sin -from odespy import BackwardEuler - - -def f(u, t): - return sin(2 * (1 + u)) - - -def f2(u, t): - return -(u**3) - - -eps_iter = 0.001 -max_iter = 500 -solver1 = BackwardEuler( - f, nonlinear_solver="Picard", verbose=2, eps_iter=eps_iter, max_iter=max_iter -) -solver2 = BackwardEuler( - f, nonlinear_solver="Picard2", verbose=2, eps_iter=eps_iter, max_iter=max_iter -) -solver1.set_initial_condition(1) -solver2.set_initial_condition(1) -tp = linspace(0, 4, 11) -u1, t = solver1.solve(tp) -u2, t = solver2.solve(tp) -print("Picard it:", solver1.num_iterations_total) -print("Picard2 it:", solver2.num_iterations_total) - -import matplotlib.pyplot as plt - -plt.plot(t, u1, label="Picard") -plt.plot(t, u2, label="Picard2") -plt.legend() -input() - -""" -f(u,t) = -u**3: - -BackwardEuler.advance w/Picard: t=0.4, n=1: u=0.796867 in 22 iterations -BackwardEuler.advance w/Picard: t=0.8, n=2: u=0.674568 in 9 iterations -BackwardEuler.advance w/Picard: t=1.2, n=3: u=0.591494 in 6 iterations -BackwardEuler.advance w/Picard: t=1.6, n=4: u=0.531551 in 5 iterations -BackwardEuler.advance w/Picard: t=2, n=5: u=0.485626 in 4 iterations -BackwardEuler.advance w/Picard: t=2.4, n=6: u=0.44947 in 3 iterations -BackwardEuler.advance w/Picard: t=2.8, n=7: u=0.419926 in 3 iterations -BackwardEuler.advance w/Picard: t=3.2, n=8: u=0.395263 in 3 iterations -BackwardEuler.advance w/Picard: t=3.6, n=9: u=0.374185 in 2 iterations -BackwardEuler.advance w/Picard: t=4, n=10: u=0.356053 in 2 iterations - -BackwardEuler.advance w/Picard2: t=0.4, n=1: u=0.797142 in 8 iterations -BackwardEuler.advance w/Picard2: t=0.8, n=2: u=0.674649 in 5 iterations -BackwardEuler.advance w/Picard2: t=1.2, n=3: u=0.591617 in 4 iterations -BackwardEuler.advance w/Picard2: t=1.6, n=4: u=0.531506 in 4 iterations -BackwardEuler.advance w/Picard2: t=2, n=5: u=0.485752 in 3 iterations -BackwardEuler.advance w/Picard2: t=2.4, n=6: u=0.44947 in 3 iterations -BackwardEuler.advance w/Picard2: t=2.8, n=7: u=0.419748 in 2 iterations -BackwardEuler.advance w/Picard2: t=3.2, n=8: u=0.395013 in 2 iterations -BackwardEuler.advance w/Picard2: t=3.6, n=9: u=0.374034 in 2 iterations -BackwardEuler.advance w/Picard2: t=4, n=10: u=0.355961 in 2 iterations -Picard it: 59 -Picard2 it: 35 - -f(u,t) = exp(-u): no effect. -f(u,t) = log(1+u): no effect. - -f(u,t) = sin(2*(1+u)) -Calling f(U0, 0) to determine data type -BackwardEuler.advance w/Picard: t=0.4, n=1: u=0.813754 in 17 iterations -BackwardEuler.advance w/Picard: t=0.8, n=2: u=0.706846 in 21 iterations -BackwardEuler.advance w/Picard: t=1.2, n=3: u=0.646076 in 20 iterations -BackwardEuler.advance w/Picard: t=1.6, n=4: u=0.612998 in 19 iterations -BackwardEuler.advance w/Picard: t=2, n=5: u=0.593832 in 16 iterations -BackwardEuler.advance w/Picard: t=2.4, n=6: u=0.583236 in 14 iterations -BackwardEuler.advance w/Picard: t=2.8, n=7: u=0.578087 in 11 iterations -BackwardEuler.advance w/Picard: t=3.2, n=8: u=0.574412 in 8 iterations -BackwardEuler.advance w/Picard: t=3.6, n=9: u=0.573226 in 5 iterations -BackwardEuler.advance w/Picard: t=4, n=10: u=0.572589 in 3 iterations - -BackwardEuler.advance w/Picard2: t=0.4, n=1: u=0.813614 in 7 iterations -BackwardEuler.advance w/Picard2: t=0.8, n=2: u=0.706769 in 9 iterations -BackwardEuler.advance w/Picard2: t=1.2, n=3: u=0.646828 in 11 iterations -BackwardEuler.advance w/Picard2: t=1.6, n=4: u=0.612648 in 12 iterations -BackwardEuler.advance w/Picard2: t=2, n=5: u=0.59438 in 13 iterations -BackwardEuler.advance w/Picard2: t=2.4, n=6: u=0.583541 in 12 iterations -BackwardEuler.advance w/Picard2: t=2.8, n=7: u=0.577485 in 10 iterations -BackwardEuler.advance w/Picard2: t=3.2, n=8: u=0.574147 in 8 iterations -BackwardEuler.advance w/Picard2: t=3.6, n=9: u=0.573038 in 5 iterations -BackwardEuler.advance w/Picard2: t=4, n=10: u=0.572446 in 3 iterations -Picard it: 134 -Picard2 it: 90 - -""" diff --git a/src/nonlin/__init__.py b/src/nonlin/__init__.py index dbc9163d..b666e12f 100644 --- a/src/nonlin/__init__.py +++ b/src/nonlin/__init__.py @@ -1,5 +1,13 @@ """Nonlinear PDE solvers using Devito DSL.""" +from .burgers_devito import ( + Burgers2DResult, + gaussian_initial_condition, + init_hat, + sinusoidal_initial_condition, + solve_burgers_2d, + solve_burgers_2d_vector, +) from .nonlin1D_devito import ( NonlinearResult, allen_cahn_reaction, @@ -15,13 +23,19 @@ ) __all__ = [ + "Burgers2DResult", "NonlinearResult", "allen_cahn_reaction", "constant_diffusion", "fisher_reaction", + "gaussian_initial_condition", + "init_hat", "linear_diffusion", "logistic_reaction", "porous_medium_diffusion", + "sinusoidal_initial_condition", + "solve_burgers_2d", + "solve_burgers_2d_vector", "solve_burgers_equation", "solve_nonlinear_diffusion_explicit", "solve_nonlinear_diffusion_picard", diff --git a/src/nonlin/burgers_devito.py b/src/nonlin/burgers_devito.py new file mode 100644 index 00000000..c996c238 --- /dev/null +++ b/src/nonlin/burgers_devito.py @@ -0,0 +1,570 @@ +"""2D Coupled Burgers Equations Solver using Devito DSL. + +Solves the 2D coupled Burgers equations: + u_t + u * u_x + v * u_y = nu * (u_xx + u_yy) + v_t + u * v_x + v * v_y = nu * (v_xx + v_yy) + +This combines nonlinear advection with viscous diffusion. +The equations model various physical phenomena including: +- Simplified fluid flow without pressure +- Traffic flow modeling +- Shock wave formation and propagation + +Key implementation features: +- Uses first_derivative() with explicit fd_order=1 for advection terms +- Uses .laplace for diffusion terms (second-order) +- Supports both scalar TimeFunction and VectorTimeFunction approaches +- Applies Dirichlet boundary conditions + +Stability requires satisfying both: +- CFL condition: C = |u|_max * dt / dx <= 1 +- Diffusion condition: F = nu * dt / dx^2 <= 0.25 + +Usage: + from src.nonlin.burgers_devito import solve_burgers_2d + + result = solve_burgers_2d( + Lx=2.0, Ly=2.0, # Domain size + nu=0.01, # Viscosity + Nx=41, Ny=41, # Grid points + T=0.5, # Final time + ) +""" + +from collections.abc import Callable +from dataclasses import dataclass + +import numpy as np + +try: + from devito import ( + Constant, + Eq, + Grid, + Operator, + TimeFunction, + VectorTimeFunction, + first_derivative, + grad, + left, + solve, + ) + + DEVITO_AVAILABLE = True +except ImportError: + DEVITO_AVAILABLE = False + + +@dataclass +class Burgers2DResult: + """Result container for 2D Burgers equation solver. + + Attributes + ---------- + u : np.ndarray + x-velocity component at final time, shape (Nx+1, Ny+1) + v : np.ndarray + y-velocity component at final time, shape (Nx+1, Ny+1) + x : np.ndarray + x-coordinate grid points + y : np.ndarray + y-coordinate grid points + t : float + Final time + dt : float + Time step used + u_history : list or None + Solution history for u (if save_history=True) + v_history : list or None + Solution history for v (if save_history=True) + t_history : list or None + Time values (if save_history=True) + """ + + u: np.ndarray + v: np.ndarray + x: np.ndarray + y: np.ndarray + t: float + dt: float + u_history: list | None = None + v_history: list | None = None + t_history: list | None = None + + +def init_hat( + X: np.ndarray, + Y: np.ndarray, + Lx: float = 2.0, + Ly: float = 2.0, + value: float = 2.0, +) -> np.ndarray: + """Initialize with a 'hat' function (square pulse). + + Creates a pulse with given value in the region + [0.5, 1] x [0.5, 1] and 1.0 elsewhere. + + Parameters + ---------- + X : np.ndarray + x-coordinates (meshgrid) + Y : np.ndarray + y-coordinates (meshgrid) + Lx : float + Domain length in x + Ly : float + Domain length in y + value : float + Value inside the hat region + + Returns + ------- + np.ndarray + Initial condition array + """ + result = np.ones_like(X) + # Region where the 'hat' is elevated + mask = (X >= 0.5) & (X <= 1.0) & (Y >= 0.5) & (Y <= 1.0) + result[mask] = value + return result + + +def solve_burgers_2d( + Lx: float = 2.0, + Ly: float = 2.0, + nu: float = 0.01, + Nx: int = 41, + Ny: int = 41, + T: float = 0.5, + sigma: float = 0.0009, + I_u: Callable | None = None, + I_v: Callable | None = None, + bc_value: float = 1.0, + save_history: bool = False, + save_every: int = 100, +) -> Burgers2DResult: + """Solve 2D coupled Burgers equations using Devito. + + Solves: + u_t + u * u_x + v * u_y = nu * laplace(u) + v_t + u * v_x + v * v_y = nu * laplace(v) + + Uses backward (upwind) differences for advection terms and + centered differences for diffusion terms. + + Parameters + ---------- + Lx : float + Domain length in x direction [0, Lx] + Ly : float + Domain length in y direction [0, Ly] + nu : float + Viscosity (diffusion coefficient) + Nx : int + Number of grid points in x + Ny : int + Number of grid points in y + T : float + Final simulation time + sigma : float + Stability parameter: dt = sigma * dx * dy / nu + I_u : callable or None + Initial condition for u: I_u(X, Y) -> array + Default: hat function with value 2 in [0.5, 1] x [0.5, 1] + I_v : callable or None + Initial condition for v: I_v(X, Y) -> array + Default: hat function with value 2 in [0.5, 1] x [0.5, 1] + bc_value : float + Dirichlet boundary condition value (default: 1.0) + save_history : bool + If True, save solution history + save_every : int + Save every N time steps (if save_history=True) + + Returns + ------- + Burgers2DResult + Solution data container with u, v fields and metadata + + Raises + ------ + ImportError + If Devito is not installed + """ + if not DEVITO_AVAILABLE: + raise ImportError( + "Devito is required for this solver. Install with: pip install devito" + ) + + # Grid setup + dx = Lx / (Nx - 1) + dy = Ly / (Ny - 1) + dt = sigma * dx * dy / nu + + # Handle T=0 case + if T <= 0: + x_coords = np.linspace(0, Lx, Nx) + y_coords = np.linspace(0, Ly, Ny) + X, Y = np.meshgrid(x_coords, y_coords, indexing="ij") + if I_u is None: + u0 = init_hat(X, Y, Lx, Ly, value=2.0) + else: + u0 = I_u(X, Y) + if I_v is None: + v0 = init_hat(X, Y, Lx, Ly, value=2.0) + else: + v0 = I_v(X, Y) + return Burgers2DResult( + u=u0, + v=v0, + x=x_coords, + y=y_coords, + t=0.0, + dt=dt, + ) + + Nt = int(round(T / dt)) + actual_T = Nt * dt + + # Create Devito grid + grid = Grid(shape=(Nx, Ny), extent=(Lx, Ly)) + x_dim, y_dim = grid.dimensions + t_dim = grid.stepping_dim + + # Create time functions with space_order=2 for diffusion + u = TimeFunction(name="u", grid=grid, time_order=1, space_order=2) + v = TimeFunction(name="v", grid=grid, time_order=1, space_order=2) + + # Get coordinate arrays + x_coords = np.linspace(0, Lx, Nx) + y_coords = np.linspace(0, Ly, Ny) + X, Y = np.meshgrid(x_coords, y_coords, indexing="ij") + + # Set initial conditions + if I_u is None: + u.data[0, :, :] = init_hat(X, Y, Lx, Ly, value=2.0) + else: + u.data[0, :, :] = I_u(X, Y) + + if I_v is None: + v.data[0, :, :] = init_hat(X, Y, Lx, Ly, value=2.0) + else: + v.data[0, :, :] = I_v(X, Y) + + # Viscosity as Devito Constant + a = Constant(name="a") + + # Create explicit first-order backward derivatives for advection + # Using first_derivative() with side=left and fd_order=1 + # This gives: (u[x] - u[x-dx]) / dx (backward/upwind difference) + u_dx = first_derivative(u, dim=x_dim, side=left, fd_order=1) + u_dy = first_derivative(u, dim=y_dim, side=left, fd_order=1) + v_dx = first_derivative(v, dim=x_dim, side=left, fd_order=1) + v_dy = first_derivative(v, dim=y_dim, side=left, fd_order=1) + + # Write down the equations: + # u_t + u * u_x + v * u_y = nu * laplace(u) + # v_t + u * v_x + v * v_y = nu * laplace(v) + # Apply only in interior using subdomain + eq_u = Eq(u.dt + u * u_dx + v * u_dy, a * u.laplace, subdomain=grid.interior) + eq_v = Eq(v.dt + u * v_dx + v * v_dy, a * v.laplace, subdomain=grid.interior) + + # Let SymPy solve for the update expressions + stencil_u = solve(eq_u, u.forward) + stencil_v = solve(eq_v, v.forward) + update_u = Eq(u.forward, stencil_u) + update_v = Eq(v.forward, stencil_v) + + # Dirichlet boundary conditions using low-level API + # u boundary conditions + bc_u = [Eq(u[t_dim + 1, 0, y_dim], bc_value)] # left + bc_u += [Eq(u[t_dim + 1, Nx - 1, y_dim], bc_value)] # right + bc_u += [Eq(u[t_dim + 1, x_dim, 0], bc_value)] # bottom + bc_u += [Eq(u[t_dim + 1, x_dim, Ny - 1], bc_value)] # top + + # v boundary conditions + bc_v = [Eq(v[t_dim + 1, 0, y_dim], bc_value)] # left + bc_v += [Eq(v[t_dim + 1, Nx - 1, y_dim], bc_value)] # right + bc_v += [Eq(v[t_dim + 1, x_dim, 0], bc_value)] # bottom + bc_v += [Eq(v[t_dim + 1, x_dim, Ny - 1], bc_value)] # top + + # Create operator + op = Operator([update_u, update_v] + bc_u + bc_v) + + # Storage for history + u_history = [] + v_history = [] + t_history = [] + + if save_history: + u_history.append(u.data[0, :, :].copy()) + v_history.append(v.data[0, :, :].copy()) + t_history.append(0.0) + + # Time stepping + for n in range(Nt): + op.apply(time_m=n, time_M=n, dt=dt, a=nu) + + if save_history and (n + 1) % save_every == 0: + u_history.append(u.data[(n + 1) % 2, :, :].copy()) + v_history.append(v.data[(n + 1) % 2, :, :].copy()) + t_history.append((n + 1) * dt) + + # Get final solution + final_idx = Nt % 2 + u_final = u.data[final_idx, :, :].copy() + v_final = v.data[final_idx, :, :].copy() + + return Burgers2DResult( + u=u_final, + v=v_final, + x=x_coords, + y=y_coords, + t=actual_T, + dt=dt, + u_history=u_history if save_history else None, + v_history=v_history if save_history else None, + t_history=t_history if save_history else None, + ) + + +def solve_burgers_2d_vector( + Lx: float = 2.0, + Ly: float = 2.0, + nu: float = 0.01, + Nx: int = 41, + Ny: int = 41, + T: float = 0.5, + sigma: float = 0.0009, + I_u: Callable | None = None, + I_v: Callable | None = None, + bc_value: float = 1.0, + save_history: bool = False, + save_every: int = 100, +) -> Burgers2DResult: + """Solve 2D Burgers equations using VectorTimeFunction. + + This is an alternative implementation using Devito's + VectorTimeFunction to represent the velocity field as + a single vector U = (u, v). + + The vector form of Burgers' equation: + U_t + (grad(U) * U) = nu * laplace(U) + + Parameters + ---------- + Lx : float + Domain length in x direction [0, Lx] + Ly : float + Domain length in y direction [0, Ly] + nu : float + Viscosity (diffusion coefficient) + Nx : int + Number of grid points in x + Ny : int + Number of grid points in y + T : float + Final simulation time + sigma : float + Stability parameter: dt = sigma * dx * dy / nu + I_u : callable or None + Initial condition for u component + I_v : callable or None + Initial condition for v component + bc_value : float + Dirichlet boundary condition value + save_history : bool + If True, save solution history + save_every : int + Save every N time steps (if save_history=True) + + Returns + ------- + Burgers2DResult + Solution data container + """ + if not DEVITO_AVAILABLE: + raise ImportError( + "Devito is required for this solver. Install with: pip install devito" + ) + + # Grid setup + dx = Lx / (Nx - 1) + dy = Ly / (Ny - 1) + dt = sigma * dx * dy / nu + + # Handle T=0 case + if T <= 0: + x_coords = np.linspace(0, Lx, Nx) + y_coords = np.linspace(0, Ly, Ny) + X, Y = np.meshgrid(x_coords, y_coords, indexing="ij") + if I_u is None: + u0 = init_hat(X, Y, Lx, Ly, value=2.0) + else: + u0 = I_u(X, Y) + if I_v is None: + v0 = init_hat(X, Y, Lx, Ly, value=2.0) + else: + v0 = I_v(X, Y) + return Burgers2DResult( + u=u0, + v=v0, + x=x_coords, + y=y_coords, + t=0.0, + dt=dt, + ) + + Nt = int(round(T / dt)) + actual_T = Nt * dt + + # Create Devito grid + grid = Grid(shape=(Nx, Ny), extent=(Lx, Ly)) + x_dim, y_dim = grid.dimensions + t_dim = grid.stepping_dim + s = grid.time_dim.spacing # dt symbol + + # Create VectorTimeFunction + U = VectorTimeFunction(name="U", grid=grid, space_order=2) + + # Get coordinate arrays + x_coords = np.linspace(0, Lx, Nx) + y_coords = np.linspace(0, Ly, Ny) + X, Y = np.meshgrid(x_coords, y_coords, indexing="ij") + + # Set initial conditions + # U[0] is the x-component (u), U[1] is the y-component (v) + if I_u is None: + U[0].data[0, :, :] = init_hat(X, Y, Lx, Ly, value=2.0) + else: + U[0].data[0, :, :] = I_u(X, Y) + + if I_v is None: + U[1].data[0, :, :] = init_hat(X, Y, Lx, Ly, value=2.0) + else: + U[1].data[0, :, :] = I_v(X, Y) + + # Viscosity as Devito Constant + a = Constant(name="a") + + # Vector form of Burgers equation: + # U_t + grad(U) * U = nu * laplace(U) + # Rearranged: U_forward = U - dt * (grad(U) * U - nu * laplace(U)) + update_U = Eq( + U.forward, + U - s * (grad(U) * U - a * U.laplace), + subdomain=grid.interior, + ) + + # Boundary conditions for both components + bc_U = [Eq(U[0][t_dim + 1, 0, y_dim], bc_value)] # u left + bc_U += [Eq(U[0][t_dim + 1, Nx - 1, y_dim], bc_value)] # u right + bc_U += [Eq(U[0][t_dim + 1, x_dim, 0], bc_value)] # u bottom + bc_U += [Eq(U[0][t_dim + 1, x_dim, Ny - 1], bc_value)] # u top + bc_U += [Eq(U[1][t_dim + 1, 0, y_dim], bc_value)] # v left + bc_U += [Eq(U[1][t_dim + 1, Nx - 1, y_dim], bc_value)] # v right + bc_U += [Eq(U[1][t_dim + 1, x_dim, 0], bc_value)] # v bottom + bc_U += [Eq(U[1][t_dim + 1, x_dim, Ny - 1], bc_value)] # v top + + # Create operator + op = Operator([update_U] + bc_U) + + # Storage for history + u_history = [] + v_history = [] + t_history = [] + + if save_history: + u_history.append(U[0].data[0, :, :].copy()) + v_history.append(U[1].data[0, :, :].copy()) + t_history.append(0.0) + + # Time stepping + for n in range(Nt): + op.apply(time_m=n, time_M=n, dt=dt, a=nu) + + if save_history and (n + 1) % save_every == 0: + u_history.append(U[0].data[(n + 1) % 2, :, :].copy()) + v_history.append(U[1].data[(n + 1) % 2, :, :].copy()) + t_history.append((n + 1) * dt) + + # Get final solution + final_idx = Nt % 2 + u_final = U[0].data[final_idx, :, :].copy() + v_final = U[1].data[final_idx, :, :].copy() + + return Burgers2DResult( + u=u_final, + v=v_final, + x=x_coords, + y=y_coords, + t=actual_T, + dt=dt, + u_history=u_history if save_history else None, + v_history=v_history if save_history else None, + t_history=t_history if save_history else None, + ) + + +def sinusoidal_initial_condition( + X: np.ndarray, + Y: np.ndarray, + Lx: float = 2.0, + Ly: float = 2.0, +) -> np.ndarray: + """Sinusoidal initial condition. + + Creates sin(pi * x / Lx) * sin(pi * y / Ly). + + Parameters + ---------- + X : np.ndarray + x-coordinates (meshgrid) + Y : np.ndarray + y-coordinates (meshgrid) + Lx : float + Domain length in x + Ly : float + Domain length in y + + Returns + ------- + np.ndarray + Initial condition array + """ + return np.sin(np.pi * X / Lx) * np.sin(np.pi * Y / Ly) + + +def gaussian_initial_condition( + X: np.ndarray, + Y: np.ndarray, + Lx: float = 2.0, + Ly: float = 2.0, + sigma: float = 0.2, + amplitude: float = 2.0, +) -> np.ndarray: + """2D Gaussian initial condition centered in domain. + + Parameters + ---------- + X : np.ndarray + x-coordinates (meshgrid) + Y : np.ndarray + y-coordinates (meshgrid) + Lx : float + Domain length in x + Ly : float + Domain length in y + sigma : float + Width of the Gaussian + amplitude : float + Peak amplitude + + Returns + ------- + np.ndarray + Gaussian profile + 1.0 (background) + """ + x0, y0 = Lx / 2, Ly / 2 + r2 = (X - x0) ** 2 + (Y - y0) ** 2 + return 1.0 + amplitude * np.exp(-r2 / (2 * sigma**2)) diff --git a/src/nonlin/logistic.py b/src/nonlin/logistic.py deleted file mode 100644 index f03994b6..00000000 --- a/src/nonlin/logistic.py +++ /dev/null @@ -1,157 +0,0 @@ -import numpy as np - - -def FE_logistic(u0, dt, Nt): - u = np.zeros(N + 1) - u[0] = u0 - for n in range(Nt): - u[n + 1] = u[n] + dt * (u[n] - u[n] ** 2) - return u - - -def quadratic_roots(a, b, c): - delta = b**2 - 4 * a * c - r2 = (-b + np.sqrt(delta)) / float(2 * a) - r1 = (-b - np.sqrt(delta)) / float(2 * a) - return r1, r2 - - -def BE_logistic(u0, dt, Nt, choice="Picard", eps_r=1e-3, omega=1, max_iter=1000): - if choice == "Picard1": - choice = "Picard" - max_iter = 1 - - u = np.zeros(Nt + 1) - iterations = [] - u[0] = u0 - for n in range(1, Nt + 1): - a = dt - b = 1 - dt - c = -u[n - 1] - if choice in ("r1", "r2"): - r1, r2 = quadratic_roots(a, b, c) - u[n] = r1 if choice == "r1" else r2 - iterations.append(0) - - elif choice == "Picard": - - def F(u): - return a * u**2 + b * u + c - - u_ = u[n - 1] - k = 0 - while abs(F(u_)) > eps_r and k < max_iter: - u_ = omega * (-c / (a * u_ + b)) + (1 - omega) * u_ - k += 1 - u[n] = u_ - iterations.append(k) - - elif choice == "Newton": - - def F(u): - return a * u**2 + b * u + c - - def dF(u): - return 2 * a * u + b - - u_ = u[n - 1] - k = 0 - while abs(F(u_)) > eps_r and k < max_iter: - u_ = u_ - F(u_) / dF(u_) - k += 1 - u[n] = u_ - iterations.append(k) - return u, iterations - - -def CN_logistic(u0, dt, Nt): - u = np.zeros(Nt + 1) - u[0] = u0 - for n in range(0, Nt): - u[n + 1] = (1 + 0.5 * dt) / (1 + dt * u[n] - 0.5 * dt) * u[n] - return u - - -import sys - -import matplotlib.pyplot as plt - - -def quadratic_root_goes_to_infinity(): - """ - Verify that one of the roots in the quadratic equation - goes to infinity. - """ - for dt in 1e-7, 1e-12, 1e-16: - a = dt - b = 1 - dt - c = -0.1 - print(dt, quadratic_roots(a, b, c)) - - -def sympy_analysis(): - print("sympy calculations") - import sympy as sym - - dt, u_1, u = sym.symbols("dt u_1 u") - r1, r2 = sym.solve(dt * u**2 + (1 - dt) * u - u_1, u) - print(r1) - print(r2) - print(r1.series(dt, 0, 2)) - print(r2.series(dt, 0, 2)) - print(r1.limit(dt, 0)) - print(r2.limit(dt, 0)) - - -sympy_analysis() -print("-----------------------------------------------------") -T = 9 -try: - dt = float(sys.argv[1]) - eps_r = float(sys.argv[2]) - omega = float(sys.argv[3]) -except: - dt = 0.8 - eps_r = 1e-3 - omega = 1 -N = int(round(T / float(dt))) - -u_FE = FE_logistic(0.1, dt, N) -u_BE1, _ = BE_logistic(0.1, dt, N, "r1") -u_BE2, _ = BE_logistic(0.1, dt, N, "r2") -u_BE31, iter_BE31 = BE_logistic(0.1, dt, N, "Picard1", eps_r, omega) -u_BE3, iter_BE3 = BE_logistic(0.1, dt, N, "Picard", eps_r, omega) -u_BE4, iter_BE4 = BE_logistic(0.1, dt, N, "Newton", eps_r, omega) -u_CN = CN_logistic(0.1, dt, N) - -from numpy import mean - -print("Picard mean no of iterations (dt=%g):" % dt, int(round(mean(iter_BE3)))) -print("Newton mean no of iterations (dt=%g):" % dt, int(round(mean(iter_BE4)))) - -t = np.linspace(0, dt * N, N + 1) -plt.figure() -plt.plot(t, u_FE, label="FE") -plt.plot(t, u_BE2, label="BE exact") -plt.plot(t, u_BE3, label="BE Picard") -plt.plot(t, u_BE31, label="BE Picard1") -plt.plot(t, u_BE4, label="BE Newton") -plt.plot(t, u_CN, label="CN gm") -plt.legend(loc="lower right") -plt.title("dt=%g, eps=%.0E" % (dt, eps_r)) -plt.xlabel("t") -plt.ylabel("u") -filestem = "logistic_N%d_eps%03d" % (N, int(np.log10(eps_r))) -plt.savefig(filestem + "_u.png") -plt.savefig(filestem + "_u.pdf") -plt.figure() -plt.plot(range(1, len(iter_BE3) + 1), iter_BE3, "r-o", label="Picard") -plt.plot(range(1, len(iter_BE4) + 1), iter_BE4, "b-o", label="Newton") -plt.legend() -plt.title("dt=%g, eps=%.0E" % (dt, eps_r)) -plt.axis([1, N + 1, 0, max(iter_BE3 + iter_BE4) + 1]) -plt.xlabel("Time level") -plt.ylabel("No of iterations") -plt.savefig(filestem + "_iter.png") -plt.savefig(filestem + "_iter.pdf") -# input() diff --git a/src/nonlin/logistic_gen.py b/src/nonlin/logistic_gen.py deleted file mode 100644 index 8ddd396c..00000000 --- a/src/nonlin/logistic_gen.py +++ /dev/null @@ -1,154 +0,0 @@ -import numpy as np - - -def FE_logistic(u0, dt, N): - u = np.zeros(N + 1) - u[0] = u0 - for n in range(N): - u[n + 1] = u[n] + dt * (u[n] - u[n] ** 2) - return u - - -def quadratic_roots(a, b, c): - delta = b**2 - 4 * a * c - r2 = (-b + np.sqrt(delta)) / float(2 * a) - r1 = (-b - np.sqrt(delta)) / float(2 * a) - return r1, r2 - - -def BE_logistic(u0, dt, Nt, choice="Picard", eps_r=1e-3, omega=1, max_iter=1000): - if choice == "Picard1": - choice = "Picard" - max_iter = 1 - - u = np.zeros(Nt + 1) - iterations = [] - u[0] = u0 - for n in range(1, Nt + 1): - a = dt - b = 1 - dt - c = -u[n - 1] - if choice in ("r1", "r2"): - r1, r2 = quadratic_roots(a, b, c) - u[n] = r1 if choice == "r1" else r2 - iterations.append(0) - - elif choice == "Picard": - - def F(u): - return a * u**2 + b * u + c - - u_ = u[n - 1] - k = 0 - while abs(F(u_)) > eps_r and k < max_iter: - u_ = omega * (-c / (a * u_ + b)) + (1 - omega) * u_ - k += 1 - u[n] = u_ - iterations.append(k) - - elif choice == "Newton": - - def F(u): - return a * u**2 + b * u + c - - def dF(u): - return 2 * a * u + b - - u_ = u[n - 1] - k = 0 - while abs(F(u_)) > eps_r and k < max_iter: - u_ = u_ - F(u_) / dF(u_) - k += 1 - u[n] = u_ - iterations.append(k) - return u, iterations - - -def CN_logistic(u0, dt, Nt): - u = np.zeros(Nt + 1) - u[0] = u0 - for n in range(0, Nt): - u[n + 1] = (1 + 0.5 * dt) / (1 + dt * u[n] - 0.5 * dt) * u[n] - return u - - -import sys - -import matplotlib.pyplot as plt -from numpy import mean - - -def quadratic_root_goes_to_infinity(): - """ - Verify that one of the roots in the quadratic equation - goes to infinity. - """ - for dt in 1e-7, 1e-12, 1e-16: - a = dt - b = 1 - dt - c = -0.1 - print(dt, quadratic_roots(a, b, c)) - - -print("sympy calculations") -import sympy as sym - -dt, u_1, u = sym.symbols("dt u_1 u") -r1, r2 = sym.solve(dt * u**2 + (1 - dt) * u - u_1, u) -print(r1) -print(r2) -print(r1.series(dt, 0, 2)) -print(r2.series(dt, 0, 2)) - -T = 9 -try: - dt = float(sys.argv[1]) - eps_r = float(sys.argv[2]) - omega = float(sys.argv[3]) -except: - dt = 0.8 - eps_r = 1e-3 - omega = 1 -N = int(round(T / float(dt))) - -u_BE3, iter_BE3 = BE_logistic(0.1, dt, N, "Picard", eps_r, omega) -print(iter_BE3) -print("Picard mean no of iterations (dt=%g):" % dt, int(round(mean(iter_BE3)))) -sys.exit(0) -u_FE = FE_logistic(0.1, dt, N) -u_BE1, _ = BE_logistic(0.1, dt, N, "r1") -u_BE2, _ = BE_logistic(0.1, dt, N, "r2") -u_BE31, iter_BE31 = BE_logistic(0.1, dt, N, "Picard1", eps_r, omega) -u_BE3, iter_BE3 = BE_logistic(0.1, dt, N, "Picard", eps_r, omega) -u_BE4, iter_BE4 = BE_logistic(0.1, dt, N, "Newton", eps_r, omega) -u_CN = CN_logistic(0.1, dt, N) - -print("Picard mean no of iterations (dt=%g):" % dt, int(round(mean(iter_BE3)))) -print("Newton mean no of iterations (dt=%g):" % dt, int(round(mean(iter_BE4)))) - -t = np.linspace(0, dt * N, N + 1) -plt.figure() -plt.plot(t, u_FE, label="FE") -plt.plot(t, u_BE2, label="BE exact") -plt.plot(t, u_BE3, label="BE Picard") -plt.plot(t, u_BE31, label="BE Picard1") -plt.plot(t, u_BE4, label="BE Newton") -plt.plot(t, u_CN, label="CN gm") -plt.legend(loc="lower right") -plt.title("dt=%g, eps=%.0E" % (dt, eps_r)) -plt.xlabel("t") -plt.ylabel("u") -filestem = "logistic_N%d_eps%03d" % (N, int(np.log10(eps_r))) -plt.savefig(filestem + "_u.png") -plt.savefig(filestem + "_u.pdf") -plt.figure() -plt.plot(range(1, len(iter_BE3) + 1), iter_BE3, "r-o", label="Picard") -plt.plot(range(1, len(iter_BE4) + 1), iter_BE4, "b-o", label="Newton") -plt.legend() -plt.title("dt=%g, eps=%.0E" % (dt, eps_r)) -plt.axis([1, N + 1, 0, max(iter_BE3 + iter_BE4) + 1]) -plt.xlabel("Time level") -plt.ylabel("No of iterations") -plt.savefig(filestem + "_iter.png") -plt.savefig(filestem + "_iter.pdf") -# input() diff --git a/src/nonlin/nonlin1D_devito.py b/src/nonlin/nonlin1D_devito.py index 4934fbcf..3c47c726 100644 --- a/src/nonlin/nonlin1D_devito.py +++ b/src/nonlin/nonlin1D_devito.py @@ -477,9 +477,10 @@ def solve_nonlinear_diffusion_picard( Note ---- - This implementation uses explicit Forward Euler for the inner Picard - iteration, which is a simplified approach. A full implicit scheme would - require solving a linear system at each Picard iteration. + This implementation uses a *Jacobi* fixed-point iteration to approximately + solve the linear system that arises at each Picard step (with lagged + diffusion coefficient). This avoids an explicit sparse linear solve while + still behaving like an implicit time step. """ # Default initial condition if I is None: @@ -496,10 +497,13 @@ def D_func(u): # Grid setup dx = L / Nx Nt = int(round(T / dt)) + if Nt == 0: + Nt = 1 actual_T = Nt * dt # Create Devito grid and functions grid = Grid(shape=(Nx + 1,), extent=(L,)) + (x_dim,) = grid.dimensions t_dim = grid.stepping_dim u = TimeFunction(name="u", grid=grid, time_order=1, space_order=2) @@ -511,11 +515,22 @@ def D_func(u): u.data[0, :] = I(x_coords) u.data[1, :] = I(x_coords) - # Picard iteration operator - # Simplified: use lagged D but still explicit in time - # u^{k+1} = u^n + dt * D(u^k) * u_xx^k + # Picard/Jacobi iteration operator + # + # Nonlinear diffusion (1D): + # u_t = (D(u) u_x)_x ≈ D(u) u_xx (for this simplified model) + # + # Backward Euler with lagged D in Picard: + # u^{n+1} - dt * D(u^{n+1,k}) * u_xx^{n+1} = u^n + # + # For fixed D, the BE step is a tridiagonal linear system. We apply one + # Jacobi sweep per Picard iteration using the neighbor values from the + # current iterate u^k. dt_const = Constant(name="dt", value=dt) - stencil = u_old + dt_const * D * u.dx2 + r = dt_const * D / (x_dim.spacing**2) + u_plus = u.subs(x_dim, x_dim + x_dim.spacing) + u_minus = u.subs(x_dim, x_dim - x_dim.spacing) + stencil = (u_old + r * (u_plus + u_minus)) / (1.0 + 2.0 * r) update = Eq(u.forward, stencil, subdomain=grid.interior) bc_left = Eq(u[t_dim + 1, 0], 0.0) diff --git a/src/nonlin/split_diffu_react.py b/src/nonlin/split_diffu_react.py index 8485a411..32645cbc 100644 --- a/src/nonlin/split_diffu_react.py +++ b/src/nonlin/split_diffu_react.py @@ -1,4 +1,14 @@ -import sys +"""Operator splitting methods for the reaction-diffusion equation. + +Solves: du/dt = a * d^2u/dx^2 + f(u) +where f(u) = -b*u (linear reaction term) + +Demonstrates: +- Forward Euler on full equation +- Ordinary (1st order) splitting +- Strange splitting (1st order) +- Strange splitting (2nd order with Crank-Nicolson and AB2) +""" import numpy as np import scipy.sparse @@ -6,65 +16,76 @@ def diffusion_FE(I, a, f, L, dt, F, t, T, step_no, user_action=None): - """Diffusion solver, Forward Euler method. - Note that t always covers the whole global time interval, whether - splitting is the case or not. T, on the other hand, is - the end of the global time interval if there is no split, - but if splitting, we use T=dt. When splitting, step_no keeps - track of the time step number (required for lookup in t). + """Forward Euler scheme for the diffusion equation. + + Solves: du/dt = a * d^2u/dx^2 + f(u, t) + + Parameters + ---------- + I : array or callable + Initial condition (array or function of x) + a : float + Diffusion coefficient + f : callable or None + Source term f(u, t), or None/0 for no source + L : float + Domain length [0, L] + dt : float + Time step + F : float + Fourier number = a*dt/dx^2 + t : array + Global time mesh + T : float + End time for this solve + step_no : int + Starting step number in global time array + user_action : callable, optional + Callback function(u, x, t, n) + + Returns + ------- + u : array + Solution at final time """ - Nt = int(round(T / float(dt))) dx = np.sqrt(a * dt / F) Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - # Make sure dx and dt are compatible with x and t - dx = x[1] - x[0] - dt = t[1] - t[0] + x = np.linspace(0, L, Nx + 1) - u = np.zeros(Nx + 1) # solution array - u_1 = np.zeros(Nx + 1) # solution at t-dt + u = np.zeros(Nx + 1) + u_1 = np.zeros(Nx + 1) - # Allow f to be None or 0 + # Handle source term if f is None or f == 0: - f = lambda x, t: np.zeros(x.size) if isinstance(x, np.ndarray) else 0 + + def f(u, t): + return np.zeros_like(u) if isinstance(u, np.ndarray) else 0 # Set initial condition - if isinstance(I, np.ndarray): # I is an array - u_1 = np.copy(I) - else: # I is a function - for i in range(0, Nx + 1): + if isinstance(I, np.ndarray): + u_1[:] = I + else: + for i in range(Nx + 1): u_1[i] = I(x[i]) if user_action is not None: - user_action(u_1, x, t, step_no + 0) - - for n in range(0, Nt): - # Update all inner points - u[1:Nx] = ( - u_1[1:Nx] - + F * (u_1[0 : Nx - 1] - 2 * u_1[1:Nx] + u_1[2 : Nx + 1]) - + dt * f(u_1[1:Nx], t[step_no + n]) - ) + user_action(u_1, x, t, step_no) - # Insert boundary conditions + for n in range(Nt): + # Interior points: Forward Euler + u[1:-1] = ( + u_1[1:-1] + + F * (u_1[:-2] - 2 * u_1[1:-1] + u_1[2:]) + + dt * f(u_1[1:-1], t[step_no + n]) + ) + # Boundary conditions (Dirichlet u=0) u[0] = 0 - u[Nx] = 0 - - # sl: ...testing ------------------------- - # print 'time:', t[step_no+n] - # print 'diff part from diffusion_FE:' - # print u_1[1:Nx] + F*(u_1[0:Nx-1] - 2*u_1[1:Nx] + u_1[2:Nx+1]) - # print 'react part from diffusion_FE:' - # print dt*f(u_1[1:Nx], t[step_no+n]) - # print ' ' - # if step_no == 1: sys.exit(0) - # ---------------------------------- + u[-1] = 0 if user_action is not None: - user_action(u, x, t, step_no + (n + 1)) + user_action(u, x, t, step_no + n + 1) - # Switch variables before next step u_1, u = u, u_1 return u_1 @@ -73,69 +94,97 @@ def diffusion_FE(I, a, f, L, dt, F, t, T, step_no, user_action=None): def diffusion_theta( I, a, f, L, dt, F, t, T, step_no, theta=0.5, u_L=0, u_R=0, user_action=None ): - """ + """Theta-rule scheme for the diffusion equation. + Full solver for the model problem using the theta-rule difference approximation in time (no restriction on F, i.e., the time step when theta >= 0.5). Vectorized implementation and sparse (tridiagonal) coefficient matrix. - """ + Parameters + ---------- + I : array or callable + Initial condition + a : float + Diffusion coefficient + f : callable or None + Source term f(u, t) + L : float + Domain length [0, L] + dt : float + Time step + F : float + Fourier number = a*dt/dx^2 + t : array + Global time mesh + T : float + End time for this solve + step_no : int + Starting step number + theta : float + Theta parameter (0=explicit, 0.5=Crank-Nicolson, 1=implicit) + u_L, u_R : float + Dirichlet boundary values + user_action : callable, optional + Callback function(u, x, t, n) + + Returns + ------- + u : array + Solution at final time + """ Nt = int(round(T / float(dt))) - # t = np.linspace(0, Nt*dt, Nt+1) # Mesh points in time dx = np.sqrt(a * dt / F) Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - # Make sure dx and dt are compatible with x and t + x = np.linspace(0, L, Nx + 1) dx = x[1] - x[0] dt = t[1] - t[0] - u = np.zeros(Nx + 1) # solution array at t[n+1] - u_1 = np.zeros(Nx + 1) # solution at t[n] + u = np.zeros(Nx + 1) + u_1 = np.zeros(Nx + 1) - # Representation of sparse matrix and right-hand side + # Build tridiagonal matrix diagonal = np.zeros(Nx + 1) lower = np.zeros(Nx) upper = np.zeros(Nx) b = np.zeros(Nx + 1) - # Precompute sparse matrix (scipy format) Fl = F * theta Fr = F * (1 - theta) diagonal[:] = 1 + 2 * Fl - lower[:] = -Fl # 1 - upper[:] = -Fl # 1 - # Insert boundary conditions + lower[:] = -Fl + upper[:] = -Fl + # Boundary conditions diagonal[0] = 1 upper[0] = 0 diagonal[Nx] = 1 lower[-1] = 0 - diags = [0, -1, 1] A = scipy.sparse.diags( diagonals=[diagonal, lower, upper], offsets=[0, -1, 1], shape=(Nx + 1, Nx + 1), format="csr", ) - # print A.todense() - # Allow f to be None or 0 + # Handle source term if f is None or f == 0: - f = lambda x, t: np.zeros(x.size) if isinstance(x, np.ndarray) else 0 + + def f(u, t): + return np.zeros_like(u) if isinstance(u, np.ndarray) else 0 # Set initial condition - if isinstance(I, np.ndarray): # I is an array - u_1 = np.copy(I) - else: # I is a function - for i in range(0, Nx + 1): + if isinstance(I, np.ndarray): + u_1[:] = I + else: + for i in range(Nx + 1): u_1[i] = I(x[i]) if user_action is not None: - user_action(u_1, x, t, step_no + 0) + user_action(u_1, x, t, step_no) - # Time loop - for n in range(0, Nt): + for n in range(Nt): b[1:-1] = ( u_1[1:-1] + Fr * (u_1[:-2] - 2 * u_1[1:-1] + u_1[2:]) @@ -143,32 +192,53 @@ def diffusion_theta( + dt * (1 - theta) * f(u_1[1:-1], t[step_no + n]) ) b[0] = u_L - b[-1] = u_R # boundary conditions + b[-1] = u_R u[:] = scipy.sparse.linalg.spsolve(A, b) if user_action is not None: - user_action(u, x, t, step_no + (n + 1)) + user_action(u, x, t, step_no + n + 1) - # Update u_1 before next step u_1, u = u, u_1 - # u is now contained in u_1 (swapping) return u_1 def reaction_FE(I, f, L, Nx, dt, dt_Rfactor, t, step_no, user_action=None): - """Reaction solver, Forward Euler method. + """Reaction solver using Forward Euler method. + Note that t covers the whole global time interval. - dt is the step of the diffustion part, i.e. there + dt is the step of the diffusion part, i.e. there is a local time interval [0, dt] the reaction_FE deals with each time it is called. step_no keeps track of the (global) time step number (required for lookup in t). - """ - - # bypass = True - # if not bypass: # original code from sl + Parameters + ---------- + I : array + Initial condition (solution from diffusion step) + f : callable + Reaction term f(u, t) + L : float + Domain length + Nx : int + Number of spatial intervals + dt : float + Diffusion time step (local interval length) + dt_Rfactor : int + Refinement factor for reaction substeps + t : array + Global time mesh + step_no : int + Current global step number + user_action : callable, optional + Callback function + + Returns + ------- + u : array + Solution after reaction step + """ u = np.copy(I) dt_local = dt / float(dt_Rfactor) Nt_local = int(round(dt / float(dt_local))) @@ -178,41 +248,83 @@ def reaction_FE(I, f, L, Nx, dt, dt_Rfactor, t, step_no, user_action=None): time = t[step_no] + n * dt_local u[1:Nx] = u[1:Nx] + dt_local * f(u[1:Nx], time) - # BC already inserted in diffusion step, i.e. no action here - return u - # else: - # return I + +def reaction_AB2(I, f, L, Nx, dt, dt_Rfactor, t, step_no): + """Reaction solver using 2nd-order Adams-Bashforth method. + + Parameters + ---------- + I : array + Initial condition + f : callable + Reaction term f(u, t) + L : float + Domain length + Nx : int + Number of spatial intervals + dt : float + Diffusion time step + dt_Rfactor : int + Number of substeps for reaction + t : array + Global time mesh + step_no : int + Current global step number + + Returns + ------- + u : array + Solution after reaction step + """ + u = np.copy(I) + dt_local = dt / float(dt_Rfactor) + Nt_local = int(round(dt / float(dt_local))) + + # Store previous f values for AB2 + f_prev = f(u[1:Nx], t[step_no]) + + for n in range(Nt_local): + time = t[step_no] + n * dt_local + f_curr = f(u[1:Nx], time) + + if n == 0: + # First step: use Forward Euler + u[1:Nx] = u[1:Nx] + dt_local * f_curr + else: + # AB2: u^{n+1} = u^n + dt/2 * (3*f^n - f^{n-1}) + u[1:Nx] = u[1:Nx] + dt_local * (1.5 * f_curr - 0.5 * f_prev) + + f_prev = f_curr + + return u def ordinary_splitting(I, a, b, f, L, dt, dt_Rfactor, F, t, T, user_action=None): - """1st order scheme, i.e. Forward Euler is enough for both + """Ordinary (1st order) operator splitting. + + 1st order scheme, i.e. Forward Euler is enough for both the diffusion and the reaction part. The time step dt is given for the diffusion step, while the time step for the reaction part is found as dt/dt_Rfactor, where dt_Rfactor >= 1. """ Nt = int(round(T / float(dt))) - # t = np.linspace(0, Nt*dt, Nt+1) # Mesh points, global time dx = np.sqrt(a * dt / F) Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space + x = np.linspace(0, L, Nx + 1) u = np.zeros(Nx + 1) - # Set initial condition u(x,0) = I(x) - for i in range(0, Nx + 1): + # Set initial condition + for i in range(Nx + 1): u[i] = I(x[i]) - # In the following loop, each time step is "covered twice", - # first for diffusion, then for reaction - for n in range(0, Nt): - # Note: could avoid the call to diffusion_FE here... - - # Diffusion step (one time step dt) + for n in range(Nt): + # Step 1: Diffusion u_s = diffusion_FE( I=u, a=a, f=0, L=L, dt=dt, F=F, t=t, T=dt, step_no=n, user_action=None ) - # Reaction step (potentially many smaller steps within dt) + # Step 2: Reaction u = reaction_FE( I=u_s, f=f, @@ -230,24 +342,26 @@ def ordinary_splitting(I, a, b, f, L, dt, dt_Rfactor, F, t, T, user_action=None) def Strange_splitting_1stOrder(I, a, b, f, L, dt, dt_Rfactor, F, t, T, user_action=None): - """Strange splitting while still using FE for the diffusion + """Strange splitting with Forward Euler (1st order accurate). + + Strange splitting while still using FE for the diffusion step and for the reaction step. Gives 1st order scheme. Introduce an extra time mesh t2 for the diffusion part, since it steps dt/2. """ Nt = int(round(T / float(dt))) - t2 = np.linspace(0, Nt * dt, (Nt + 1) + Nt) # Mesh points in diff + t2 = np.linspace(0, Nt * dt, (Nt + 1) + Nt) # Mesh points for half-steps dx = np.sqrt(a * dt / F) Nx = int(round(L / dx)) x = np.linspace(0, L, Nx + 1) u = np.zeros(Nx + 1) - # Set initial condition u(x,0) = I(x) - for i in range(0, Nx + 1): + # Set initial condition + for i in range(Nx + 1): u[i] = I(x[i]) - for n in range(0, Nt): - # Diffusion step (1/2 dt: from t_n to t_n+1/2) + for n in range(Nt): + # Step 1: Half diffusion step u_s = diffusion_FE( I=u, a=a, @@ -261,8 +375,7 @@ def Strange_splitting_1stOrder(I, a, b, f, L, dt, dt_Rfactor, F, t, T, user_acti user_action=None, ) - # Reaction step (1 dt: from t_n to t_n+1) - # (potentially many smaller steps within dt) + # Step 2: Full reaction step u_sss = reaction_FE( I=u_s, f=f, @@ -275,7 +388,7 @@ def Strange_splitting_1stOrder(I, a, b, f, L, dt, dt_Rfactor, F, t, T, user_acti user_action=None, ) - # Diffusion step (1/2 dt: from t_n+1/2 to t_n) + # Step 3: Half diffusion step u = diffusion_FE( I=u_sss, a=a, @@ -294,29 +407,25 @@ def Strange_splitting_1stOrder(I, a, b, f, L, dt, dt_Rfactor, F, t, T, user_acti def Strange_splitting_2andOrder(I, a, b, f, L, dt, dt_Rfactor, F, t, T, user_action=None): - """Strange splitting using Crank-Nicolson for the diffusion - step (theta-rule) and Adams-Bashforth 2 for the reaction step. - Gives 2nd order scheme. Introduce an extra time mesh t2 for - the diffusion part, since it steps dt/2. - """ - import odespy + """Strange splitting with Crank-Nicolson and AB2 (2nd order accurate). + Strange splitting using Crank-Nicolson for the diffusion + step (theta-rule with theta=0.5) and Adams-Bashforth 2 for + the reaction step. Gives 2nd order scheme. + """ Nt = int(round(T / float(dt))) - t2 = np.linspace(0, Nt * dt, (Nt + 1) + Nt) # Mesh points in diff + t2 = np.linspace(0, Nt * dt, (Nt + 1) + Nt) # Mesh points for half-steps dx = np.sqrt(a * dt / F) Nx = int(round(L / dx)) x = np.linspace(0, L, Nx + 1) u = np.zeros(Nx + 1) - # Set initial condition u(x,0) = I(x) - for i in range(0, Nx + 1): + # Set initial condition + for i in range(Nx + 1): u[i] = I(x[i]) - reaction_solver = odespy.AdamsBashforth2(f) - - for n in range(0, Nt): - # Diffusion step (1/2 dt: from t_n to t_n+1/2) - # Crank-Nicolson (theta = 0.5, gives 2nd order) + for n in range(Nt): + # Step 1: Half diffusion step (Crank-Nicolson) u_s = diffusion_theta( I=u, a=a, @@ -333,26 +442,19 @@ def Strange_splitting_2andOrder(I, a, b, f, L, dt, dt_Rfactor, F, t, T, user_act user_action=None, ) - # u_s = diffusion_FE(I=u, a=a, f=0, L=L, dt=dt/2.0, F=F/2.0, - # t=t2, T=dt/2.0, step_no=2*n, - # user_action=None) - - # Reaction step (1 dt: from t_n to t_n+1) - # (potentially many smaller steps within dt) - # sl: testing ----------------------------------- - reaction_solver.set_initial_condition(u_s) - t_points = np.linspace(0, dt, dt_Rfactor + 1) - u_AB2, t_ = reaction_solver.solve(t_points) # t_ not needed - u_sss = u_AB2[-1, :] # pick sol at last point in time - # ----------------------------------------------- - - # u_sss = reaction_FE(I=u_s, f=f, L=L, Nx=Nx, - # dt=dt, dt_Rfactor=dt_Rfactor, - # t=t, step_no=n, - # user_action=None) - - # Diffusion step (1/2 dt: from t_n+1/2 to t_n) - # Crank-Nicolson (theta = 0.5, gives 2nd order) + # Step 2: Full reaction step (AB2) + u_sss = reaction_AB2( + I=u_s, + f=f, + L=L, + Nx=Nx, + dt=dt, + dt_Rfactor=dt_Rfactor, + t=t, + step_no=n, + ) + + # Step 3: Half diffusion step (Crank-Nicolson) u = diffusion_theta( I=u_sss, a=a, @@ -369,18 +471,25 @@ def Strange_splitting_2andOrder(I, a, b, f, L, dt, dt_Rfactor, F, t, T, user_act user_action=None, ) - # u = diffusion_FE(I=u_sss, a=a, f=0, L=L, dt=dt/2.0, F=F/2.0, - # t=t2, T=dt/2.0, step_no=2*n+1, - # user_action=None) - if user_action is not None: user_action(u, x, t, n + 1) -def convergence_rates(scheme="diffusion"): - """Computes empirical conv. rates for the different - splitting schemes""" +def convergence_rates(scheme="diffusion", Nx_values=None): + """Compute empirical convergence rates for splitting schemes. + + Parameters + ---------- + scheme : str + One of: "diffusion", "ordinary_splitting", + "Strange_splitting_1stOrder", "Strange_splitting_2andOrder" + Nx_values : list, optional + Grid resolutions to test + Returns + ------- + dict with E (errors), h (step sizes), r (convergence rates) + """ F = 0.5 T = 1.2 a = 3.5 @@ -388,8 +497,11 @@ def convergence_rates(scheme="diffusion"): L = 1.5 k = np.pi / L + if Nx_values is None: + Nx_values = [10, 20, 40, 80, 160] + def exact(x, t): - """exact sol. to: du/dt = a*d^2u/dx^2 - b*u""" + """Exact solution to: du/dt = a*d^2u/dx^2 - b*u""" return np.exp(-(a * k**2 + b) * t) * np.sin(k * x) def f(u, t): @@ -398,31 +510,27 @@ def f(u, t): def I(x): return exact(x, 0) - global error # error computed in the user action function - error = 0 - - # Convergence study - def action(u, x, t, n): - global error - if n == 1: # New simulation, - reset error - error = 0 - else: - error = max(error, np.abs(u - exact(x, t[n])).max()) - E = [] h = [] - Nx_values = [10, 20, 40, 80, 160] + for Nx in Nx_values: dx = L / Nx dt = F / a * dx**2 Nt = int(round(T / float(dt))) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points, global time + t = np.linspace(0, Nt * dt, Nt + 1) + x = np.linspace(0, L, Nx + 1) + + # Track maximum error via user_action + error = [0.0] + + def action(u, x, t_arr, n): + if n > 0: + err = np.abs(u - exact(x, t_arr[n])).max() + error[0] = max(error[0], err) if scheme == "diffusion": - print("Running FE on whole eqn...") diffusion_FE(I, a, f, L, dt, F, t, T, step_no=0, user_action=action) elif scheme == "ordinary_splitting": - print("Running ordinary splitting...") ordinary_splitting( I=I, a=a, @@ -437,7 +545,6 @@ def action(u, x, t, n): user_action=action, ) elif scheme == "Strange_splitting_1stOrder": - print("Running Strange splitting with 1st order schemes...") Strange_splitting_1stOrder( I=I, a=a, @@ -452,7 +559,6 @@ def action(u, x, t, n): user_action=action, ) elif scheme == "Strange_splitting_2andOrder": - print("Running Strange splitting with 2nd order schemes...") Strange_splitting_2andOrder( I=I, a=a, @@ -467,24 +573,22 @@ def action(u, x, t, n): user_action=action, ) else: - print("Unknown scheme requested!") - sys.exit(0) + raise ValueError(f"Unknown scheme: {scheme}") h.append(dt) - E.append(error) + E.append(error[0]) - print("E:", E) - print("h:", h) - - # Convergence rates + # Compute convergence rates r = [ np.log(E[i] / E[i - 1]) / np.log(h[i] / h[i - 1]) for i in range(1, len(Nx_values)) ] - print("Computed rates:", r) + return {"E": E, "h": h, "r": r} -if __name__ == "__main__": + +def demo(): + """Run convergence rate demonstration for all schemes.""" schemes = [ "diffusion", "ordinary_splitting", @@ -492,5 +596,25 @@ def action(u, x, t, n): "Strange_splitting_2andOrder", ] + results = {} for scheme in schemes: - convergence_rates(scheme=scheme) + print(f"\nRunning {scheme}...") + result = convergence_rates(scheme=scheme) + results[scheme] = result + print(f" Errors: {result['E']}") + print(f" Rates: {result['r']}") + + return results + + +# Run quick convergence test and store result for testing +_test_result = convergence_rates(scheme="diffusion", Nx_values=[10, 20, 40]) +RESULT = { + "errors": _test_result["E"], + "rates": _test_result["r"], + "converges": all(0.8 < r < 1.2 for r in _test_result["r"]), # First-order in dt +} + + +if __name__ == "__main__": + demo() diff --git a/src/nonlin/split_logistic.py b/src/nonlin/split_logistic.py index 98b94beb..bff458ee 100644 --- a/src/nonlin/split_logistic.py +++ b/src/nonlin/split_logistic.py @@ -1,67 +1,43 @@ +"""Operator splitting methods for the logistic equation. + +Demonstrates ordinary splitting, Strange splitting, and exact treatment +of the linear term f_0(u) = u. + +This module provides both verbose and compact implementations of splitting +methods for educational purposes. +""" + import numpy as np -def solver(dt, T, f, f_0, f_1): - """ - Solve u'=f by the Forward Euler method and by ordinary and - Strange splitting: f(u) = f_0(u) + f_1(u). - """ - Nt = int(round(T / float(dt))) - t = np.linspace(0, Nt * dt, Nt + 1) - u_FE = np.zeros(len(t)) - u_split1 = np.zeros(len(t)) # 1st-order splitting - u_split2 = np.zeros(len(t)) # 2nd-order splitting - u_split3 = np.zeros(len(t)) # 2nd-order splitting w/exact f_0 +def exact_solution(t): + """Exact solution to u' = u(1-u), u(0) = 0.1.""" + return 1 / (1 + 9 * np.exp(-t)) - # Set initial values - u_FE[0] = 0.1 - u_split1[0] = 0.1 - u_split2[0] = 0.1 - u_split3[0] = 0.1 - for n in range(len(t) - 1): - # Forward Euler method - u_FE[n + 1] = u_FE[n] + dt * f(u_FE[n]) +def f(u): + """Full logistic equation RHS: f(u) = u(1-u).""" + return u * (1 - u) - # --- Ordinary splitting --- - # First step - u_s_n = u_split1[n] - u_s = u_s_n + dt * f_0(u_s_n) - # Second step - u_ss_n = u_s - u_ss = u_ss_n + dt * f_1(u_ss_n) - u_split1[n + 1] = u_ss - - # --- Strange splitting --- - # First step - u_s_n = u_split2[n] - u_s = u_s_n + dt / 2.0 * f_0(u_s_n) - # Second step - u_sss_n = u_s - u_sss = u_sss_n + dt * f_1(u_sss_n) - # Third step - u_ss_n = u_sss - u_ss = u_ss_n + dt / 2.0 * f_0(u_ss_n) - u_split2[n + 1] = u_ss - - # --- Strange splitting using exact integrator for u'=f_0 --- - # First step - u_s_n = u_split3[n] - u_s = u_s_n * np.exp(dt / 2.0) # exact - # Second step - u_sss_n = u_s - u_sss = u_sss_n + dt * f_1(u_sss_n) - # Third step - u_ss_n = u_sss - u_ss = u_ss_n * np.exp(dt / 2.0) # exact - u_split3[n + 1] = u_ss - return u_FE, u_split1, u_split2, u_split3, t +def f_0(u): + """Linear part: f_0(u) = u.""" + return u -def solver_compact(dt, T, f, f_0, f_1): - """ - As solver, but shorter code in the splitting steps. +def f_1(u): + """Nonlinear part: f_1(u) = -u^2.""" + return -(u**2) + + +def solver(dt, T): + """Solve u'=f by Forward Euler and by splitting: f(u) = f_0(u) + f_1(u). + + Returns solutions from: + - Forward Euler on full equation + - Ordinary (1st order) splitting + - Strange (2nd order) splitting with FE substeps + - Strange splitting with exact treatment of f_0 """ Nt = int(round(T / float(dt))) t = np.linspace(0, Nt * dt, Nt + 1) @@ -70,141 +46,82 @@ def solver_compact(dt, T, f, f_0, f_1): u_split2 = np.zeros(len(t)) # 2nd-order splitting u_split3 = np.zeros(len(t)) # 2nd-order splitting w/exact f_0 - # Set initial values + # Initial condition u_FE[0] = 0.1 u_split1[0] = 0.1 u_split2[0] = 0.1 u_split3[0] = 0.1 + # Ordinary splitting for n in range(len(t) - 1): - # Forward Euler method + # Forward Euler on full equation u_FE[n + 1] = u_FE[n] + dt * f(u_FE[n]) - # Ordinary splitting + # Ordinary splitting: f_0 step then f_1 step u_s = u_split1[n] + dt * f_0(u_split1[n]) u_split1[n + 1] = u_s + dt * f_1(u_s) - # Strange splitting - u_s = u_split2[n] + dt / 2.0 * f_0(u_split2[n]) - u_sss = u_s + dt * f_1(u_s) - u_split2[n + 1] = u_sss + dt / 2.0 * f_0(u_sss) + # Strange splitting: half f_0, full f_1, half f_0 + u_s = u_split2[n] + 0.5 * dt * f_0(u_split2[n]) + u_ss = u_s + dt * f_1(u_s) + u_split2[n + 1] = u_ss + 0.5 * dt * f_0(u_ss) - # Strange splitting using exact integrator for u'=f_0 - u_s = u_split3[n] * np.exp(dt / 2.0) # exact + # Strange splitting with exact f_0 (u' = u => u(t) = u_0*exp(t)) + u_s = u_split3[n] * np.exp(0.5 * dt) u_ss = u_s + dt * f_1(u_s) - u_split3[n + 1] = u_ss * np.exp(dt / 2.0) + u_split3[n + 1] = u_ss * np.exp(0.5 * dt) + # end-splitting-loop return u_FE, u_split1, u_split2, u_split3, t -def demo(dt=0.2): - u_exact = lambda t: 1.0 / (9 * np.exp(-t) + 1) - u_FE, u_split1, u_split2, u_split3, t = solver( - dt, 8, f=lambda u: u * (1 - u), f_0=lambda u: u, f_1=lambda u: -(u**2) - ) - - import matplotlib.pyplot as plt - - plt.plot( - t, - u_FE, - "r-", - t, - u_split1, - "b-", - t, - u_split2, - "g-", - t, - u_split3, - "y-", - t, - u_exact(t), - "k--", - ) - plt.legend( - ["no split", "split", "strange", r"strange w/exact $f_0$", "exact"], - loc="lower right", - ) - plt.xlabel("t") - plt.ylabel("u") - plt.title("Time step: %g" % dt) - plt.savefig("tmp1.png") - plt.savefig("tmp1.pdf") - plt.show() - - -def test_solver(): - np.set_printoptions(precision=15) - u_FE_expected = np.array( - [ - float(x) - for x in list( - """ -[ 0.1 0.118 0.1388152 0.162724308049792 - 0.189973329573694 0.220750022298569 0.255153912289319 - 0.293163990955874 0.334607764028414 0.379136845684478 - 0.426215265270258 0.47512642785443 0.525002688936174 - 0.574877662045366 0.62375632919069 0.67069320338774 0.714865969451186 - 0.755632492485546 0.792562898242672 0.825444288357041 - 0.854261491392197]"""[2:-1].split() - ) - ] - ) - u_split1_expected = np.array( - [ - float(x) - for x in list( - """ -[ 0.1 0.11712 0.1365934768128 0.158538732137911 - 0.183007734044179 0.209963633605659 0.239259958824966 - 0.270625296155645 0.303657796722007 0.33783343550351 0.37253027072271 - 0.407068029717088 0.440758773984993 0.472961259290703 - 0.503130113545367 0.530851841841463 0.555862750949651 - 0.578048082546307 0.597425498363755 0.614118436921094 - 0.628325385390187]"""[2:-1].split() - ) - ] - ) - u_split2_expected = np.array( - [ - float(x) - for x in list( - """ -[ 0.1 0.118338 0.139461146546647 0.1635705540078 - 0.190798102531391 0.221174981642529 0.254599657026743 - 0.290810238700023 0.329367696455926 0.369656716957107 0.41090943878828 - 0.452253464828953 0.492779955548098 0.531621845295344 - 0.568028513268957 0.601423369535241 0.631435056657206 - 0.657899755122731 0.68083880192866 0.700420209898537 - 0.716913803147616]"""[2:-1].split() - ) - ] - ) - u_split3_expected = np.array( - [ - float(x) - for x in list( - """ -[ 0.1 0.119440558200865 0.142033597399576 - 0.168033940732164 0.197614356585594 0.230823935778256 - 0.267544980228041 0.307455512661905 0.350006879617614 - 0.394426527229859 0.439753524322399 0.484908174583947 0.5287881185737 - 0.570374606392027 0.608827962442147 0.643553318053156 - 0.674226057210925 0.700777592993228 0.723351459147494 - 0.742244162719702 0.757844497686122]"""[2:-1].split() - ) - ] - ) - for func in solver, solver_compact: - u_FE, u_split1, u_split2, u_split3, t = solver( - dt=0.2, T=4, f=lambda u: u * (1 - u), f_0=lambda u: u, f_1=lambda u: -(u**2) - ) - for quantity in "u_FE", "u_split1", "u_split2", "u_split3": - diff = np.abs(eval(quantity + "_expected") - eval(quantity)).max() - assert diff < 1e-14 +def demo(dt=0.1, T=8.0, plot=False): + """Run demonstration of splitting methods.""" + u_FE, u_OS, u_SS, u_SS_exact, t = solver(dt, T) + u_e = exact_solution(t) + + errors = { + "FE": np.max(np.abs(u_FE - u_e)), + "ordinary_split": np.max(np.abs(u_OS - u_e)), + "strange_split": np.max(np.abs(u_SS - u_e)), + "strange_exact": np.max(np.abs(u_SS_exact - u_e)), + } + + if plot: + import matplotlib.pyplot as plt + + plt.figure(figsize=(10, 6)) + plt.plot(t, u_e, "k-", label="exact", linewidth=2) + plt.plot(t, u_FE, "b--", label="FE") + plt.plot(t, u_OS, "r-.", label="ordinary split") + plt.plot(t, u_SS, "g:", label="Strange split") + plt.plot(t, u_SS_exact, "m-", label="Strange (exact f_0)") + plt.legend() + plt.xlabel("t") + plt.ylabel("u") + plt.title(f"Splitting methods, dt={dt}") + plt.savefig("split_logistic.png") + plt.savefig("split_logistic.pdf") + + return errors + + +# Run demonstration and store result for testing +_demo_result = demo(dt=0.1, T=8.0) +RESULT = { + "FE_error": _demo_result["FE"], + "ordinary_split_error": _demo_result["ordinary_split"], + "strange_split_error": _demo_result["strange_split"], + "strange_exact_error": _demo_result["strange_exact"], +} if __name__ == "__main__": - test_solver() - demo(0.05) + print("Logistic equation splitting demonstration") + print("=" * 50) + + for dt in [0.2, 0.1, 0.05]: + print(f"\ndt = {dt}:") + errors = demo(dt=dt) + for method, err in errors.items(): + print(f" {method:20s}: max error = {err:.6f}") diff --git a/src/systems/__init__.py b/src/systems/__init__.py new file mode 100644 index 00000000..198493c8 --- /dev/null +++ b/src/systems/__init__.py @@ -0,0 +1,17 @@ +"""Systems of PDEs solvers using Devito DSL. + +This module provides solvers for coupled systems of PDEs, +including the 2D Shallow Water Equations for tsunami modeling. +""" + +from src.systems.swe_devito import ( + SWEResult, + create_swe_operator, + solve_swe, +) + +__all__ = [ + "SWEResult", + "create_swe_operator", + "solve_swe", +] diff --git a/src/systems/swe_devito.py b/src/systems/swe_devito.py new file mode 100644 index 00000000..a608b86b --- /dev/null +++ b/src/systems/swe_devito.py @@ -0,0 +1,462 @@ +"""2D Shallow Water Equations Solver using Devito DSL. + +Solves the 2D Shallow Water Equations (SWE): + + deta/dt + dM/dx + dN/dy = 0 (continuity) + dM/dt + d(M^2/D)/dx + d(MN/D)/dy + gD*deta/dx + friction*M = 0 (x-momentum) + dN/dt + d(MN/D)/dx + d(N^2/D)/dy + gD*deta/dy + friction*N = 0 (y-momentum) + +where: + - eta: wave height (surface elevation above mean sea level) + - M, N: discharge fluxes in x and y directions (M = u*D, N = v*D) + - D = h + eta: total water column depth + - h: bathymetry (depth from mean sea level to seafloor) + - g: gravitational acceleration + - friction = g * alpha^2 * sqrt(M^2 + N^2) / D^(7/3) + - alpha: Manning's roughness coefficient + +The equations are discretized using the FTCS (Forward Time, Centered Space) +scheme with the solve() function to isolate forward time terms. + +Applications: + - Tsunami propagation modeling + - Storm surge prediction + - Dam break simulations + - Coastal engineering + +Usage: + from src.systems import solve_swe + + result = solve_swe( + Lx=100.0, Ly=100.0, # Domain size [m] + Nx=401, Ny=401, # Grid points + T=3.0, # Final time [s] + dt=1/4500, # Time step [s] + g=9.81, # Gravity [m/s^2] + alpha=0.025, # Manning's roughness + h0=50.0, # Constant depth [m] + ) +""" + +from dataclasses import dataclass + +import numpy as np + +try: + from devito import ( + ConditionalDimension, + Eq, + Function, + Grid, + Operator, + TimeFunction, + solve, + sqrt, + ) + DEVITO_AVAILABLE = True +except ImportError: + DEVITO_AVAILABLE = False + + +@dataclass +class SWEResult: + """Results from the Shallow Water Equations solver. + + Attributes + ---------- + eta : np.ndarray + Final wave height field, shape (Ny, Nx) + M : np.ndarray + Final x-discharge flux, shape (Ny, Nx) + N : np.ndarray + Final y-discharge flux, shape (Ny, Nx) + x : np.ndarray + x-coordinates, shape (Nx,) + y : np.ndarray + y-coordinates, shape (Ny,) + t : float + Final simulation time + dt : float + Time step used + eta_snapshots : np.ndarray or None + Saved snapshots of eta, shape (nsnaps, Ny, Nx) + t_snapshots : np.ndarray or None + Time values for snapshots + """ + eta: np.ndarray + M: np.ndarray + N: np.ndarray + x: np.ndarray + y: np.ndarray + t: float + dt: float + eta_snapshots: np.ndarray | None = None + t_snapshots: np.ndarray | None = None + + +def create_swe_operator( + eta: "TimeFunction", + M: "TimeFunction", + N: "TimeFunction", + h: "Function", + D: "Function", + g: float, + alpha: float, + grid: "Grid", + eta_save: "TimeFunction | None" = None, +) -> "Operator": + """Create the Devito operator for the Shallow Water Equations. + + This function constructs the finite difference operator that solves + the coupled system of three PDEs (continuity + two momentum equations). + + Parameters + ---------- + eta : TimeFunction + Wave height field (surface elevation) + M : TimeFunction + Discharge flux in x-direction + N : TimeFunction + Discharge flux in y-direction + h : Function + Bathymetry (static field, depth to seafloor) + D : Function + Total water depth (D = h + eta) + g : float + Gravitational acceleration [m/s^2] + alpha : float + Manning's roughness coefficient + grid : Grid + Devito computational grid + eta_save : TimeFunction, optional + TimeFunction for saving snapshots at reduced frequency + + Returns + ------- + Operator + Devito operator that advances the solution by one time step + """ + # Friction term: represents energy loss due to seafloor interaction + # friction = g * alpha^2 * sqrt(M^2 + N^2) / D^(7/3) + friction_M = g * alpha**2 * sqrt(M**2 + N**2) / D**(7.0/3.0) + + # Continuity equation: deta/dt + dM/dx + dN/dy = 0 + # Using centered differences for spatial derivatives + pde_eta = Eq(eta.dt + M.dxc + N.dyc) + + # x-Momentum equation: + # dM/dt + d(M^2/D)/dx + d(MN/D)/dy + gD*deta/dx + friction*M = 0 + # Note: We use eta.forward for the pressure gradient term to improve stability + pde_M = Eq( + M.dt + + (M**2 / D).dxc + + (M * N / D).dyc + + g * D * eta.forward.dxc + + friction_M * M + ) + + # y-Momentum equation: + # dN/dt + d(MN/D)/dx + d(N^2/D)/dy + gD*deta/dy + friction*N = 0 + # Note: Uses M.forward to maintain temporal consistency + friction_N = g * alpha**2 * sqrt(M.forward**2 + N**2) / D**(7.0/3.0) + pde_N = Eq( + N.dt + + (M.forward * N / D).dxc + + (N**2 / D).dyc + + g * D * eta.forward.dyc + + friction_N * N + ) + + # Use solve() to isolate the forward time terms + stencil_eta = solve(pde_eta, eta.forward) + stencil_M = solve(pde_M, M.forward) + stencil_N = solve(pde_N, N.forward) + + # Update equations for interior points only (avoiding boundaries) + update_eta = Eq(eta.forward, stencil_eta, subdomain=grid.interior) + update_M = Eq(M.forward, stencil_M, subdomain=grid.interior) + update_N = Eq(N.forward, stencil_N, subdomain=grid.interior) + + # Update total water depth D = h + eta + eq_D = Eq(D, eta.forward + h) + + # Build equation list + equations = [update_eta, update_M, update_N, eq_D] + + # Add snapshot saving if eta_save is provided + if eta_save is not None: + equations.append(Eq(eta_save, eta)) + + return Operator(equations) + + +def solve_swe( + Lx: float = 100.0, + Ly: float = 100.0, + Nx: int = 401, + Ny: int = 401, + T: float = 3.0, + dt: float = 1/4500, + g: float = 9.81, + alpha: float = 0.025, + h0: float | np.ndarray = 50.0, + eta0: np.ndarray | None = None, + M0: np.ndarray | None = None, + N0: np.ndarray | None = None, + nsnaps: int = 0, +) -> SWEResult: + """Solve the 2D Shallow Water Equations using Devito. + + Parameters + ---------- + Lx : float + Domain extent in x-direction [m] + Ly : float + Domain extent in y-direction [m] + Nx : int + Number of grid points in x-direction + Ny : int + Number of grid points in y-direction + T : float + Final simulation time [s] + dt : float + Time step [s] + g : float + Gravitational acceleration [m/s^2] + alpha : float + Manning's roughness coefficient + h0 : float or ndarray + Bathymetry: either constant depth or 2D array (Ny, Nx) + eta0 : ndarray, optional + Initial wave height, shape (Ny, Nx). Default: Gaussian at center. + M0 : ndarray, optional + Initial x-discharge flux, shape (Ny, Nx). Default: 100 * eta0. + N0 : ndarray, optional + Initial y-discharge flux, shape (Ny, Nx). Default: zeros. + nsnaps : int + Number of snapshots to save (0 = no snapshots) + + Returns + ------- + SWEResult + Solution data including final fields and optional snapshots + + Raises + ------ + ImportError + If Devito is not installed + """ + if not DEVITO_AVAILABLE: + raise ImportError( + "Devito is required for this solver. " + "Install with: pip install devito" + ) + + # Compute number of time steps + Nt = int(T / dt) + + # Create coordinate arrays + x = np.linspace(0.0, Lx, Nx) + y = np.linspace(0.0, Ly, Ny) + X, Y = np.meshgrid(x, y) + + # Set up bathymetry + if isinstance(h0, (int, float)): + h_array = h0 * np.ones((Ny, Nx), dtype=np.float32) + else: + h_array = np.asarray(h0, dtype=np.float32) + + # Default initial conditions + if eta0 is None: + # Gaussian pulse at center + eta0 = 0.5 * np.exp(-((X - Lx/2)**2 / 10) - ((Y - Ly/2)**2 / 10)) + eta0 = np.asarray(eta0, dtype=np.float32) + + if M0 is None: + M0 = 100.0 * eta0 + M0 = np.asarray(M0, dtype=np.float32) + + if N0 is None: + N0 = np.zeros_like(M0) + N0 = np.asarray(N0, dtype=np.float32) + + # Create Devito grid + grid = Grid(shape=(Ny, Nx), extent=(Ly, Lx), dtype=np.float32) + + # Create TimeFunction fields for the three unknowns + eta = TimeFunction(name='eta', grid=grid, space_order=2) + M = TimeFunction(name='M', grid=grid, space_order=2) + N = TimeFunction(name='N', grid=grid, space_order=2) + + # Create static Functions for bathymetry and total depth + h = Function(name='h', grid=grid) + D = Function(name='D', grid=grid) + + # Set initial conditions + eta.data[0, :, :] = eta0 + M.data[0, :, :] = M0 + N.data[0, :, :] = N0 + h.data[:] = h_array + D.data[:] = eta0 + h_array + + # Set up snapshot saving with ConditionalDimension + eta_save = None + if nsnaps > 0: + factor = max(1, round(Nt / nsnaps)) + time_subsampled = ConditionalDimension( + 't_sub', parent=grid.time_dim, factor=factor + ) + eta_save = TimeFunction( + name='eta_save', grid=grid, space_order=2, + save=nsnaps, time_dim=time_subsampled + ) + + # Create the operator + op = create_swe_operator(eta, M, N, h, D, g, alpha, grid, eta_save) + + # Apply the operator + op.apply( + eta=eta, M=M, N=N, D=D, h=h, + time=Nt - 2, dt=dt, + **({"eta_save": eta_save} if eta_save is not None else {}) + ) + + # Extract results + eta_final = eta.data[0, :, :].copy() + M_final = M.data[0, :, :].copy() + N_final = N.data[0, :, :].copy() + + # Extract snapshots if saved + eta_snapshots = None + t_snapshots = None + if eta_save is not None: + eta_snapshots = eta_save.data.copy() + t_snapshots = np.linspace(0, T, nsnaps) + + return SWEResult( + eta=eta_final, + M=M_final, + N=N_final, + x=x, + y=y, + t=T, + dt=dt, + eta_snapshots=eta_snapshots, + t_snapshots=t_snapshots, + ) + + +def gaussian_tsunami_source( + X: np.ndarray, + Y: np.ndarray, + x0: float, + y0: float, + amplitude: float = 0.5, + sigma_x: float = 10.0, + sigma_y: float = 10.0, +) -> np.ndarray: + """Create a Gaussian tsunami source. + + Parameters + ---------- + X : ndarray + X-coordinate meshgrid + Y : ndarray + Y-coordinate meshgrid + x0 : float + Source center x-coordinate + y0 : float + Source center y-coordinate + amplitude : float + Peak amplitude [m] + sigma_x : float + Width parameter in x-direction + sigma_y : float + Width parameter in y-direction + + Returns + ------- + ndarray + Initial wave height field + """ + return amplitude * np.exp( + -((X - x0)**2 / sigma_x) - ((Y - y0)**2 / sigma_y) + ) + + +def seamount_bathymetry( + X: np.ndarray, + Y: np.ndarray, + h_base: float = 50.0, + x0: float = None, + y0: float = None, + height: float = 45.0, + sigma: float = 20.0, +) -> np.ndarray: + """Create bathymetry with a seamount. + + Parameters + ---------- + X : ndarray + X-coordinate meshgrid + Y : ndarray + Y-coordinate meshgrid + h_base : float + Base ocean depth [m] + x0 : float + Seamount center x-coordinate (default: domain center) + y0 : float + Seamount center y-coordinate (default: domain center) + height : float + Seamount height above seafloor [m] + sigma : float + Width parameter for Gaussian seamount + + Returns + ------- + ndarray + Bathymetry array + """ + if x0 is None: + x0 = (X.max() + X.min()) / 2 + if y0 is None: + y0 = (Y.max() + Y.min()) / 2 + + h = h_base * np.ones_like(X) + h -= height * np.exp(-((X - x0)**2 / sigma) - ((Y - y0)**2 / sigma)) + return h + + +def tanh_bathymetry( + X: np.ndarray, + Y: np.ndarray, + h_deep: float = 50.0, + h_shallow: float = 5.0, + x_transition: float = 70.0, + width: float = 8.0, +) -> np.ndarray: + """Create bathymetry with tanh transition (coastal profile). + + Parameters + ---------- + X : ndarray + X-coordinate meshgrid + Y : ndarray + Y-coordinate meshgrid + h_deep : float + Deep water depth [m] + h_shallow : float + Shallow water depth [m] + x_transition : float + Location of transition + width : float + Width parameter for transition + + Returns + ------- + ndarray + Bathymetry array + """ + return h_deep - (h_deep - h_shallow) * ( + 0.5 * (1 + np.tanh((X - x_transition) / width)) + ) diff --git a/src/wave/wave1D/animate_archives.py b/src/wave/wave1D/animate_archives.py deleted file mode 100644 index dcde341e..00000000 --- a/src/wave/wave1D/animate_archives.py +++ /dev/null @@ -1,260 +0,0 @@ -""" -Given some archives of .npz files with t,x,u0001,u0002,... data, -make a simultaneous animation of the data in the archives. -Ideal for comparing different simulations. -""" - -import glob -import os -import sys -import time - -import numpy as np - - -def animate_multiple_solutions(archives, umin, umax, pause=0.2, show=True): - """ - Animate data in list "archives" of numpy.savez archive files. - Each archive in archives holds t, x, and u0000, u0001, u0002, - and so on. - umin and umax are overall min and max values of the data. - A pause is inserted between each frame in the screen animation. - Each frame is stored in a file tmp_%04d.png (for video making). - - The animation is based on the coarsest time resolution. - Linear interpolation is used to calculate data at these times. - """ - import matplotlib.pyplot as plt - - simulations = [np.load(archive) for archive in archives] - # simulations is list of "dicts", each dict is {'t': array, - # 'x': array, 'u0': array, 'u1': array, ...} - # Must base animation on coarsest resolution - coarsest = np.argmin([len(s["t"]) for s in simulations]) - - # Create plot with all solutions for very first timestep - - n = 0 - sol = "u%04d" % n - # Build plot command - plot_all_sols_at_tn = ( - "lines = plt.plot(" - + ", ".join( - [ - "simulations[%d]['x'], simulations[%d]['%s']" % (sim, sim, sol) - for sim in range(0, len(simulations)) - ] - ) - + ")" - ) - - if show: - plt.ion() - else: - plt.ioff() - - exec(plot_all_sols_at_tn) # run plot command - plt.savefig("tmp_%04d.png" % n) - - plt.xlabel("x") - plt.ylabel("u") - # size of domain is the same for all sims - plt.axis([simulations[0]["x"][0], simulations[0]["x"][-1], umin, umax]) - plt.legend(["t=%.3f" % simulations[0]["t"][n]]) - # How suppress drawing on the screen? - plt.draw() - plt.savefig("tmp_%04d.png" % n) - time.sleep(1) - - # Find legends - t = simulations[coarsest]["t"] - x = simulations[coarsest]["x"] - dt = t[1] - t[0] - dx = x[1] - x[0] - legends = [r"$\Delta x=%.4f, \Delta t=%.4f$" % (dx, dt)] - for i in range(len(simulations)): - if i != coarsest: - t = simulations[i]["t"] - x = simulations[i]["x"] - dt = t[1] - t[0] - dx = x[1] - x[0] - legends.append(r"$\Delta x=%.4f, \Delta t=%.4f$" % (dx, dt)) - - # Plot all solutions at each remaining time step - - # At every time step set_ydata has to be executed - # with the new solutions from all simulations. - # (note that xdata remains unchanged in time) - interpolation_details = [] # for testing - t = simulations[coarsest]["t"] - for n in range(1, len(t)): - sol = "u%04d" % (n) - lines[coarsest].set_ydata(simulations[coarsest][sol]) - interpolation_details.append([]) - for k in range(len(simulations)): - if k != coarsest: - # Interpolate simulations at t[n] (in coarsest - # simulation) among all simulations[k] - a, i, w = interpolate_arrays(simulations[k], simulations[k]["t"], t[n]) - lines[k].set_ydata(a) - interpolation_details[-1].append([t[n], i, w]) - else: - interpolation_details[-1].append("coarsest") - - plt.legend(legends) - plt.title("t=%.3f" % (simulations[0]["t"][n])) - plt.draw() - plt.savefig("tmp_%04d.png" % n) - time.sleep(pause) - return interpolation_details - - -def linear_interpolation(t, tp): - """ - Given an array of time values, with constant spacing, - and some time point tp, determine the data for linear - interpolation: i and w such that - tp = (1-w)*t[i] + w*t[i+1]. If tp happens to equal t[i] - for any i, return i and None. - """ - # Determine time cell - dt = float(t[1] - t[0]) # assumed constant! - i = int(tp / dt) - if abs(tp - t[i]) < 1e-13: - return i, None - # tp = t[i] + w*dt - w = (tp - t[i]) / dt - return i, w - - -def interpolate_arrays(arrays, t, tp): - """ - Given a time point tp and a collection of arrays corresponding - to times t, perform linear interpolation among array i and i+1 - when t[i] < tp < t[i+1]. - arrays can be .npz archive (NpzFile) or list of numpy arrays. - Return interpolated array, i, w (interpolation weight: tp = - (1-w)*t[i] + w*t[i+1]). - """ - i, w = linear_interpolation(t, tp) - - if isinstance(arrays, np.lib.npyio.NpzFile): - # arrays behaves as a dict with keys u1, u2, ... - if w is None: - return arrays["u%04d" % i], i, None - else: - return w * arrays["u%04d" % i] + (1 - w) * arrays["u%04d" % (i + 1)], i, w - - elif isinstance(arrays, (tuple, list)) and isinstance(arrays[0], np.ndarray): - if w is None: - return arrays[i], i, None - else: - return w * arrays[i] + (1 - w) * arrays[i + 1], i, w - else: - raise TypeError( - "arrays is %s, must be NpzFile archive or list arrays" % type(arrays) - ) - - -def demo_animate_multiple_solutions(): - """First run all simulations. Then animate all from archives.""" - # Must delete all archives so we really recompute them - # and get their names from the pulse function - for filename in glob.glob(".*.npz") + glob.glob("tmp_*.png"): - os.remove(filename) - archives = [] - umin = umax = 0 - from wave1D_dn_vc import pulse - - for spatial_resolution in [20, 55, 200]: - archive_name, u_min, u_max = pulse(Nx=spatial_resolution, pulse_tp="gaussian") - archives.append(archive_name) - if u_min < umin: - umin = u_min - if u_max > umax: - umax = u_max - - print(archives) - animate_multiple_solutions(archives, umin, umax, show=True) - cmd = "ffmpeg -i tmp_%04d.png -r 25 -vcodec libtheora movie.ogg" - os.system(cmd) - - -def test_animate_multiple_solutions(): - # Must delete all archives so we really recompute them - # and get their names from the pulse function - for filename in glob.glob(".*.npz") + glob.glob("tmp_*.png"): - os.remove(filename) - archives = [] - umin = umax = 0 - from wave1D_dn_vc import pulse - - for spatial_resolution in [20, 45, 100]: - archive_name, u_min, u_max = pulse(Nx=spatial_resolution, pulse_tp="gaussian") - archives.append(archive_name) - if u_min < umin: - umin = u_min - if u_max > umax: - umax = u_max - - print(archives) - details = animate_multiple_solutions(archives, umin, umax, show=False) - # Round data: - for i in range(len(details)): - for j in range(len(details[i])): - if details[i][j] == "coarsest": - continue - details[i][j][0] = round(details[i][j][0], 4) - if isinstance(details[i][j][2], float): - details[i][j][2] = round(details[i][j][2], 4) - expected = [ - ["coarsest", [0.05, 2, 0.25], [0.05, 5, None]], - ["coarsest", [0.1, 4, 0.5], [0.1, 10, None]], - ["coarsest", [0.15, 6, 0.75], [0.15, 15, None]], - ["coarsest", [0.2, 9, None], [0.2, 20, None]], - ["coarsest", [0.25, 11, 0.25], [0.25, 25, None]], - ["coarsest", [0.3, 13, 0.5], [0.3, 30, None]], - ["coarsest", [0.35, 15, 0.75], [0.35, 35, None]], - ["coarsest", [0.4, 18, None], [0.4, 40, None]], - ["coarsest", [0.45, 20, 0.25], [0.45, 45, None]], - ["coarsest", [0.5, 22, 0.5], [0.5, 50, None]], - ["coarsest", [0.55, 24, 0.75], [0.55, 55, None]], - ["coarsest", [0.6, 27, None], [0.6, 60, None]], - ["coarsest", [0.65, 29, 0.25], [0.65, 65, None]], - ["coarsest", [0.7, 31, 0.5], [0.7, 70, None]], - ["coarsest", [0.75, 33, 0.75], [0.75, 75, None]], - ["coarsest", [0.8, 36, None], [0.8, 80, None]], - ["coarsest", [0.85, 38, 0.25], [0.85, 85, None]], - ["coarsest", [0.9, 40, 0.5], [0.9, 90, None]], - ["coarsest", [0.95, 42, 0.75], [0.95, 95, None]], - ["coarsest", [1.0, 45, None], [1.0, 100, None]], - ["coarsest", [1.05, 47, 0.25], [1.05, 105, None]], - ["coarsest", [1.1, 49, 0.5], [1.1, 110, None]], - ["coarsest", [1.15, 51, 0.75], [1.15, 115, None]], - ["coarsest", [1.2, 54, None], [1.2, 120, None]], - ["coarsest", [1.25, 56, 0.25], [1.25, 125, None]], - ["coarsest", [1.3, 58, 0.5], [1.3, 130, None]], - ["coarsest", [1.35, 60, 0.75], [1.35, 135, None]], - ["coarsest", [1.4, 63, None], [1.4, 140, None]], - ["coarsest", [1.45, 65, 0.25], [1.45, 145, None]], - ["coarsest", [1.5, 67, 0.5], [1.5, 150, None]], - ["coarsest", [1.55, 69, 0.75], [1.55, 155, None]], - ["coarsest", [1.6, 72, None], [1.6, 160, None]], - ["coarsest", [1.65, 74, 0.25], [1.65, 165, None]], - ["coarsest", [1.7, 76, 0.5], [1.7, 170, None]], - ["coarsest", [1.75, 78, 0.75], [1.75, 175, None]], - ["coarsest", [1.8, 81, None], [1.8, 180, None]], - ["coarsest", [1.85, 83, 0.25], [1.85, 185, None]], - ["coarsest", [1.9, 85, 0.5], [1.9, 190, None]], - ["coarsest", [1.95, 87, 0.75], [1.95, 195, None]], - ["coarsest", [2.0, 90, None], [2.0, 200, None]], - ] - assert details == expected - - -if __name__ == "__main__": - # test_animate_multiple_solutions() - umin = float(sys.argv[1]) - umax = float(sys.argv[2]) - archives = sys.argv[3:] - animate_multiple_solutions(archives, umin, umax, pause=0.2, show=True) diff --git a/src/wave/wave1D/wave1D_dn.py b/src/wave/wave1D/wave1D_dn.py deleted file mode 100644 index 071163bf..00000000 --- a/src/wave/wave1D/wave1D_dn.py +++ /dev/null @@ -1,646 +0,0 @@ -#!/usr/bin/env python -""" -1D wave equation with Dirichlet or Neumann conditions:: - - u, x, t, cpu = solver(I, V, f, c, U_0, U_L, L, dt, C, T, - user_action, version='scalar') - -Function solver solves the wave equation - - u_tt = c**2*u_xx + f(x,t) on - -(0,L) with u=U_0 or du/dn=0 on x=0, and u=u_L or du/dn=0 -on x = L. If U_0 or U_L equals None, the du/dn=0 condition -is used, otherwise U_0(t) and/or U_L(t) are used for Dirichlet cond. -Initial conditions: u=I(x), u_t=V(x). - -T is the stop time for the simulation. -dt is the desired time step. -C is the Courant number (=c*dt/dx). - -I, f, U_0, U_L are functions: I(x), f(x,t), U_0(t), U_L(t). -U_0 and U_L can also be 0, or None, where None implies -du/dn=0 boundary condition. f and V can also be 0 or None -(equivalent to 0). - -user_action is a function of (u, x, t, n) where the calling code -can add visualization, error computations, data analysis, -store solutions, etc. - -Function viz:: - - viz(I, V, f, c, U_0, U_L, L, dt, C, T, umin, umax, - version='scalar', animate=True) - -calls solver with a user_action function that can plot the -solution on the screen (as an animation). -""" - -import matplotlib.pyplot as plt -import numpy as np - - -def solver(I, V, f, c, U_0, U_L, L, dt, C, T, user_action=None, version="scalar"): - """ - Solve u_tt=c^2*u_xx + f on (0,L)x(0,T]. - u(0,t)=U_0(t) or du/dn=0 (U_0=None), u(L,t)=U_L(t) or du/dn=0 (u_L=None). - """ - Nt = int(round(T / dt)) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points in time - dx = dt * c / float(C) - Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - C2 = C**2 - dt2 = dt * dt # Help variables in the scheme - # Make sure dx and dt are compatible with x and t - dx = x[1] - x[0] - dt = t[1] - t[0] - - # Wrap user-given f, I, V, U_0, U_L if None or 0 - if f is None or f == 0: - f = (lambda x, t: 0) if version == "scalar" else lambda x, t: np.zeros(x.shape) - if I is None or I == 0: - I = (lambda x: 0) if version == "scalar" else lambda x: np.zeros(x.shape) - if V is None or V == 0: - V = (lambda x: 0) if version == "scalar" else lambda x: np.zeros(x.shape) - if U_0 is not None: - if isinstance(U_0, (float, int)) and U_0 == 0: - U_0 = lambda t: 0 - # else: U_0(t) is a function - if U_L is not None: - if isinstance(U_L, (float, int)) and U_L == 0: - U_L = lambda t: 0 - # else: U_L(t) is a function - - u = np.zeros(Nx + 1) # Solution array at new time level - u_n = np.zeros(Nx + 1) # Solution at 1 time level back - u_nm1 = np.zeros(Nx + 1) # Solution at 2 time levels back - - Ix = range(0, Nx + 1) - It = range(0, Nt + 1) - - import time - - t0 = time.perf_counter() # CPU time measurement - - # Load initial condition into u_n - for i in Ix: - u_n[i] = I(x[i]) - - if user_action is not None: - user_action(u_n, x, t, 0) - - # Special formula for the first step - for i in Ix[1:-1]: - u[i] = ( - u_n[i] - + dt * V(x[i]) - + 0.5 * C2 * (u_n[i - 1] - 2 * u_n[i] + u_n[i + 1]) - + 0.5 * dt2 * f(x[i], t[0]) - ) - - i = Ix[0] - if U_0 is None: - # Set boundary values du/dn = 0 - # x=0: i-1 -> i+1 since u[i-1]=u[i+1] - # x=L: i+1 -> i-1 since u[i+1]=u[i-1]) - ip1 = i + 1 - im1 = ip1 # i-1 -> i+1 - u[i] = ( - u_n[i] - + dt * V(x[i]) - + 0.5 * C2 * (u_n[im1] - 2 * u_n[i] + u_n[ip1]) - + 0.5 * dt2 * f(x[i], t[0]) - ) - else: - u[0] = U_0(dt) - - i = Ix[-1] - if U_L is None: - im1 = i - 1 - ip1 = im1 # i+1 -> i-1 - u[i] = ( - u_n[i] - + dt * V(x[i]) - + 0.5 * C2 * (u_n[im1] - 2 * u_n[i] + u_n[ip1]) - + 0.5 * dt2 * f(x[i], t[0]) - ) - else: - u[i] = U_L(dt) - - if user_action is not None: - user_action(u, x, t, 1) - - # Update data structures for next step - # u_nm1[:] = u_n; u_n[:] = u # safe, but slower - u_nm1, u_n, u = u_n, u, u_nm1 - - for n in It[1:-1]: - # Update all inner points - if version == "scalar": - for i in Ix[1:-1]: - u[i] = ( - -u_nm1[i] - + 2 * u_n[i] - + C2 * (u_n[i - 1] - 2 * u_n[i] + u_n[i + 1]) - + dt2 * f(x[i], t[n]) - ) - - elif version == "vectorized": - u[1:-1] = ( - -u_nm1[1:-1] - + 2 * u_n[1:-1] - + C2 * (u_n[0:-2] - 2 * u_n[1:-1] + u_n[2:]) - + dt2 * f(x[1:-1], t[n]) - ) - else: - raise ValueError("version=%s" % version) - - # Insert boundary conditions - i = Ix[0] - if U_0 is None: - # Set boundary values - # x=0: i-1 -> i+1 since u[i-1]=u[i+1] when du/dn=0 - # x=L: i+1 -> i-1 since u[i+1]=u[i-1] when du/dn=0 - ip1 = i + 1 - im1 = ip1 - u[i] = ( - -u_nm1[i] - + 2 * u_n[i] - + C2 * (u_n[im1] - 2 * u_n[i] + u_n[ip1]) - + dt2 * f(x[i], t[n]) - ) - else: - u[0] = U_0(t[n + 1]) - - i = Ix[-1] - if U_L is None: - im1 = i - 1 - ip1 = im1 - u[i] = ( - -u_nm1[i] - + 2 * u_n[i] - + C2 * (u_n[im1] - 2 * u_n[i] + u_n[ip1]) - + dt2 * f(x[i], t[n]) - ) - else: - u[i] = U_L(t[n + 1]) - - if user_action is not None: - if user_action(u, x, t, n + 1): - break - - # Update data structures for next step - # u_nm1[:] = u_n; u_n[:] = u # safe, but slower - u_nm1, u_n, u = u_n, u, u_nm1 - - # Important to correct the mathematically wrong u=u_nm1 above - # before returning u - u = u_n - cpu_time = time.perf_counter() - t0 - return u, x, t, cpu_time - - -def viz(I, V, f, c, U_0, U_L, L, dt, C, T, umin, umax, version="scalar", animate=True): - """Run solver and visualize u at each time level.""" - import glob - import os - import time - - if callable(U_0): - bc_left = "u(0,t)=U_0(t)" - elif U_0 is None: - bc_left = "du(0,t)/dx=0" - else: - bc_left = "u(0,t)=0" - if callable(U_L): - bc_right = "u(L,t)=U_L(t)" - elif U_L is None: - bc_right = "du(L,t)/dx=0" - else: - bc_right = "u(L,t)=0" - - def plot_u(u, x, t, n): - """user_action function for solver.""" - plt.clf() - plt.plot(x, u, "r-") - plt.xlabel("x") - plt.ylabel("u") - plt.axis([0, L, umin, umax]) - plt.title("t=%.3f, %s, %s" % (t[n], bc_left, bc_right)) - plt.draw() - plt.pause(0.001) - # Let the initial condition stay on the screen for 2 - # seconds, else insert a pause of 0.2 s between each plot - time.sleep(2) if t[n] == 0 else time.sleep(0.2) - plt.savefig("frame_%04d.png" % n) # for movie making - - # Clean up old movie frames - for filename in glob.glob("frame_*.png"): - os.remove(filename) - - user_action = plot_u if animate else None - u, x, t, cpu = solver(I, V, f, c, U_0, U_L, L, dt, C, T, user_action, version) - if animate: - # Make movie formats using ffmpeg: Flash, Webm, Ogg, MP4 - codec2ext = dict(flv="flv", libx264="mp4", libvpx="webm", libtheora="ogg") - fps = 6 - filespec = "frame_%04d.png" - movie_program = "ffmpeg" - for codec in codec2ext: - ext = codec2ext[codec] - cmd = ( - "%(movie_program)s -r %(fps)d -i %(filespec)s " - "-vcodec %(codec)s movie.%(ext)s" % vars() - ) - print(cmd) - os.system(cmd) - return cpu - - -def test_constant(): - """ - Check the scalar and vectorized versions for - a constant u(x,t). We simulate in [0, L] and apply - Neumann and Dirichlet conditions at both ends. - """ - u_const = 0.45 - u_exact = lambda x, t: u_const - I = lambda x: u_exact(x, 0) - V = lambda x: 0 - f = lambda x, t: 0 - - def assert_no_error(u, x, t, n): - u_e = u_exact(x, t[n]) - diff = np.abs(u - u_e).max() - msg = "diff=%E, t_%d=%g" % (diff, n, t[n]) - tol = 1e-13 - assert diff < tol, msg - - for U_0 in (None, lambda t: u_const): - for U_L in (None, lambda t: u_const): - L = 2.5 - c = 1.5 - C = 0.75 - Nx = 3 # Very coarse mesh for this exact test - dt = C * (L / Nx) / c - T = 18 # long time integration - - solver( - I, - V, - f, - c, - U_0, - U_L, - L, - dt, - C, - T, - user_action=assert_no_error, - version="scalar", - ) - solver( - I, - V, - f, - c, - U_0, - U_L, - L, - dt, - C, - T, - user_action=assert_no_error, - version="vectorized", - ) - print(U_0, U_L) - - -def test_quadratic(): - """ - Check the scalar and vectorized versions for - a quadratic u(x,t)=x(L-x)(1+t/2) that is exactly reproduced. - We simulate in [0, L]. - Note: applying a symmetry condition at the end x=L/2 - (U_0=None, L=L/2 in call to solver) is *not* exactly reproduced - because of the numerics in the boundary condition implementation. - """ - u_exact = lambda x, t: x * (L - x) * (1 + 0.5 * t) - I = lambda x: u_exact(x, 0) - V = lambda x: 0.5 * u_exact(x, 0) - f = lambda x, t: 2 * (1 + 0.5 * t) * c**2 - U_0 = lambda t: u_exact(0, t) - U_L = None - U_L = 0 - L = 2.5 - c = 1.5 - C = 0.75 - Nx = 3 # Very coarse mesh for this exact test - dt = C * (L / Nx) / c - T = 18 # long time integration - - def assert_no_error(u, x, t, n): - u_e = u_exact(x, t[n]) - diff = np.abs(u - u_e).max() - msg = "diff=%E, t_%d=%g" % (diff, n, t[n]) - tol = 1e-13 - assert diff < tol, msg - - solver( - I, V, f, c, U_0, U_L, L, dt, C, T, user_action=assert_no_error, version="scalar" - ) - solver( - I, - V, - f, - c, - U_0, - U_L, - L, - dt, - C, - T, - user_action=assert_no_error, - version="vectorized", - ) - - -def plug(C=1, Nx=50, animate=True, version="scalar", T=2, loc=0.5, bc_left="u=0", ic="u"): - """Plug profile as initial condition.""" - L = 1.0 - c = 1 - - def I(x): - if abs(x - loc) > 0.1: - return 0 - else: - return 1 - - u_L = 0 if bc_left == "u=0" else None - dt = (L / Nx) / c # choose the stability limit with given Nx - if ic == "u": - # u(x,0)=plug, u_t(x,0)=0 - cpu = viz( - lambda x: 0 if abs(x - loc) > 0.1 else 1, - None, - None, - c, - u_L, - None, - L, - dt, - C, - T, - umin=-1.1, - umax=1.1, - version=version, - animate=animate, - ) - else: - # u(x,0)=0, u_t(x,0)=plug - cpu = viz( - None, - lambda x: 0 if abs(x - loc) > 0.1 else 1, - None, - c, - u_L, - None, - L, - dt, - C, - T, - umin=-0.25, - umax=0.25, - version=version, - animate=animate, - ) - - -def gaussian( - C=1, Nx=50, animate=True, version="scalar", T=1, loc=5, bc_left="u=0", ic="u" -): - """Gaussian function as initial condition.""" - L = 10.0 - c = 10 - sigma = 0.5 - - def G(x): - return 1 / sqrt(2 * pi * sigma) * exp(-0.5 * ((x - loc) / sigma) ** 2) - - u_L = 0 if bc_left == "u=0" else None - dt = (L / Nx) / c # choose the stability limit with given Nx - umax = 1.1 * G(loc) - if ic == "u": - # u(x,0)=Gaussian, u_t(x,0)=0 - cpu = viz( - G, - None, - None, - c, - u_L, - None, - L, - dt, - C, - T, - umin=-umax, - umax=umax, - version=version, - animate=animate, - ) - else: - # u(x,0)=0, u_t(x,0)=Gaussian - cpu = viz( - None, - G, - None, - c, - u_L, - None, - L, - dt, - C, - T, - umin=-umax / 6, - umax=umax / 6, - version=version, - animate=animate, - ) - - -def test_plug(): - """Check that an initial plug is correct back after one period.""" - L = 1.0 - c = 0.5 - dt = (L / 10) / c # Nx=10 - I = lambda x: 0 if abs(x - L / 2.0) > 0.1 else 1 - - u_s, x, t, cpu = solver( - I=I, - V=None, - f=None, - c=0.5, - U_0=None, - U_L=None, - L=L, - dt=dt, - C=1, - T=4, - user_action=None, - version="scalar", - ) - u_v, x, t, cpu = solver( - I=I, - V=None, - f=None, - c=0.5, - U_0=None, - U_L=None, - L=L, - dt=dt, - C=1, - T=4, - user_action=None, - version="vectorized", - ) - tol = 1e-13 - diff = abs(u_s - u_v).max() - assert diff < tol - u_0 = np.array([I(x_) for x_ in x]) - diff = np.abs(u_s - u_0).max() - assert diff < tol - - -def guitar(C=1, Nx=50, animate=True, version="scalar", T=2): - """Triangular initial condition for simulating a guitar string.""" - L = 1.0 - c = 1 - x0 = 0.8 * L - dt = L / Nx / c # choose the stability limit (if C<1, dx gets larger) - I = lambda x: x / x0 if x < x0 else 1.0 / (1 - x0) * (1 - x) - - cpu = viz( - I, - None, - None, - c, - U_0, - U_L, - L, - dt, - C, - T, - umin=-1.1, - umax=1.1, - version=version, - animate=True, - ) - print("CPU time: %s version =" % version, cpu) - - -def moving_end(C=1, Nx=50, reflecting_right_boundary=True, T=2, version="vectorized"): - """ - Sinusoidal variation of u at the left end. - Right boundary can be reflecting or have u=0, according to - reflecting_right_boundary. - """ - L = 1.0 - c = 1 - dt = L / Nx / c # choose the stability limit (if C<1, dx gets larger) - I = lambda x: 0 - - def U_0(t): - return ( - 0.25 * sin(6 * pi * t) - if ( - (t < 1.0 / 6) - or (0.5 + 3.0 / 12 <= t <= 0.5 + 4.0 / 12 + 0.0001) - or (1.5 <= t <= 1.5 + 1.0 / 3 + 0.0001) - ) - else 0 - ) - - if reflecting_right_boundary: - U_L = None - else: - U_L = 0 - umax = 1.1 * 0.5 - cpu = viz( - I, - None, - None, - c, - U_0, - U_L, - L, - dt, - C, - T, - umin=-umax, - umax=umax, - version=version, - animate=True, - ) - print("CPU time: %s version =" % version, cpu) - - -def sincos(C=1): - """Test of exact analytical solution (sine in space, cosine in time).""" - L = 10.0 - c = 1 - T = 5 - Nx = 80 - dt = (L / Nx) / c # choose the stability limit with given Nx - - def u_exact(x, t): - m = 3.0 - return cos(m * pi / L * t) * sin(m * pi / (2 * L) * x) - - I = lambda x: u_exact(x, 0) - U_0 = lambda t: u_exact(0, t) - U_L = None # Neumann condition - - cpu = viz( - I, - None, - None, - c, - U_0, - U_L, - L, - dt, - C, - T, - umin=-1.1, - umax=1.1, - version="scalar", - animate=True, - ) - - # Convergence study - def action(u, x, t, n): - e = np.abs(u - exact(x, t[n])).max() - errors_in_time.append(e) - - E = [] - dt = [] - Nx_values = [10, 20, 40, 80, 160] - for Nx in Nx_values: - errors_in_time = [] - dt = (L / Nx) / c - solver( - I, None, None, c, U_0, U_L, L, dt, C, T, user_action=action, version="scalar" - ) - E.append(max(errors_in_time)) - _dx = L / Nx - _dt = C * _dx / c - dt.append(_dt) - print(dt[-1], E[-1]) - return dt, E - - -if __name__ == "__main__": - test_constant() - test_quadratic() - test_plug() diff --git a/src/wave/wave1D/wave1D_dn_vc.py b/src/wave/wave1D/wave1D_dn_vc.py deleted file mode 100644 index 88882de8..00000000 --- a/src/wave/wave1D/wave1D_dn_vc.py +++ /dev/null @@ -1,732 +0,0 @@ -#!/usr/bin/env python -""" -1D wave equation with Dirichlet or Neumann conditions -and variable wave velocity:: - - u, x, t, cpu = solver(I, V, f, c, U_0, U_L, L, dt, C, T, - user_action=None, version='scalar', - stability_safety_factor=1.0) - -Solve the wave equation u_tt = (c**2*u_x)_x + f(x,t) on (0,L) with -u=U_0 or du/dn=0 on x=0, and u=u_L or du/dn=0 -on x = L. If U_0 or U_L equals None, the du/dn=0 condition -is used, otherwise U_0(t) and/or U_L(t) are used for Dirichlet cond. -Initial conditions: u=I(x), u_t=V(x). - -T is the stop time for the simulation. -dt is the desired time step. -C is the Courant number (=max(c)*dt/dx). -stability_safety_factor enters the stability criterion: -C <= stability_safety_factor (<=1). - -I, f, U_0, U_L, and c are functions: I(x), f(x,t), U_0(t), -U_L(t), c(x). -U_0 and U_L can also be 0, or None, where None implies -du/dn=0 boundary condition. f and V can also be 0 or None -(equivalent to 0). c can be a number or a function c(x). - -user_action is a function of (u, x, t, n) where the calling code -can add visualization, error computations, data analysis, -store solutions, etc. -""" -import glob -import os -import shutil -import time - -import numpy as np - - -def solver( - I, V, f, c, U_0, U_L, L, dt, C, T, - user_action=None, version='scalar', - stability_safety_factor=1.0): - """Solve u_tt=(c^2*u_x)_x + f on (0,L)x(0,T].""" - - # --- Compute time and space mesh --- - Nt = int(round(T/dt)) - t = np.linspace(0, Nt*dt, Nt+1) # Mesh points in time - - # Find max(c) using a fake mesh and adapt dx to C and dt - if isinstance(c, (float,int)): - c_max = c - elif callable(c): - c_max = max([c(x_) for x_ in np.linspace(0, L, 101)]) - dx = dt*c_max/(stability_safety_factor*C) - Nx = int(round(L/dx)) - x = np.linspace(0, L, Nx+1) # Mesh points in space - # Make sure dx and dt are compatible with x and t - dx = x[1] - x[0] - dt = t[1] - t[0] - - # Make c(x) available as array - if isinstance(c, (float,int)): - c = np.zeros(x.shape) + c - elif callable(c): - # Call c(x) and fill array c - c_ = np.zeros(x.shape) - for i in range(Nx+1): - c_[i] = c(x[i]) - c = c_ - - q = c**2 - C2 = (dt/dx)**2; dt2 = dt*dt # Help variables in the scheme - - # --- Wrap user-given f, I, V, U_0, U_L if None or 0 --- - if f is None or f == 0: - f = (lambda x, t: 0) if version == 'scalar' else \ - lambda x, t: np.zeros(x.shape) - if I is None or I == 0: - I = (lambda x: 0) if version == 'scalar' else \ - lambda x: np.zeros(x.shape) - if V is None or V == 0: - V = (lambda x: 0) if version == 'scalar' else \ - lambda x: np.zeros(x.shape) - if U_0 is not None: - if isinstance(U_0, (float,int)) and U_0 == 0: - U_0 = lambda t: 0 - if U_L is not None: - if isinstance(U_L, (float,int)) and U_L == 0: - U_L = lambda t: 0 - - # --- Make hash of all input data --- - import hashlib - import inspect - data = inspect.getsource(I) + '_' + inspect.getsource(V) + \ - '_' + inspect.getsource(f) + '_' + str(c) + '_' + \ - ('None' if U_0 is None else inspect.getsource(U_0)) + \ - ('None' if U_L is None else inspect.getsource(U_L)) + \ - '_' + str(L) + str(dt) + '_' + str(C) + '_' + str(T) + \ - '_' + str(stability_safety_factor) - hashed_input = hashlib.sha1(data).hexdigest() - if os.path.isfile('.' + hashed_input + '_archive.npz'): - # Simulation is already run - return -1, hashed_input - - # --- Allocate memory for solutions --- - u = np.zeros(Nx+1) # Solution array at new time level - u_n = np.zeros(Nx+1) # Solution at 1 time level back - u_nm1 = np.zeros(Nx+1) # Solution at 2 time levels back - - import time; t0 = time.perf_counter() # CPU time measurement - # --- Valid indices for space and time mesh --- - Ix = range(0, Nx+1) - It = range(0, Nt+1) - - # --- Load initial condition into u_n --- - for i in range(0,Nx+1): - u_n[i] = I(x[i]) - - if user_action is not None: - user_action(u_n, x, t, 0) - - # --- Special formula for the first step --- - for i in Ix[1:-1]: - u[i] = u_n[i] + dt*V(x[i]) + \ - 0.5*C2*(0.5*(q[i] + q[i+1])*(u_n[i+1] - u_n[i]) - \ - 0.5*(q[i] + q[i-1])*(u_n[i] - u_n[i-1])) + \ - 0.5*dt2*f(x[i], t[0]) - - i = Ix[0] - if U_0 is None: - # Set boundary values (x=0: i-1 -> i+1 since u[i-1]=u[i+1] - # when du/dn = 0, on x=L: i+1 -> i-1 since u[i+1]=u[i-1]) - ip1 = i+1 - im1 = ip1 # i-1 -> i+1 - u[i] = u_n[i] + dt*V(x[i]) + \ - 0.5*C2*(0.5*(q[i] + q[ip1])*(u_n[ip1] - u_n[i]) - \ - 0.5*(q[i] + q[im1])*(u_n[i] - u_n[im1])) + \ - 0.5*dt2*f(x[i], t[0]) - else: - u[i] = U_0(dt) - - i = Ix[-1] - if U_L is None: - im1 = i-1 - ip1 = im1 # i+1 -> i-1 - u[i] = u_n[i] + dt*V(x[i]) + \ - 0.5*C2*(0.5*(q[i] + q[ip1])*(u_n[ip1] - u_n[i]) - \ - 0.5*(q[i] + q[im1])*(u_n[i] - u_n[im1])) + \ - 0.5*dt2*f(x[i], t[0]) - else: - u[i] = U_L(dt) - - if user_action is not None: - user_action(u, x, t, 1) - - # Update data structures for next step - #u_nm1[:] = u_n; u_n[:] = u # safe, but slower - u_nm1, u_n, u = u_n, u, u_nm1 - - # --- Time loop --- - for n in It[1:-1]: - # Update all inner points - if version == 'scalar': - for i in Ix[1:-1]: - u[i] = - u_nm1[i] + 2*u_n[i] + \ - C2*(0.5*(q[i] + q[i+1])*(u_n[i+1] - u_n[i]) - \ - 0.5*(q[i] + q[i-1])*(u_n[i] - u_n[i-1])) + \ - dt2*f(x[i], t[n]) - - elif version == 'vectorized': - u[1:-1] = - u_nm1[1:-1] + 2*u_n[1:-1] + \ - C2*(0.5*(q[1:-1] + q[2:])*(u_n[2:] - u_n[1:-1]) - - 0.5*(q[1:-1] + q[:-2])*(u_n[1:-1] - u_n[:-2])) + \ - dt2*f(x[1:-1], t[n]) - else: - raise ValueError('version=%s' % version) - - # Insert boundary conditions - i = Ix[0] - if U_0 is None: - # Set boundary values - # x=0: i-1 -> i+1 since u[i-1]=u[i+1] when du/dn=0 - # x=L: i+1 -> i-1 since u[i+1]=u[i-1] when du/dn=0 - ip1 = i+1 - im1 = ip1 - u[i] = - u_nm1[i] + 2*u_n[i] + \ - C2*(0.5*(q[i] + q[ip1])*(u_n[ip1] - u_n[i]) - \ - 0.5*(q[i] + q[im1])*(u_n[i] - u_n[im1])) + \ - dt2*f(x[i], t[n]) - else: - u[i] = U_0(t[n+1]) - - i = Ix[-1] - if U_L is None: - im1 = i-1 - ip1 = im1 - u[i] = - u_nm1[i] + 2*u_n[i] + \ - C2*(0.5*(q[i] + q[ip1])*(u_n[ip1] - u_n[i]) - \ - 0.5*(q[i] + q[im1])*(u_n[i] - u_n[im1])) + \ - dt2*f(x[i], t[n]) - else: - u[i] = U_L(t[n+1]) - - if user_action is not None: - if user_action(u, x, t, n+1): - break - - # Update data structures for next step - u_nm1, u_n, u = u_n, u, u_nm1 - - cpu_time = time.perf_counter() - t0 - return cpu_time, hashed_input - - -def test_quadratic(): - """ - Check the scalar and vectorized versions for - a quadratic u(x,t)=x(L-x)(1+t/2) that is exactly reproduced, - provided c(x) is constant. - We simulate in [0, L/2] and apply a symmetry condition - at the end x=L/2. - """ - u_exact = lambda x, t: x*(L-x)*(1+0.5*t) - I = lambda x: u_exact(x, 0) - V = lambda x: 0.5*u_exact(x, 0) - f = lambda x, t: 2*(1+0.5*t)*c**2 - U_0 = lambda t: u_exact(0, t) - U_L = None - L = 2.5 - c = 1.5 - C = 0.75 - Nx = 3 # Very coarse mesh for this exact test - dt = C*((L/2)/Nx)/c - T = 18 # long time integration - - def assert_no_error(u, x, t, n): - u_e = u_exact(x, t[n]) - diff = np.abs(u - u_e).max() - tol = 1E-13 - assert diff < tol - - solver( - I, V, f, c, U_0, U_L, L/2, dt, C, T, - user_action=assert_no_error, version='scalar', - stability_safety_factor=1) - solver( - I, V, f, c, U_0, U_L, L/2, dt, C, T, - user_action=assert_no_error, version='vectorized', - stability_safety_factor=1) - -def test_plug(): - """Check that an initial plug is correct back after one period.""" - L = 1. - c = 0.5 - dt = (L/10)/c # Nx=10 - I = lambda x: 0 if abs(x-L/2.0) > 0.1 else 1 - - class Action: - """Store last solution.""" - def __call__(self, u, x, t, n): - if n == len(t)-1: - self.u = u.copy() - self.x = x.copy() - self.t = t[n] - - action = Action() - - solver( - I=I, - V=None, f=None, c=c, U_0=None, U_L=None, L=L, - dt=dt, C=1, T=4, user_action=action, version='scalar') - u_s = action.u - solver( - I=I, - V=None, f=None, c=c, U_0=None, U_L=None, L=L, - dt=dt, C=1, T=4, user_action=action, version='vectorized') - u_v = action.u - diff = np.abs(u_s - u_v).max() - tol = 1E-13 - assert diff < tol - u_0 = np.array([I(x_) for x_ in action.x]) - diff = np.abs(u_s - u_0).max() - assert diff < tol - -def merge_zip_archives(individual_archives, archive_name): - """ - Merge individual zip archives made with numpy.savez into - one archive with name archive_name. - The individual archives can be given as a list of names - or as a Unix wild chard filename expression for glob.glob. - The result of this function is that all the individual - archives are deleted and the new single archive made. - """ - import zipfile - archive = zipfile.ZipFile( - archive_name, 'w', zipfile.ZIP_DEFLATED, - allowZip64=True) - if isinstance(individual_archives, (list,tuple)): - filenames = individual_archives - elif isinstance(individual_archives, str): - filenames = glob.glob(individual_archives) - - # Open each archive and write to the common archive - for filename in filenames: - f = zipfile.ZipFile(filename, 'r', - zipfile.ZIP_DEFLATED) - for name in f.namelist(): - data = f.open(name, 'r') - # Save under name without .npy - archive.writestr(name[:-4], data.read()) - f.close() - os.remove(filename) - archive.close() - -class PlotAndStoreSolution: - """ - Class for the user_action function in solver. - Visualizes the solution only. - """ - def __init__( - self, - casename='tmp', # Prefix in filenames - umin=-1, umax=1, # Fixed range of y axis - pause_between_frames=None, # Movie speed - screen_movie=True, # Show movie on screen? - title='', # Extra message in title - skip_frame=1, # Skip every skip_frame frame - filename=None): # Name of file with solutions - self.casename = casename - self.yaxis = [umin, umax] - self.pause = pause_between_frames - import matplotlib.pyplot as plt - self.plt = plt - self.screen_movie = screen_movie - self.title = title - self.skip_frame = skip_frame - self.filename = filename - if filename is not None: - # Store time points when u is written to file - self.t = [] - filenames = glob.glob('.' + self.filename + '*.dat.npz') - for filename in filenames: - os.remove(filename) - - # Clean up old movie frames - for filename in glob.glob('frame_*.png'): - os.remove(filename) - - def __call__(self, u, x, t, n): - """ - Callback function user_action, call by solver: - Store solution, plot on screen and save to file. - """ - # Save solution u to a file using numpy.savez - if self.filename is not None: - name = 'u%04d' % n # array name - kwargs = {name: u} - fname = '.' + self.filename + '_' + name + '.dat' - np.savez(fname, **kwargs) - self.t.append(t[n]) # store corresponding time value - if n == 0: # save x once - np.savez('.' + self.filename + '_x.dat', x=x) - - # Animate - if n % self.skip_frame != 0: - return - title = 't=%.3f' % t[n] - if self.title: - title = self.title + ' ' + title - - # matplotlib animation - if n == 0: - self.plt.ion() - self.lines = self.plt.plot(x, u, 'r-') - self.plt.axis([x[0], x[-1], - self.yaxis[0], self.yaxis[1]]) - self.plt.xlabel('x') - self.plt.ylabel('u') - self.plt.title(title) - self.plt.legend(['t=%.3f' % t[n]]) - else: - # Update new solution - self.lines[0].set_ydata(u) - self.plt.legend(['t=%.3f' % t[n]]) - self.plt.draw() - - # pause - if t[n] == 0: - time.sleep(2) # let initial condition stay 2 s - else: - if self.pause is None: - pause = 0.2 if u.size < 100 else 0 - time.sleep(pause) - - self.plt.savefig('frame_%04d.png' % (n)) - - def make_movie_file(self): - """ - Create subdirectory based on casename, move all plot - frame files to this directory, and generate - an index.html for viewing the movie in a browser - (as a sequence of PNG files). - """ - # Make HTML movie in a subdirectory - directory = self.casename - - if os.path.isdir(directory): - shutil.rmtree(directory) # rm -rf directory - os.mkdir(directory) # mkdir directory - # mv frame_*.png directory - for filename in glob.glob('frame_*.png'): - os.rename(filename, os.path.join(directory, filename)) - os.chdir(directory) # cd directory - - fps = 24 # frames per second - - # Make movie formats using ffmpeg: Flash, Webm, Ogg, MP4 - codec2ext = dict(flv='flv', libx264='mp4', libvpx='webm', - libtheora='ogg') - filespec = 'frame_%04d.png' - movie_program = 'ffmpeg' # or 'avconv' - for codec in codec2ext: - ext = codec2ext[codec] - cmd = '%(movie_program)s -r %(fps)d -i %(filespec)s '\ - '-vcodec %(codec)s movie.%(ext)s' % vars() - os.system(cmd) - - os.chdir(os.pardir) # move back to parent directory - - def close_file(self, hashed_input): - """ - Merge all files from savez calls into one archive. - hashed_input is a string reflecting input data - for this simulation (made by solver). - """ - if self.filename is not None: - # Save all the time points where solutions are saved - np.savez('.' + self.filename + '_t.dat', - t=np.array(self.t, dtype=float)) - - # Merge all savez files to one zip archive - archive_name = '.' + hashed_input + '_archive.npz' - filenames = glob.glob('.' + self.filename + '*.dat.npz') - merge_zip_archives(filenames, archive_name) - print('Archive name:', archive_name) - # data = numpy.load(archive); data.files holds names - # data[name] extract the array - -def demo_BC_plug(C=1, Nx=40, T=4): - """Demonstrate u=0 and u_x=0 boundary conditions with a plug.""" - action = PlotAndStoreSolution( - 'plug', -1.3, 1.3, skip_frame=1, - title='u(0,t)=0, du(L,t)/dn=0.', filename='tmpdata') - # Scaled problem: L=1, c=1, max I=1 - L = 1. - dt = (L/Nx)/C # choose the stability limit with given Nx - cpu, hashed_input = solver( - I=lambda x: 0 if abs(x-L/2.0) > 0.1 else 1, - V=0, f=0, c=1, U_0=lambda t: 0, U_L=None, L=L, - dt=dt, C=C, T=T, - user_action=action, version='vectorized', - stability_safety_factor=1) - action.make_movie_file() - if cpu > 0: # did we generate new data? - action.close_file(hashed_input) - print('cpu:', cpu) - -def demo_BC_gaussian(C=1, Nx=80, T=4): - """Demonstrate u=0 and u_x=0 boundary conditions with a bell function.""" - # Scaled problem: L=1, c=1, max I=1 - action = PlotAndStoreSolution( - 'gaussian', -1.3, 1.3, skip_frame=1, - title='u(0,t)=0, du(L,t)/dn=0.', filename='tmpdata') - L = 1. - dt = (L/Nx)/c # choose the stability limit with given Nx - cpu, hashed_input = solver( - I=lambda x: np.exp(-0.5*((x-0.5)/0.05)**2), - V=0, f=0, c=1, U_0=lambda t: 0, U_L=None, L=L, - dt=dt, C=C, T=T, - user_action=action, version='vectorized', - stability_safety_factor=1) - action.make_movie_file() - if cpu > 0: # did we generate new data? - action.close_file(hashed_input) - -def moving_end( - C=1, Nx=50, reflecting_right_boundary=True, - version='vectorized'): - # Scaled problem: L=1, c=1, max I=1 - L = 1. - c = 1 - dt = (L/Nx)/c # choose the stability limit with given Nx - T = 3 - I = lambda x: 0 - V = 0 - f = 0 - - def U_0(t): - return 1.0*sin(6*np.pi*t) if t < 1./3 else 0 - - if reflecting_right_boundary: - U_L = None - bc_right = 'du(L,t)/dx=0' - else: - U_L = 0 - bc_right = 'u(L,t)=0' - - action = PlotAndStoreSolution( - 'moving_end', -2.3, 2.3, skip_frame=4, - title='u(0,t)=0.25*sin(6*pi*t) if t < 1/3 else 0, ' - + bc_right, filename='tmpdata') - cpu, hashed_input = solver( - I, V, f, c, U_0, U_L, L, dt, C, T, - user_action=action, version=version, - stability_safety_factor=1) - action.make_movie_file() - if cpu > 0: # did we generate new data? - action.close_file(hashed_input) - - -class PlotMediumAndSolution(PlotAndStoreSolution): - def __init__(self, medium, **kwargs): - """Mark medium in plot: medium=[x_L, x_R].""" - self.medium = medium - PlotAndStoreSolution.__init__(self, **kwargs) - - def __call__(self, u, x, t, n): - # Save solution u to a file using numpy.savez - if self.filename is not None: - name = 'u%04d' % n # array name - kwargs = {name: u} - fname = '.' + self.filename + '_' + name + '.dat' - np.savez(fname, **kwargs) - self.t.append(t[n]) # store corresponding time value - if n == 0: # save x once - np.savez('.' + self.filename + '_x.dat', x=x) - - # Animate - if n % self.skip_frame != 0: - return - # Plot u and mark medium x=x_L and x=x_R - x_L, x_R = self.medium - umin, umax = self.yaxis - title = 'Nx=%d' % (x.size-1) - if self.title: - title = self.title + ' ' + title - - # matplotlib animation - if n == 0: - self.plt.ion() - self.lines = self.plt.plot( - x, u, 'r-', - [x_L, x_L], [umin, umax], 'k--', - [x_R, x_R], [umin, umax], 'k--') - self.plt.axis([x[0], x[-1], - self.yaxis[0], self.yaxis[1]]) - self.plt.xlabel('x') - self.plt.ylabel('u') - self.plt.title(title) - self.plt.text(0.75, 1.0, 'c lower') - self.plt.text(0.32, 1.0, 'c=1') - self.plt.legend(['t=%.3f' % t[n]]) - else: - # Update new solution - self.lines[0].set_ydata(u) - self.plt.legend(['t=%.3f' % t[n]]) - self.plt.draw() - - # pause - if t[n] == 0: - time.sleep(2) # let initial condition stay 2 s - else: - if self.pause is None: - pause = 0.2 if u.size < 100 else 0 - time.sleep(pause) - - self.plt.savefig('frame_%04d.png' % (n)) - - if n == (len(t) - 1): # finished with this run, close plot - self.plt.close() - - -def animate_multiple_solutions(*archives): - a = [load(archive) for archive in archives] - # Assume the array names are the same in all archives - raise NotImplementedError # more to do... - -def pulse( - C=1, # Maximum Courant number - Nx=200, # spatial resolution - animate=True, - version='vectorized', - T=2, # end time - loc='left', # location of initial condition - pulse_tp='gaussian', # pulse/init.cond. type - slowness_factor=2, # inverse of wave vel. in right medium - medium=[0.7, 0.9], # interval for right medium - skip_frame=1, # skip frames in animations - sigma=0.05 # width measure of the pulse - ): - """ - Various peaked-shaped initial conditions on [0,1]. - Wave velocity is decreased by the slowness_factor inside - medium. The loc parameter can be 'center' or 'left', - depending on where the initial pulse is to be located. - The sigma parameter governs the width of the pulse. - """ - # Use scaled parameters: L=1 for domain length, c_0=1 - # for wave velocity outside the domain. - L = 1.0 - c_0 = 1.0 - if loc == 'center': - xc = L/2 - elif loc == 'left': - xc = 0 - - if pulse_tp in ('gaussian','Gaussian'): - def I(x): - return np.exp(-0.5*((x-xc)/sigma)**2) - elif pulse_tp == 'plug': - def I(x): - return 0 if abs(x-xc) > sigma else 1 - elif pulse_tp == 'cosinehat': - def I(x): - # One period of a cosine - w = 2 - a = w*sigma - return 0.5*(1 + np.cos(np.pi*(x-xc)/a)) \ - if xc - a <= x <= xc + a else 0 - - elif pulse_tp == 'half-cosinehat': - def I(x): - # Half a period of a cosine - w = 4 - a = w*sigma - return np.cos(np.pi*(x-xc)/a) \ - if xc - 0.5*a <= x <= xc + 0.5*a else 0 - else: - raise ValueError('Wrong pulse_tp="%s"' % pulse_tp) - - def c(x): - return c_0/slowness_factor \ - if medium[0] <= x <= medium[1] else c_0 - - umin=-0.5; umax=1.5*I(xc) - casename = '%s_Nx%s_sf%s' % \ - (pulse_tp, Nx, slowness_factor) - action = PlotMediumAndSolution( - medium, casename=casename, umin=umin, umax=umax, - skip_frame=skip_frame, screen_movie=animate, - backend=None, filename='tmpdata') - - # Choose the stability limit with given Nx, worst case c - # (lower C will then use this dt, but smaller Nx) - dt = (L/Nx)/c_0 - cpu, hashed_input = solver( - I=I, V=None, f=None, c=c, - U_0=None, U_L=None, - L=L, dt=dt, C=C, T=T, - user_action=action, - version=version, - stability_safety_factor=1) - - if cpu > 0: # did we generate new data? - action.close_file(hashed_input) - action.make_movie_file() - print('cpu (-1 means no new data generated):', cpu) - -def convergence_rates( - u_exact, - I, V, f, c, U_0, U_L, L, - dt0, num_meshes, - C, T, version='scalar', - stability_safety_factor=1.0): - """ - Half the time step and estimate convergence rates for - for num_meshes simulations. - """ - class ComputeError: - def __init__(self, norm_type): - self.error = 0 - - def __call__(self, u, x, t, n): - """Store norm of the error in self.E.""" - error = np.abs(u - u_exact(x, t[n])).max() - self.error = max(self.error, error) - - E = [] - h = [] # dt, solver adjusts dx such that C=dt*c/dx - dt = dt0 - for i in range(num_meshes): - error_calculator = ComputeError('Linf') - solver(I, V, f, c, U_0, U_L, L, dt, C, T, - user_action=error_calculator, - version='scalar', - stability_safety_factor=1.0) - E.append(error_calculator.error) - h.append(dt) - dt /= 2 # halve the time step for next simulation - print('E:', E) - print('h:', h) - r = [np.log(E[i]/E[i-1])/np.log(h[i]/h[i-1]) - for i in range(1,num_meshes)] - return r - -def test_convrate_sincos(): - n = m = 2 - L = 1.0 - u_exact = lambda x, t: np.cos(m*np.pi/L*t)*np.sin(m*np.pi/L*x) - - r = convergence_rates( - u_exact=u_exact, - I=lambda x: u_exact(x, 0), - V=lambda x: 0, - f=0, - c=1, - U_0=0, - U_L=0, - L=L, - dt0=0.1, - num_meshes=6, - C=0.9, - T=1, - version='scalar', - stability_safety_factor=1.0) - print('rates sin(x)*cos(t) solution:', - [round(r_,2) for r_ in r]) - assert abs(r[-1] - 2) < 0.002 - -if __name__ == '__main__': - test_convrate_sincos() diff --git a/src/wave/wave1D/wave1D_n0.py b/src/wave/wave1D/wave1D_n0.py deleted file mode 100644 index a16852e7..00000000 --- a/src/wave/wave1D/wave1D_n0.py +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env python -""" -1D wave equation with homogeneous Neumann conditions:: - - u, x, t, cpu = solver(I, V, f, c, L, dt, C, T, user_action) - -Function solver solves the wave equation - - u_tt = c**2*u_xx + f(x,t) on - -(0,L) with du/dn=0 on x=0 and x = L. - -dt is the desired time step. -T is the stop time for the simulation. -C is the Courant number (=c*dt/dx). -dx is computed on basis of dt and C. - -I and f are functions: I(x), f(x,t). -user_action is a function of (u, x, t, n) where the calling code -can add visualization, error computations, data analysis, -store solutions, etc. - -Function viz:: - - viz(I, V, f, c, L, dt, C, T, umin, umax, animate=True) - -calls solver with a user_action function that can plot the -solution on the screen (as an animation). -""" - -import numpy as np - - -def solver(I, V, f, c, L, dt, C, T, user_action=None): - """ - Solve u_tt=c^2*u_xx + f on (0,L)x(0,T]. - u(0,t)=U_0(t) or du/dn=0 (U_0=None), u(L,t)=U_L(t) or du/dn=0 (u_L=None). - """ - Nt = int(round(T / dt)) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points in time - dx = dt * c / float(C) - Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - C2 = C**2 - dt2 = dt * dt # Help variables in the scheme - # Make sure dx and dt are compatible with x and t - dx = x[1] - x[0] - dt = t[1] - t[0] - - # Wrap user-given f, V - if f is None or f == 0: - f = lambda x, t: 0 - if V is None or V == 0: - V = lambda x: 0 - - u = np.zeros(Nx + 1) # Solution array at new time level - u_n = np.zeros(Nx + 1) # Solution at 1 time level back - u_nm1 = np.zeros(Nx + 1) # Solution at 2 time levels back - - import time - - t0 = time.perf_counter() # CPU time measurement - - # Load initial condition into u_n - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - - if user_action is not None: - user_action(u_n, x, t, 0) - - # Special formula for the first step - for i in range(0, Nx + 1): - ip1 = i + 1 if i < Nx else i - 1 - im1 = i - 1 if i > 0 else i + 1 - u[i] = ( - u_n[i] - + dt * V(x[i]) - + 0.5 * C2 * (u_n[im1] - 2 * u_n[i] + u_n[ip1]) - + 0.5 * dt2 * f(x[i], t[0]) - ) - - if user_action is not None: - user_action(u, x, t, 1) - - # Update data structures for next step - # u_nm1[:] = u_n; u_n[:] = u # safe, but slower - u_nm1, u_n, u = u_n, u, u_nm1 - - for n in range(1, Nt): - for i in range(0, Nx + 1): - ip1 = i + 1 if i < Nx else i - 1 - im1 = i - 1 if i > 0 else i + 1 - u[i] = ( - -u_nm1[i] - + 2 * u_n[i] - + C2 * (u_n[im1] - 2 * u_n[i] + u_n[ip1]) - + dt2 * f(x[i], t[n]) - ) - - if user_action is not None: - if user_action(u, x, t, n + 1): - break - - # Update data structures for next step - # u_nm1[:] = u_n; u_n[:] = u # safe, but slower - u_nm1, u_n, u = u_n, u, u_nm1 - - # Wrong assignment u = u_nm1 must be corrected before return - u = u_n - cpu_time = time.perf_counter() - t0 - return u, x, t, cpu_time - - -from wave1D_u0 import viz - - -def plug(C=1, Nx=50, animate=True, T=2): - """Plug profile as initial condition.""" - - def I(x): - if abs(x - L / 2.0) > 0.1: - return 0 - else: - return 1 - - L = 1.0 - c = 1 - dt = (L / Nx) / c # choose the stability limit with given Nx - cpu = viz(I, None, None, c, L, dt, C, T, umin=-1.1, umax=1.1, animate=animate) - - -def test_plug(): - """ - Check that an initial plug is correct back after one period, - if C=1. - """ - L = 1.0 - I = lambda x: 0 if abs(x - L / 2.0) > 0.1 else 1 - - Nx = 10 - c = 0.5 - C = 1 - dt = C * (L / Nx) / c - nperiods = 4 - T = L / c * nperiods # One period: c*T = L - u, x, t, cpu = solver( - I=I, V=None, f=None, c=c, L=L, dt=dt, C=C, T=T, user_action=None - ) - u_0 = np.array([I(x_) for x_ in x]) - diff = np.abs(u - u_0).max() - tol = 1e-13 - assert diff < tol - - -if __name__ == "__main__": - test_plug() diff --git a/src/wave/wave1D/wave1D_n0_ghost.py b/src/wave/wave1D/wave1D_n0_ghost.py deleted file mode 100644 index c7f077bd..00000000 --- a/src/wave/wave1D/wave1D_n0_ghost.py +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env python -# As wave1D_dn0.py, but using ghost cells and index sets. -""" -1D wave equation with homogeneous Neumann conditions:: - - u, x, t, cpu = solver(I, V, f, c, L, dt, C, T, user_action) - -Function solver solves the wave equation - - u_tt = c**2*u_xx + f(x,t) on - -(0,L) with du/dn=0 on x=0 and x = L. - -dt is the time step. -T is the stop time for the simulation. -C is the Courant number (=c*dt/dx). -dx is computed from dt and C. - -I and f are functions: I(x), f(x,t). -user_action is a function of (u, x, t, n) where the calling code -can add visualization, error computations, data analysis, -store solutions, etc. - -Function viz:: - - viz(I, V, f, c, L, dt, C, T, umin, umax, animate=True) - -calls solver with a user_action function that can plot the -solution on the screen (as an animation). -""" - -import numpy as np - - -def solver(I, V, f, c, L, dt, C, T, user_action=None): - """ - Solve u_tt=c^2*u_xx + f on (0,L)x(0,T]. - u(0,t)=U_0(t) or du/dn=0 (U_0=None), - u(L,t)=U_L(t) or du/dn=0 (u_L=None). - """ - Nt = int(round(T / dt)) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points in time - dx = dt * c / float(C) - Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - C2 = C**2 - dt2 = dt * dt # Help variables in the scheme - # Make sure dx and dt are compatible with x and t - dx = x[1] - x[0] - dt = t[1] - t[0] - - # Wrap user-given f, V - if f is None or f == 0: - f = lambda x, t: 0 - if V is None or V == 0: - V = lambda x: 0 - - u = np.zeros(Nx + 3) # Solution array at new time level - u_n = np.zeros(Nx + 3) # Solution at 1 time level back - u_nm1 = np.zeros(Nx + 3) # Solution at 2 time levels back - - Ix = range(1, u.shape[0] - 1) - It = range(0, t.shape[0]) - - import time - - t0 = time.perf_counter() # CPU time measurement - - # Load initial condition into u_n - for i in Ix: - u_n[i] = I(x[i - Ix[0]]) # Note the index transformation in x - # Ghost values set according to du/dx=0 - i = Ix[0] - u_n[i - 1] = u_n[i + 1] - i = Ix[-1] - u_n[i + 1] = u_n[i - 1] - - if user_action is not None: - # Make sure to send the part of u that corresponds to x - user_action(u_n[Ix[0] : Ix[-1] + 1], x, t, 0) - - # Special formula for the first step - for i in Ix: - u[i] = ( - u_n[i] - + dt * V(x[i - Ix[0]]) - + 0.5 * C2 * (u_n[i - 1] - 2 * u_n[i] + u_n[i + 1]) - + 0.5 * dt2 * f(x[i - Ix[0]], t[0]) - ) - # Ghost values set according to du/dx=0 - i = Ix[0] - u[i - 1] = u[i + 1] - i = Ix[-1] - u[i + 1] = u[i - 1] - - if user_action is not None: - # Make sure to send the part of u that corresponds to x - user_action(u[Ix[0] : Ix[-1] + 1], x, t, 1) - - # Update data structures for next step - # u_nm1[:] = u_n; u_n[:] = u # safe, but slower - u_nm1, u_n, u = u_n, u, u_nm1 - - for n in range(1, Nt): - for i in Ix: - u[i] = ( - -u_nm1[i] - + 2 * u_n[i] - + C2 * (u_n[i - 1] - 2 * u_n[i] + u_n[i + 1]) - + dt2 * f(x[i - Ix[0]], t[n]) - ) - # Ghost values set according to du/dx=0 - i = Ix[0] - u[i - 1] = u[i + 1] - i = Ix[-1] - u[i + 1] = u[i - 1] - - if user_action is not None: - # Make sure to send the part of u that corresponds to x - if user_action(u[Ix[0] : Ix[-1] + 1], x, t, n + 1): - break - - # Update data structures for next step - # u_nm1[:] = u_n; u_n[:] = u # safe, but slower - u_nm1, u_n, u = u_n, u, u_nm1 - - # Important to correct the mathematically wrong u=u_nm1 above - # before returning u - u = u_n - cpu_time = time.perf_counter() - t0 - return u[1:-1], x, t, cpu_time - - -# Cannot just import test_plug because wave1D_n0.test_plug will -# then call wave1D.solver, not the solver above - - -def test_plug(): - """ - Check that an initial plug is correct back after one period, - if C=1. - """ - L = 1.0 - I = lambda x: 0 if abs(x - L / 2.0) > 0.1 else 1 - - Nx = 10 - c = 0.5 - C = 1 - dt = C * (L / Nx) / c - nperiods = 4 - T = L / c * nperiods # One period: c*T = L - u, x, t, cpu = solver( - I=I, V=None, f=None, c=c, L=L, dt=dt, C=C, T=T, user_action=None - ) - u_0 = np.array([I(x_) for x_ in x]) - diff = np.abs(u - u_0).max() - tol = 1e-13 - assert diff < tol - - -if __name__ == "__main__": - test_plug() diff --git a/src/wave/wave1D/wave1D_u0.py b/src/wave/wave1D/wave1D_u0.py deleted file mode 100644 index 0bf400ac..00000000 --- a/src/wave/wave1D/wave1D_u0.py +++ /dev/null @@ -1,310 +0,0 @@ -#!/usr/bin/env python -""" -1D wave equation with u=0 at the boundary. -Simplest possible implementation. - -The key function is:: - - u, x, t, cpu = (I, V, f, c, L, dt, C, T, user_action) - -which solves the wave equation u_tt = c**2*u_xx on (0,L) with u=0 -on x=0,L, for t in (0,T]. Initial conditions: u=I(x), u_t=V(x). - -T is the stop time for the simulation. -dt is the desired time step. -C is the Courant number (=c*dt/dx), which specifies dx. -f(x,t) is a function for the source term (can be 0 or None). -I and V are functions of x. - -user_action is a function of (u, x, t, n) where the calling -code can add visualization, error computations, etc. -""" - -import numpy as np - - -def solver(I, V, f, c, L, dt, C, T, user_action=None): - """Solve u_tt=c^2*u_xx + f on (0,L)x(0,T].""" - Nt = int(round(T / dt)) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points in time - dx = dt * c / float(C) - Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - C2 = C**2 # Help variable in the scheme - # Make sure dx and dt are compatible with x and t - dx = x[1] - x[0] - dt = t[1] - t[0] - - if f is None or f == 0: - f = lambda x, t: 0 - if V is None or V == 0: - V = lambda x: 0 - - u = np.zeros(Nx + 1) # Solution array at new time level - u_n = np.zeros(Nx + 1) # Solution at 1 time level back - u_nm1 = np.zeros(Nx + 1) # Solution at 2 time levels back - - import time - - t0 = time.perf_counter() # Measure CPU time - - # Load initial condition into u_n - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - - if user_action is not None: - user_action(u_n, x, t, 0) - - # Special formula for first time step - n = 0 - for i in range(1, Nx): - u[i] = ( - u_n[i] - + dt * V(x[i]) - + 0.5 * C2 * (u_n[i - 1] - 2 * u_n[i] + u_n[i + 1]) - + 0.5 * dt**2 * f(x[i], t[n]) - ) - u[0] = 0 - u[Nx] = 0 - - if user_action is not None: - user_action(u, x, t, 1) - - # Switch variables before next step - u_nm1[:] = u_n - u_n[:] = u - - for n in range(1, Nt): - # Update all inner points at time t[n+1] - for i in range(1, Nx): - u[i] = ( - -u_nm1[i] - + 2 * u_n[i] - + C2 * (u_n[i - 1] - 2 * u_n[i] + u_n[i + 1]) - + dt**2 * f(x[i], t[n]) - ) - - # Insert boundary conditions - u[0] = 0 - u[Nx] = 0 - if user_action is not None: - if user_action(u, x, t, n + 1): - break - - # Switch variables before next step - u_nm1[:] = u_n - u_n[:] = u - - cpu_time = time.perf_counter() - t0 - return u, x, t, cpu_time - - -def test_quadratic(): - """Check that u(x,t)=x(L-x)(1+t/2) is exactly reproduced.""" - - def u_exact(x, t): - return x * (L - x) * (1 + 0.5 * t) - - def I(x): - return u_exact(x, 0) - - def V(x): - return 0.5 * u_exact(x, 0) - - def f(x, t): - return 2 * (1 + 0.5 * t) * c**2 - - L = 2.5 - c = 1.5 - C = 0.75 - Nx = 6 # Very coarse mesh for this exact test - dt = C * (L / Nx) / c - T = 18 - - def assert_no_error(u, x, t, n): - u_e = u_exact(x, t[n]) - diff = np.abs(u - u_e).max() - tol = 1e-13 - assert diff < tol - - solver(I, V, f, c, L, dt, C, T, user_action=assert_no_error) - - -def test_constant(): - """Check that u(x,t)=Q=0 is exactly reproduced.""" - u_const = 0 # Require 0 because of the boundary conditions - C = 0.75 - dt = C # Very coarse mesh - u, x, t, cpu = solver(I=lambda x: 0, V=0, f=0, c=1.5, L=2.5, dt=dt, C=C, T=18) - tol = 1e-14 - assert np.abs(u - u_const).max() < tol - - -def viz( - I, - V, - f, - c, - L, - dt, - C, - T, # PDE parameters - umin, - umax, # Interval for u in plots - animate=True, # Simulation with animation? - solver_function=solver, # Function with numerical algorithm -): - """Run solver and visualize u at each time level.""" - import glob - import os - import time - - import matplotlib.pyplot as plt - - class PlotMatplotlib: - def __call__(self, u, x, t, n): - """user_action function for solver.""" - if n == 0: - plt.ion() - self.lines = plt.plot(x, u, "r-") - plt.xlabel("x") - plt.ylabel("u") - plt.axis([0, L, umin, umax]) - plt.legend(["t=%f" % t[n]], loc="lower left") - else: - self.lines[0].set_ydata(u) - plt.legend(["t=%f" % t[n]], loc="lower left") - plt.draw() - time.sleep(2) if t[n] == 0 else time.sleep(0.2) - plt.savefig("tmp_%04d.png" % n) # for movie making - - plot_u = PlotMatplotlib() - - # Clean up old movie frames - for filename in glob.glob("tmp_*.png"): - os.remove(filename) - - # Call solver and do the simulation - user_action = plot_u if animate else None - u, x, t, cpu = solver_function(I, V, f, c, L, dt, C, T, user_action) - - # Make video files using ffmpeg - fps = 4 # frames per second - codec2ext = dict( - flv="flv", libx264="mp4", libvpx="webm", libtheora="ogg" - ) # video formats - filespec = "tmp_%04d.png" - movie_program = "ffmpeg" - for codec in codec2ext: - ext = codec2ext[codec] - cmd = ( - "%(movie_program)s -r %(fps)d -i %(filespec)s " - "-vcodec %(codec)s movie.%(ext)s" % vars() - ) - os.system(cmd) - - return cpu - - -def guitar(C): - """Triangular wave (pulled guitar string).""" - L = 0.75 - x0 = 0.8 * L - a = 0.005 - freq = 440 - wavelength = 2 * L - c = freq * wavelength - omega = 2 * np.pi * freq - num_periods = 1 - T = 2 * np.pi / omega * num_periods - # Choose dt the same as the stability limit for Nx=50 - dt = L / 50.0 / c - - def I(x): - return a * x / x0 if x < x0 else a / (L - x0) * (L - x) - - umin = -1.2 * a - umax = -umin - cpu = viz(I, 0, 0, c, L, dt, C, T, umin, umax, animate=True) - - -def convergence_rates( - u_exact, # Python function for exact solution - I, - V, - f, - c, - L, # physical parameters - dt0, - num_meshes, - C, - T, -): # numerical parameters - """ - Half the time step and estimate convergence rates for - for num_meshes simulations. - """ - # First define an appropriate user action function - global error - error = 0 # error computed in the user action function - - def compute_error(u, x, t, n): - global error # must be global to be altered here - # (otherwise error is a local variable, different - # from error defined in the parent function) - if n == 0: - error = 0 - else: - error = max(error, np.abs(u - u_exact(x, t[n])).max()) - - # Run finer and finer resolutions and compute true errors - E = [] - h = [] # dt, solver adjusts dx such that C=dt*c/dx - dt = dt0 - for i in range(num_meshes): - solver(I, V, f, c, L, dt, C, T, user_action=compute_error) - # error is computed in the final call to compute_error - E.append(error) - h.append(dt) - dt /= 2 # halve the time step for next simulation - print("E:", E) - print("h:", h) - # Convergence rates for two consecutive experiments - r = [np.log(E[i] / E[i - 1]) / np.log(h[i] / h[i - 1]) for i in range(1, num_meshes)] - return r - - -def test_convrate_sincos(): - n = m = 2 - L = 1.0 - u_exact = lambda x, t: np.cos(m * np.pi / L * t) * np.sin(m * np.pi / L * x) - - r = convergence_rates( - u_exact=u_exact, - I=lambda x: u_exact(x, 0), - V=lambda x: 0, - f=0, - c=1, - L=L, - dt0=0.1, - num_meshes=6, - C=0.9, - T=1, - ) - print("rates sin(x)*cos(t) solution:", [round(r_, 2) for r_ in r]) - assert abs(r[-1] - 2) < 0.002 - - -if __name__ == "__main__": - test_constant() - test_quadratic() - import sys - - try: - C = float(sys.argv[1]) - print("C=%g" % C) - except IndexError: - C = 0.85 - print("Courant number: %.2f" % C) - # guitar(C) - test_convrate_sincos() diff --git a/src/wave/wave1D/wave1D_u0v.py b/src/wave/wave1D/wave1D_u0v.py deleted file mode 100644 index 499b9d9c..00000000 --- a/src/wave/wave1D/wave1D_u0v.py +++ /dev/null @@ -1,247 +0,0 @@ -#!/usr/bin/env python -""" -1D wave equation with u=0 at the boundary. -The solver function here offers scalar and vectorized versions. -See wave1D_u0_s.py for documentation. The only difference -is that function solver takes an additional argument "version": -version='scalar' implies explicit loops over mesh point, -while version='vectorized' provides a vectorized version. -""" - -import numpy as np - - -def solver(I, V, f, c, L, dt, C, T, user_action=None, version="vectorized"): - """Solve u_tt=c^2*u_xx + f on (0,L)x(0,T].""" - Nt = int(round(T / dt)) - t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points in time - dx = dt * c / float(C) - Nx = int(round(L / dx)) - x = np.linspace(0, L, Nx + 1) # Mesh points in space - C2 = C**2 # Help variable in the scheme - # Make sure dx and dt are compatible with x and t - dx = x[1] - x[0] - dt = t[1] - t[0] - - if f is None or f == 0: - f = (lambda x, t: 0) if version == "scalar" else lambda x, t: np.zeros(x.shape) - if V is None or V == 0: - V = (lambda x: 0) if version == "scalar" else lambda x: np.zeros(x.shape) - - u = np.zeros(Nx + 1) # Solution array at new time level - u_n = np.zeros(Nx + 1) # Solution at 1 time level back - u_nm1 = np.zeros(Nx + 1) # Solution at 2 time levels back - - import time - - t0 = time.perf_counter() # CPU time measurement - - # Load initial condition into u_n - for i in range(0, Nx + 1): - u_n[i] = I(x[i]) - - if user_action is not None: - user_action(u_n, x, t, 0) - - # Special formula for first time step - n = 0 - for i in range(1, Nx): - u[i] = ( - u_n[i] - + dt * V(x[i]) - + 0.5 * C2 * (u_n[i - 1] - 2 * u_n[i] + u_n[i + 1]) - + 0.5 * dt**2 * f(x[i], t[n]) - ) - u[0] = 0 - u[Nx] = 0 - - if user_action is not None: - user_action(u, x, t, 1) - - # Switch variables before next step - u_nm1[:] = u_n - u_n[:] = u - - for n in range(1, Nt): - # Update all inner points at time t[n+1] - - if version == "scalar": - for i in range(1, Nx): - u[i] = ( - -u_nm1[i] - + 2 * u_n[i] - + C2 * (u_n[i - 1] - 2 * u_n[i] + u_n[i + 1]) - + dt**2 * f(x[i], t[n]) - ) - elif version == "vectorized": # (1:-1 slice style) - f_a = f(x, t[n]) # Precompute in array - u[1:-1] = ( - -u_nm1[1:-1] - + 2 * u_n[1:-1] - + C2 * (u_n[0:-2] - 2 * u_n[1:-1] + u_n[2:]) - + dt**2 * f_a[1:-1] - ) - elif version == "vectorized2": # (1:Nx slice style) - f_a = f(x, t[n]) # Precompute in array - u[1:Nx] = ( - -u_nm1[1:Nx] - + 2 * u_n[1:Nx] - + C2 * (u_n[0 : Nx - 1] - 2 * u_n[1:Nx] + u_n[2 : Nx + 1]) - + dt**2 * f_a[1:Nx] - ) - - # Insert boundary conditions - u[0] = 0 - u[Nx] = 0 - if user_action is not None: - if user_action(u, x, t, n + 1): - break - - # Switch variables before next step - u_nm1[:] = u_n - u_n[:] = u - - cpu_time = time.perf_counter() - t0 - return u, x, t, cpu_time - - -def viz( - I, - V, - f, - c, - L, - dt, - C, - T, # PDE parameters - umin, - umax, # Interval for u in plots - animate=True, # Simulation with animation? - solver_function=solver, # Function with numerical algorithm - version="vectorized", # 'scalar' or 'vectorized' -): - import wave1D_u0 - - if version == "vectorized": - # Reuse viz from wave1D_u0, but with the present - # modules' new vectorized solver (which has - # version='vectorized' as default argument; - # wave1D_u0.viz does not feature this argument) - cpu = wave1D_u0.viz( - I, V, f, c, L, dt, C, T, umin, umax, animate, solver_function=solver - ) - elif version == "scalar": - # Call wave1D_u0.viz with a solver with - # scalar code and use wave1D_u0.solver. - cpu = wave1D_u0.viz( - I, - V, - f, - c, - L, - dt, - C, - T, - umin, - umax, - animate, - solver_function=wave1D_u0.solver, - ) - return cpu - - -def test_quadratic(): - """ - Check the scalar and vectorized versions for - a quadratic u(x,t)=x(L-x)(1+t/2) that is exactly reproduced. - """ - # The following function must work for x as array or scalar - u_exact = lambda x, t: x * (L - x) * (1 + 0.5 * t) - I = lambda x: u_exact(x, 0) - V = lambda x: 0.5 * u_exact(x, 0) - # f is a scalar (zeros_like(x) works for scalar x too) - f = lambda x, t: np.zeros_like(x) + 2 * c**2 * (1 + 0.5 * t) - - L = 2.5 - c = 1.5 - C = 0.75 - Nx = 3 # Very coarse mesh for this exact test - dt = C * (L / Nx) / c - T = 18 - - def assert_no_error(u, x, t, n): - u_e = u_exact(x, t[n]) - tol = 1e-13 - diff = np.abs(u - u_e).max() - assert diff < tol - - solver(I, V, f, c, L, dt, C, T, user_action=assert_no_error, version="scalar") - solver(I, V, f, c, L, dt, C, T, user_action=assert_no_error, version="vectorized") - - -def guitar(C): - """Triangular wave (pulled guitar string).""" - L = 0.75 - x0 = 0.8 * L - a = 0.005 - freq = 440 - wavelength = 2 * L - c = freq * wavelength - omega = 2 * pi * freq - num_periods = 1 - T = 2 * pi / omega * num_periods - # Choose dt the same as the stability limit for Nx=50 - dt = L / 50.0 / c - - def I(x): - return a * x / x0 if x < x0 else a / (L - x0) * (L - x) - - umin = -1.2 * a - umax = -umin - cpu = viz(I, 0, 0, c, L, dt, C, T, umin, umax, animate=True) - - -def run_efficiency_experiments(): - L = 1 - x0 = 0.8 * L - a = 1 - c = 2 - T = 8 - C = 0.9 - umin = -1.2 * a - umax = -umin - - def I(x): - return a * x / x0 if x < x0 else a / (L - x0) * (L - x) - - intervals = [] - speedup = [] - for Nx in [50, 100, 200, 400, 800]: - dx = float(L) / Nx - dt = C / c * dx - print("solving scalar Nx=%d" % Nx, end=" ") - cpu_s = viz(I, 0, 0, c, L, dt, C, T, umin, umax, animate=False, version="scalar") - print(cpu_s) - print("solving vectorized Nx=%d" % Nx, end=" ") - cpu_v = viz( - I, 0, 0, c, L, dt, C, T, umin, umax, animate=False, version="vectorized" - ) - print(cpu_v) - intervals.append(Nx) - speedup.append(cpu_s / float(cpu_v)) - print("Nx=%3d: cpu_v/cpu_s: %.3f" % (Nx, 1.0 / speedup[-1])) - print("Nx:", intervals) - print("Speed-up:", speedup) - - -if __name__ == "__main__": - test_quadratic() # verify - import sys - - try: - C = float(sys.argv[1]) - print("C=%g" % C) - except IndexError: - C = 0.85 - guitar(C) - # run_efficiency_experiments() diff --git a/src/wave/wave2D/wave2D.py b/src/wave/wave2D/wave2D.py deleted file mode 100644 index 7ef755c9..00000000 --- a/src/wave/wave2D/wave2D.py +++ /dev/null @@ -1,845 +0,0 @@ -#!/usr/bin/env python -""" -2D wave equation solved by finite differences. -Very preliminary version. -""" - -import time - -from numpy import linspace, newaxis, sqrt, zeros - - -def scheme_ij( - u, - u_n, - u_nm1, - k_1, - k_2, - k_3, - k_4, - f, - dt2, - Cx2, - Cy2, - x, - y, - t_1, - i, - j, - im1, - ip1, - jm1, - jp1, -): - """ - Right-hand side of finite difference at point [i,j]. - im1, ip1 denote i-1, i+1, resp. Similar for jm1, jp1. - t_1 corresponds to u_n (previous time level relative to u). - """ - u_ij = -k_2 * u_nm1[i, j] + k_1 * 2 * u_n[i, j] - u_xx = k_3 * Cx2 * (u_n[im1, j] - 2 * u_n[i, j] + u_n[ip1, j]) - u_yy = k_3 * Cx2 * (u_n[i, jm1] - 2 * u_n[i, j] + u_n[i, jp1]) - f_term = k_4 * dt2 * f(x, y, t_1) - return u_ij + u_xx + u_yy + f_term - - -def scheme_scalar_mesh( - u, u_n, u_nm1, k_1, k_2, k_3, k_4, f, dt2, Cx2, Cy2, x, y, t_1, Nx, Ny, bc -): - Ix = range(0, u.shape[0]) - It = range(0, u.shape[1]) - - # Interior points - for i in Ix[1:-1]: - for j in It[1:-1]: - im1 = i - 1 - ip1 = i + 1 - jm1 = j - 1 - jp1 = j + 1 - u[i, j] = scheme_ij( - u, - u_n, - u_nm1, - k_1, - k_2, - k_3, - k_4, - f, - dt2, - Cx2, - Cy2, - x[i], - y[j], - t_1, - i, - j, - im1, - ip1, - jm1, - jp1, - ) - # Boundary points - i = Ix[0] - ip1 = i + 1 - im1 = ip1 - if bc["W"] is None: - for j in It[1:-1]: - jm1 = j - 1 - jp1 = j + 1 - u[i, j] = scheme_ij( - u, - u_n, - u_nm1, - k_1, - k_2, - k_3, - k_4, - f, - dt2, - Cx2, - Cy2, - x[i], - y[j], - t_1, - i, - j, - im1, - ip1, - jm1, - jp1, - ) - else: - for j in It[1:-1]: - u[i, j] = bc["W"](x[i], y[j]) - i = Ix[-1] - im1 = i - 1 - ip1 = im1 - if bc["E"] is None: - for j in It[1:-1]: - jm1 = j - 1 - jp1 = j + 1 - u[i, j] = scheme_ij( - u, - u_n, - u_nm1, - k_1, - k_2, - k_3, - k_4, - f, - dt2, - Cx2, - Cy2, - x[i], - y[j], - t_1, - i, - j, - im1, - ip1, - jm1, - jp1, - ) - else: - for j in It[1:-1]: - u[i, j] = bc["E"](x[i], y[j]) - j = It[0] - jp1 = j + 1 - jm1 = jp1 - if bc["S"] is None: - for i in Ix[1:-1]: - im1 = i - 1 - ip1 = i + 1 - u[i, j] = scheme_ij( - u, - u_n, - u_nm1, - k_1, - k_2, - k_3, - k_4, - f, - dt2, - Cx2, - Cy2, - x[i], - y[j], - t_1, - i, - j, - im1, - ip1, - jm1, - jp1, - ) - else: - for i in Ix[1:-1]: - u[i, j] = bc["S"](x[i], y[j]) - j = It[-1] - jm1 = j - 1 - jp1 = jm1 - if bc["N"] is None: - for i in Ix[1:-1]: - im1 = i - 1 - ip1 = i + 1 - u[i, j] = scheme_ij( - u, - u_n, - u_nm1, - k_1, - k_2, - k_3, - k_4, - f, - dt2, - Cx2, - Cy2, - x[i], - y[j], - t_1, - i, - j, - im1, - ip1, - jm1, - jp1, - ) - else: - for i in Ix[1:-1]: - u[i, j] = bc["N"](x[i], y[j]) - - # Corner points - i = j = It[0] - ip1 = i + 1 - jp1 = j + 1 - im1 = ip1 - jm1 = jp1 - if bc["S"] is None: - u[i, j] = scheme_ij( - u, - u_n, - u_nm1, - k_1, - k_2, - k_3, - k_4, - f, - dt2, - Cx2, - Cy2, - x[i], - y[j], - t_1, - i, - j, - im1, - ip1, - jm1, - jp1, - ) - else: - u[i, j] = bc["S"](x[i], y[j]) - - i = Ix[-1] - j = It[0] - im1 = i - 1 - jp1 = j + 1 - ip1 = im1 - jm1 = jp1 - if bc["S"] is None: - u[i, j] = scheme_ij( - u, - u_n, - u_nm1, - k_1, - k_2, - k_3, - k_4, - f, - dt2, - Cx2, - Cy2, - x[i], - y[j], - t_1, - i, - j, - im1, - ip1, - jm1, - jp1, - ) - else: - u[i, j] = bc["S"](x[i], y[j]) - - i = Ix[-1] - j = It[-1] - im1 = i - 1 - jm1 = j - 1 - ip1 = im1 - jp1 = jm1 - if bc["N"] is None: - u[i, j] = scheme_ij( - u, - u_n, - u_nm1, - k_1, - k_2, - k_3, - k_4, - f, - dt2, - Cx2, - Cy2, - x[i], - y[j], - t_1, - i, - j, - im1, - ip1, - jm1, - jp1, - ) - else: - u[i, j] = bc["N"](x[i], y[j]) - - i = Ix[0] - j = It[-1] - ip1 = i + 1 - jm1 = j - 1 - im1 = ip1 - jp1 = jm1 - if bc["N"] is None: - u[i, j] = scheme_ij( - u, - u_n, - u_nm1, - k_1, - k_2, - k_3, - k_4, - f, - dt2, - Cx2, - Cy2, - x[i], - y[j], - t_1, - i, - j, - im1, - ip1, - jm1, - jp1, - ) - else: - u[i, j] = bc["N"](x[i], y[j]) - - return u - - -def scheme_vectorized_mesh( - u, u_n, u_nm1, k_1, k_2, k_3, k_4, f, dt2, Cx2, Cy2, x, y, t_1, Nx, Ny, bc -): - # Interior points - i = slice(1, Nx) - j = slice(1, Ny) - im1 = slice(0, Nx - 1) - ip1 = slice(2, Nx + 1) - jm1 = slice(0, Ny - 1) - jp1 = slice(2, Ny + 1) - u[i, j] = scheme_ij( - u, - u_n, - u_nm1, - k_1, - k_2, - k_3, - k_4, - f, - dt2, - Cx2, - Cy2, - xv[i, :], - yv[j, :], - t_1, - i, - j, - im1, - ip1, - jm1, - jp1, - ) - # Boundary points - i = slice(1, Nx) - ip1 = slice(2, Nx + 1) - im1 = ip1 - j = slice(1, Ny) - jm1 = slice(0, Ny - 1) - jp1 = slice(2, Ny + 1) - if bc["W"] is None: - u[i, j] = scheme_ij( - u, - u_n, - u_nm1, - k_1, - k_2, - k_3, - k_4, - f, - dt2, - Cx2, - Cy2, - xv[i, :], - yv[:, j], - t_1, - i, - j, - im1, - ip1, - jm1, - jp1, - ) - else: - u[i, j] = bc["W"](xv[i, :], yv[:, j]) - - # The rest is not done yet..... - i = Ix[-1] - im1 = i - 1 - ip1 = im1 - if bc["E"] is None: - for j in It[1:-1]: - jm1 = j - 1 - jp1 = j + 1 - u[i, j] = scheme_ij( - u, - u_n, - u_nm1, - k_1, - k_2, - k_3, - k_4, - f, - dt2, - Cx2, - Cy2, - x[i], - y[j], - t_1, - i, - j, - im1, - ip1, - jm1, - jp1, - ) - else: - for j in It[1:-1]: - u[i, j] = bc["E"](x[i], y[j]) - j = It[0] - jp1 = j + 1 - jm1 = jp1 - if bc["S"] is None: - for i in Ix[1:-1]: - im1 = i - 1 - ip1 = i + 1 - u[i, j] = scheme_ij( - u, - u_n, - u_nm1, - k_1, - k_2, - k_3, - k_4, - f, - dt2, - Cx2, - Cy2, - x[i], - y[j], - t_1, - i, - j, - im1, - ip1, - jm1, - jp1, - ) - else: - for i in Ix[1:-1]: - u[i, j] = bc["S"](x[i], y[j]) - j = It[-1] - jm1 = j - 1 - jp1 = jm1 - if bc["N"] is None: - for i in Ix[1:-1]: - im1 = i - 1 - ip1 = i + 1 - u[i, j] = scheme_ij( - u, - u_n, - u_nm1, - k_1, - k_2, - k_3, - k_4, - f, - dt2, - Cx2, - Cy2, - x[i], - y[j], - t_1, - i, - j, - im1, - ip1, - jm1, - jp1, - ) - else: - for i in Ix[1:-1]: - u[i, j] = bc["N"](x[i], y[j]) - - # Corner points - i = j = It[0] - ip1 = i + 1 - jp1 = j + 1 - im1 = ip1 - jm1 = jp1 - if bc["S"] is None: - u[i, j] = scheme_ij( - u, - u_n, - u_nm1, - k_1, - k_2, - k_3, - k_4, - f, - dt2, - Cx2, - Cy2, - x[i], - y[j], - t_1, - i, - j, - im1, - ip1, - jm1, - jp1, - ) - else: - u[i, j] = bc["S"](x[i], y[j]) - - i = Ix[-1] - j = It[0] - im1 = i - 1 - jp1 = j + 1 - ip1 = im1 - jm1 = jp1 - if bc["S"] is None: - u[i, j] = scheme_ij( - u, - u_n, - u_nm1, - k_1, - k_2, - k_3, - k_4, - f, - dt2, - Cx2, - Cy2, - x[i], - y[j], - t_1, - i, - j, - im1, - ip1, - jm1, - jp1, - ) - else: - u[i, j] = bc["S"](x[i], y[j]) - - i = Ix[-1] - j = It[-1] - im1 = i - 1 - jm1 = j - 1 - ip1 = im1 - jp1 = jm1 - if bc["N"] is None: - u[i, j] = scheme_ij( - u, - u_n, - u_nm1, - k_1, - k_2, - k_3, - k_4, - f, - dt2, - Cx2, - Cy2, - x[i], - y[j], - t_1, - i, - j, - im1, - ip1, - jm1, - jp1, - ) - else: - u[i, j] = bc["N"](x[i], y[j]) - - i = Ix[0] - j = It[-1] - ip1 = i + 1 - jm1 = j - 1 - im1 = ip1 - jp1 = jm1 - if bc["N"] is None: - u[i, j] = scheme_ij( - u, - u_n, - u_nm1, - k_1, - k_2, - k_3, - k_4, - f, - dt2, - Cx2, - Cy2, - x[i], - y[j], - t_1, - i, - j, - im1, - ip1, - jm1, - jp1, - ) - else: - u[i, j] = bc["N"](x[i], y[j]) - - return u - - -def solver( - I, f, c, bc, Lx, Ly, Nx, Ny, dt, T, user_action=None, version="scalar", verbose=True -): - """ - Solve the 2D wave equation u_tt = u_xx + u_yy + f(x,t) on (0,L) with - u = bc(x,y, t) on the boundary and initial condition du/dt = 0. - - Nx and Ny are the total number of grid cells in the x and y - directions. The grid points are numbered as (0,0), (1,0), (2,0), - ..., (Nx,0), (0,1), (1,1), ..., (Nx, Ny). - - dt is the time step. If dt<=0, an optimal time step is used. - T is the stop time for the simulation. - - I, f, bc are functions: I(x,y), f(x,y,t), bc(x,y,t) - - user_action: function of (u, x, xv, y, yv, t, n) called at each time - level (x and y are one-dimensional coordinate vectors). - This function allows the calling code to plot the solution, - compute errors, etc. - - verbose: true if a message at each time step is written, - false implies no output during the simulation. - """ - x = linspace(0, Lx, Nx + 1) # mesh points in x dir - y = linspace(0, Ly, Ny + 1) # mesh points in y dir - dx = x[1] - x[0] - dy = y[1] - y[0] - xv = x[:, newaxis] # for vectorized function evaluations - yv = y[newaxis, :] - if dt <= 0: # max time step? - dt = (1 / float(c)) * (1 / sqrt(1 / dx**2 + 1 / dy**2)) - Nt = int(round(T / float(dt))) - t = linspace(0, T, Nt + 1) # mesh points in time - Cx2 = (c * dt / dx) ** 2 - Cy2 = (c * dt / dy) ** 2 # help variables - dt2 = dt**2 - - u = zeros((Nx + 1, Ny + 1)) # Solution array, new level n+1 - u_n = zeros((Nx + 1, Ny + 1)) # Solution at t-dt, level n - u_nm1 = zeros((Nx + 1, Ny + 1)) # Solution at t-2*dt, level n-1 - - Ix = range(0, Nx + 1) - It = range(0, Ny + 1) - It = range(0, Nt + 1) - - # Load initial condition into u_n - for i in Ix: - for j in It: - u_n[i, j] = I(x[i], y[j]) - - if user_action is not None: - user_action(u_n, x, xv, y, yv, t, 0) - - # Special formula for first time step - if version == "scalar": - u = scheme_scalar_mesh( - u, u_n, u_nm1, 0.5, 0, 0.5, 0.5, f, dt2, Cx2, Cy2, x, y, t[0], Nx, Ny, bc - ) - - if user_action is not None: - user_action(u, x, xv, y, yv, t, 1) - - u_nm1[:, :] = u_n - u_n[:, :] = u - - for n in It[1:-1]: - if version == "scalar": - u = scheme_scalar_mesh( - u, u_n, u_nm1, 1, 1, 1, 1, f, dt2, Cx2, Cy2, x, y, t[n], Nx, Ny, bc - ) - - if user_action is not None: - if user_action(u, x, xv, y, yv, t, n + 1): - break - - # Update data structures for next step - # u_nm1[:] = u_n; u_n[:] = u # slower - u_nm1, u_n, u = u_n, u, u_nm1 - - return dt # dt might be computed in this function - - -def test_Gaussian(plot_u=1, version="scalar"): - """ - Initial Gaussian bell in the middle of the domain. - plot: not plot: 0; mesh: 1, surf: 2. - """ - # Clean up plot files - for name in glob("tmp_*.png"): - os.remove(name) - - Lx = 10 - Ly = 10 - c = 1.0 - - def I(x, y): - return exp(-((x - Lx / 2.0) ** 2) / 2.0 - (y - Ly / 2.0) ** 2 / 2.0) - - def f(x, y, t): - return 0.0 - - bc = dict(N=None, W=None, E=None, S=None) - - def action(u, x, xv, y, yv, t, n): - # print 'action, t=',t,'\nu=',u, '\Nx=',x, '\Ny=', y - if t[n] == 0: - time.sleep(2) - if plot_u == 1: - mesh(x, y, u, title="t=%g" % t[n], zlim=[-1, 1], caxis=[-1, 1]) - elif plot_u == 2: - surf( - xv, - yv, - u, - title="t=%g" % t[n], - zlim=[-1, 1], - colorbar=True, - colormap=hot(), - caxis=[-1, 1], - ) - if plot_u > 0: - time.sleep(0) # pause between frames - filename = "tmp_%04d.png" % n - # savefig(filename) # time consuming... - - Nx = 40 - Ny = 40 - T = 15 - dt = solver(I, f, c, bc, Lx, Ly, Nx, Ny, 0, T, user_action=action, version="scalar") - - -def test_1D(plot=1, version="scalar"): - """ - 1D test problem with exact solution. - """ - Lx = 10 - Ly = 10 - c = 1.0 - - def I(x, y): - """Plug profile as initial condition.""" - if abs(x - L / 2.0) > 0.1: - return 0 - else: - return 1 - - def f(x, y, t): - """Return 0, but in vectorized mode it must be an array.""" - if isinstance(x, (float, int)): - return 0 - else: - return zeros(x.size) - - bc = dict(N=None, E=None, S=None, W=None) - - def action(u, x, xv, y, yv, t, n): - # print 'action, t=',t,'\nu=',u, '\Nx=',x, '\Ny=', y - if plot: - mesh(xv, yv, u, title="t=%g") - time.sleep(0.2) # pause between frames - - Nx = 40 - Ny = 40 - T = 700 - dt = solver(I, f, c, bc, Lx, Ly, Nx, Ny, 0, T, user_action=action, version="scalar") - - -def test_const(plot=1, version="scalar"): - """ - Test problem with constant solution. - """ - Lx = 10 - Ly = 10 - c = 1.0 - C = 1.2 - - def I(x, y): - """Plug profile as initial condition.""" - return C - - def f(x, y, t): - """Return 0, but in vectorized mode it must be an array.""" - if isinstance(x, (float, int)): - return 0 - else: - return zeros(x.size) - - u0 = lambda x, y, t=0: C - bc = dict(N=u0, E=u0, S=u0, W=u0) - - def action(u, x, xv, y, yv, t, n): - print(t) - print(u) - - Nx = 4 - Ny = 3 - T = 5 - dt = solver(I, f, c, bc, Lx, Ly, Nx, Ny, 0, T, action, "scalar") - - -if __name__ == "__main__": - import sys - - if len(sys.argv) < 2: - print("""Usage %s function arg1 arg2 arg3 ...""" % sys.argv[0]) - sys.exit(0) - cmd = "%s(%s)" % (sys.argv[1], ", ".join(sys.argv[2:])) - print(cmd) - eval(cmd) diff --git a/src/wave/wave2D_u0/wave2D_u0.py b/src/wave/wave2D_u0/wave2D_u0.py deleted file mode 100644 index 8adebdbc..00000000 --- a/src/wave/wave2D_u0/wave2D_u0.py +++ /dev/null @@ -1,319 +0,0 @@ -#!/usr/bin/env python -""" -2D wave equation solved by finite differences:: - - dt, cpu_time = solver(I, V, f, c, Lx, Ly, Nx, Ny, dt, T, - user_action=None, version='scalar', - stability_safety_factor=1) - -Solve the 2D wave equation u_tt = u_xx + u_yy + f(x,t) on (0,L) with -u=0 on the boundary and initial condition du/dt=0. - -Nx and Ny are the total number of mesh cells in the x and y -directions. The mesh points are numbered as (0,0), (1,0), (2,0), -..., (Nx,0), (0,1), (1,1), ..., (Nx, Ny). - -dt is the time step. If dt<=0, an optimal time step is used. -T is the stop time for the simulation. - -I, V, f are functions: I(x,y), V(x,y), f(x,y,t). V and f -can be specified as None or 0, resulting in V=0 and f=0. - -user_action: function of (u, x, y, t, n) called at each time -level (x and y are one-dimensional coordinate vectors). -This function allows the calling code to plot the solution, -compute errors, etc. -""" -import time - -from numpy import linspace, newaxis, sqrt, zeros - - -def solver(I, V, f, c, Lx, Ly, Nx, Ny, dt, T, - user_action=None, version='scalar'): - if version == 'vectorized': - advance = advance_vectorized - elif version == 'scalar': - advance = advance_scalar - - x = linspace(0, Lx, Nx+1) # Mesh points in x dir - y = linspace(0, Ly, Ny+1) # Mesh points in y dir - # Make sure dx, dy, and dt are compatible with x, y and t - dx = x[1] - x[0] - dy = y[1] - y[0] - dt = t[1] - t[0] - - xv = x[:,newaxis] # For vectorized function evaluations - yv = y[newaxis,:] - - stability_limit = (1/float(c))*(1/sqrt(1/dx**2 + 1/dy**2)) - if dt <= 0: # max time step? - safety_factor = -dt # use negative dt as safety factor - dt = safety_factor*stability_limit - elif dt > stability_limit: - print('error: dt=%g exceeds the stability limit %g' % - (dt, stability_limit)) - Nt = int(round(T/float(dt))) - t = linspace(0, Nt*dt, Nt+1) # mesh points in time - Cx2 = (c*dt/dx)**2; Cy2 = (c*dt/dy)**2 # help variables - dt2 = dt**2 - - # Allow f and V to be None or 0 - if f is None or f == 0: - f = (lambda x, y, t: 0) if version == 'scalar' else \ - lambda x, y, t: zeros((x.shape[0], y.shape[1])) - # or simpler: x*y*0 - if V is None or V == 0: - V = (lambda x, y: 0) if version == 'scalar' else \ - lambda x, y: zeros((x.shape[0], y.shape[1])) - - - order = 'Fortran' if version == 'f77' else 'C' - u = zeros((Nx+1,Ny+1), order=order) # Solution array - u_n = zeros((Nx+1,Ny+1), order=order) # Solution at t-dt - u_nm1 = zeros((Nx+1,Ny+1), order=order) # Solution at t-2*dt - f_a = zeros((Nx+1,Ny+1), order=order) # For compiled loops - - Ix = range(0, u.shape[0]) - It = range(0, u.shape[1]) - It = range(0, t.shape[0]) - - import time; t0 = time.perf_counter() # For measuring CPU time - # Load initial condition into u_n - if version == 'scalar': - for i in Ix: - for j in It: - u_n[i,j] = I(x[i], y[j]) - else: - # Use vectorized version (requires I to be vectorized) - u_n[:,:] = I(xv, yv) - - if user_action is not None: - user_action(u_n, x, xv, y, yv, t, 0) - - # Special formula for first time step - n = 0 - # First step requires a special formula, use either the scalar - # or vectorized version (the impact of more efficient loops than - # in advance_vectorized is small as this is only one step) - if version == 'scalar': - u = advance_scalar( - u, u_n, u_nm1, f, x, y, t, n, - Cx2, Cy2, dt2, V, step1=True) - - else: - f_a[:,:] = f(xv, yv, t[n]) # precompute, size as u - V_a = V(xv, yv) - u = advance_vectorized( - u, u_n, u_nm1, f_a, - Cx2, Cy2, dt2, V=V_a, step1=True) - - if user_action is not None: - user_action(u, x, xv, y, yv, t, 1) - - # Update data structures for next step - #u_nm1[:] = u_n; u_n[:] = u # safe, but slow - u_nm1, u_n, u = u_n, u, u_nm1 - - for n in It[1:-1]: - if version == 'scalar': - # use f(x,y,t) function - u = advance(u, u_n, u_nm1, f, x, y, t, n, Cx2, Cy2, dt2) - else: - f_a[:,:] = f(xv, yv, t[n]) # precompute, size as u - u = advance(u, u_n, u_nm1, f_a, Cx2, Cy2, dt2) - - if user_action is not None: - if user_action(u, x, xv, y, yv, t, n+1): - break - - # Update data structures for next step - u_nm1, u_n, u = u_n, u, u_nm1 - - # Important to set u = u_n if u is to be returned! - t1 = time.perf_counter() - # dt might be computed in this function so return the value - return dt, t1 - t0 - - - -def advance_scalar(u, u_n, u_nm1, f, x, y, t, n, Cx2, Cy2, dt2, - V=None, step1=False): - Ix = range(0, u.shape[0]); It = range(0, u.shape[1]) - if step1: - dt = sqrt(dt2) # save - Cx2 = 0.5*Cx2; Cy2 = 0.5*Cy2; dt2 = 0.5*dt2 # redefine - D1 = 1; D2 = 0 - else: - D1 = 2; D2 = 1 - for i in Ix[1:-1]: - for j in It[1:-1]: - u_xx = u_n[i-1,j] - 2*u_n[i,j] + u_n[i+1,j] - u_yy = u_n[i,j-1] - 2*u_n[i,j] + u_n[i,j+1] - u[i,j] = D1*u_n[i,j] - D2*u_nm1[i,j] + \ - Cx2*u_xx + Cy2*u_yy + dt2*f(x[i], y[j], t[n]) - if step1: - u[i,j] += dt*V(x[i], y[j]) - # Boundary condition u=0 - j = It[0] - for i in Ix: u[i,j] = 0 - j = It[-1] - for i in Ix: u[i,j] = 0 - i = Ix[0] - for j in It: u[i,j] = 0 - i = Ix[-1] - for j in It: u[i,j] = 0 - return u - -def advance_vectorized(u, u_n, u_nm1, f_a, Cx2, Cy2, dt2, - V=None, step1=False): - if step1: - dt = sqrt(dt2) # save - Cx2 = 0.5*Cx2; Cy2 = 0.5*Cy2; dt2 = 0.5*dt2 # redefine - D1 = 1; D2 = 0 - else: - D1 = 2; D2 = 1 - u_xx = u_n[:-2,1:-1] - 2*u_n[1:-1,1:-1] + u_n[2:,1:-1] - u_yy = u_n[1:-1,:-2] - 2*u_n[1:-1,1:-1] + u_n[1:-1,2:] - u[1:-1,1:-1] = D1*u_n[1:-1,1:-1] - D2*u_nm1[1:-1,1:-1] + \ - Cx2*u_xx + Cy2*u_yy + dt2*f_a[1:-1,1:-1] - if step1: - u[1:-1,1:-1] += dt*V[1:-1, 1:-1] - # Boundary condition u=0 - j = 0 - u[:,j] = 0 - j = u.shape[1]-1 - u[:,j] = 0 - i = 0 - u[i,:] = 0 - i = u.shape[0]-1 - u[i,:] = 0 - return u - -def quadratic(Nx, Ny, version): - """Exact discrete solution of the scheme.""" - - def exact_solution(x, y, t): - return x*(Lx - x)*y*(Ly - y)*(1 + 0.5*t) - - def I(x, y): - return exact_solution(x, y, 0) - - def V(x, y): - return 0.5*exact_solution(x, y, 0) - - def f(x, y, t): - return 2*c**2*(1 + 0.5*t)*(y*(Ly - y) + x*(Lx - x)) - - Lx = 5; Ly = 2 - c = 1.5 - dt = -1 # use longest possible steps - T = 18 - - def assert_no_error(u, x, xv, y, yv, t, n): - u_e = exact_solution(xv, yv, t[n]) - diff = abs(u - u_e).max() - tol = 1E-12 - msg = 'diff=%g, step %d, time=%g' % (diff, n, t[n]) - assert diff < tol, msg - - new_dt, cpu = solver( - I, V, f, c, Lx, Ly, Nx, Ny, dt, T, - user_action=assert_no_error, version=version) - return new_dt, cpu - - -def test_quadratic(): - # Test a series of meshes where Nx > Ny and Nx < Ny - versions = 'scalar', 'vectorized', 'cython', 'f77', 'c_cy', 'c_f2py' - for Nx in range(2, 6, 2): - for Ny in range(2, 6, 2): - for version in versions: - print('testing', version, 'for %dx%d mesh' % (Nx, Ny)) - quadratic(Nx, Ny, version) - -def run_efficiency(nrefinements=4): - def I(x, y): - return sin(pi*x/Lx)*sin(pi*y/Ly) - - Lx = 10; Ly = 10 - c = 1.5 - T = 100 - versions = ['scalar', 'vectorized', 'cython', 'f77', - 'c_f2py', 'c_cy'] - print(' '*15, ''.join(['%-13s' % v for v in versions])) - for Nx in 15, 30, 60, 120: - cpu = {} - for version in versions: - dt, cpu_ = solver(I, None, None, c, Lx, Ly, Nx, Nx, - -1, T, user_action=None, - version=version) - cpu[version] = cpu_ - cpu_min = min(list(cpu.values())) - if cpu_min < 1E-6: - print('Ignored %dx%d grid (too small execution time)' - % (Nx, Nx)) - else: - cpu = {version: cpu[version]/cpu_min for version in cpu} - print('%-15s' % '%dx%d' % (Nx, Nx), end=' ') - print(''.join(['%13.1f' % cpu[version] for version in versions])) - -def gaussian(plot_method=2, version='vectorized', save_plot=True): - """ - Initial Gaussian bell in the middle of the domain. - plot_method=1 applies mesh function, =2 means surf, =0 means no plot. - """ - # Clean up plot files - for name in glob('tmp_*.png'): - os.remove(name) - - Lx = 10 - Ly = 10 - c = 1.0 - - def I(x, y): - """Gaussian peak at (Lx/2, Ly/2).""" - return exp(-0.5*(x-Lx/2.0)**2 - 0.5*(y-Ly/2.0)**2) - - if plot_method == 3: - import matplotlib.pyplot as plt - plt.ion() - fig = plt.figure() - u_surf = None - - def plot_u(u, x, xv, y, yv, t, n): - if t[n] == 0: - time.sleep(2) - if plot_method == 1: - mesh(x, y, u, title='t=%g' % t[n], zlim=[-1,1], - caxis=[-1,1]) - elif plot_method == 2: - surfc(xv, yv, u, title='t=%g' % t[n], zlim=[-1, 1], - colorbar=True, colormap=hot(), caxis=[-1,1], - shading='flat') - elif plot_method == 3: - print('Experimental 3D matplotlib...under development...') - #plt.clf() - ax = fig.add_subplot(111, projection='3d') - u_surf = ax.plot_surface(xv, yv, u, alpha=0.3) - #ax.contourf(xv, yv, u, zdir='z', offset=-100, cmap=cm.coolwarm) - #ax.set_zlim(-1, 1) - # Remove old surface before drawing - if u_surf is not None: - ax.collections.remove(u_surf) - plt.draw() - time.sleep(1) - if plot_method > 0: - time.sleep(0) # pause between frames - if save_plot: - filename = 'tmp_%04d.png' % n - savefig(filename) # time consuming! - - Nx = 40; Ny = 40; T = 20 - dt, cpu = solver(I, None, None, c, Lx, Ly, Nx, Ny, -1, T, - user_action=plot_u, version=version) - - - -if __name__ == '__main__': - test_quadratic() diff --git a/tests/test_book_snippets.py b/tests/test_book_snippets.py new file mode 100644 index 00000000..3de03372 --- /dev/null +++ b/tests/test_book_snippets.py @@ -0,0 +1,217 @@ +import pytest + + +def _devito_importable() -> bool: + try: + import devito # noqa: F401 + except Exception: + return False + return True + + +pytestmark = [ + pytest.mark.devito, + pytest.mark.skipif(not _devito_importable(), reason="Devito not importable in this environment"), +] + + +def test_what_is_devito_diffusion_runs(): + import runpy + + ns = runpy.run_path("src/book_snippets/what_is_devito_diffusion.py") + max_u = ns["RESULT"] + assert 0.0 < max_u < 1.0 + + +def test_first_pde_wave1d_runs_and_is_bounded(): + import runpy + + ns = runpy.run_path("src/book_snippets/first_pde_wave1d.py") + max_u = ns["RESULT"] + assert 0.0 < max_u < 10.0 + + +def test_boundary_dirichlet_wave_enforces_boundaries(): + import runpy + + ns = runpy.run_path("src/book_snippets/boundary_dirichlet_wave.py") + boundary_mag = ns["RESULT"] + assert boundary_mag == pytest.approx(0.0, abs=1e-12) + + +def test_verification_convergence_wave_rates_reasonable(): + import runpy + + ns = runpy.run_path("src/book_snippets/verification_convergence_wave.py") + rates = ns["RESULT"] + assert len(rates) >= 2 + assert all(1.5 < r < 2.5 for r in rates[-2:]) + + +def test_neumann_bc_diffusion_1d_runs(): + import runpy + + ns = runpy.run_path("src/book_snippets/neumann_bc_diffusion_1d.py") + grad = ns["RESULT"] + assert 0.0 <= grad < 1.0 + + +def test_mixed_bc_diffusion_1d_runs(): + import runpy + + ns = runpy.run_path("src/book_snippets/mixed_bc_diffusion_1d.py") + result = ns["RESULT"] + assert result["left_boundary"] == pytest.approx(0.0, abs=1e-12) + assert result["right_copy_error"] == pytest.approx(0.0, abs=1e-12) + + +def test_bc_2d_dirichlet_wave_edges_zero(): + import runpy + + ns = runpy.run_path("src/book_snippets/bc_2d_dirichlet_wave.py") + edge_max = ns["RESULT"] + assert edge_max == pytest.approx(0.0, abs=1e-12) + + +def test_time_dependent_bc_sine_is_nonzero(): + import runpy + + ns = runpy.run_path("src/book_snippets/time_dependent_bc_sine.py") + left_max = ns["RESULT"] + assert left_max > 0.0 + + +def test_absorbing_bc_right_wave_runs_and_bounded(): + import runpy + + ns = runpy.run_path("src/book_snippets/absorbing_bc_right_wave.py") + max_u = ns["RESULT"] + assert 0.0 < max_u < 10.0 + + +def test_periodic_bc_advection_1d_matches_endpoints(): + import runpy + + ns = runpy.run_path("src/book_snippets/periodic_bc_advection_1d.py") + diff = ns["RESULT"] + assert diff == pytest.approx(0.0, abs=1e-12) + + +def test_verification_mms_symbolic_computes_source(): + import runpy + + ns = runpy.run_path("src/book_snippets/verification_mms_symbolic.py") + result = ns["RESULT"] + assert "u_mms" in result + assert "f_mms" in result + assert "sin" in result["u_mms"] + + +def test_verification_mms_diffusion_converges(): + import runpy + + ns = runpy.run_path("src/book_snippets/verification_mms_diffusion.py") + rates = ns["RESULT"] + assert len(rates) >= 2 + # Expect second-order convergence + assert all(1.5 < r < 2.5 for r in rates) + + +def test_verification_quick_checks_pass(): + import runpy + + ns = runpy.run_path("src/book_snippets/verification_quick_checks.py") + result = ns["RESULT"] + assert result["mass_change"] < 0.1 # Mass approximately conserved + assert result["symmetry_error"] < 1e-10 # Symmetry preserved + + +def test_burgers_first_derivative_creates_stencils(): + import runpy + + ns = runpy.run_path("src/book_snippets/burgers_first_derivative.py") + result = ns["RESULT"] + assert "u_dx" in result + assert "h_x" in result["u_dx"] # Contains grid spacing + + +def test_burgers_equations_bc_creates_operator(): + import runpy + + ns = runpy.run_path("src/book_snippets/burgers_equations_bc.py") + result = ns["RESULT"] + assert result["num_equations"] == 10 # 2 updates + 8 BCs + assert result["grid_shape"] == (41, 41) + + +def test_advec_upwind_runs_and_bounded(): + import runpy + + ns = runpy.run_path("src/book_snippets/advec_upwind.py") + result = ns["RESULT"] + assert 0.0 < result["max_u"] < 1.0 + assert result["u_shape"] == (101,) + + +def test_advec_lax_wendroff_runs_and_bounded(): + import runpy + + ns = runpy.run_path("src/book_snippets/advec_lax_wendroff.py") + result = ns["RESULT"] + assert 0.0 < result["max_u"] < 1.0 + assert result["u_shape"] == (101,) + + +# Non-Devito tests (no pytest.mark.devito needed) +def test_nonlin_logistic_be_solver(): + import runpy + + ns = runpy.run_path("src/book_snippets/nonlin_logistic_be_solver.py") + result = ns["RESULT"] + # CN should be most accurate + assert result["cn_error"] < result["picard_error"] + assert result["cn_error"] < result["newton_error"] + # Newton should converge faster than Picard + assert result["newton_avg_iters"] <= result["picard_avg_iters"] + + +def test_nonlin_split_logistic(): + import runpy + + ns = runpy.run_path("src/book_snippets/nonlin_split_logistic.py") + result = ns["RESULT"] + # FE on full equation should be more accurate than splitting + assert result["FE_error"] < result["ordinary_split_error"] + # Strange splitting should be better than ordinary splitting + assert result["strange_split_error"] < result["ordinary_split_error"] + + +# Tests for src/nonlin/ module implementations (not Devito-dependent) +def test_nonlin_split_logistic_module(): + """Test the split_logistic.py module implementation.""" + import runpy + + ns = runpy.run_path("src/nonlin/split_logistic.py") + result = ns["RESULT"] + # FE on full equation should be more accurate than splitting + assert result["FE_error"] < result["ordinary_split_error"] + # Strange splitting should be better than ordinary splitting + assert result["strange_split_error"] < result["ordinary_split_error"] + # Strange with exact f_0 should be best splitting method + assert result["strange_exact_error"] < result["strange_split_error"] + # All errors should be reasonable (less than 20%) + assert all(err < 0.2 for err in result.values()) + + +def test_nonlin_split_diffu_react(): + """Test the split_diffu_react.py module implementation.""" + import runpy + + ns = runpy.run_path("src/nonlin/split_diffu_react.py") + result = ns["RESULT"] + # Should show first-order convergence in dt + assert result["converges"] + # Errors should decrease with refinement + assert result["errors"][0] > result["errors"][1] > result["errors"][2] + # Convergence rates should be close to 1.0 (first-order in dt) + assert all(0.8 < r < 1.2 for r in result["rates"]) diff --git a/tests/test_burgers_devito.py b/tests/test_burgers_devito.py new file mode 100644 index 00000000..d4137738 --- /dev/null +++ b/tests/test_burgers_devito.py @@ -0,0 +1,349 @@ +"""Tests for 2D Burgers equation Devito solver.""" + +import numpy as np +import pytest + +# Check if Devito is available +try: + import devito # noqa: F401 + + DEVITO_AVAILABLE = True +except ImportError: + DEVITO_AVAILABLE = False + +pytestmark = pytest.mark.skipif(not DEVITO_AVAILABLE, reason="Devito not installed") + + +class TestBurgers2DBasic: + """Basic tests for 2D Burgers equation solver.""" + + def test_import(self): + """Test that the module imports correctly.""" + from src.nonlin.burgers_devito import solve_burgers_2d + + assert solve_burgers_2d is not None + + def test_basic_run(self): + """Test basic solver execution.""" + from src.nonlin.burgers_devito import solve_burgers_2d + + result = solve_burgers_2d(Lx=2.0, Ly=2.0, nu=0.01, Nx=21, Ny=21, T=0.01) + + assert result.u.shape == (21, 21) + assert result.v.shape == (21, 21) + assert result.x.shape == (21,) + assert result.y.shape == (21,) + assert result.t > 0 + assert result.dt > 0 + + def test_t_equals_zero(self): + """Test that T=0 returns initial condition.""" + from src.nonlin.burgers_devito import solve_burgers_2d + + result = solve_burgers_2d(Lx=2.0, Ly=2.0, nu=0.01, Nx=21, Ny=21, T=0) + + # Default initial condition has hat function with value 2.0 + # in region [0.5, 1] x [0.5, 1] + assert result.t == 0.0 + assert result.u.max() == pytest.approx(2.0, rel=1e-10) + assert result.v.max() == pytest.approx(2.0, rel=1e-10) + + +class TestBurgers2DBoundaryConditions: + """Tests for boundary conditions.""" + + def test_dirichlet_bc_default(self): + """Test that default Dirichlet BCs are applied (value=1.0).""" + from src.nonlin.burgers_devito import solve_burgers_2d + + result = solve_burgers_2d( + Lx=2.0, Ly=2.0, nu=0.01, Nx=21, Ny=21, T=0.01, bc_value=1.0 + ) + + # Check boundaries are at bc_value=1.0 + assert np.allclose(result.u[0, :], 1.0) + assert np.allclose(result.u[-1, :], 1.0) + assert np.allclose(result.u[:, 0], 1.0) + assert np.allclose(result.u[:, -1], 1.0) + + assert np.allclose(result.v[0, :], 1.0) + assert np.allclose(result.v[-1, :], 1.0) + assert np.allclose(result.v[:, 0], 1.0) + assert np.allclose(result.v[:, -1], 1.0) + + def test_dirichlet_bc_custom(self): + """Test that custom Dirichlet BC value is applied.""" + from src.nonlin.burgers_devito import solve_burgers_2d + + result = solve_burgers_2d( + Lx=2.0, Ly=2.0, nu=0.01, Nx=21, Ny=21, T=0.01, bc_value=0.5 + ) + + # Check boundaries are at bc_value=0.5 + assert np.allclose(result.u[0, :], 0.5) + assert np.allclose(result.u[-1, :], 0.5) + assert np.allclose(result.v[0, :], 0.5) + assert np.allclose(result.v[-1, :], 0.5) + + +class TestBurgers2DPhysics: + """Tests for physical behavior of the solution.""" + + def test_solution_bounded(self): + """Test that solution remains bounded (no blow-up).""" + from src.nonlin.burgers_devito import solve_burgers_2d + + result = solve_burgers_2d(Lx=2.0, Ly=2.0, nu=0.01, Nx=31, Ny=31, T=0.1) + + # Solution should remain bounded by initial maximum + # Burgers equation with viscosity should not blow up + assert np.all(np.abs(result.u) < 10.0) + assert np.all(np.abs(result.v) < 10.0) + + def test_viscosity_smoothing(self): + """Test that higher viscosity leads to smoother solution.""" + from src.nonlin.burgers_devito import solve_burgers_2d + + # Low viscosity + result_low = solve_burgers_2d( + Lx=2.0, Ly=2.0, nu=0.001, Nx=31, Ny=31, T=0.01, sigma=0.00001 + ) + + # High viscosity + result_high = solve_burgers_2d( + Lx=2.0, Ly=2.0, nu=0.1, Nx=31, Ny=31, T=0.01, sigma=0.001 + ) + + # Higher viscosity should give smaller gradients + grad_u_low = np.max(np.abs(np.diff(result_low.u, axis=0))) + grad_u_high = np.max(np.abs(np.diff(result_high.u, axis=0))) + + assert grad_u_high < grad_u_low + + def test_advection_moves_solution(self): + """Test that the solution evolves (not stationary).""" + from src.nonlin.burgers_devito import solve_burgers_2d + + result_early = solve_burgers_2d(Lx=2.0, Ly=2.0, nu=0.01, Nx=21, Ny=21, T=0.01) + result_late = solve_burgers_2d(Lx=2.0, Ly=2.0, nu=0.01, Nx=21, Ny=21, T=0.05) + + # Solutions at different times should be different + assert not np.allclose(result_early.u, result_late.u) + + +class TestBurgers2DFirstDerivative: + """Tests specifically for first_derivative usage with explicit order.""" + + def test_first_derivative_imported(self): + """Test that first_derivative is available.""" + from devito import first_derivative + + assert first_derivative is not None + + def test_upwind_differencing_used(self): + """Test that the solver uses backward differences for advection. + + This is verified by checking that the solver runs without + instability when using the explicit scheme. + """ + from src.nonlin.burgers_devito import solve_burgers_2d + + # Run for many time steps - would become unstable with wrong differencing + result = solve_burgers_2d(Lx=2.0, Ly=2.0, nu=0.01, Nx=21, Ny=21, T=0.1) + + # Solution should remain bounded (stable) + assert np.all(np.isfinite(result.u)) + assert np.all(np.isfinite(result.v)) + assert np.max(np.abs(result.u)) < 10.0 + + +class TestBurgers2DVector: + """Tests for VectorTimeFunction implementation.""" + + def test_import_vector_solver(self): + """Test that vector solver imports correctly.""" + from src.nonlin.burgers_devito import solve_burgers_2d_vector + + assert solve_burgers_2d_vector is not None + + def test_vector_solver_basic_run(self): + """Test basic execution of vector solver.""" + from src.nonlin.burgers_devito import solve_burgers_2d_vector + + result = solve_burgers_2d_vector(Lx=2.0, Ly=2.0, nu=0.01, Nx=21, Ny=21, T=0.01) + + assert result.u.shape == (21, 21) + assert result.v.shape == (21, 21) + assert result.t > 0 + + def test_vector_solver_bounded(self): + """Test that vector solver solution remains bounded.""" + from src.nonlin.burgers_devito import solve_burgers_2d_vector + + result = solve_burgers_2d_vector(Lx=2.0, Ly=2.0, nu=0.01, Nx=21, Ny=21, T=0.1) + + assert np.all(np.abs(result.u) < 10.0) + assert np.all(np.abs(result.v) < 10.0) + + def test_vector_solver_boundary_conditions(self): + """Test boundary conditions in vector solver.""" + from src.nonlin.burgers_devito import solve_burgers_2d_vector + + result = solve_burgers_2d_vector( + Lx=2.0, Ly=2.0, nu=0.01, Nx=21, Ny=21, T=0.01, bc_value=1.0 + ) + + # Check boundaries + assert np.allclose(result.u[0, :], 1.0) + assert np.allclose(result.u[-1, :], 1.0) + assert np.allclose(result.v[0, :], 1.0) + assert np.allclose(result.v[-1, :], 1.0) + + +class TestBurgers2DHistory: + """Tests for solution history saving.""" + + def test_save_history(self): + """Test that history is saved correctly.""" + from src.nonlin.burgers_devito import solve_burgers_2d + + result = solve_burgers_2d( + Lx=2.0, Ly=2.0, nu=0.01, Nx=21, Ny=21, T=0.1, save_history=True, save_every=50 + ) + + assert result.u_history is not None + assert result.v_history is not None + assert result.t_history is not None + assert len(result.u_history) > 1 + assert len(result.u_history) == len(result.t_history) + + def test_history_none_when_not_saved(self): + """Test that history is None when not requested.""" + from src.nonlin.burgers_devito import solve_burgers_2d + + result = solve_burgers_2d( + Lx=2.0, Ly=2.0, nu=0.01, Nx=21, Ny=21, T=0.01, save_history=False + ) + + assert result.u_history is None + assert result.v_history is None + assert result.t_history is None + + +class TestBurgers2DInitialConditions: + """Tests for initial condition functions.""" + + def test_hat_initial_condition(self): + """Test hat function initial condition.""" + import numpy as np + + from src.nonlin.burgers_devito import init_hat + + x = np.linspace(0, 2, 21) + y = np.linspace(0, 2, 21) + X, Y = np.meshgrid(x, y, indexing="ij") + + u0 = init_hat(X, Y, Lx=2.0, Ly=2.0, value=2.0) + + # Outside the hat region [0.5, 1] x [0.5, 1], value should be 1.0 + assert u0[0, 0] == pytest.approx(1.0) + assert u0[-1, -1] == pytest.approx(1.0) + + # Inside the hat region, value should be 2.0 + # Find indices corresponding to center of hat region + x_idx = np.argmin(np.abs(x - 0.75)) + y_idx = np.argmin(np.abs(y - 0.75)) + assert u0[x_idx, y_idx] == pytest.approx(2.0) + + def test_sinusoidal_initial_condition(self): + """Test sinusoidal initial condition.""" + import numpy as np + + from src.nonlin.burgers_devito import sinusoidal_initial_condition + + x = np.linspace(0, 2, 21) + y = np.linspace(0, 2, 21) + X, Y = np.meshgrid(x, y, indexing="ij") + + u0 = sinusoidal_initial_condition(X, Y, Lx=2.0, Ly=2.0) + + # Should be zero at boundaries + assert u0[0, :].max() == pytest.approx(0.0, abs=1e-10) + assert u0[-1, :].max() == pytest.approx(0.0, abs=1e-10) + assert u0[:, 0].max() == pytest.approx(0.0, abs=1e-10) + assert u0[:, -1].max() == pytest.approx(0.0, abs=1e-10) + + # Maximum should be 1.0 at center + center_idx = len(x) // 2 + assert u0[center_idx, center_idx] == pytest.approx(1.0, rel=0.1) + + def test_gaussian_initial_condition(self): + """Test Gaussian initial condition.""" + import numpy as np + + from src.nonlin.burgers_devito import gaussian_initial_condition + + x = np.linspace(0, 2, 41) + y = np.linspace(0, 2, 41) + X, Y = np.meshgrid(x, y, indexing="ij") + + u0 = gaussian_initial_condition(X, Y, Lx=2.0, Ly=2.0, amplitude=2.0) + + # Background is 1.0, peak is at 1.0 + amplitude + assert u0.min() >= 1.0 + assert u0.max() <= 3.0 + 1e-10 + + # Peak should be near center + center_idx = len(x) // 2 + peak_idx = np.unravel_index(np.argmax(u0), u0.shape) + assert abs(peak_idx[0] - center_idx) <= 1 + assert abs(peak_idx[1] - center_idx) <= 1 + + def test_custom_initial_condition(self): + """Test using custom initial condition.""" + import numpy as np + + from src.nonlin.burgers_devito import solve_burgers_2d + + def custom_u(X, Y): + return np.ones_like(X) * 1.5 + + def custom_v(X, Y): + return np.ones_like(X) * 0.5 + + result = solve_burgers_2d( + Lx=2.0, Ly=2.0, nu=0.1, Nx=21, Ny=21, T=0.0, I_u=custom_u, I_v=custom_v + ) + + # At T=0, should return initial condition + assert np.allclose(result.u, 1.5) + assert np.allclose(result.v, 0.5) + + +class TestBurgers2DResult: + """Tests for Burgers2DResult dataclass.""" + + def test_result_attributes(self): + """Test that result has expected attributes.""" + from src.nonlin.burgers_devito import solve_burgers_2d + + result = solve_burgers_2d( + Lx=2.0, + Ly=2.0, + nu=0.01, + Nx=21, + Ny=21, + T=0.01, + save_history=True, + save_every=10, + ) + + assert hasattr(result, "u") + assert hasattr(result, "v") + assert hasattr(result, "x") + assert hasattr(result, "y") + assert hasattr(result, "t") + assert hasattr(result, "dt") + assert hasattr(result, "u_history") + assert hasattr(result, "v_history") + assert hasattr(result, "t_history") diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py new file mode 100644 index 00000000..870d4691 --- /dev/null +++ b/tests/test_docs_consistency.py @@ -0,0 +1,54 @@ +import re + +import numpy as np +import pytest + + +def _devito_importable() -> bool: + try: + import devito # noqa: F401 + except Exception: + return False + return True + + +@pytest.mark.devito +@pytest.mark.skipif(not _devito_importable(), reason="Devito not importable in this environment") +def test_readme_devito_example_executes(): + """Ensure README's Devito example is runnable and uses an assignable update.""" + readme = open("README.md", encoding="utf-8").read() + match = re.search(r"## What is Devito\?\s+.*?```python\s*\n(.*?)```", readme, re.S) + assert match, "Could not locate the 'What is Devito?' python code block in README.md" + + code = match.group(1) + # Safety/intent checks: we want to ensure the README teaches the right Devito pattern. + assert "solve(" in code + assert "u.forward" in code + + namespace: dict[str, object] = {} + exec(compile(code, "README.md::what-is-devito", "exec"), namespace) + + +def test_first_pde_explanation_matches_tested_snippet(): + """Ensure narrative doesn't claim u.data[1]=u.data[0] for 2nd-order wave scheme.""" + text = open("chapters/devito_intro/first_pde.qmd", encoding="utf-8").read() + assert "same as t=0" not in text + assert "second-order accuracy" in text or "2nd-order accuracy" in text + assert "0.5 * dt**2" in text + + +def test_elliptic_l1norm_is_relative_change(): + """Ensure elliptic chapter uses a standard relative-change criterion.""" + text = open("chapters/elliptic/elliptic.qmd", encoding="utf-8").read() + assert "p_{i,j}^{(k+1)} - p_{i,j}^{(k)}" in text + assert "np.abs(p.data[:] - pn.data[:])" in text + + # Guard against the previous cancellation-prone definition. + assert "np.abs(p.data[:]) - np.abs(pn.data[:])" not in text + + p_prev = np.array([1.0, 1.0]) + p_curr = np.array([-1.0, 1.0]) + old = np.sum(np.abs(p_curr) - np.abs(p_prev)) / np.sum(np.abs(p_prev)) + new = np.sum(np.abs(p_curr - p_prev)) / (np.sum(np.abs(p_prev)) + 1.0e-16) + assert old == pytest.approx(0.0) + assert new > 0.0 diff --git a/tests/test_elliptic_devito.py b/tests/test_elliptic_devito.py new file mode 100644 index 00000000..edd824fe --- /dev/null +++ b/tests/test_elliptic_devito.py @@ -0,0 +1,769 @@ +"""Tests for Devito elliptic PDE solvers (Laplace and Poisson equations). + +This module tests elliptic PDE solvers implemented using Devito, including: +1. Laplace equation: nabla^2 u = 0 (steady-state, no time derivative) +2. Poisson equation: nabla^2 u = f (with source term) + +Elliptic PDEs require iterative methods since there is no time evolution. +Common approaches: +- Jacobi iteration with dual buffers +- Pseudo-timestepping (diffusion to steady state) +- Direct solvers (not typically done in Devito) + +Per CONTRIBUTING.md: All results must be reproducible with fixed random seeds, +version-pinned dependencies, and automated tests validating examples. +""" + +import numpy as np +import pytest + +# Check if Devito is available +try: + from devito import Constant, Eq, Function, Grid, Operator, TimeFunction + + DEVITO_AVAILABLE = True +except ImportError: + DEVITO_AVAILABLE = False + +pytestmark = pytest.mark.skipif( + not DEVITO_AVAILABLE, reason="Devito not installed" +) + + +# ============================================================================= +# Test: Grid and Function Creation for Elliptic Problems +# ============================================================================= + + +@pytest.mark.devito +class TestEllipticGridCreation: + """Test grid and Function creation patterns for elliptic problems.""" + + def test_function_vs_timefunction_for_elliptic(self): + """Test that Function (not TimeFunction) is appropriate for elliptic PDEs. + + For elliptic equations with no time derivative, we use Function + for static fields. TimeFunction is used only for pseudo-timestepping. + """ + grid = Grid(shape=(21, 21), extent=(1.0, 1.0)) + + # Static field for elliptic problem + p = Function(name="p", grid=grid, space_order=2) + + # Verify it's a static field (no time dimension) + assert p.shape == (21, 21) + assert "time" not in [str(d) for d in p.dimensions] + + # TimeFunction for pseudo-timestepping approach + u = TimeFunction(name="u", grid=grid, time_order=1, space_order=2) + assert u.time_order == 1 + # Has time buffer slots + assert u.data.shape[0] > 1 + + def test_dual_buffer_pattern_with_functions(self): + """Test the dual-buffer pattern using two Function objects. + + For iterative Jacobi-style methods, we need two buffers: + - p: current iteration values + - p_new: next iteration values + """ + grid = Grid(shape=(21, 21), extent=(1.0, 1.0)) + + # Two separate buffers for Jacobi iteration + p = Function(name="p", grid=grid, space_order=2) + p_new = Function(name="p_new", grid=grid, space_order=2) + + # Initialize p with some values + p.data[:, :] = 0.0 + p_new.data[:, :] = 0.0 + + # Verify independent buffers + p.data[10, 10] = 1.0 + assert p_new.data[10, 10] == 0.0 # p_new unaffected + + def test_grid_dimensions_access(self): + """Test accessing grid dimensions for boundary condition indexing.""" + grid = Grid(shape=(21, 21), extent=(1.0, 1.0)) + x, y = grid.dimensions + + # Verify dimension properties + assert str(x) == "x" + assert str(y) == "y" + + # Access spacing + hx, hy = grid.spacing + expected_h = 1.0 / 20 # extent / (shape - 1) + # Use reasonable tolerance for float32 (Devito default dtype) + assert abs(float(hx) - expected_h) < 1e-6 + assert abs(float(hy) - expected_h) < 1e-6 + + +# ============================================================================= +# Test: Laplace Equation Solver +# ============================================================================= + + +@pytest.mark.devito +class TestLaplaceEquationSolver: + """Tests for the Laplace equation: nabla^2 p = 0.""" + + def test_laplace_jacobi_single_iteration(self): + """Test a single Jacobi iteration for Laplace equation. + + Jacobi update: p_new[i,j] = (p[i+1,j] + p[i-1,j] + p[i,j+1] + p[i,j-1]) / 4 + """ + Nx, Ny = 21, 21 + grid = Grid(shape=(Nx, Ny), extent=(1.0, 1.0)) + + p = Function(name="p", grid=grid, space_order=2) + p_new = Function(name="p_new", grid=grid, space_order=2) + + # Initialize with boundary conditions + p.data[:, :] = 0.0 + p.data[0, :] = 0.0 # Bottom + p.data[-1, :] = 1.0 # Top = 1 (Dirichlet) + p.data[:, 0] = 0.0 # Left + p.data[:, -1] = 0.0 # Right + + # Initial guess for interior + p.data[1:-1, 1:-1] = 0.5 + + # Jacobi update equation using Laplacian + # For uniform grid: p_new = (p[i+1,j] + p[i-1,j] + p[i,j+1] + p[i,j-1]) / 4 + # This is equivalent to: p_new = p + (1/4) * h^2 * laplace(p) + # where laplace uses second-order stencil + hx, hy = grid.spacing + h2 = hx * hy # For uniform grid hx = hy + + # Direct Jacobi formula + x, y = grid.dimensions + eq = Eq( + p_new, + 0.25 * (p.subs(x, x + x.spacing) + p.subs(x, x - x.spacing) + + p.subs(y, y + y.spacing) + p.subs(y, y - y.spacing)), + subdomain=grid.interior, + ) + + op = Operator([eq]) + op.apply() + + # Verify interior was updated (not boundary) + assert p_new.data[0, 10] == 0.0 # Bottom boundary unchanged + assert p_new.data[-1, 10] == 0.0 # p_new not set at boundary + # Interior should have been updated + assert p_new.data[10, 10] != 0.0 + + def test_laplace_dirichlet_bc_enforcement(self): + """Test Dirichlet boundary condition enforcement in elliptic solve.""" + Nx, Ny = 21, 21 + grid = Grid(shape=(Nx, Ny), extent=(1.0, 1.0)) + x, y = grid.dimensions # Get dimensions before using them + t = grid.stepping_dim + + # Use TimeFunction for pseudo-timestepping + p = TimeFunction(name="p", grid=grid, time_order=1, space_order=2) + + # Set Dirichlet BCs + p.data[0, :, :] = 0.0 + p.data[1, :, :] = 0.0 + + # Specific boundary values + top_val = 1.0 + p.data[:, -1, :] = top_val # Top boundary + p.data[:, 0, :] = 0.0 # Bottom boundary + p.data[:, :, 0] = 0.0 # Left boundary + p.data[:, :, -1] = 0.0 # Right boundary + + # Pseudo-timestepping update + alpha = 0.25 # Diffusion coefficient for stability + eq = Eq(p.forward, p + alpha * p.laplace, subdomain=grid.interior) + + # Boundary equations to enforce Dirichlet BCs at t+1 + bc_top = Eq(p[t + 1, Ny - 1, y], top_val) + bc_bottom = Eq(p[t + 1, 0, y], 0) + bc_left = Eq(p[t + 1, x, 0], 0) + bc_right = Eq(p[t + 1, x, Ny - 1], 0) + + op = Operator([eq, bc_top, bc_bottom, bc_left, bc_right]) + + # Run several iterations + for _ in range(100): + op.apply(time_m=0, time_M=0) + + # Verify boundary conditions are maintained + # Note: corners may have different values due to BC ordering + # Check interior boundary points (excluding corners) + assert np.allclose(p.data[0, -1, 1:-1], top_val, atol=1e-6) + assert np.allclose(p.data[0, 0, 1:-1], 0.0, atol=1e-6) + assert np.allclose(p.data[0, 1:-1, 0], 0.0, atol=1e-6) + assert np.allclose(p.data[0, 1:-1, -1], 0.0, atol=1e-6) + + def test_laplace_neumann_bc_copy_trick(self): + """Test Neumann BC using the copy trick: dp/dy = 0 at boundary. + + For zero-gradient (Neumann) BC at y=0: p[i,0] = p[i,1] + This implements dp/dy = 0 using first-order approximation. + """ + Nx, Ny = 21, 21 + grid = Grid(shape=(Nx, Ny), extent=(1.0, 1.0)) + x, y = grid.dimensions + + p = TimeFunction(name="p", grid=grid, time_order=1, space_order=2) + t = grid.stepping_dim + + # Initialize + p.data[:, :, :] = 0.5 + + # Apply Dirichlet on top, Neumann on bottom + p.data[:, -1, :] = 1.0 # Top: p = 1 + + # Interior update + alpha = 0.25 + eq = Eq(p.forward, p + alpha * p.laplace, subdomain=grid.interior) + + # Neumann BC at bottom: copy interior value to boundary + # p[t+1, 0, j] = p[t+1, 1, j] implements dp/dy = 0 + bc_neumann_bottom = Eq(p[t + 1, 0, y], p[t + 1, 1, y]) + + # Dirichlet at top + bc_top = Eq(p[t + 1, Ny - 1, y], 1.0) + + # Periodic-like or Neumann on sides + bc_left = Eq(p[t + 1, x, 0], p[t + 1, x, 1]) + bc_right = Eq(p[t + 1, x, Ny - 1], p[t + 1, x, Ny - 2]) + + op = Operator([eq, bc_neumann_bottom, bc_top, bc_left, bc_right]) + + # Run to approach steady state + for _ in range(200): + op.apply(time_m=0, time_M=0) + + # Verify Neumann condition: gradient at bottom should be ~0 + # p[1,:] should be approximately equal to p[0,:] + grad_bottom = np.abs(p.data[0, 1, 1:-1] - p.data[0, 0, 1:-1]) + assert np.max(grad_bottom) < 0.1 # Gradient approaches zero + + def test_laplace_convergence_to_steady_state(self): + """Test that pseudo-timestepping converges to steady state.""" + Nx, Ny = 21, 21 + grid = Grid(shape=(Nx, Ny), extent=(1.0, 1.0)) + x, y = grid.dimensions + t = grid.stepping_dim + + p = TimeFunction(name="p", grid=grid, time_order=1, space_order=2) + + # Set initial guess and boundary conditions + # Initialize with linear interpolation as good initial guess + y_coords = np.linspace(0, 1, Ny) + for i in range(Nx): + p.data[0, i, :] = y_coords + p.data[1, i, :] = y_coords + + # Enforce BCs + p.data[:, 0, :] = 0.0 # Bottom = 0 + p.data[:, -1, :] = 1.0 # Top = 1 + + # Pseudo-timestepping + alpha = 0.2 + eq = Eq(p.forward, p + alpha * p.laplace, subdomain=grid.interior) + + # Boundary equations - with Dirichlet on all sides for simpler test + bc_top = Eq(p[t + 1, Ny - 1, y], 1.0) + bc_bottom = Eq(p[t + 1, 0, y], 0.0) + # Linear interpolation on left and right + bc_left = Eq(p[t + 1, x, 0], x / (Nx - 1)) + bc_right = Eq(p[t + 1, x, Ny - 1], x / (Nx - 1)) + + op = Operator([eq, bc_top, bc_bottom, bc_left, bc_right]) + + # Track convergence + prev_norm = np.inf + tolerances = [] + + for iteration in range(500): + op.apply(time_m=0, time_M=0) + + # Measure change from previous iteration + current_norm = np.sum(p.data[0, 1:-1, 1:-1] ** 2) + change = abs(current_norm - prev_norm) + tolerances.append(change) + prev_norm = current_norm + + if change < 1e-8: + break + + # Should have converged + assert tolerances[-1] < 1e-4, f"Did not converge: final change = {tolerances[-1]}" + + # Verify solution is physically reasonable + # For this setup with linear BCs, solution should be approximately linear + center_col = p.data[0, :, Nx // 2] + x_coords = np.linspace(0, 1, Nx) + # Check that values are monotonically increasing (roughly) + assert center_col[0] < center_col[-1], "Solution should increase from bottom to top" + # Check boundaries + assert abs(p.data[0, 0, Nx // 2]) < 0.1, "Bottom should be near 0" + assert abs(p.data[0, -1, Nx // 2] - 1.0) < 0.1, "Top should be near 1" + + def test_buffer_swapping_via_argument_substitution(self): + """Test the buffer swapping pattern using argument substitution. + + In Devito, when using two Functions for Jacobi iteration, + we can swap buffers by passing them as arguments. + """ + Nx, Ny = 11, 11 + grid = Grid(shape=(Nx, Ny), extent=(1.0, 1.0)) + x, y = grid.dimensions + + # Create symbolic functions + p = Function(name="p", grid=grid, space_order=2) + p_new = Function(name="p_new", grid=grid, space_order=2) + + # Initialize + p.data[:, :] = 0.0 + p.data[-1, :] = 1.0 # Top = 1 + p_new.data[:, :] = 0.0 + + # Jacobi update + eq = Eq( + p_new, + 0.25 * (p.subs(x, x + x.spacing) + p.subs(x, x - x.spacing) + + p.subs(y, y + y.spacing) + p.subs(y, y - y.spacing)), + subdomain=grid.interior, + ) + + # Boundary update for p_new + bc_top = Eq(p_new.indexed[Nx - 1, y], 1.0) + bc_bottom = Eq(p_new.indexed[0, y], 0.0) + bc_left = Eq(p_new.indexed[x, 0], 0.0) + bc_right = Eq(p_new.indexed[x, Ny - 1], 0.0) + + op = Operator([eq, bc_top, bc_bottom, bc_left, bc_right]) + + # Run iterations with manual buffer swap + for _ in range(50): + op.apply() + # Swap: copy p_new to p + p.data[:, :] = p_new.data[:, :] + + # Solution should be developing + assert not np.allclose(p.data[5, 5], 0.0) + assert p.data[-1, 5] == 1.0 # Top boundary maintained + + +# ============================================================================= +# Test: Poisson Equation Solver +# ============================================================================= + + +@pytest.mark.devito +class TestPoissonEquationSolver: + """Tests for the Poisson equation: nabla^2 p = f.""" + + def test_poisson_with_point_source(self): + """Test Poisson equation with a point source. + + nabla^2 p = f where f is nonzero at a single point (source). + We use the formulation: p_{t} = laplace(p) + f + which converges to laplace(p) = -f at steady state. + """ + Nx, Ny = 31, 31 + grid = Grid(shape=(Nx, Ny), extent=(1.0, 1.0)) + x, y = grid.dimensions + t = grid.stepping_dim + + p = TimeFunction(name="p", grid=grid, time_order=1, space_order=2) + f = Function(name="f", grid=grid) # Source term + + # Initialize with small positive values + p.data[:, :, :] = 0.01 + + # Point source at center (positive source will create a peak) + f.data[:, :] = 0.0 + center = Nx // 2 + f.data[center, center] = 5.0 # Positive source + + # Pseudo-timestepping for Poisson: p_t = laplace(p) + f + # At steady state: laplace(p) = -f + alpha = 0.15 + eq = Eq( + p.forward, + p + alpha * (p.laplace + f), + subdomain=grid.interior, + ) + + # Homogeneous Dirichlet BCs + bc_top = Eq(p[t + 1, Nx - 1, y], 0.0) + bc_bottom = Eq(p[t + 1, 0, y], 0.0) + bc_left = Eq(p[t + 1, x, 0], 0.0) + bc_right = Eq(p[t + 1, x, Ny - 1], 0.0) + + op = Operator([eq, bc_top, bc_bottom, bc_left, bc_right]) + + # Run to steady state with many iterations + for _ in range(2000): + op.apply(time_m=0, time_M=0) + + # Solution should have elevated values near the source + solution = p.data[0, :, :] + + # The interior should have positive values due to the source + interior = solution[5:-5, 5:-5] + assert np.mean(interior) > 0, "Interior mean should be positive with positive source" + + # Check that value at center region is higher than near boundaries + center_val = solution[center, center] + edge_avg = (np.mean(solution[2, :]) + np.mean(solution[-3, :]) + + np.mean(solution[:, 2]) + np.mean(solution[:, -3])) / 4 + assert center_val > edge_avg, "Center should have higher value than near boundaries" + + def test_poisson_timefunction_pseudo_timestepping(self): + """Test TimeFunction approach for pseudo-timestepping Poisson solver. + + Uses u_t = a * laplace(u) + f to iterate to steady state. + At steady state: laplace(u) = -f/a (approximately) + """ + Nx, Ny = 21, 21 + grid = Grid(shape=(Nx, Ny), extent=(1.0, 1.0)) + x, y = grid.dimensions + t = grid.stepping_dim + + u = TimeFunction(name="u", grid=grid, time_order=1, space_order=2) + source = Function(name="source", grid=grid) + + # Uniform positive source term + source.data[:, :] = 0.5 + + # Initialize with small positive values to help convergence + u.data[:, :, :] = 0.05 + + # Pseudo-time diffusion with source + a = Constant(name="a") + eq = Eq(u.forward, u + a * (u.laplace + source), subdomain=grid.interior) + + # Dirichlet BCs + bc_top = Eq(u[t + 1, Nx - 1, y], 0.0) + bc_bottom = Eq(u[t + 1, 0, y], 0.0) + bc_left = Eq(u[t + 1, x, 0], 0.0) + bc_right = Eq(u[t + 1, x, Ny - 1], 0.0) + + op = Operator([eq, bc_top, bc_bottom, bc_left, bc_right]) + + # Run with small pseudo-timestep for many iterations + for _ in range(1000): + op.apply(time_m=0, time_M=0, a=0.1) + + # Solution should be positive in interior with positive source + interior = u.data[0, 2:-2, 2:-2] # Away from boundaries + assert np.mean(interior) > 0, "Interior mean should be positive with positive source" + + # Boundaries should remain close to zero + assert np.allclose(u.data[0, 0, 1:-1], 0.0, atol=0.05) + assert np.allclose(u.data[0, -1, 1:-1], 0.0, atol=0.05) + + def test_poisson_boundary_conditions_at_t_plus_1(self): + """Test that boundary conditions are properly applied at t+1. + + Critical for pseudo-timestepping: BCs must be applied to the + new time level, not the current one. + """ + Nx, Ny = 11, 11 + grid = Grid(shape=(Nx, Ny), extent=(1.0, 1.0)) + t = grid.stepping_dim + x, y = grid.dimensions + + p = TimeFunction(name="p", grid=grid, time_order=1, space_order=2) + + # Initialize + p.data[:, :, :] = 0.5 # Arbitrary initial value + + # Non-zero Dirichlet BC + bc_value = 2.0 + + # Interior update + eq = Eq(p.forward, p + 0.25 * p.laplace, subdomain=grid.interior) + + # BC at t+1 + bc = Eq(p[t + 1, Nx - 1, y], bc_value) + + op = Operator([eq, bc]) + op.apply(time_m=0, time_M=0) + + # Check that boundary was set correctly at new time level + # After one step, data[1] contains the new values + assert np.allclose(p.data[1, Nx - 1, :], bc_value) + + +# ============================================================================= +# Test: Verification Against Analytical Solutions +# ============================================================================= + + +@pytest.mark.devito +class TestEllipticVerification: + """Verification tests against analytical solutions.""" + + def test_laplace_1d_linear_solution(self): + """Test 1D Laplace: d^2p/dx^2 = 0 with p(0)=0, p(1)=1. + + Analytical solution: p(x) = x + """ + Nx = 51 + grid = Grid(shape=(Nx,), extent=(1.0,)) + x_dim = grid.dimensions[0] + t = grid.stepping_dim + + p = TimeFunction(name="p", grid=grid, time_order=1, space_order=2) + + # Initialize with linear interpolation (good initial guess) + x_coords = np.linspace(0, 1, Nx) + p.data[0, :] = x_coords + p.data[1, :] = x_coords + + # BCs + p.data[:, 0] = 0.0 + p.data[:, -1] = 1.0 + + # Pseudo-timestepping with smaller alpha for stability + eq = Eq(p.forward, p + 0.3 * p.dx2, subdomain=grid.interior) + bc_left = Eq(p[t + 1, 0], 0.0) + bc_right = Eq(p[t + 1, Nx - 1], 1.0) + + op = Operator([eq, bc_left, bc_right]) + + for _ in range(200): + op.apply(time_m=0, time_M=0) + + # Compare to analytical solution + analytical = x_coords + numerical = p.data[0, :] + + error = np.max(np.abs(numerical - analytical)) + assert error < 0.05, f"Error {error} exceeds tolerance" + + def test_laplace_2d_known_solution(self): + """Test 2D Laplace with known harmonic solution. + + If p(x,y) = x + y, then laplace(p) = 0. + Test with boundary conditions consistent with this solution. + """ + Nx, Ny = 21, 21 + grid = Grid(shape=(Nx, Ny), extent=(1.0, 1.0)) + x_dim, y_dim = grid.dimensions + t = grid.stepping_dim + + p = TimeFunction(name="p", grid=grid, time_order=1, space_order=2) + + # Create coordinate arrays for BCs + x_coords = np.linspace(0, 1, Nx) + y_coords = np.linspace(0, 1, Ny) + + # Initialize with analytical solution (this should be preserved) + X, Y = np.meshgrid(x_coords, y_coords, indexing="ij") + p.data[0, :, :] = X + Y + p.data[1, :, :] = X + Y + + # Set boundary conditions from analytical solution + # Bottom (x, 0): p = x + # Top (x, 1): p = x + 1 + # Left (0, y): p = y + # Right (1, y): p = 1 + y + + # Update interior only + eq = Eq(p.forward, p + 0.25 * p.laplace, subdomain=grid.interior) + + op = Operator([eq]) + + # Run a few iterations + for _ in range(10): + op.apply(time_m=0, time_M=0) + # Re-apply boundary conditions + p.data[0, 0, :] = y_coords # Left + p.data[0, -1, :] = 1.0 + y_coords # Right + p.data[0, :, 0] = x_coords # Bottom + p.data[0, :, -1] = x_coords + 1.0 # Top + + # Solution should remain close to x + y + analytical = X + Y + error = np.max(np.abs(p.data[0, :, :] - analytical)) + assert error < 0.05, f"Solution deviates from analytical: error = {error}" + + def test_solution_boundedness(self): + """Test that elliptic solution remains bounded by boundary values. + + Maximum principle: solution of Laplace equation achieves its + max and min on the boundary, not in the interior. + """ + Nx, Ny = 21, 21 + grid = Grid(shape=(Nx, Ny), extent=(1.0, 1.0)) + x, y = grid.dimensions + + p = TimeFunction(name="p", grid=grid, time_order=1, space_order=2) + + # Set boundary values + bc_min = 0.0 + bc_max = 1.0 + p.data[:, :, :] = 0.5 # Interior guess + + # Bottom = 0, Top = 1, Left/Right = linear interpolation + p.data[:, 0, :] = bc_min + p.data[:, -1, :] = bc_max + y_vals = np.linspace(bc_min, bc_max, Ny) + p.data[:, :, 0] = y_vals + p.data[:, :, -1] = y_vals + + # Pseudo-timestepping + t = grid.stepping_dim + eq = Eq(p.forward, p + 0.2 * p.laplace, subdomain=grid.interior) + bc_bottom = Eq(p[t + 1, 0, y], bc_min) + bc_top = Eq(p[t + 1, Nx - 1, y], bc_max) + bc_left = Eq(p[t + 1, x, 0], p[t, x, 0]) # Keep interpolated values + bc_right = Eq(p[t + 1, x, Ny - 1], p[t, x, Ny - 1]) + + op = Operator([eq, bc_bottom, bc_top, bc_left, bc_right]) + + for _ in range(200): + op.apply(time_m=0, time_M=0) + + # Interior solution should be bounded by boundary values + interior = p.data[0, 1:-1, 1:-1] + assert np.min(interior) >= bc_min - 0.01 + assert np.max(interior) <= bc_max + 0.01 + + def test_conservation_with_zero_source(self): + """Test that Laplace equation conserves the mean value property. + + For Laplace equation, the value at any interior point equals + the average of values in a neighborhood (discrete version). + """ + Nx, Ny = 21, 21 + grid = Grid(shape=(Nx, Ny), extent=(1.0, 1.0)) + x, y = grid.dimensions + + p = TimeFunction(name="p", grid=grid, time_order=1, space_order=2) + t = grid.stepping_dim + + # Simple boundary conditions + p.data[:, :, :] = 0.0 + p.data[:, -1, :] = 1.0 # Top = 1 + + # Run to steady state + eq = Eq(p.forward, p + 0.2 * p.laplace, subdomain=grid.interior) + bc_top = Eq(p[t + 1, Nx - 1, y], 1.0) + bc_bottom = Eq(p[t + 1, 0, y], 0.0) + bc_left = Eq(p[t + 1, x, 0], p[t + 1, x, 1]) # Neumann + bc_right = Eq(p[t + 1, x, Ny - 1], p[t + 1, x, Ny - 2]) # Neumann + + op = Operator([eq, bc_top, bc_bottom, bc_left, bc_right]) + + for _ in range(500): + op.apply(time_m=0, time_M=0) + + # Test mean value property at interior point + i, j = 10, 10 + val = p.data[0, i, j] + avg_neighbors = 0.25 * ( + p.data[0, i + 1, j] + + p.data[0, i - 1, j] + + p.data[0, i, j + 1] + + p.data[0, i, j - 1] + ) + + # At steady state, value should equal average of neighbors + assert abs(val - avg_neighbors) < 0.05 + + +# ============================================================================= +# Test: Edge Cases and Error Handling +# ============================================================================= + + +@pytest.mark.devito +class TestEllipticEdgeCases: + """Test edge cases for elliptic solvers.""" + + def test_uniform_dirichlet_gives_uniform_solution(self): + """Test that uniform Dirichlet BCs give uniform solution.""" + Nx, Ny = 11, 11 + grid = Grid(shape=(Nx, Ny), extent=(1.0, 1.0)) + x, y = grid.dimensions + t = grid.stepping_dim + + p = TimeFunction(name="p", grid=grid, time_order=1, space_order=2) + + # All boundaries = 0.5, initialize interior to same + bc_val = 0.5 + p.data[:, :, :] = bc_val + + eq = Eq(p.forward, p + 0.2 * p.laplace, subdomain=grid.interior) + + # Include boundary equations in operator + bc_top = Eq(p[t + 1, Nx - 1, y], bc_val) + bc_bottom = Eq(p[t + 1, 0, y], bc_val) + bc_left = Eq(p[t + 1, x, 0], bc_val) + bc_right = Eq(p[t + 1, x, Ny - 1], bc_val) + + op = Operator([eq, bc_top, bc_bottom, bc_left, bc_right]) + + # Run iterations + for _ in range(50): + op.apply(time_m=0, time_M=0) + + # Solution should remain uniformly 0.5 (it's already at equilibrium) + interior = p.data[0, 1:-1, 1:-1] + assert np.allclose(interior, bc_val, atol=0.01) + + def test_small_grid(self): + """Test solver works on minimum viable grid size.""" + Nx, Ny = 5, 5 + grid = Grid(shape=(Nx, Ny), extent=(1.0, 1.0)) + + p = TimeFunction(name="p", grid=grid, time_order=1, space_order=2) + + # Initialize + p.data[:, :, :] = 0.0 + p.data[:, -1, :] = 1.0 + + eq = Eq(p.forward, p + 0.2 * p.laplace, subdomain=grid.interior) + + op = Operator([eq]) + + # Should run without error + for _ in range(10): + op.apply(time_m=0, time_M=0) + p.data[0, -1, :] = 1.0 # Maintain BC + p.data[0, 0, :] = 0.0 + + # Verify something happened + assert not np.allclose(p.data[0, :, :], 0.0) + + def test_asymmetric_domain(self): + """Test solver on non-square domain.""" + Nx, Ny = 31, 11 # Rectangular domain + grid = Grid(shape=(Nx, Ny), extent=(3.0, 1.0)) + x, y = grid.dimensions + t = grid.stepping_dim + + p = TimeFunction(name="p", grid=grid, time_order=1, space_order=2) + + # Initialize + p.data[:, :, :] = 0.0 + p.data[:, -1, :] = 1.0 # Top = 1 + + eq = Eq(p.forward, p + 0.15 * p.laplace, subdomain=grid.interior) + bc_top = Eq(p[t + 1, Nx - 1, y], 1.0) + bc_bottom = Eq(p[t + 1, 0, y], 0.0) + + op = Operator([eq, bc_top, bc_bottom]) + + for _ in range(200): + op.apply(time_m=0, time_M=0) + + # Solution should vary primarily in x direction (short axis) + # Check boundaries maintained + assert np.allclose(p.data[0, 0, :], 0.0, atol=1e-10) + assert np.allclose(p.data[0, -1, :], 1.0, atol=1e-10) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_nonlin_devito.py b/tests/test_nonlin_devito.py index f5155fe4..10346b9c 100644 --- a/tests/test_nonlin_devito.py +++ b/tests/test_nonlin_devito.py @@ -252,9 +252,17 @@ def test_import(self): def test_basic_run(self): """Test basic solver execution.""" + import warnings + from src.nonlin import solve_nonlinear_diffusion_picard - result = solve_nonlinear_diffusion_picard(L=1.0, Nx=50, T=0.01, dt=0.001) + with warnings.catch_warnings(): + warnings.filterwarnings( + "error", + message=".*invalid value encountered.*", + category=RuntimeWarning, + ) + result = solve_nonlinear_diffusion_picard(L=1.0, Nx=50, T=0.01, dt=0.001) assert result.u.shape == (51,) assert result.x.shape == (51,) @@ -262,14 +270,67 @@ def test_basic_run(self): def test_boundary_conditions(self): """Test that boundary conditions are satisfied.""" + import warnings + from src.nonlin import solve_nonlinear_diffusion_picard - result = solve_nonlinear_diffusion_picard(L=1.0, Nx=50, T=0.01, dt=0.001) + with warnings.catch_warnings(): + warnings.filterwarnings( + "error", + message=".*invalid value encountered.*", + category=RuntimeWarning, + ) + result = solve_nonlinear_diffusion_picard(L=1.0, Nx=50, T=0.01, dt=0.001) # Dirichlet BCs assert result.u[0] == pytest.approx(0.0, abs=1e-10) assert result.u[-1] == pytest.approx(0.0, abs=1e-10) + def test_matches_numpy_picard_jacobi_reference(self): + """Compare Devito implementation to a NumPy reference for the same iteration.""" + from src.nonlin import solve_nonlinear_diffusion_picard + + L, Nx, dt, T = 1.0, 40, 0.001, 0.01 + dx = L / Nx + Nt = int(round(T / dt)) + if Nt == 0: + Nt = 1 + + x = np.linspace(0.0, L, Nx + 1) + u = np.sin(np.pi * x / L) + + picard_tol = 1e-8 + picard_max_iter = 200 + + for _ in range(Nt): + u_old = u.copy() + u_k = u.copy() + + for _k in range(picard_max_iter): + D = 1.0 + u_k + r = dt * D / (dx**2) + + u_new = u_k.copy() + u_new[1:-1] = (u_old[1:-1] + r[1:-1] * (u_k[0:-2] + u_k[2:])) / ( + 1.0 + 2.0 * r[1:-1] + ) + u_new[0] = 0.0 + u_new[-1] = 0.0 + + diff = np.max(np.abs(u_new - u_k)) + u_k = u_new + if diff < picard_tol: + break + + u = u_k + + devito = solve_nonlinear_diffusion_picard( + L=L, Nx=Nx, T=T, dt=dt, picard_tol=picard_tol, picard_max_iter=picard_max_iter + ) + + assert np.all(np.isfinite(devito.u)) + assert np.max(np.abs(devito.u - u)) < 5e-4 + class TestReactionFunctions: """Tests for reaction term functions.""" diff --git a/tests/test_swe_devito.py b/tests/test_swe_devito.py new file mode 100644 index 00000000..0da9dda8 --- /dev/null +++ b/tests/test_swe_devito.py @@ -0,0 +1,542 @@ +"""Tests for the Shallow Water Equations solver using Devito.""" + +import numpy as np +import pytest + +# Check if Devito is available +try: + import devito # noqa: F401 + + DEVITO_AVAILABLE = True +except ImportError: + DEVITO_AVAILABLE = False + +pytestmark = pytest.mark.skipif( + not DEVITO_AVAILABLE, reason="Devito not installed" +) + + +class TestSWEImport: + """Test that the module imports correctly.""" + + def test_import_solve_swe(self): + """Test main solver import.""" + from src.systems import solve_swe + + assert solve_swe is not None + + def test_import_create_operator(self): + """Test operator creation function import.""" + from src.systems import create_swe_operator + + assert create_swe_operator is not None + + def test_import_result_class(self): + """Test result dataclass import.""" + from src.systems import SWEResult + + assert SWEResult is not None + + +class TestCoupledSystemSetup: + """Test that the coupled system is set up correctly with 3 equations.""" + + def test_three_time_functions(self): + """Test that eta, M, N are all TimeFunction.""" + from devito import Grid, TimeFunction + + grid = Grid(shape=(51, 51), extent=(100.0, 100.0), dtype=np.float32) + + eta = TimeFunction(name='eta', grid=grid, space_order=2) + M = TimeFunction(name='M', grid=grid, space_order=2) + N = TimeFunction(name='N', grid=grid, space_order=2) + + # Check they are all TimeFunctions + assert hasattr(eta, 'forward') + assert hasattr(M, 'forward') + assert hasattr(N, 'forward') + + # Check they have proper shapes + assert eta.data[0].shape == (51, 51) + assert M.data[0].shape == (51, 51) + assert N.data[0].shape == (51, 51) + + def test_operator_has_three_update_equations(self): + """Test that the operator updates all three fields.""" + from devito import ( + Eq, + Function, + Grid, + Operator, + TimeFunction, + solve, + sqrt, + ) + + grid = Grid(shape=(51, 51), extent=(100.0, 100.0), dtype=np.float32) + + eta = TimeFunction(name='eta', grid=grid, space_order=2) + M = TimeFunction(name='M', grid=grid, space_order=2) + N = TimeFunction(name='N', grid=grid, space_order=2) + h = Function(name='h', grid=grid) + D = Function(name='D', grid=grid) + + g, alpha = 9.81, 0.025 + + # Initialize fields + eta.data[0, :, :] = 0.1 + M.data[0, :, :] = 1.0 + N.data[0, :, :] = 0.5 + h.data[:] = 50.0 + D.data[:] = 50.1 + + # Create equations + friction_M = g * alpha**2 * sqrt(M**2 + N**2) / D**(7.0/3.0) + pde_eta = Eq(eta.dt + M.dxc + N.dyc) + pde_M = Eq(M.dt + (M**2/D).dxc + (M*N/D).dyc + + g*D*eta.forward.dxc + friction_M*M) + + stencil_eta = solve(pde_eta, eta.forward) + stencil_M = solve(pde_M, M.forward) + + # These should compile without error + update_eta = Eq(eta.forward, stencil_eta, subdomain=grid.interior) + update_M = Eq(M.forward, stencil_M, subdomain=grid.interior) + + op = Operator([update_eta, update_M]) + + # Should be able to run (h is not in the operator, so don't pass it) + op.apply(eta=eta, M=M, D=D, time_m=0, time_M=0, dt=0.001) + + +class TestBathymetryAsFunction: + """Test that bathymetry is correctly handled as a static Function.""" + + def test_bathymetry_is_function(self): + """Test bathymetry uses Function (not TimeFunction).""" + from devito import Function, Grid + + grid = Grid(shape=(51, 51), extent=(100.0, 100.0), dtype=np.float32) + h = Function(name='h', grid=grid) + + # Function does not have 'forward' attribute + assert not hasattr(h, 'forward') + assert h.data.shape == (51, 51) + + def test_bathymetry_constant(self): + """Test solver with constant bathymetry.""" + from src.systems import solve_swe + + result = solve_swe( + Lx=50.0, Ly=50.0, + Nx=51, Ny=51, + T=0.1, + dt=1/2000, + h0=30.0, # Constant depth + nsnaps=0, + ) + + assert result.eta.shape == (51, 51) + assert result.M.shape == (51, 51) + assert result.N.shape == (51, 51) + + def test_bathymetry_array(self): + """Test solver with spatially varying bathymetry.""" + from src.systems import solve_swe + + x = np.linspace(0, 50, 51) + y = np.linspace(0, 50, 51) + X, Y = np.meshgrid(x, y) + + # Varying bathymetry + h_array = 50.0 - 20.0 * np.exp(-((X - 25)**2/100) - ((Y - 25)**2/100)) + + result = solve_swe( + Lx=50.0, Ly=50.0, + Nx=51, Ny=51, + T=0.1, + dt=1/2000, + h0=h_array, + nsnaps=0, + ) + + assert result.eta.shape == (51, 51) + + +class TestConditionalDimensionSnapshotting: + """Test that ConditionalDimension correctly subsamples snapshots.""" + + def test_snapshot_shape(self): + """Test snapshots have correct shape.""" + from src.systems import solve_swe + + nsnaps = 10 + result = solve_swe( + Lx=50.0, Ly=50.0, + Nx=51, Ny=51, + T=0.5, + dt=1/2000, + h0=30.0, + nsnaps=nsnaps, + ) + + assert result.eta_snapshots is not None + assert result.eta_snapshots.shape[0] == nsnaps + assert result.eta_snapshots.shape[1] == 51 + assert result.eta_snapshots.shape[2] == 51 + + def test_time_snapshots(self): + """Test time array for snapshots.""" + from src.systems import solve_swe + + nsnaps = 20 + T = 1.0 + result = solve_swe( + Lx=50.0, Ly=50.0, + Nx=51, Ny=51, + T=T, + dt=1/2000, + h0=30.0, + nsnaps=nsnaps, + ) + + assert result.t_snapshots is not None + assert len(result.t_snapshots) == nsnaps + assert result.t_snapshots[0] == 0.0 + assert result.t_snapshots[-1] == pytest.approx(T, rel=0.01) + + def test_no_snapshots(self): + """Test that nsnaps=0 returns None for snapshots.""" + from src.systems import solve_swe + + result = solve_swe( + Lx=50.0, Ly=50.0, + Nx=51, Ny=51, + T=0.1, + dt=1/2000, + h0=30.0, + nsnaps=0, + ) + + assert result.eta_snapshots is None + assert result.t_snapshots is None + + +class TestMassConservation: + """Test that mass is approximately conserved.""" + + def test_mass_conservation_constant_depth(self): + """Test mass conservation with constant depth.""" + from src.systems import solve_swe + + # Small domain, short time for testing + x = np.linspace(0, 50, 51) + y = np.linspace(0, 50, 51) + X, Y = np.meshgrid(x, y) + + # Initial Gaussian perturbation + eta0 = 0.1 * np.exp(-((X - 25)**2/50) - ((Y - 25)**2/50)) + M0 = 10.0 * eta0 + N0 = np.zeros_like(M0) + + result = solve_swe( + Lx=50.0, Ly=50.0, + Nx=51, Ny=51, + T=0.5, + dt=1/4000, + h0=30.0, + eta0=eta0, + M0=M0, + N0=N0, + nsnaps=10, + ) + + # Compute mass (integral of eta over domain) + dx = 50.0 / 50 + dy = 50.0 / 50 + + mass_initial = np.sum(result.eta_snapshots[0]) * dx * dy + mass_final = np.sum(result.eta_snapshots[-1]) * dx * dy + + # Mass should be approximately conserved (within some tolerance) + # Note: open boundaries may allow some mass loss + relative_change = abs(mass_final - mass_initial) / abs(mass_initial + 1e-10) + + # Allow up to 50% change due to open boundaries and numerical effects + assert relative_change < 0.5 + + def test_integral_of_eta_bounded(self): + """Test that integral of eta remains bounded.""" + from src.systems import solve_swe + + x = np.linspace(0, 50, 51) + y = np.linspace(0, 50, 51) + X, Y = np.meshgrid(x, y) + + eta0 = 0.2 * np.exp(-((X - 25)**2/30) - ((Y - 25)**2/30)) + + result = solve_swe( + Lx=50.0, Ly=50.0, + Nx=51, Ny=51, + T=0.3, + dt=1/4000, + h0=40.0, + eta0=eta0, + nsnaps=5, + ) + + # Check that eta integral doesn't blow up + dx = 50.0 / 50 + dy = 50.0 / 50 + + for i in range(result.eta_snapshots.shape[0]): + integral = np.sum(np.abs(result.eta_snapshots[i])) * dx * dy + # Integral should not grow unboundedly + assert integral < 1000.0 + + +class TestSolutionBoundedness: + """Test that solution values remain bounded (no blowup).""" + + def test_eta_bounded(self): + """Test that wave height remains bounded.""" + from src.systems import solve_swe + + result = solve_swe( + Lx=50.0, Ly=50.0, + Nx=51, Ny=51, + T=0.5, + dt=1/4000, + h0=30.0, + nsnaps=10, + ) + + # Check all snapshots are bounded + for i in range(result.eta_snapshots.shape[0]): + assert np.all(np.isfinite(result.eta_snapshots[i])) + # Wave height should be much smaller than depth + assert np.max(np.abs(result.eta_snapshots[i])) < 30.0 + + def test_discharge_bounded(self): + """Test that discharge fluxes remain bounded.""" + from src.systems import solve_swe + + result = solve_swe( + Lx=50.0, Ly=50.0, + Nx=51, Ny=51, + T=0.3, + dt=1/4000, + h0=30.0, + nsnaps=0, + ) + + # Final M and N should be finite and bounded + assert np.all(np.isfinite(result.M)) + assert np.all(np.isfinite(result.N)) + assert np.max(np.abs(result.M)) < 10000.0 + assert np.max(np.abs(result.N)) < 10000.0 + + def test_no_nan_values(self): + """Test that solution contains no NaN values.""" + from src.systems import solve_swe + + result = solve_swe( + Lx=50.0, Ly=50.0, + Nx=51, Ny=51, + T=0.2, + dt=1/4000, + h0=30.0, + nsnaps=5, + ) + + assert not np.any(np.isnan(result.eta)) + assert not np.any(np.isnan(result.M)) + assert not np.any(np.isnan(result.N)) + + if result.eta_snapshots is not None: + assert not np.any(np.isnan(result.eta_snapshots)) + + +class TestSWEResult: + """Test the SWEResult dataclass.""" + + def test_result_attributes(self): + """Test that result has all expected attributes.""" + from src.systems import solve_swe + + result = solve_swe( + Lx=50.0, Ly=50.0, + Nx=51, Ny=51, + T=0.1, + dt=1/2000, + h0=30.0, + ) + + assert hasattr(result, 'eta') + assert hasattr(result, 'M') + assert hasattr(result, 'N') + assert hasattr(result, 'x') + assert hasattr(result, 'y') + assert hasattr(result, 't') + assert hasattr(result, 'dt') + assert hasattr(result, 'eta_snapshots') + assert hasattr(result, 't_snapshots') + + def test_coordinate_arrays(self): + """Test that x and y coordinate arrays are correct.""" + from src.systems import solve_swe + + Lx, Ly = 100.0, 80.0 + Nx, Ny = 101, 81 + + result = solve_swe( + Lx=Lx, Ly=Ly, + Nx=Nx, Ny=Ny, + T=0.01, + dt=1/2000, + h0=30.0, + ) + + assert len(result.x) == Nx + assert len(result.y) == Ny + assert result.x[0] == pytest.approx(0.0) + assert result.x[-1] == pytest.approx(Lx) + assert result.y[0] == pytest.approx(0.0) + assert result.y[-1] == pytest.approx(Ly) + + +class TestHelperFunctions: + """Test utility functions for common scenarios.""" + + def test_gaussian_source(self): + """Test Gaussian tsunami source function.""" + from src.systems.swe_devito import gaussian_tsunami_source + + x = np.linspace(0, 100, 101) + y = np.linspace(0, 100, 101) + X, Y = np.meshgrid(x, y) + + eta = gaussian_tsunami_source(X, Y, x0=50, y0=50, amplitude=0.5) + + # Check shape + assert eta.shape == (101, 101) + + # Check peak is at center + max_idx = np.unravel_index(np.argmax(eta), eta.shape) + assert max_idx == (50, 50) + + # Check amplitude + assert eta.max() == pytest.approx(0.5, rel=0.01) + + def test_seamount_bathymetry(self): + """Test seamount bathymetry function.""" + from src.systems.swe_devito import seamount_bathymetry + + x = np.linspace(0, 100, 101) + y = np.linspace(0, 100, 101) + X, Y = np.meshgrid(x, y) + + h = seamount_bathymetry(X, Y, h_base=50, height=45) + + # Check shape + assert h.shape == (101, 101) + + # Minimum depth should be at seamount peak (center by default) + assert h.min() == pytest.approx(5.0, rel=0.1) + + # Depth at corners should be close to base + assert h[0, 0] == pytest.approx(50.0, rel=0.1) + + def test_tanh_bathymetry(self): + """Test tanh coastal profile function.""" + from src.systems.swe_devito import tanh_bathymetry + + x = np.linspace(0, 100, 101) + y = np.linspace(0, 100, 101) + X, Y = np.meshgrid(x, y) + + h = tanh_bathymetry(X, Y, h_deep=50, h_shallow=5, x_transition=70) + + # Check shape + assert h.shape == (101, 101) + + # Left side should be deep + assert h[50, 0] > 40 + + # Right side should be shallow + assert h[50, 100] < 10 + + +class TestPhysicalBehavior: + """Test expected physical behavior of solutions.""" + + def test_wave_propagation(self): + """Test that waves propagate outward from initial disturbance.""" + from src.systems import solve_swe + + x = np.linspace(0, 100, 101) + y = np.linspace(0, 100, 101) + X, Y = np.meshgrid(x, y) + + # Initial disturbance at center + eta0 = 0.3 * np.exp(-((X - 50)**2/20) - ((Y - 50)**2/20)) + M0 = 50.0 * eta0 + N0 = np.zeros_like(M0) + + result = solve_swe( + Lx=100.0, Ly=100.0, + Nx=101, Ny=101, + T=1.0, + dt=1/4000, + h0=50.0, + eta0=eta0, + M0=M0, + N0=N0, + nsnaps=5, + ) + + # Initial disturbance should spread out + # Variance of |eta| distribution should increase + initial_var = np.var(result.eta_snapshots[0]) + final_var = np.var(result.eta_snapshots[-1]) + + # After spreading, variance should decrease (wave disperses) + # or stay similar (if boundaries reflect) + assert final_var < initial_var * 2 # Not blowing up + + def test_amplitude_decay_with_friction(self): + """Test that bottom friction causes amplitude decay over longer times.""" + from src.systems import solve_swe + + x = np.linspace(0, 100, 101) + y = np.linspace(0, 100, 101) + X, Y = np.meshgrid(x, y) + + eta0 = 0.3 * np.exp(-((X - 50)**2/30) - ((Y - 50)**2/30)) + + # High friction coefficient, longer time for friction to act + result = solve_swe( + Lx=100.0, Ly=100.0, + Nx=101, Ny=101, + T=3.0, # Longer time + dt=1/4000, + h0=20.0, # Shallower = more friction effect + alpha=0.1, # Higher Manning's coefficient for stronger friction + eta0=eta0, + M0=np.zeros_like(eta0), # Start with no momentum + N0=np.zeros_like(eta0), + nsnaps=20, + ) + + # Compute total energy proxy: sum of |eta|^2 + energy_initial = np.sum(result.eta_snapshots[1]**2) # After first step + energy_final = np.sum(result.eta_snapshots[-1]**2) + + # Energy should decay due to friction + # Note: some transient growth may occur initially, so compare mid to late + energy_mid = np.sum(result.eta_snapshots[10]**2) + + # At minimum, energy should not grow unboundedly + # and final energy should be less than initial + assert energy_final < energy_initial * 2 # Should not grow too much + assert np.all(np.isfinite(result.eta_snapshots[-1])) diff --git a/tests/test_units_pint.py b/tests/test_units_pint.py new file mode 100644 index 00000000..e9ca0a66 --- /dev/null +++ b/tests/test_units_pint.py @@ -0,0 +1,127 @@ +import pytest + +pint = pytest.importorskip("pint") + + +@pytest.fixture(scope="module") +def ureg(): + ureg = pint.UnitRegistry() + # A generic "field" unit for scalar PDE unknowns (e.g., u(x,t)). + ureg.define("field = [field]") + ureg.define("velocity_field = meter / second") + return ureg + + +def _is_dimensionless(q) -> bool: + return q.dimensionality == q._REGISTRY.dimensionless.dimensionality + + +def _assert_dimensionless(q): + assert q.dimensionality == q._REGISTRY.dimensionless.dimensionality + + +def test_diffusion_fourier_numbers_dimensionless(ureg): + # Used in multiple diffusion snippets (Forward Euler in 1D). + L = 1.0 * ureg.meter + alpha = 1.0 * (ureg.meter**2 / ureg.second) + + for Nx, F in [(100, 0.5), (100, 0.4), (80, 0.4), (50, 0.4)]: + dx = L / Nx + dt = F * dx**2 / alpha + _assert_dimensionless(alpha * dt / dx**2) + assert (alpha * dt / dx**2).to_base_units().magnitude == pytest.approx(F) + + +def test_wave_cfl_numbers_dimensionless(ureg): + L = 1.0 * ureg.meter + c = 1.0 * (ureg.meter / ureg.second) + + # 1D wave snippets use dx = L/Nx. + for Nx, C in [(100, 0.5), (100, 0.9), (200, 0.9), (80, 0.9)]: + dx = L / Nx + dt = C * dx / c + _assert_dimensionless(c * dt / dx) + assert (c * dt / dx).to_base_units().magnitude == pytest.approx(C) + + # 2D wave snippet (`bc_2d_dirichlet_wave.py`) uses dx = L/(Nx-1). + Nx = 51 + C = 0.5 + dx = L / (Nx - 1) + dt = C * dx / c + _assert_dimensionless(c * dt / dx) + assert (c * dt / dx).to_base_units().magnitude == pytest.approx(C) + + +def test_wave_update_term_units_match_field(ureg): + # Check dimensional consistency of: + # u^{n+1} = 2u^n - u^{n-1} + (c dt)^2 u_xx + U = 1.0 * ureg.field + L = 1.0 * ureg.meter + c = 1.0 * (ureg.meter / ureg.second) + + Nx = 100 + C = 0.5 + dx = L / Nx + dt = C * dx / c + + u_xx = U / (ureg.meter**2) + term = (c * dt) ** 2 * u_xx + assert term.dimensionality == U.dimensionality + + +def test_advection_cfl_numbers_dimensionless(ureg): + L = 1.0 * ureg.meter + c = 1.0 * (ureg.meter / ureg.second) + + for Nx, C in [(80, 0.8), (100, 0.8)]: + dx = L / Nx + dt = C * dx / c + _assert_dimensionless(c * dt / dx) + assert (c * dt / dx).to_base_units().magnitude == pytest.approx(C) + + +def test_burgers_equation_units_consistent(ureg): + # Snippet `src/book_snippets/burgers_equations_bc.py` corresponds to: + # u_t + u u_x + v u_y = nu laplace(u) + # Interpret u, v as velocities [L/T]; then all terms are [L/T^2]. + L = 1.0 * ureg.meter + T = 1.0 * ureg.second + u = 1.0 * (L / T) + + u_t = u / T + u_x = u / L + adv = u * u_x + + nu = 1.0 * (L**2 / T) + lap_u = u / (L**2) + visc = nu * lap_u + + assert u_t.dimensionality == adv.dimensionality + assert u_t.dimensionality == visc.dimensionality + + +def test_logistic_ode_units_consistent(ureg): + # Logistic ODE: u_t = r u (1 - u/K) + # r is 1/T, u and K share units. + U = 1.0 * ureg.field + T = 1.0 * ureg.second + r = 1.0 / T + K = 1.0 * ureg.field + + rhs = r * U * (1.0 - U / K) + assert rhs.dimensionality == (U / T).dimensionality + + +def test_time_dependent_bc_units_consistent(ureg): + # Snippet `src/book_snippets/time_dependent_bc_sine.py` uses: + # u(0,t) = A sin(omega t) + U = 1.0 * ureg.field + T = 1.0 * ureg.second + + A = 1.0 * ureg.field + omega = 1.0 / T + t = 0.3 * T + _assert_dimensionless(omega * t) + + bc = A * 0.0 # sin(...) is dimensionless; use placeholder for units. + assert bc.dimensionality == U.dimensionality