Hide code cell content

import mmf_setup; mmf_setup.nbinit()
import os, sys
from pathlib import Path
FIG_DIR = Path(mmf_setup.ROOT) / '../Docs/_build/figures/'
os.makedirs(FIG_DIR, exist_ok=True)
import logging; logging.getLogger("matplotlib").setLevel(logging.CRITICAL)
%matplotlib
import numpy as np, matplotlib.pyplot as plt
try: from myst_nb import glue
except: glue = None

from matplotlib.animation import FuncAnimation
from gpe.contexts import FPS

This cell adds /home/docs/checkouts/readthedocs.org/user_builds/gpe/checkouts/latest/src to your path, and contains some definitions for equations and some CSS for styling the notebook. If things look a bit strange, please try the following:

  • Choose "Trust Notebook" from the "File" menu.
  • Re-execute this cell.
  • Reload the notebook.

Using matplotlib backend: module://matplotlib_inline.backend_inline

Spectral Methods: Example 1#

In §1.2, [Boyd, 1989] solves the following equation using a low-order polynomial expansion:

\[\begin{gather*} u_{,xx} - (x^6 + 3x^2)u = 0, \qquad u(-1) = u(1) = 1, \end{gather*}\]

which has the analytic solution

\[\begin{gather*} u(x) = e^{(x^4 - 1)/4}. \end{gather*}\]

Hide code cell source

import sympy
x = sympy.symbols('x', real=True)
u_exact = sympy.exp((x**4-1)/4)

def deq(u):
    return (u.diff(x, x) - (x**6 + 3*x**2)*u)

assert deq(u_exact).simplify() == 0
assert u_exact.subs([(x, -1)]) == 1
assert u_exact.subs([(x, 1)]) == 1

Read the solution there, then repeat that analysis using a Fourier basis using everything you learn, including appropriately chosen collocation points, and considering the discussion about \(a_1 = 0\).

Solution#

Here we use a basis constructed from the Chebyshev polynomials:

\[\begin{gather*} T_n(x) = \cos(n\cos^{-1}x). \end{gather*}\]

As Boyd points out, the solutions must be even (prove this!), hence we need only consider the even terms in this series. We use \(T_0(x) = 1\) to satisfy the boundary conditions, and then use the following basis, which

\[\begin{gather*} \phi_n(x) = T_{2n}(x) - 1, \qquad n \in \{1, 2, \cdots, N\},\\ u_{\vect{a}}(x) = 1 + \sum_{n=1} a_n \phi_n(x) = 1 + \sum_{n=1} a_n \bigl(\cos(2n\cos^{-1}x) -1\bigr). \end{gather*}\]

The goal is to determine the parameters \(a_n\) in order to minimize some appropriate residual \(R(\vect{a})\).

Spectral vs Pseudo-Spectral#

Consider directly fitting the solution \(u_*(x)\). The idea of a spectral method would be to use some sort of residual like the least-squares overlap:

\[\begin{gather*} R(\vect{a}) = \int_{-1}^{1}\d{x} \bigl(u(x) - u_{\vect{a}}(x)\bigr)^2. \end{gather*}\]

Given that the Chebyshev polynomials are orthogonal with a weight, a better option might be

\[\begin{gather*} R(\vect{a}) = \int_{-1}^{1}\frac{\d{x}}{\sqrt{1-x^2}} \bigl(u(x) - u_{\vect{a}}(x)\bigr)^2. \end{gather*}\]

A pseudo-spectral method replaces these integrals by evaluating the residual at a carefully chosen set of collocation points \(x_i\) and quadrature weights \(w_i\):

\[\begin{gather*} R(\vect{a}) = \sum_{i=1}^{N} w_i \bigl(u(x_i) - u_{\vect{a}}(x_i)\bigr)^2. \end{gather*}\]

As [Boyd, 1989] discusses, if these are optimally chosen, then this method is as good as the spectral approach.

Best Spectral Fit to Solution#

Since we know the solution, we can compute the best fit solution, in the least-squared

import numpy as np
from scipy.optimize import curve_fit

def model(x, *a):
    n_ = 1 + np.arange(len(a), dtype=float)[None, :]
    a_ = np.array(a)[None, :]
    x_ = np.asarray(x)[:, None]
    return 1 + (a_ * (np.cos(2*n_*np.arccos(x_))-1)).sum(axis=-1)

exact = sympy.lambdify(x, u_exact, "numpy")

fig, (ax, ax1) = plt.subplots(1, 2, figsize=(8, 3), constrained_layout=True)
xdata = np.linspace(-1, 1, 500)
ydata = exact(xdata)
ax.plot(xdata, ydata, '-k')
max_errs_spec = []
Ns = np.arange(2, 10)
for N in Ns:
    N = int(N)
    p, pcov = curve_fit(model, xdata, ydata, p0=np.ones(N))
    ax.plot(xdata, model(xdata, *p), '--', label=f"{N=}")
    max_errs_spec.append((abs(model(xdata, *p) - exact(xdata))).max())
ax.legend(loc='upper center')
ax.set(xlabel="$x$", ylabel="$u(x)$")

ax1.loglog(Ns, max_errs_spec, '-x')
ax1.set(xlabel="$N$", ylabel="max err", xticks=Ns, xticklabels=Ns)
ax1.yaxis.tick_right()
ax1.yaxis.set_ticks_position('both')
p2, pcov = curve_fit(model, xdata, ydata, p0=np.ones(2))
print(p2)
[0.10985818 0.03058958]
../_images/c2060c9065beb66a55d030be2254c56288376c1eac84ed49a2561a526a3df862.png

Hide code cell source

th = np.linspace(0, 2*np.pi, 1000)[:-1]
dth = np.diff(th).mean()

@np.vectorize
def get_f(k):
    return np.sum(np.exp(-1j*k*th + np.cos(th)**4))*dth

k = 2*np.arange(1, 10)
fk = abs(get_f(k))
fk_ = 1/k**(k/4+0.5)
fk_ *= fk[0]/fk_[0]

fig, ax = plt.subplots(figsize=(4, 3))
ax.semilogy(k, fk, label=r"$|\tilde{f}_{k}|$")
ax.semilogy(k, fk_, '--', label=r"$\propto 1/k^{k/4+1/2}$")
ax.set(xlabel="$k=2N$", ylabel=r"$\tilde{f}_{k}$", 
       title=r"Saddle-point approximation of $\tilde{f}_{k}$")
ax.legend();
../_images/d4920fde2e185d7c8568283c9eff219e058adc885e432528d57d049b6df982cc.png

Hide code cell source

fig, ax = plt.subplots(figsize=(4, 3))
max_errs_spec = []
Ns = np.arange(2, 10)
for N in Ns:
    p, pcov = curve_fit(model, xdata, ydata, p0=np.ones(N), 
                        xtol=1e-12, maxfev=10000)
    max_errs_spec.append((abs(model(xdata, *p) - exact(xdata))).max())
ax.loglog(Ns, max_errs_spec, '-x', label="Error")
ax.loglog(Ns, max_errs_spec[0]*np.exp(-Ns**(1.4) + Ns[0]**(1.4)), '--', 
          label=r"$\propto e^{-N^{1.4}}$")
k = 2*(Ns+1)
err = 1/k**(k/4 + 0.5)
ax.loglog(Ns, max_errs_spec[0]*err/err[0], '-', label=r"$\propto 1/k_{\max}^{k_{\max}/4+1/2}$")
ax.set(xlabel="$N$", ylabel="max err", xticks=Ns, xticklabels=Ns);
ax.legend();
../_images/b144b65585a21f4cc5644b2cb863a5daf4606d54c585e78cb967ac6fd3a04825.png

Questions

The right plot shows the absolute errors on a log-log plot.

  1. Why do the errors bottom out at \(\sim 10^{-9}\)?

  2. How do the errors scale as a function of the number of basis functions?

Best Pseudo-Spectral Fit#

For the Chebyshev polynomials, the collocation points are the roots of the \(T_{N}(x)\), in other words, where \(\cos(N \cos^{-1} x) = 0\), or \(N\cos^{-1}x = (i + \tfrac{1}{2})\pi\):

\[\begin{gather*} x_{i} = \cos\frac{(i + \tfrac{1}{2}) \pi}{N}. \end{gather*}\]

In our cases, an \(N\) term solution is built upon \(T_{2n}\) up to \(T_{2N}(x)\). Recall that we skip the odd orders because of the parity of our solution: likewise, we need only keep the positive collocation points:

\[\begin{gather*} x_{i} = \left.\cos\frac{(i + \tfrac{1}{2}) \pi}{2N}\right|_{i=0}^{N-1}. \end{gather*}\]

With these, we can exactly solve for the coefficients \(a_n\) using numpy.linalg.solve(), minimizing the pseudo-spectral residual to exactly zero:

def get_xi(N):
    """Return the N collocation points."""
    i = np.arange(N)
    xi = np.cos((i + 0.5) * np.pi / 2 / N)
    return xi
    
def get_a(N, exact=exact):
    x_ = get_xi(N)[:, None]
    n_ = 1 + np.arange(N, dtype=float)[None, :]
    k_ = 2*n_
    A = np.cos(k_*np.arccos(x_)) - 1
    a = np.linalg.solve(A, exact(x_.ravel()) - 1)
    return a

Hide code cell source

fig, (ax, ax1) = plt.subplots(1, 2, figsize=(8, 3), constrained_layout=True)
xdata = np.linspace(-1, 1, 500)
ydata = exact(xdata)
ax.plot(xdata, ydata, '-k')
max_errs_pspec = []
Ns = np.arange(2, 16)
for N in Ns:
    N = int(N)
    a = get_a(N)
    max_errs_pspec.append((abs(model(xdata, *a) - exact(xdata))).max())
    if N > 6:
        continue
    ax.plot(xdata, model(xdata, *a), '--', label=f"{N=}")
ax.legend(loc='upper center')
ax.set(xlabel="$x$", ylabel="$u(x)$")

ax1.loglog(Ns, max_errs_pspec, '-x', label="Pseudo-spectral")
ax1.loglog(Ns[:len(max_errs_spec)], max_errs_spec, ':+', label="Spectral")

k = 2*(Ns+1)
err = 1/k**(k/4 + 0.5)
ax1.loglog(Ns, max_errs_pspec[0]*err/err[0], '--', label=r"$\propto 1/k_{\max}^{k_{\max}/4+1/2}$")
ax1.set(xlabel="$N$", ylabel="max err", xticks=Ns, xticklabels=Ns)
ax1.yaxis.tick_right()
ax1.yaxis.set_ticks_position('both')
ax1.legend(loc="lower left")
a2 = get_a(2)
print(a2)
[0.10705744 0.03425387]
../_images/e4a98c8a38db461ad3e336e39bc06a295fd5b2cd5863bf26c155f123deed0a9a.png

Although the errors are slightly larger than with the spectral method (which directly minimizes the residual), they still decrease exponentially, and are very comparable, and code is much simpler (no need to call curve_fit()).

Solving the BVP#

This previous analysis shows how well we might do with (pseudo-)spectral methods, but cheats by using the exact solution. We now apply the method to directly solve the ODE. The only change is that we now use the ODE as our residual:

\[\begin{gather*} \left.u_{,xx}(x) - (x^6 + 3x^2)u(x) = 0\right|_{x\in \{x_{i}\}}, \qquad x_i = \left.\cos \frac{i + \tfrac{1}{2}}{2N}\pi\right|_{i=0}^{N-1}. \end{gather*}\]

Note that these equations are inhomogeneous because of our incorporation of the boundary conditions into \(u_{\vect{a}}(x)\):

\[\begin{gather*} \sum_{n}[\phi_{n}''(x_{i}) - (x_{i}^6 + 3x_{i}^2)\phi_{n}(x_{i})]a_{n}= x_{i}^6 + 3x_{i}^2. \end{gather*}\]

The only thing we need is the second derivative of the basis functions, which can be computed using the chain rule:

\[\begin{gather*} x = \cos \theta, \qquad T_{n}(x) = \cos n \theta,\\ \diff{x}{\theta} = -\sin \theta, \qquad T'_{n}(x) = -n\sin n \theta \diff{\theta}{x} = \frac{n\sin n \theta}{\sin \theta},\\ T''_{n}(x) = -\frac{n^2\cos n \theta \sin \theta - n\sin n \theta \cos \theta}{\sin^3 \theta}. \end{gather*}\]
def phi(x, n, d=0):
    """Return the dth derivative of phi_n(x)"""
    th = np.arccos(x)
    cn = np.cos(2*n*th)

    if d == 0:
        return cn - 1
    
    sn = np.sin(2*n*th)
    s = np.sin(th)

    if d == 1:
        return (2*n) * sn / s

    c = np.cos(th)
    if d == 2:
        return -((2*n)**2 * cn * s - (2*n) * sn * c) / s**3


# Check that these are correct... Don't use x0 = 0.5!
x0 = 0.3
h = 1e-8
n = 3
assert np.allclose((phi(x0+h, n) - phi(x0-h, n))/2/h, phi(x0, n, d=1))
assert np.allclose((phi(x0+h, n, d=1) - phi(x0-h, n, d=1))/2/h, phi(x0, n, d=2))

def get_a(N):
    i = np.arange(N)
    xi = np.cos((i + 0.5) * np.pi / 2 / N)
    phis = np.array([phi(xi, n=n) for n in 1+np.arange(N)])
    d2phis = np.array([phi(xi, n=n, d=2) for n in 1+np.arange(N)])
    rhs = xi**6 + 3*xi**2
    A = np.transpose(d2phis - rhs[None, :]*phis)
    a = np.linalg.solve(A, rhs)
    return a

a2 = get_a(2)
print(a2)
[0.10519309 0.03853519]
fig, (ax, ax1) = plt.subplots(1, 2, figsize=(8, 3), constrained_layout=True)
xdata = np.linspace(-1, 1, 500)
ydata = exact(xdata)
ax.plot(xdata, ydata, '-k')
max_errs_ode = []
Ns = np.arange(2, 16)
for N in Ns:
    N = int(N)
    a = get_a(N)
    max_errs_ode.append((abs(model(xdata, *a) - exact(xdata))).max())
    if N > 6:
        continue
    ax.plot(xdata, model(xdata, *a), '--', label=f"{N=}")
ax.legend(loc='upper center')
ax.set(xlabel="$x$", ylabel="$u(x)$")

ax1.loglog(Ns, max_errs_ode, '-x', label="Pseudo-spectral ODE errors")
ax1.loglog(Ns[:len(max_errs_spec)], max_errs_spec, ':+', label="Spectral best fit")

k = 2*(Ns+1)
err = 1/k**(k/4 + 0.5)
ax1.loglog(Ns, max_errs_pspec[0]*err/err[0], '--', label=r"$\propto 1/k_{\max}^{k_{\max}/4+1/2}$")
ax1.set(xlabel="$N$", ylabel="max err", xticks=Ns, xticklabels=Ns)
ax1.yaxis.tick_right()
ax1.yaxis.set_ticks_position('both')
ax1.legend(loc="lower left")
a2 = get_a(2)
print(a2)
[0.10519309 0.03853519]
../_images/29ba20094a036470dcbedfa82828ab2792c2cafcb677fa55a45ea6df014ca24d.png