import numpy as np
import scipy.fft as fft
[docs]
def steady_state_transport_solver(
srf_flx,
z,
profiles,
domain,
modes=(512, 512),
meas_pt=(0.0, 0.0),
srf_bg_conc=0.0,
footprint=False,
analytic=False,
fetch=-1e9,
ivp_method="SIE",
):
"""
Solves the steady-state advection-diffusion equation for a concentration
with flux boundary condition given vertical profiles of wind and eddy diffusivity
using the Fourier, linear shooting, and semi-implicit Euler methods.
Parameters
----------
srf_flx : ndarray of float
2D field of surface kinematic flux at z=z0 [m/s].
z : ndarray of float
1D array of vertical grid points from z0 to zm [m].
profiles : tuple of ndarray
Tuple containing 1D arrays of vertical profiles of zonal wind, meridional wind [m/s],
and eddy diffusivity [m²/s].
domain : tuple of float
Tuple containing domain sizes (xmax, ymax) [m].
modes : tuple of int, optional
Tuple containing the number of zonal and meridional Fourier modes (nlx, nly).
Default is (512, 512).
meas_pt : tuple of float, optional
Coordinates of the measurement point (xm, ym) [m] relative to `srf_flx`,
where the origin is at `srf_flx[0, 0]`. Default is (0.0, 0.0).
srf_bg_conc : float, optional
Surface background concentration at z=z0 [scalar_unit]. Default is 0.0.
footprint : bool, optional
If True, activates footprints (Green's function) for output. Default is False.
analytic : bool, optional
If True, uses the analytic solution for constant wind and eddy diffusivity.
Default is False.
fetch : float, optional
Width of the zero-flux halo around the domain [m]. Default is -1e9, which sets
the fetch to `max(xmax, ymax)`.
ivp_method : str, optional
Method for solving the initial value problem. Options are "SIE" (default),
"EI", "TSEI3", or "EE".
Returns
-------
srf_conc : ndarray of float
2D field of surface concentrations at z=z0.
bg_conc : float
Background concentration at z=zm.
conc : ndarray of float
2D field of concentration at z=zm or Green's function.
flx : ndarray of float
2D field of kinematic flux at z=zm or footprint.
"""
q0 = srf_flx
p000 = srf_bg_conc
u, v, K = profiles
xmx, ymx = domain
nlx, nly = modes
xm, ym = meas_pt
# number of grid cells
ny, nx = q0.shape
# grid increments
dx, dy = xmx / nx, ymx / ny
if fetch < 0.0:
fetch = max(xmx, ymx)
# pad width
px = int(fetch / dx)
py = int(fetch / dy)
# construct zero-flux halo by padding
q0 = np.pad(q0, ((py, py), (px, px)), mode="constant", constant_values=0.0)
# extent domain
nx = nx + 2 * px
ny = ny + 2 * py
if (nlx > nx) or (nly > ny):
print("Warning: Number of Fourier modes must not exeed number of grid cells.")
print("Setting both equal.")
nlx, nly = nx, ny
# Deltas for truncated Fourier transform
dlx, dly = (nx - nlx) // 2, (ny - nly) // 2
if footprint:
# Fourier trafo of delta distribution
tfftq0 = np.ones((nly, nlx), dtype=complex) / nx / ny
else:
fftq0 = fft.fft2(q0, norm="forward") # fft of source
# shift zero wave number to center of array
fftq0 = fft.fftshift(fftq0)
# truncate fourier series by removing higher-frequency components
tfftq0 = fftq0[dly : ny - dly, dlx : nx - dlx]
# unshift
tfftq0 = fft.ifftshift(tfftq0)
# Fourier summation index
ilx = fft.fftfreq(nlx, d=1.0 / nlx)
ily = fft.fftfreq(nly, d=1.0 / nly)
# define truncated zonal and meridional wavenumbers
lx = 2.0 * np.pi / dx / nx * ilx
ly = 2.0 * np.pi / dy / ny * ily
Lx, Ly = np.meshgrid(lx, ly)
dz = np.diff(z, axis=0)
nz = len(z)
# define mask to seperate degenerated and non-degenerated system
msk = np.ones((nly, nlx), dtype=bool) # all n and m not equal 0
msk[0, 0] = False
one = np.ones((nly, nlx), dtype=complex)[msk]
zero = np.zeros((nly, nlx), dtype=complex)[msk]
Kinv = 1.0 / K[nz - 1]
# Eigenvalue determining solution for z > zm
eigval = np.sqrt(
Lx[msk] ** 2
+ Ly[msk] ** 2
+ 1j * u[nz - 1] * Kinv * Lx[msk]
+ 1j * v[nz - 1] * Kinv * Ly[msk]
)
# initialization
tfftp0 = np.zeros((nly, nlx), dtype=complex)
tfftp = np.zeros((nly, nlx), dtype=complex)
tfftq = np.zeros((nly, nlx), dtype=complex)
tfftp0[0, 0] = p000
tfftq[0, 0] = tfftq0[0, 0] # conservation by design
if analytic:
# constant profiles solution
# for validation purposes
h = z[nz - 1] - z[0]
tfftp0[msk] = tfftq0[msk] * Kinv / eigval
tfftp[0, 0] = p000 - tfftq0[0, 0] * Kinv * h
tfftq[msk] = tfftq0[msk] * np.exp(-eigval * h)
tfftp[msk] = tfftq[msk] * Kinv / eigval
else:
# solve non-degenerated problem for (n,m) =/= (0,0)
# by linear shooting method
# use two auxillary initial value problems
tfftp1, tfftq1 = ivp_solver(
(one, zero), profiles, z, Lx[msk], Ly[msk], method=ivp_method
)
tfftp2, tfftq2 = ivp_solver(
(zero, tfftq0[msk]), profiles, z, Lx[msk], Ly[msk], method=ivp_method
)
alpha = -(tfftq2 - K[nz - 1] * eigval * tfftp2) / (
tfftq1 - K[nz - 1] * eigval * tfftp1
)
# linear combination of the two solution of the IVP
tfftp0[msk] = alpha
tfftp[msk] = alpha * tfftp1 + tfftp2
tfftq[msk] = alpha * tfftq1 + tfftq2
# solve degenerated problem for (n,m) = (0,0)
# with Euler forward method
tfftp[0, 0] = p000
for i in range(nz - 1):
tfftp[0, 0] = tfftp[0, 0] - tfftq0[0, 0] / K[i] * dz[i]
# shift green function in Fourier space to measurement point
if footprint:
tfftp0 = tfftp0 * np.exp(1j * (Lx * (xm + fetch) + Ly * (ym + fetch)))
tfftp = tfftp * np.exp(1j * (Lx * (xm + fetch) + Ly * (ym + fetch)))
tfftq = tfftq * np.exp(1j * (Lx * (xm + fetch) + Ly * (ym + fetch)))
# shift such that xm, ym are in the middle of the domain
elif xm**2 + ym**2 > 0.0:
tfftp0 = tfftp0 * np.exp(1j * (Lx * (xm - xmx / 2) + Ly * (ym - ymx / 2)))
tfftp = tfftp * np.exp(1j * (Lx * (xm - xmx / 2) + Ly * (ym - ymx / 2)))
tfftq = tfftq * np.exp(1j * (Lx * (xm - xmx / 2) + Ly * (ym - ymx / 2)))
# shift zero to center
tfftp0 = fft.fftshift(tfftp0)
tfftp = fft.fftshift(tfftp)
tfftq = fft.fftshift(tfftq)
# untruncate
fftp0 = np.pad(
tfftp0, ((dly, dly), (dlx, dlx)), mode="constant", constant_values=0.0
)
fftp = np.pad(tfftp, ((dly, dly), (dlx, dlx)), mode="constant", constant_values=0.0)
fftq = np.pad(tfftq, ((dly, dly), (dlx, dlx)), mode="constant", constant_values=0.0)
# unshift
fftp0 = fft.ifftshift(fftp0)
fftp = fft.ifftshift(fftp)
fftq = fft.ifftshift(fftq)
if footprint:
# use fft to reverse sign, make green's function to footprint
p0 = fft.fft2(fftp0, norm="backward").real # concentration
p = fft.fft2(fftp, norm="backward").real # concentration
q = fft.fft2(fftq, norm="backward").real # kinematic flux
else:
# use ifft as usual
p0 = fft.ifft2(fftp0, norm="forward").real # concentration
p = fft.ifft2(fftp, norm="forward").real # concentration
q = fft.ifft2(fftq, norm="forward").real # kinematic flux
srf_conc = p0[py : ny - py, px : nx - px]
bg_conc = fftp[0, 0].real
conc = p[py : ny - py, px : nx - px]
flx = q[py : ny - py, px : nx - px]
return srf_conc, bg_conc, conc, flx
[docs]
def ivp_solver(fftpq, profiles, z, Lx, Ly, method="SIE"):
"""
Solves the initial value problem resulting from the discretization of the
steady-state advection-diffusion equation using the Fast Fourier Transform.
Parameters
----------
fftpq : tuple of ndarray
Tuple containing the initial Fourier-transformed pressure and flux fields (fftp0, fftq0).
profiles : tuple of ndarray
Tuple containing 1D arrays of vertical profiles of zonal wind, meridional wind [m/s],
and eddy diffusivity [m²/s].
z : ndarray of float
1D array of vertical grid points from z0 to zm [m].
Lx : ndarray of float
2D array of zonal wavenumbers.
Ly : ndarray of float
2D array of meridional wavenumbers.
method : str, optional
Method for solving the initial value problem. Options are:
- ``SIE`` (Semi-Implicit Euler, default)
- ``EI`` (Exponential Integrator)
- ``TSEI3`` (Taylor Series Exponential Integrator, 3rd order)
- ``EE`` (Explicit Euler)
Returns
-------
fftp : ndarray of complex
Fourier-transformed pressure field after solving the IVP.
fftq : ndarray of complex
Fourier-transformed flux field after solving the IVP.
"""
fftp0, fftq0 = fftpq
u, v, K = profiles
fftp, fftq = np.copy(fftp0), np.copy(fftq0)
nz = len(z)
dz = np.diff(z, axis=0)
for i in range(nz - 1):
Ti = -K[i] * (Lx**2 + Ly**2) - 1j * u[i] * Lx - 1j * v[i] * Ly
Kinv = 1.0 / K[i]
dzi = dz[i]
# exponential integrator (exact) method
if method == "EI":
eig = np.sqrt(Ti * Kinv)
dum = np.cos(eig * dzi) * fftp - Kinv / eig * np.sin(eig * dzi) * fftq
fftq = Ti / eig * np.sin(eig * dzi) * fftp + np.cos(eig * dzi) * fftq
fftp = dum
# Taylor series for exponential integrator method up to 3rd order
elif method == "TSEI3":
a = 1.0 - 0.5 * Kinv * Ti * dzi**2
b = -Kinv * dzi - 1.0 / 6.0 * Kinv**2 * Ti * dzi**3
c = Ti * dzi - 1.0 / 6.0 * Kinv * Ti**2 * dzi**3
d = 1.0 - 0.5 * Kinv * Ti * dzi**2
dum = a * fftp + b * fftq
fftq = c * fftp + d * fftq
fftp = dum
# Semi-implicit Euler method
elif method == "SIE":
fftp = fftp - dzi * Kinv * fftq
fftq = fftq + dzi * Ti * fftp
# Explicit Euler method
elif method == "EE":
dum = fftp - dz[i] / K[i] * fftq
fftq = fftq + dz[i] * Ti * fftp
fftp = dum
else:
raise ValueError(
"Invalid method. Choose from 'SIE', 'EI', 'TSEI3', or 'EE'."
)
return fftp, fftq