Source code for spectral_toolbox

#!/usr/bin/env/ python3
"""
Objects for implementing spectral methods.

This module provides several methods contained within the SpectralToolbox class
for performing standard vector calculus operations in spectral space. It is
intended for use on a two-dimensional domain of equally-spaced gridpoints.

Classes
-------
- `SpectralToolbox` -- Superclass to implement standard techniques of spectral methods

Miscellaneous Functions for Unit Testing
----------------------------------------
- `test_func` -- Test function for unit testing
- `check_derivatives`
- `check_jacobian`
- `check_multiply`
- `check_laplacian`

All of the above compare the computed solution to an analytical solution.

Notes
-----
1) Invoking this module from the command line without any arguments will perform unit tests on the methods contained within.

2) The Fourier representation used is:

.. math:: u(x, y, t) = \\sum_{k_{1} = -N/2}^{N/2 -1} \\sum_{k_{2} = -N/2}^{N/2 - 1} \hat{u}(t) \\exp((2i\\pi/L)(k_{1}x + k_{2}y))

The spectrum is stored in the logical-to-mathematicians order, i.e. the wavenumbers are stored as:

+--------------------+--------------------+--------------------+--------------------+
|    (-N/2)(-N/2)    |  (-N/2)(-N/2 + 1)  |         ...        |   (-N/2)(N/2 - 1)  |
+--------------------+--------------------+--------------------+--------------------+
|  (-N/2 + 1)(-N/2)  |(-N/2 + 1)(-N/2 + 1)|         ...        |  (-N/2 + 1)(N/2 -1)|
+--------------------+--------------------+--------------------+--------------------+
|         ...        |         ...        |         ...        |         ...        |
+--------------------+--------------------+--------------------+--------------------+
|  (N/2 - 1)(-N/2)   | (N/2 - 1)(-N/2 + 1)|         ...        |  (N/2 - 1)(N/2 -1) |
+--------------------+--------------------+--------------------+--------------------+

With the Nyquist frequencies (corresponding to the most negative frequencies) located where one or both index = -N/2,
and the constant mode corresponding to :math:`k_{1} = 0`, :math:`k_{2} = 0` in the (N, N) position in the array.

2) The sign matrix, `sign_mat`, is used to relate the truncated Fourier series of a function, with wavenumbers that
run from [-N/2, N/2), with the FFT, with wavenumbers in [0, N). The point of doing this is to make the
operations in spectral space more intuitive from a mathematical perspective. We proceed by discretising the
desired function on an equispaced grid as above, noting that the sum runs from k = -N/2 to N/2 -1. In order to
make the sum correspond with the FFT, we perform the change of variables :math:`p = k + N/2`:

.. math:: f(x_{j}) = \\sum_{p=0}^{N-1} \\hat{f}(p-N/2) e^{2i\\pi k p/N}

.. math:: f(x_{j}) = \\sum_{p=0}^{N-1} \\hat{f}(p-N/2) (-1)^{p} e^{2i\\pi k/N}

Note that a factor of :math:`(-1)^{p}` is obtained. These alternating signs are implemented in the sign matrix
to permit a single multiplication operation.

See also
--------
numpy.fft, numpy

| Author: Adam G. Peddle
| Contact: ap553@exeter.ac.uk
| Version: 1.0
"""

import numpy as np
try:
    import pyfftw
    fftw_flag = True
except ImportError:
    fftw_flag = False

[docs]class SpectralToolbox: """ Implements the required vector calculus techniques for spectral methods. This class stores the required constants as attributes to be used by its methods for spectral methods in 2 spatial dimensions. **Attributes** - `N` : The number of grid points in the domain, such that there are N X N equally-spaced points - `L` : The side length of the domain - `factor` : Factor arising in spectral differentiation on non-2pi domain (see below) - `sign_mat` : Matrix to reorder spectral space (see below) - `deriv_mat_x1`, deriv_mat_x2` : matrices for spectral differentiation in x1 and x2 directions (see calc_derivative method) - `laplace_op` : Array to implement Laplacian (see laplacian method) **Methods** - `dealias_pad` -- Pads the input array with zeros to prevent aliasing - `dealias_unpad` -- Removes the padding from dealias_pad - `forward_fft` -- Wrapper to perform Fast Fourier Transform with desired normalisation - `inverse_fft` -- Wrapper to invert FFT with desired normalisation - `calc_derivative` -- Computes 1st or 2nd order derivatives along x and/or y directions - `multiply_nonlinear` -- Multiplies two functions in a pseudo-spectral fashion - `solve_inverse_laplacian` -- Solves the inverse Laplacian problem with a chosen constant of integration - `jacobian1` -- Computes Jacobian of a single function - `jacobian` -- Computes Jacobian of two functions - `laplacian` -- Computes the Laplacian of a given function **Example** | ``>> st = SpectralToolbox(128, 2*np.pi)`` | ``>> du_dx = st.calc_derivative(A, 'x') # Compute x-derivative`` """ def __init__(self, N, L): self.fftw_flag = fftw_flag if fftw_flag: pyfftw.interfaces.cache.enable() self.fft_use = pyfftw.interfaces.numpy_fft else: self.fft_use = np.fft self.N = N if N%2: # Methods are not currently implemented for odd values of N raise ValueError("N must be even") self.L = L self.factor = 2.0*np.pi/L self.M = 3*N//2 #self.M = 2*N # Create sign matrix self.sign_mat = np.zeros((self.M, self.M), dtype='complex') for k1 in range(0,self.M): for k2 in range(0,self.M): self.sign_mat[k1,k2] = (-1)**(k1+k2) self.sign_mat_fft_big = self.sign_mat/(self.M**2) self.sign_mat_fft_small = self.sign_mat[0:N, 0:N]/(N**2) self.sign_mat_ifft_big = (self.M**2)*self.sign_mat self.sign_mat_ifft_small = (N**2)*self.sign_mat[0:N, 0:N] # Create derivative matrices self.deriv_mat_x1 = np.zeros((self.N, self.N), dtype = complex) self.deriv_mat_x2 = np.zeros((self.N, self.N), dtype = complex) for k1 in range(-self.N//2,self.N//2): for k2 in range(-self.N//2,self.N//2): self.deriv_mat_x1[k1+self.N//2,k2+self.N//2] = 1j * k1 * self.factor self.deriv_mat_x2[k1+self.N//2,k2+self.N//2] = 1j * k2 * self.factor # Create laplacian matrix. Factor of multiplication is included # implicitly due to inclusion in derivative matrices. self.laplace_op = np.zeros((self.N, self.N), dtype = complex) self.laplace_op = self.deriv_mat_x1**2 + self.deriv_mat_x2**2
[docs] def dealias_pad(self, A): """ Pads the spectrum with zeros in high frequencies for aliasing control. This method doubles the size of the spectrum and sets all high frequencies to zero for aliasing control. This causes the resolution in real space (after inverse FFT) to be doubled. The higher frequencies are then discarded upon returning to spectral space. The array is doubled in a symmetric fashion. **Parameters** - `A` : the spectrum to be padded **Returns** - `B` : the padded spectrum **See also** dealias_unpad **Example** | ``>> A = np.ones((2,2), dtype = complex)`` | ``>> st.dealias_pad(A)`` | ``>> array([[ 0.+0.j, 0.+0.j, 0.+0.j, 0.+0.j],`` | ``[ 0.+0.j, 1.+0.j, 1.+0.j, 0.+0.j],`` | ``[ 0.+0.j, 1.+0.j, 1.+0.j, 0.+0.j],`` | ``[ 0.+0.j, 0.+0.j, 0.+0.j, 0.+0.j]])`` """ #m, n = A.shape #B = np.zeros((2*m, 2*n), dtype = complex) #B[m//2:3*m//2, n//2:3*n//2] = A[:,:] B = np.zeros((self.M, self.M), dtype = complex) B[self.M//6:5*self.M//6, self.M//6:5*self.M//6] = A[:,:] #np.set_printoptions(precision=1, linewidth=240) #print(B) #exit() return B
[docs] def dealias_unpad(self, A): """ Unpads the spectrum, removing high frequencies, for aliasing control. This method reverses the padding set up in the dealias_pad method, returning the size of the spectrum to the initial value upon returning to spectral space. The highest half of the frequencies are discarded. **Parameters** - `A` : the spectrum to be unpadded **Returns** - `B` : the unpadded spectrum **See also** dealias_pad **Example** | ``>> A = array([[ 0.+0.j, 0.+0.j, 0.+0.j, 0.+0.j],`` | ``[ 0.+0.j, 1.+0.j, 1.+0.j, 0.+0.j],`` | ``[ 0.+0.j, 1.+0.j, 1.+0.j, 0.+0.j],`` | ``[ 0.+0.j, 0.+0.j, 0.+0.j, 0.+0.j]])`` | ``>> st.dealias_unpad(A)`` | ``>> array([[ 1.+0.j, 1.+0.j],`` | ``[ 1.+0.j, 1.+0.j]])`` """ #m, n = A.shape #B = np.zeros((m//2, n//2), dtype = complex) #B = A[m//4:3*m//4, n//4:3*n//4] B = np.zeros((self.N, self.N), dtype = complex) B = A[self.M//6:5*self.M//6, self.M//6:5*self.M//6] #np.set_printoptions(precision=1, linewidth=240) #print(B) #exit() return B
[docs] def forward_fft(self, array_in): """ Wrapper to implement the forward Fast Fourier Transform. This method implements the forward FFT (i.e. from real-space to spectral space). The values are normalised such that the spectral coefficient corresponds to a wave of exactly that amplitude, rather than the normalised value returned by the standard FFT. The sign matrix is used to yield a spectrum running from -N/2 to N/2 -1. **Parameters** - `array_in` : the array of real values **Returns** - `out` : the spectral coefficients, in the chosen framework **See Also** Note 2 in the class header (`sign matrix`), inverse_fft """ # Find side length, as real array may or may not be doubled for # aliasing control side = array_in.shape[0] if side == self.N: #out = np.fft.fft2(self.sign_mat_fft_small*array_in) out = self.fft_use.fft2(self.sign_mat_fft_small*array_in) elif side == self.M: out = self.fft_use.fft2(self.sign_mat_fft_big*array_in) #out = np.fft.fft2(self.sign_mat_fft_big*array_in) return out
[docs] def inverse_fft(self, array_in): """ Wrapper to implement the inverse Fast Fourier Transform. This method implements the inverse FFT (i.e. from spectral space to real space). The values are normalised such that the spectral coefficient corresponds to a wave of exactly that amplitude, rather than the normalised value returned by the standard FFT. The sign matrix is used to yield a spectrum running from -N/2 to N/2 -1. **Parameters** - `array_in` : the array of spectral coefficients **Returns** - `out` : the real values at gridpoints, in the chosen framework **See Also** Note 2 in the class header (sign matrix), forward_fft """ # Find side length, as spectrum may or may not have been doubled # for aliasing control side = array_in.shape[0] if side == self.N: out = self.sign_mat_ifft_small*self.fft_use.ifft2(array_in) #out = self.sign_mat_ifft_small*np.fft.ifft2(array_in) elif side == self.M: #out = self.sign_mat_ifft_big*np.fft.ifft2(array_in) out = self.sign_mat_ifft_big*self.fft_use.ifft2(array_in) return out
[docs] def calc_derivative(self, array_in, direction1, direction2 = False): """ Computes 1st or 2nd order derivatives in x and/or y directions. This method implements spectral (spatial) differentiation through the use of the derivative matrices, stored as attributes of the SpectralToolbox class. The Nyquist frequency is treated specially for odd orders of multiplication. **Parameters** - `array_in` : the array of spectral coefficients to be differentiated - `direction1` : the first direction to be differentiated ('x' or 'y') - `direction2` : the optional second direction to be differentiated ('x' or 'y') **Returns** - `out` : the differentiated spectrum **Notes** Recall that the function, u, is represented in Fourier space as: .. math:: u(x, y, t) = \\sum_{k_{1} = -N/2}^{N/2 -1} \\sum_{k_{2} = -N/2}^{N/2 - 1} \\hat{u}(t) \\exp((2i\\pi/L)(k_{1}x + k_{2}y)) Then the first derivative in the x-direction, as :math:`\\hat{u}(t)` is not a function of space, is simply: .. math:: \\frac{\\partial u(x, y, t)}{\\partial x} = \\sum_{k_{1} = -N/2}^{N/2 -1} \\sum_{k_{2} = -N/2}^{N/2 - 1} \\hat{u}(t) (2i\\pi k_{1}/L) \\exp((2i\\pi/L)(k_{1}x + k_{2}y)) Thus, differentiation in spectral space is a matter of multiplying each Fourier mode by the length factor (:math:`B=2\\pi/L`) times i times the associated wavenumber. To reduce loops and make the implementation simpler, these modes are contained in the derivatrive matrices, which take the form: .. math:: \\text{deriv\\_mat\\_x1} = \\begin{array}{cccc} iBk_{1} & iBk_{1} & \\cdots & iBk_{1} \\\\ iBk_{2} & iBk_{2} & \\cdots & iBk_{2} \\\\ \\vdots & \\vdots & \\ddots & \\vdots \\\\ iBk_{n} & iBk_{n} & \\cdots & iBk_{n} \\end{array} and: .. math:: \\text{deriv\\_mat\\_x2} = \\begin{array}{cccc} iBk_{1} & iBk_{2} & \\cdots & iBk_{n} \\\\ iBk_{1} & iBk_{2} & \\cdots & iBk_{n}\\\\ \\vdots & \\vdots & \\ddots & \\vdots \\\\ iBk_{1} & iBk_{2} & \\cdots & iBk_{n} \\end{array} **Example** | ``>> A = array([[ 1.+0.j, 1.+0.j],`` | ``[ 1.+0.j, 1.+0.j]])`` | | ``>> st.calc_derivative(A, 'x')`` | ``>> array([[ 0.+0.j, 0.+0.j],`` | ``[ 0.+0.j, 0.+0.j]])`` """ A = array_in.copy() if False and direction1 != direction2: #Remove Nyquist frequency for even sample size and odd order of differentiation if direction1 == 'x' or direction2 == 'x': A[0,:] = 0.0 if direction1 == 'y' or direction2 == 'y': A[:,0] = 0.0 # Note that 'x' corresponds to the x1 direction, and 'y' to the # x2 direction # Perform first derivative in desired direction if direction1 == 'x': out = self.deriv_mat_x1*A elif direction1 == 'y': out = self.deriv_mat_x2*A # Perform second derivative in desired direction if direction2 == 'x': out = self.deriv_mat_x1*out elif direction2 == 'y': out = self.deriv_mat_x2*out return out
[docs] def multiply_nonlinear(self, array1, array2): """ Simple method for multiplying two sets of data when nonlinearities are involved and transformation into real space is necessary. This method computes the product of two quantities defined in spectral space using a pseudo-spectral method, i.e. the multiplication is performed in realspace. The spectra are padded for aliasing control and must be of the same dimension. **Parameters** - `array_1`, `array_2` : the input spectra to be multiplied **Returns** - `f3_hat` : the spectrum obtainined from the multiplication **See Also** dealias_pad, dealias_unpad """ # compute grid values via FFT f1_vals = self.inverse_fft(self.dealias_pad(array1)) f2_vals = self.inverse_fft(self.dealias_pad(array2)) # multiply in space f3_vals = f1_vals * f2_vals # compute Fourier coeffs f3_hat = self.dealias_unpad(self.forward_fft(f3_vals)) return f3_hat
[docs] def solve_inverse_laplacian(self, array_in, zero_mode_val): """ Solves the inverse Laplacian problem in spectral space. This method implements the inverse Laplacian, i.e. it finds u in: .. math:: \\nabla^{2}u = f with a constant of integration which must be specified by the user. **Parameters** - `array_in` : the known spectrum, f - `zero_mode_val` : the constant of integration, corresponding to the constant Fourier mode **Returns** - `array_out` : the desired quantity, u **See Also** calc_derivative """ # Initialise output array, u array_out = np.zeros((self.N, self.N), dtype = complex) N = self.N for k1 in range(-N//2, N//2): for k2 in range(-N//2, N//2): if k1 == k2 and k1 == 0: # Set zero-th mode, i.e. constant of integration array_out[k1 + N//2, k2 + N//2] = zero_mode_val else: # All other modes are found by inverting the spectral differentiation factor laplace_op = -(k1**2 + k2**2)*self.factor**2 array_out[k1 + N//2, k2 + N//2] = array_in[k1 + N//2, k2 + N//2]/laplace_op return array_out
[docs] def jacobian1(self,A): """ Computes the Jacobian for a single scalar-valued function. This method implements the Jacobian for a single function, such as a streamfunction or velocity potential. The Jacobian in this case takes the form: .. math:: J(u) = u_{xx}u_{yy} - 2u_{xy} where subscripts denote partial derivatives. **Parameters** - `A` : The function of which the Jacobian is desired **Returns** - `J` : The Fourier modes of the Jacobian """ # Compute second derivatives in spectral space A_x_x_hat = self.calc_derivative(A, 'x', 'x') A_y_y_hat = self.calc_derivative(A, 'y', 'y') A_x_y_hat = self.calc_derivative(A, 'x', 'y') A_y_x_hat = self.calc_derivative(A, 'y', 'x') # Compute realspace representations for multiplication A_x_x = self.inverse_fft(self.dealias_pad(A_x_x_hat)) A_y_y = self.inverse_fft(self.dealias_pad(A_y_y_hat)) A_x_y = self.inverse_fft(self.dealias_pad(A_x_y_hat)) A_y_x = self.inverse_fft(self.dealias_pad(A_y_x_hat)) # Multiply in realspace J_canonical = (A_x_x*A_y_y) - (A_x_y*A_y_x) # Return to Fourier space and return spectrum return self.dealias_unpad(self.forward_fft(J_canonical))
[docs] def jacobian(self, A, B): """ Computes the Jacobian for a two scalar-valued functions. This method implements the Jacobian for two scalar-valued functions. The Jacobian may be written as: .. math:: J(A, B) = A_{x}B_{y} - A_{y}B_{x} where subscripts denote partial derivatives. **Parameters** - `A`, `B` : The Fourier representations of the functions A and B (above). Must have the same dimensions. **Returns** - `J` : The Fourier modes of the Jacobian """ # Compute the derivatives spectrally A_x_hat = self.calc_derivative(A, 'x') A_y_hat = self.calc_derivative(A, 'y') B_x_hat = self.calc_derivative(B, 'x') B_y_hat = self.calc_derivative(B, 'y') # Compute the values in realspace for multiplication A_x = self.inverse_fft(self.dealias_pad(A_x_hat)) A_y = self.inverse_fft(self.dealias_pad(A_y_hat)) B_y = self.inverse_fft(self.dealias_pad(B_y_hat)) B_x = self.inverse_fft(self.dealias_pad(B_x_hat)) # Compute the Jacobian J_canonical = (A_x*B_y) - (B_x*A_y) # Return to spectral space the return return self.dealias_unpad(self.forward_fft(J_canonical))
[docs] def laplacian(self, array_in): """ Computes the Laplacian of a given function in spectral space. The Laplacian may be written as: .. math:: f = \\nabla^{2}u = \\nabla \\cdot \\nabla u **Parameters** - `array_in` : the value of interest, in Fourier space **Returns** - `array_out` : the Laplacian of the input array **See Also** calc_derivative """ # Call-through to Laplacian operator, already computed return self.laplace_op*array_in
#Unit Tests Below# def testfunc(x,y): return np.sin(2.0*x)*np.cos(2.0*y) + np.cos(8.0*x)*np.sin(11.0*y)
[docs]def check_jacobian(st, N, L): """ """ u = np.zeros((N,N),dtype=complex) u_analytical = np.zeros((N,N)) for n1 in range(0,N): for n2 in range(0,N): x = L*n1/N y = L*n2/N u[n1,n2] = testfunc(x,y) u_analytical[n1,n2] = (-4.0*np.sin(2.0*x)*np.cos(2.0*y) - 64.0*np.cos(8.0*x)*np.sin(11.0*y))*\ (-4.0*np.sin(2.0*x)*np.cos(2.0*y) - 121.0*np.cos(8.0*x)*np.sin(11.0*y)) - \ (-4.0*np.cos(2.0*x)*np.sin(2.0*y) - 88.0*np.sin(8.0*x)*np.cos(11.0*y))**2 u_hat = st.forward_fft(u) u_hat_pad = st.jacobian1(u_hat) u_num_pad = st.inverse_fft(u_hat_pad) return(np.max(np.absolute(u_num_pad - u_analytical)))
[docs]def check_laplacian(st, N, L): """ """ u = np.zeros((N,N)) u_analytical = np.zeros((N,N)) for n1 in range(0,N): for n2 in range(0,N): x = L*n1/N y = L*n2/N u[n1,n2] = testfunc(x,y) u_analytical[n1,n2] = -8.0*np.sin(2.0*x)*np.cos(2.0*y) - 185.0*np.cos(8.0*x)*np.sin(11.0*y) u_hat = st.forward_fft(u) u_hat = st.laplacian(u_hat) u_num = st.inverse_fft(u_hat) return(np.max(np.absolute(u_num - u_analytical)))#/(u_analytical)
[docs]def check_derivatives(st, N, L): """ """ u = np.zeros((N,N),dtype=complex) u_analytical_x = np.zeros((N,N)) u_analytical_y = np.zeros((N,N)) for n1 in range(0,N): for n2 in range(0,N): x = L*n1/N y = L*n2/N u[n1,n2] = testfunc(x,y) u_analytical_x[n1,n2] = 2.0*np.cos(2.0*x)*np.cos(2.0*y) - 8.0*np.sin(8.0*x)*np.sin(11.0*y) u_analytical_y[n1,n2] = -2.0*np.sin(2.0*x)*np.sin(2.0*y) + 11.0*np.cos(8.0*x)*np.cos(11.0*y) u_hat = st.forward_fft(u) u_hat_x = st.calc_derivative(u_hat, 'x') u_hat_y = st.calc_derivative(u_hat, 'y') u_num_x = st.inverse_fft(u_hat_x) u_num_y = st.inverse_fft(u_hat_y) return(np.max(np.absolute(u_num_x - u_analytical_x)), np.max(np.absolute(u_num_y - u_analytical_y)))
[docs]def check_multiply(st, N, L): """ """ u1 = np.zeros((N,N),dtype=complex) u2 = np.zeros((N,N),dtype=complex) u_analytical = np.zeros((N,N)) for n1 in range(0,N): for n2 in range(0,N): x = L*n1/N y = L*n2/N u1[n1,n2] = np.sin(2.0*x)*np.cos(4.0*y) u2[n1,n2] = np.sin(3.0*y)*np.cos(3.0*x) u_analytical = u1*u2 u_hat1 = st.forward_fft(u1) u_hat2 = st.forward_fft(u2) u_hat = st.multiply_nonlinear(u_hat1, u_hat2) u_num = st.inverse_fft(u_hat) return(np.max(np.absolute(u_num - u_analytical)))#/(u_analytical)
if __name__ == "__main__": np.set_printoptions(linewidth = 140, precision = 2) N = 8 L = 2.0*np.pi st = SpectralToolbox(N, L) N = 256 L = 2.0*np.pi*2.0 st = SpectralToolbox(N, L) out = check_derivatives(st, N, L) print("Error in x-derivative is: {}".format(out[0])) print("Error in y-derivative is: {}".format(out[1])) out = check_laplacian(st, N, L) print("Error in Laplacian is: {}".format(out)) out = check_jacobian(st, N, L) print("Error in Jacobian is: {}".format(out)) out = check_multiply(st, N, L) print("Error in Multiply is: {}".format(out))