Source code for climlab.dynamics.adv_diff_numerics

r'''
The 1D advection-diffusion problem
----------------------------------

The equation to be solved is

.. math::

    \frac{\partial}{\partial t} \psi(x,t) &= -\frac{1}{w(x)} \frac{\partial}{\partial x} \left[ w(x) ~ \mathcal{F}(x,t) \right] + \dot{\psi}\\
    \mathcal{F} &= U(x) \psi(x) -K(x) ~ \frac{\partial \psi}{\partial x} + F(x)

for the following quantities:

- state variable :math:`\psi(x,t)`
- diffusivity :math:`K(x)` in units of :math:`x^2 ~ t^{-1}`
- advecting velocity :math:`U(x)` in units of :math:`x ~ t^{-1}`
- a prescribed flux :math:`F(x)` (including boundary conditions) in units of :math:`\psi ~ x ~ t^{-1}`
- a scalar source/sink :math:`\dot{\psi}(x)` in units of :math:`\psi ~ t^{-1}`
- weighting function :math:`w(x)` for the divergence operator on curvilinear grids.

The boundary condition is a flux condition at the end points:

.. math::
    \begin{align}  \label{eq:fluxcondition}
    \mathcal{F}(x_0) &= F(x_0)   &    \mathcal{F}(x_J) &= F(x_J)
    \end{align}

which requires that the advecting velocity :math:`u(x) = 0` at the end points :math:`x_0, x_J`

The solver is implemented on a 1D staggered grid, with J+1 flux points
and J scalar points located somewhere between the flux points.

The solver does **not** assume the gridpoints are evenly spaced in :math:`x`.

Routines are provided to compute the following:

- Advective, diffusive, and total fluxes (the terms of :math:`\mathcal{F}`)
- Tridiagonal matrix operator for the flux convergence
- The actual flux convergence, or instantaneous scalar tendency given a current value of :math:`\psi(x)`
- Future value of :math:`\psi(x)` for an implicit timestep

Some details of the solver formulas are laid out below for reference.

Spatial discretization
----------------------

We use a non-uniform staggered spatial grid with scalar :math:`\psi` evaluated at :math:`J` points,
and flux :math:`\mathcal{F}` evaluated at :math:`J+1` flux points.
The indexing will run from :math:`j=0` to :math:`j=J` for the flux points,
and :math:`i=0` to :math:`i=J-1` for the scalar points.
This notation is consistent with zero-indexed Python arrays.

We define the following arrays:

- :math:`\mathcal{X}_b[j]` is a length J+1 array defining the location of the flux points.
- :math:`\mathcal{X}[i]` is a length J array defining the location of the scalar points, where point :math:`\mathcal{X}[j]` is somewhere between :math:`\mathcal{X}_b[j]` and :math:`\mathcal{X}_b[j+1]` for all :math:`j<J`.
- :math:`\psi[i], \dot{\psi}[i]` are length J arrays defined on :math:`\mathcal{X}`.
- :math:`U[j], K[j], F[j]` are all arrays of length J+1 defined on :math:`\mathcal{X}_b`.
- The grid weights are similarly in arrays :math:`W_b[j], W[i]` respectively on :math:`\mathcal{X}_b`` and :math:`\mathcal{X}`.

Centered difference formulas for the flux
-----------------------------------------

We use centered differences in :math:`x` to discretize the spatial derivatives.
The diffusive component of the flux is thus

.. math::

    \begin{align*}
    \mathcal{F}_{diff}[j] &= - K[j] \frac{ \left( \psi[i] - \psi[i-1] \right) }{\left( \mathcal{X}[i] - \mathcal{X}[i-1] \right)}  &  j&=i=1,2,...,J-1
    \end{align*}

The diffusive flux is assumed to be zero at the boundaries.

The advective term requires an additional approximation since the scalar :math:`\psi` is not defined at the flux points.
We use a linear interpolation to the flux points:

.. math::

    \begin{align*}
    \psi_b[j] &\equiv \psi[i-1]  \left( \frac{\mathcal{X}[i] - \mathcal{X}_b[j]}{\mathcal{X}[i] - \mathcal{X}[i-1]} \right)  +   \psi[i]  \left(  \frac{ \mathcal{X}_b[j] - \mathcal{X}[i-1] }{\mathcal{X}[i] - \mathcal{X}[i-1]} \right)   &   j&=i=1,2,...,J-1
    \end{align*}

Note that for an evenly spaced grid, this reduces to the simple average :math:`\frac{1}{2} \left(  \psi[i-1] +  \psi[i] \right)`.

With this interpolation, the advective flux is approximated by

.. math::

    \begin{align*}
    \mathcal{F}_{adv}[j] &= \frac{U[j] }{\mathcal{X}[i] - \mathcal{X}[i-1]} \left(  \psi[i-1] (\mathcal{X}[i] - \mathcal{X}_b[j])   +   \psi[i] (\mathcal{X}_b[j] - \mathcal{X}[i-1])   \right) &   j&=i=1,2,...,J-1
    \end{align*}

The total flux away from the boundaries (after some recombining terms) is thus:

.. math::

    \mathcal{F}[j] = F[j] + \psi[i-1] \left( \frac{K[j] + U[j] (\mathcal{X}[i] - \mathcal{X}_b[j]) }{ \mathcal{X}[i] - \mathcal{X}[i-1] }   \right)  - \psi[i]  \left(  \frac{K[j] - U[j] (\mathcal{X}_b[j] - \mathcal{X}[i-1]) }{\mathcal{X}[i] - \mathcal{X}[i-1] } \right)

which is valid for j=i=1,2,...,J-1.

Centered difference formulas for the flux convergence
-----------------------------------------------------

Centered difference approximation of the flux convergence gives

.. math::

    \begin{align*}
    \frac{\partial }{\partial t} \psi[i] &= -\frac{ W_b[j+1] \mathcal{F}[j+1] - W_b[j] \mathcal{F}[j] }{W[i] ( \mathcal{X}_b[j+1] - \mathcal{X}_b[j] )}  + \dot{\psi}[i] & i&=j=0,1,...,J-1
    \end{align*}

The flux convergences are best expressed together in matrix form:

.. math::

    \begin{equation}
    \frac{\partial \boldsymbol{\psi}}{\partial t} = \boldsymbol{T} ~ \boldsymbol{\psi} + \boldsymbol{S}
    \end{equation}

where :math:`\boldsymbol{\psi}` is the :math:`J\times1` column vector,
:math:`\boldsymbol{S}` is a :math:`J\times1` column vector
representing the prescribed flux convergence and source terms, whose elements are

.. math::

    \begin{align}
    S[i] &= \frac{-W_b[j+1] F[j+1] + W_b[j] F[j]}{W[i] ( \mathcal{X}_b[j+1] - \mathcal{X}_b[j] )}  + \dot{\psi}[i]  &  i&=j=0,1,...,J-1
    \end{align}

and :math:`\boldsymbol{T}` is a :math:`J\times J` tridiagonal matrix:

.. math::

    \begin{equation}
    \boldsymbol{T} ~ \boldsymbol{\psi} = \left[\begin{array}{ccccccc} T_{m0} & T_{u1} & 0 & ... & 0 & 0 & 0 \\T_{l0} & T_{m1} & T_{u2} & ... & 0 & 0 & 0 \\ 0 & T_{l1} & T_{m2} & ... & 0 & 0 & 0 \\... & ... & ... & ... & ... & ... & ... \\0 & 0 & 0 & ... & T_{m(J-3)} & T_{u(J-2)} & 0 \\0 & 0 & 0 & ... & T_{l(J-3)} & T_{m(J-2)} & T_{u(J-1)} \\0 & 0 & 0 & ... & 0 & T_{l(J-2)} & T_{m(J-1)}\end{array}\right] \left[\begin{array}{c} \psi_0 \\ \psi_1 \\ \psi_2 \\... \\ \psi_{J-3} \\ \psi_{J-2} \\ \psi_{J-1} \end{array}\right]
    \end{equation}

with vectors :math:`T_l, T_m, T_u` representing respectively the lower, main, and upper diagonals of :math:`\boldsymbol{T}`.
We will treat all three vectors as length J;
the 0th element of :math:`T_u` is ignored while the (J-1)th element of :math:`T_l` is ignored
(this is consistent with the expected inputs for the Python module scipy.linalg.solve_banded).

The instantanous tendency is then easily computed by matrix multiplication.

The elements of the main diagonal of :math:`\boldsymbol{\psi}` can be computed from

.. math::

    \begin{align}  \label{eq:maindiag}
    \begin{split}
    T_m[i] &= -\left( \frac{ W_b[j+1]  \big( K[j+1] + U[j+1] (\mathcal{X}[i+1] - \mathcal{X}_b[j+1]) \big) }{ W[i] ( \mathcal{X}_b[j+1] - \mathcal{X}_b[j] )(\mathcal{X}[i+1] - \mathcal{X}[i]) }   \right)  \\
    & \qquad - \left(  \frac{W_b[j] \big( K[j] - U[j] (\mathcal{X}_b[j] - \mathcal{X}[i-1]) \big) }{W[i] ( \mathcal{X}_b[j+1] - \mathcal{X}_b[j] )(\mathcal{X}[i] - \mathcal{X}[i-1]) } \right)  \\
    i &=j=0,2,...,J-1
    \end{split}
    \end{align}

which is valid at the boundaries so long as we set :math:`W_b[0] = W_b[J] = 0`.

The lower diagonal (including the right boundary condition) is computed from

.. math::

    \begin{align}  \label{eq:lowerdiag}
    \begin{split}
    T_l[i-1] &= \left( \frac{W_b[j]}{W[i] } \right) \left( \frac{ K[j] + U[j] (\mathcal{X}[i] - \mathcal{X}_b[j]) }{( \mathcal{X}_b[j+1] - \mathcal{X}_b[j] ) (\mathcal{X}[i] - \mathcal{X}[i-1] )}   \right) \\
     i &=j =1,2,...,J-2, J-1
     \end{split}
    \end{align}

Finally the upper diagonal (including the left boundary condition) is computed from

.. math::

    \begin{align}  \label{eq:upperdiag}
    \begin{split}
    T_u[i+1] &= \left( \frac{W_b[j+1]}{W[i]} \right) \left( \frac{K[j+1] - U[j+1] (\mathcal{X}_b[j+1] - \mathcal{X}[i]) }{( \mathcal{X}_b[j+1] - \mathcal{X}_b[j] )(\mathcal{X}[i+1] - \mathcal{X}[i] ) }  \right)  \\
    i &= j=0,...,J-2
    \end{split}
    \end{align}

Implicit time discretization
----------------------------

The forward-time finite difference approximation to LHS of the flux-convergence equation is simply

.. math::
    \begin{equation}
    \frac{\partial \psi[i]}{\partial t} \approx \frac{\psi^{n+1}[i]- \psi^{n}[i]}{\Delta t}
    \end{equation}

where the superscript :math:`n` indicates the time index.

We use the implicit-time method, in which the RHS is evaluated at the future time :math:`n+1`.
Applying this to the matrix equation above
and moving all the terms at time :math:`n+1` over to the LHS yields

.. math::

    \begin{equation}  \label{eq:implicit_tridiagonal}
    \left( \boldsymbol{I} - \boldsymbol{T} \Delta t \right) \boldsymbol{\psi}^{n+1} = \boldsymbol{\psi}^{n} + \boldsymbol{S} \Delta t
    \end{equation}

where :math:`\boldsymbol{I}` is the :math:`J\times J` identity matrix.

Solving for the future value :math:`\boldsymbol{\psi}^{n+1}` is then accomplished
by solving the :math:`J \times J` tridiagonal linear system using standard routines.

Analytical benchmark
--------------------

Here is an analytical case to be used for testing purposes to validate the numerical code.
This is implemented in the CLIMLAB test suite.

- :math:`K=K_0` is constant
- :math:`w(x) = 1` everywhere (Cartesian coordinates)
- :math:`F = 0` everywhere
- :math:`\psi(x,0) = \psi_0 \sin^2\left(\frac{\pi x}{L}\right)`
- :math:`u(x) = U_0 \sin\left(\frac{\pi x}{L}\right)`
for a domain with endpoints at :math:`x=0` and :math:`x=L`.

The analytical solution is

.. math::

    \begin{align}
    \mathcal{F} &= \psi_0 \sin\left(\frac{\pi x}{L}\right) \left[U_0 \sin^2\left(\frac{\pi x}{L}\right) - 2K \frac{\pi}{L} \cos\left(\frac{\pi x}{L}\right) \right]  \\
    \frac{\partial \psi}{\partial t} &= -\psi_0 \frac{\pi}{L} \left\{ 3 U_0 \sin^2\left(\frac{\pi x}{L}\right) \cos\left(\frac{\pi x}{L}\right) -2K\frac{\pi}{L} \left[\cos^2\left(\frac{\pi x}{L}\right) -\sin^2\left(\frac{\pi x}{L}\right) \right] \right\}
    \end{align}

which satisfies the boundary condition :math:`\mathcal{F} = 0` at :math:`x=0` and :math:`x=L`.


Module function reference
-------------------------

All the functions in ``climlab.dynamics.adv_diff_numerics`` are vectorized
to handle multidimensional input. The key assumption is that
**advection-diffusion operates along the final dimension**.

Inputs should be reshaped appropriately (e.g. with ``numpy.moveaxis()``)
before calling these functions.
'''
from __future__ import division
from numpy import zeros, ones, zeros_like, ones_like, matmul, diag, diag_indices, diff, newaxis
from numpy.linalg import solve
from scipy.linalg import solve_banded

[docs] def diffusive_flux(X, Xb, K, field): '''Return the diffusive flux on cell boundaries (length J+1)''' flux = zeros_like(K) flux[...,1:-1] += field[...,:-1]*K[...,1:-1]/diff(X,axis=-1) flux[...,1:-1] -= field[...,1:]*K[...,1:-1]/diff(X,axis=-1) return flux
[docs] def advective_flux(X, Xb, U, field): '''Return the advective flux on cell boundaries (length J+1)''' flux = zeros_like(U) flux[...,1:-1] += field[...,:-1]*(U[...,1:-1]*(X[...,1:]-Xb[...,1:-1]))/diff(X,axis=-1) flux[...,1:-1] -= field[...,1:]*(-U[...,1:-1]*(Xb[...,1:-1]-X[...,:-1]))/diff(X,axis=-1) return flux
[docs] def total_flux(X, Xb, K, U, field, prescribed_flux=None): '''Return the total (advective + diffusive + prescribed) flux on cell boundaries (length J+1)''' if prescribed_flux is None: prescribed_flux = zeros_like(U) return advective_flux(X, Xb, U, field) + diffusive_flux(X, Xb, K, field) + prescribed_flux
[docs] def advdiff_tridiag(X, Xb, K, U, W=None, Wb=None, use_banded_solver=False): r'''Compute the tridiagonal matrix operator for the advective-diffusive flux convergence. Input arrays of length J+1: Xb, Wb, K, U Input arrays of length J: X, W The 0th and Jth (i.e. first and last) elements of Wb are ignored; assuming boundary condition is a prescribed flux. The return value depends on input flag ``use_banded_solver`` If ``use_banded_solver==True``, return a 3xJ array containing the elements of the tridiagonal. This version is restricted to 1D input arrays, but is suitable for use with the efficient banded solver. If ``use_banded_solver=False`` (which it must be for multidimensional input), return an array (...,J,J) with the full tridiagonal matrix. ''' J = X.shape[-1] if (W is None): W = ones_like(X) if (Wb is None): Wb = ones_like(Xb) # These are all length (J-1) in the last axis lower_diagonal = (Wb[...,1:-1]/W[...,1:] * (K[...,1:-1]+U[...,1:-1]*(X[...,1:]-Xb[...,1:-1])) / ((Xb[...,2:]-Xb[...,1:-1])*(X[...,1:]-X[...,:-1]))) upper_diagonal = (Wb[...,1:-1]/W[...,:-1] * (K[...,1:-1]-U[...,1:-1]*(Xb[...,1:-1]-X[...,:-1])) / ((Xb[...,1:-1]-Xb[...,:-2])*(X[...,1:]-X[...,:-1]))) main_diagonal_term1 = (-Wb[...,1:-1]/W[...,:-1] * (K[...,1:-1]+U[...,1:-1]*(X[...,1:]-Xb[...,1:-1])) / ((Xb[...,1:-1]-Xb[...,:-2])*(X[...,1:]-X[...,:-1]))) main_diagonal_term2 = (-Wb[...,1:-1]/W[...,1:] * (K[...,1:-1]-U[...,1:-1]*(Xb[...,1:-1]-X[...,:-1])) / ((Xb[...,2:]-Xb[...,1:-1])*(X[...,1:]-X[...,:-1]))) if use_banded_solver: # Pack the diagonals into a 3xJ array tridiag_banded = zeros((3,J)) # Lower diagonal (last element ignored) tridiag_banded[2,:-1] = lower_diagonal # Upper diagonal (first element ignored) tridiag_banded[0,1:] = upper_diagonal # Main diagonal, term 1, length J-1 tridiag_banded[1,:-1] += main_diagonal_term1 # Main diagonal, term 2, length J-1 tridiag_banded[1, 1:] += main_diagonal_term2 return tridiag_banded else: # If X.size is (...,J), then the tridiagonal operator is (...,J,J) sizeJJ = tuple([n for n in X.shape[:-1]] + [J,J]) tridiag = zeros(sizeJJ) # indices for main, upper, and lower diagonals of a JxJ matrix inds_main = diag_indices(J) inds_upper = (inds_main[0][:-1], inds_main[1][1:]) inds_lower = (inds_main[0][1:], inds_main[1][:-1]) # Lower diagonal (length J-1) tridiag[...,inds_lower[0],inds_lower[1]] = lower_diagonal # Upper diagonal (length J-1) tridiag[...,inds_upper[0],inds_upper[1]] = upper_diagonal # Main diagonal, term 1, length J-1 tridiag[...,inds_main[0][:-1],inds_main[1][:-1]] += main_diagonal_term1 # Main diagonal, term 2, length J-1 tridiag[...,inds_main[0][1:],inds_main[1][1:]] += main_diagonal_term2 return tridiag
[docs] def make_the_actual_tridiagonal_matrix(tridiag_banded): '''Convert a (3xJ) banded array into full (JxJ) tridiagonal matrix form.''' return (diag(tridiag_banded[1,:], k=0) + diag(tridiag_banded[0,1:], k=1) + diag(tridiag_banded[2,:-1], k=-1))
[docs] def compute_source(X, Xb, prescribed_flux=None, prescribed_source=None, W=None, Wb=None): '''Return the source array S consisting of the convergence of the prescribed flux plus the prescribed scalar source.''' if (W is None): W = ones_like(X) if (Wb is None): Wb = ones_like(Xb) if prescribed_flux is None: prescribed_flux = zeros_like(Xb) if prescribed_source is None: prescribed_source = zeros_like(X) F = prescribed_flux return ((-Wb[...,1:]*F[...,1:]+Wb[...,:-1]*F[...,:-1]) / (W*(Xb[...,1:]-Xb[...,:-1])) + prescribed_source)
[docs] def compute_tendency(field, tridiag, source, use_banded_solver=False): r'''Return the instantaneous scalar tendency. This is the sum of the convergence of advective+diffusive flux plus any prescribed convergence or scalar sources. The convergence is computed by matrix multiplication: .. math:: \frac{\partial \psi}{\partial t} = T \times \psi + S where :math:`T` is the tridiagonal flux convergence matrix. ''' if use_banded_solver: tridiag = make_the_actual_tridiagonal_matrix(tridiag) # np.matmul expects the final 2 dims of each array to be matrices # add a singleton dimension to field so we get (J,J)x(J,1)->(J,1) result = matmul(tridiag, field[...,newaxis]) + source[...,newaxis] # Now strip the extra dim return result[...,0]
[docs] def implicit_step_forward(initial_field, tridiag, source, timestep, use_banded_solver=False): r'''Return the field at future time using an implicit timestep. The matrix problem is .. math:: (I - T \Delta t) \psi^{n+1} = \psi^n + S \Delta t where :math:`T` is the tridiagonal matrix for the flux convergence, :math:`psi` is the state variable, the superscript :math:`n` refers to the time index, and :math:`S \Delta t` is the accumulated source over the timestep :math:`\Delta t`. Input arguments: - ``initial_field``: the current state variable :math:`\psi^n`, dimensions (...,J) - ``tridiag``: the tridiagonal matrix :math:`T`, dimensions (...,J,J) or (...,3,J) depending on the value of ``use_banded_solver`` - ``source``: prescribed sources/sinks of :math:`\psi`, dimensions (...,J) - ``timestep``: the discrete timestep in time units - ``use_banded_solver``: switch to use the optional efficient banded solver (see below) Returns the updated value of the state variable :math:`\psi^{n+1}`, dimensions (...,J) The expected shape of ``tridiag`` depends on the switch ``use_banded_solver``, which should be consistent with that used in the call to ``advdiff_tridiag()``. If ``True``, we use the efficient banded matrix solver ``scipy.linalg.solve_banded()``. However this will probably only work for a 1D state variable. The default is to use the general linear system solver ``numpy.linalg.solve()``. ''' RHS = initial_field + source*timestep I = 0.*tridiag J = initial_field.shape[-1] if use_banded_solver: I[1,:] = 1. # identity matrix in banded form IminusTdt = I-tridiag*timestep return solve_banded((1, 1), IminusTdt, RHS) else: # indices for main, upper, and lower diagonals of a JxJ matrix inds_main = diag_indices(J) I = 0.*tridiag I[...,inds_main[0],inds_main[1]] = 1. # stacked identity matrix IminusTdt = I-tridiag*timestep return solve(IminusTdt, RHS)