diff --git a/.github/environment.yml b/.github/environment.yml index f8878cee6..8c97f6738 100644 --- a/.github/environment.yml +++ b/.github/environment.yml @@ -13,7 +13,6 @@ dependencies: - numba>=0.57 - xarray>=2022.03 - verde>=1.9.0 - - xrft>=1.0 - choclo>=0.1 - boule>=0.6.0 - bordado>=0.4.0 diff --git a/README.md b/README.md index 278388f94..727dba81a 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,8 @@ Things that will *not* be covered in Harmonica: - Multi-physics partial differential equation solvers. Use [SimPEG](http://www.simpeg.xyz/) or [PyGIMLi](https://www.pygimli.org/) instead. -- Generic grid processing methods (like FFT and standard interpolation). - We'll rely on [Verde](https://www.fatiando.org/verde), - [xrft](https://xrft.readthedocs.io/en/latest/) and +- Generic grid processing methods (like standard interpolation). + We'll rely on [Verde](https://www.fatiando.org/verde) and [xarray](https://xarray.dev) for those. - Data visualization. - GUI applications. diff --git a/doc/api/index.rst b/doc/api/index.rst index 2e99f1927..86b6ddc77 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -51,8 +51,15 @@ Define filters in the frequency domain. filters.gaussian_highpass_kernel filters.reduction_to_pole_kernel -Use :func:`xrft.xrft.fft` and :func:`xrft.xrft.ifft` to apply Fast-Fourier -Transforms and its inverse on :class:`xarray.DataArray`. +FFT and padding functions that can be used with 2D :class:`xarray.DataArray`: + +.. autosummary:: + :toctree: generated/ + + filters.fft + filters.ifft + filters.pad + filters.unpad Equivalent Sources ------------------ diff --git a/doc/conf.py b/doc/conf.py index ad4d4f42c..f4a093bba 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -49,7 +49,6 @@ "scipy": ("https://docs.scipy.org/doc/scipy/reference", None), "pandas": ("http://pandas.pydata.org/pandas-docs/stable/", None), "xarray": ("http://xarray.pydata.org/en/stable/", None), - "xrft": ("https://xrft.readthedocs.io/en/stable/", None), "pooch": ("https://www.fatiando.org/pooch/latest/", None), "ensaio": ("https://www.fatiando.org/ensaio/latest/", None), "verde": ("https://www.fatiando.org/verde/latest/", None), diff --git a/doc/install.rst b/doc/install.rst index 00a53de42..147094232 100644 --- a/doc/install.rst +++ b/doc/install.rst @@ -75,7 +75,6 @@ Required: * `verde `__ * `bordado `__ * `boule `__ -* `xrft `__ * `choclo `__ * `boule `__ @@ -98,4 +97,4 @@ The examples in the :ref:`gallery` also use: * `ensaio `__ for downloading sample datasets * `pygmt `__ and `matplotlib `__ for plotting maps and figures * `pyproj `__ for cartographic projections -* `bordado `__ for working with geographic coordinates \ No newline at end of file +* `bordado `__ for working with geographic coordinates diff --git a/doc/overview.rst b/doc/overview.rst index fdd39857c..efd0e6eca 100644 --- a/doc/overview.rst +++ b/doc/overview.rst @@ -19,8 +19,7 @@ Harmonica *will not* provide: instead. - Generic processing methods like grid transformations (use `Verde `__ or `Xarray `__ - instead) or multidimensional FFT calculations (use `xrft - `__ instead). + instead). - Reference ellipsoid representations and computations like normal gravity. Use `Boule `__ instead. - Data visualization functions. Use `matplotlib `__ diff --git a/env/requirements-tests.txt b/env/requirements-tests.txt index b698be8b6..f1a6c2b3d 100644 --- a/env/requirements-tests.txt +++ b/env/requirements-tests.txt @@ -5,3 +5,4 @@ coverage pyvista vtk>=9 numba_progress +xrft diff --git a/environment.yml b/environment.yml index e97e67a24..8d2b284d5 100644 --- a/environment.yml +++ b/environment.yml @@ -15,7 +15,6 @@ dependencies: - scikit-learn - verde>=1.8.1 - xarray - - xrft>=1.0 - choclo>=0.1 - boule>=0.6 - bordado>=0.4.0 @@ -27,6 +26,7 @@ dependencies: - pytest - pytest-cov - coverage + - xrft # Documentation requirements - sphinx==7.2.* - numpydoc==1.7.* diff --git a/pyproject.toml b/pyproject.toml index 9d727e537..9e4699a56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,6 @@ dependencies = [ "numba >= 0.57", "xarray >= 2022.03", "verde >= 1.8.1", - "xrft >= 1.0", "choclo >= 0.1", "boule >= 0.6.0", "bordado >= 0.4.0", diff --git a/src/harmonica/_transformations.py b/src/harmonica/_transformations.py index 0e16cb6c5..cdae51454 100644 --- a/src/harmonica/_transformations.py +++ b/src/harmonica/_transformations.py @@ -11,6 +11,7 @@ import numpy as np from .filters._filters import ( + apply_filter, derivative_easting_kernel, derivative_northing_kernel, derivative_upward_kernel, @@ -19,7 +20,7 @@ reduction_to_pole_kernel, upward_continuation_kernel, ) -from .filters._utils import apply_filter, grid_sanity_checks +from .filters._utils import grid_sanity_checks def derivative_upward(grid, *, order=1, pad=True, pad_kwargs=None): diff --git a/src/harmonica/filters/__init__.py b/src/harmonica/filters/__init__.py index b70539e37..c84dd4c39 100644 --- a/src/harmonica/filters/__init__.py +++ b/src/harmonica/filters/__init__.py @@ -8,6 +8,7 @@ Frequency domain filters meant to be applied on regular grids. """ +from ._fft import fft, ifft from ._filters import ( derivative_easting_kernel, derivative_northing_kernel, @@ -17,3 +18,4 @@ reduction_to_pole_kernel, upward_continuation_kernel, ) +from ._padding import pad, unpad diff --git a/src/harmonica/filters/_fft.py b/src/harmonica/filters/_fft.py index d65d2de32..257024b4c 100644 --- a/src/harmonica/filters/_fft.py +++ b/src/harmonica/filters/_fft.py @@ -5,14 +5,18 @@ # This code is part of the Fatiando a Terra project (https://www.fatiando.org) # """ -Wrap xrft functions to compute FFTs and inverse FFTs. +Functions to compute the FFT and inverse FFT of 2D :class:`xarray.DataArray`. """ -import xrft +import numpy as np +import numpy.typing as npt +import xarray as xr +from ._utils import get_spacing -def fft(grid, true_phase=True, true_amplitude=True, **kwargs): - """ + +def fft(grid, *, prefix="freq_"): + r""" Compute Fast Fourier Transform of a 2D regular grid. Parameters @@ -22,60 +26,321 @@ def fft(grid, true_phase=True, true_amplitude=True, **kwargs): evenly spaced (regular grid). Its dimensions should be in the following order: *northing*, *easting*. Its coordinates should be defined in the same units. - true_phase : bool (optional) - Take the coordinates into consideration, keeping the original phase of - the coordinates in the spatial domain (``direct_lag``) and multiplies - the FFT with an exponential function corresponding to this phase. - Defaults to True. - true_amplitude : bool (optional) - If True, the FFT is multiplied by the spacing of the transformed - variables to match theoretical FT amplitude. - Defaults to True. - **kwargs - Any extra keyword arguments will be passed the :func:`xrft.fft` function. + prefix : str, optional + Prefix used for the name of the frequency coordinates and dimensions. + Returns ------- fourier_transform : :class:`xarray.DataArray` Array with the Fourier transform of the original grid. + + Notes + ----- + This function implements the discrete Fourier Transform of 2D regular grids. It's + based on the following definition of the Fourier Transform of + a :math:`g:\mathbb{R}^2 \rightarrow \mathbb{R}` function in the spatial domain: + + .. math:: + + \mathcal{F}[g](f_x, f_y) = + \int\limits_{-\infty}^{\infty} + \int\limits_{-\infty}^{\infty} + g(x, y) e^{-2 \pi i f_x x} e^{-2 \pi i f_y y} + \text{d}x + \text{d}y + + + If we consider two discretized spaces for :math:`x` and :math:`y`, both evenly + spaced with steps equal to :math:`\Delta x` and :math:`\Delta y`, respectively, then + the :math:`(k, j)` element of the discrete Fourier Transform of :math:`g` can be + defined as follows: + + .. math:: + + \mathcal{F}[g]_{(j, k)} = + \sum\limits_{n=0}^{N-1} + \sum\limits_{m=0}^{M-1} + g(x_n, y_m) e^{-2 \pi i f_x_j x_n} e^{-2 \pi i f_y_k y_m} + \Delta x \Delta y + + This function differs from the plain :func:`numpy.fft.fftn` function since it + implements the *true amplitude* and the *true phase* corrections. + """ - return xrft.fft( - grid, true_phase=true_phase, true_amplitude=true_amplitude, **kwargs + if not isinstance(grid, xr.DataArray): + msg = ( + f"Invalid 'grid' of type '{type(grid).__name__}'. " + "It must be an xarray.DataArray." + ) + raise TypeError(msg) + if grid.ndim != 2: + s = "" if grid.ndim == 1 else "s" + msg = ( + f"Invalid grid array with '{grid.ndim}' dimension{s}. " + "It must be a 2D array." + ) + raise ValueError(msg) + + # Get dimensional coordinates, spacings, and coordinates' shifts + dimensional_coords = tuple( + _get_dimensional_coordinate(grid, dim) for dim in grid.dims ) + spacings = tuple(get_spacing(grid.coords[coord]) for coord in dimensional_coords) + shifts = tuple(grid.coords[coord].values.min() for coord in dimensional_coords) + # Generate new coordinates + freqs = tuple( + _fftfreq(grid.coords[coord], spacing) + for coord, spacing in zip(dimensional_coords, spacings, strict=True) + ) + + # Compute FFT + fft = np.fft.fftshift(np.fft.fftn(grid.values)) + + # Account for true amplitude and true phase + freqs_2d = ( + freqs[0][:, None], + freqs[1][None, :], + ) # cast them as 2d to perform true shift + for freq, shift, spacing in zip(freqs_2d, shifts, spacings, strict=True): + fft *= np.exp(-2 * 1j * np.pi * freq * shift) * spacing + + # Build the FFT xr.DataArray + dims = tuple(f"{prefix}{dim}" for dim in grid.dims) + coords = { + f"{prefix}{coord}": (dim, freq) + for coord, dim, freq in zip(dimensional_coords, dims, freqs, strict=True) + } + da_fft = xr.DataArray(fft, dims=dims, coords=coords) + + # Add shifts to frequency coordinates + for coord, shift in zip(coords, shifts, strict=True): + da_fft.coords[coord].attrs.update({"shift": shift}) + return da_fft -def ifft(fourier_transform, true_phase=True, true_amplitude=True, **kwargs): - """ - Compute Inverse Fast Fourier Transform of a 2D regular grid. + +def ifft(fft_grid, *, prefix="freq_"): + r""" + Compute the inverse Fast Fourier Transform of a 2D regular grid. + + If the frequency coordinates have a *shift* attribute, it will be used to shift the + coordinates in the spatial domain to such value. + + .. important:: + + Assumes that the ``fft_grid`` is *shifted*: it was passed to + :func:`numpy.fft.fftshift`. The outputs of the ``fft`` function satisfy this + condition. Parameters ---------- - fourier_transform : :class:`xarray.DataArray` + fft_grid : :class:`xarray.DataArray` Array with a regular grid defined in the frequency domain. Its dimensions should be in the following order: *freq_northing*, *freq_easting*. - true_phase : bool (optional) - Take the coordinates into consideration, recovering the original - coordinates in the spatial domain returning to the the original phase - (``direct_lag``), and multiplies the iFFT with an exponential function - corresponding to this phase. - Defaults to True. - true_amplitude : bool (optional) - If True, output is divided by the spacing of the transformed variables - to match theoretical IFT amplitude. - Defaults to True. - **kwargs - Any extra keyword arguments will be passed the :func:`xrft.ifft` function. + prefix : str, optional + Prefix used for the name of the frequency coordinates and dimensions. Returns ------- grid : :class:`xarray.DataArray` Array with the inverse Fourier transform of the passed grid. + + Notes + ----- + This function implements the discrete inverse Fourier Transform of 2D regular grids. + It's based on the following definition of the inverse Fourier Transform of + a :math:`G:\mathbb{R}^2 \rightarrow \mathbb{R}` function in the frequency + domain: + + .. math:: + + \mathcal{F}^{-1}[G](f_x, f_y) = + \int\limits_{-\infty}^{\infty} + \int\limits_{-\infty}^{\infty} + G(f_x, f_y) e^{2 \pi i f_x x} e^{2 \pi i f_y y} + \text{d}f_x + \text{d}f_y + + + If we consider two discretized spaces for the :math:`f_x` and :math:`f_y` + frequencies, both evenly spaced with steps equal to :math:`\Delta f_x` and + :math:`\Delta f_y`, respectively, then the :math:`(n, m)` element of the discrete + inverse Fourier Transform of :math:`G` can be defined as follows: + + .. math:: + + \mathcal{F}^{-1}[G]_{(n, m)} = + \sum\limits_{j=0}^{N-1} + \sum\limits_{k=0}^{M-1} + G(f_x_j, f_y_k) e^{2 \pi i f_x_j x_n} e^{2 \pi i f_y_k y_m} + \Delta f_x \Delta f_y + + This function differs from the plain :func:`numpy.fft.ifftn` function since it + implements the *true amplitude* and the *true phase* corrections. + """ - return xrft.ifft( - fourier_transform, - true_phase=true_phase, - true_amplitude=true_amplitude, - lag=(None, None), # Mutes an annoying FutureWarning from xrft - **kwargs, + if not isinstance(fft_grid, xr.DataArray): + msg = ( + f"Invalid 'grid' of type '{type(fft_grid).__name__}'. " + "It must be an xarray.DataArray." + ) + raise TypeError(msg) + if fft_grid.ndim != 2: + s = "" if fft_grid.ndim == 1 else "s" + msg = ( + f"Invalid grid array with '{fft_grid.ndim}' dimension{s}. " + "It must be a 2D array." + ) + raise ValueError(msg) + + for dim in fft_grid.dims: + if not dim.startswith(prefix): + msg = ( + f"Invalid frequency dimension '{dim}'. " + f"It doesn't start with prefix '{prefix}'." + ) + raise ValueError(msg) + + # Get dimensional frequency coordinates and spacings + dimensional_fft_coords = tuple( + _get_dimensional_coordinate(fft_grid, dim) for dim in fft_grid.dims + ) + for coord in dimensional_fft_coords: + if not coord.startswith(prefix): + msg = ( + f"Invalid dimensional coordinate '{coord}'. " + f"It doesn't start with prefix '{prefix}'." + ) + raise ValueError(msg) + + # Generate new coordinates + coords = tuple( + _ifftfreq(fft_grid.coords[coord]) for coord in dimensional_fft_coords ) + + # Account for true amplitude and true phase + freqs = tuple(fft_grid.coords[coord].values for coord in dimensional_fft_coords) + freqs_2d = ( + freqs[0][:, None], + freqs[1][None, :], + ) # cast them as 2d to perform true shift + fft_grid = fft_grid.copy() + for coord, freq in zip(coords, freqs_2d, strict=True): + shift = coord[0] + spacing = coord[1] - coord[0] + fft_grid *= np.exp(2 * 1j * np.pi * freq * shift) / spacing + + # Compute iFFT + ifft = np.fft.ifftn(np.fft.ifftshift(fft_grid.values)) + + # Build new xr.DataArray + dims = tuple(dim.removeprefix(prefix) for dim in fft_grid.dims) + coords = { + coord.removeprefix(prefix): (dim, coordinate_array) + for coord, dim, coordinate_array in zip( + dimensional_fft_coords, dims, coords, strict=True + ) + } + da = xr.DataArray(ifft, dims=dims, coords=coords) + + return da + + +def _get_dimensional_coordinate(grid: xr.DataArray, dim: str) -> str: + """ + Get dimensional coordinate in the grid for a particular dimension. + + Parameters + ---------- + grid : xarray.DataArray + DataArray containing the coordinate. + dim : str + Dimension name. + + Returns + ------- + dimensional_coordinate : str + """ + potential_coords = [ + coord for coord in grid.coords if grid.coords[coord].dims == (dim,) + ] + if not potential_coords: + msg = f"Couldn't find dimensional coordinate for dimension '{dim}'." + raise ValueError(msg) + if len(potential_coords) > 1: + bad_coords = ", ".join(potential_coords) + msg = ( + f"Multiple dimensional coordinates ({bad_coords}) found " + f"for the '{dim}' dimension. " + "Leave only one dimensional coordinate per dimension." + ) + raise ValueError(msg) + (dimensional_coordinate,) = potential_coords + return dimensional_coordinate + + +def _fftfreq(coordinate: xr.DataArray, spacing: float | None = None) -> npt.NDArray: + """ + Get frequency coordinates from the given spatial coordinates. + + Parameters + ---------- + coordinate : xr.DataArray + DataArray containing the spatial coordinates. + spacing : float or None, optional + Precomputed spacing of the ``coordinate`` array. + Pass only if the spacing for the coordinates has been already computed. + If None, the spacing of the coordinates will be computed. + + Returns + ------- + array + 1D array with the frequency coordinates associated with the spatial coordinates. + + """ + if coordinate.ndim != 1: + msg = f"Invalid coordinate with '{coordinate.ndim}' dimensions. It must be 1D." + raise ValueError(msg) + if spacing is None: + spacing = get_spacing(coordinate) + return np.fft.fftshift(np.fft.fftfreq(coordinate.size, spacing)) + + +def _ifftfreq(freq: xr.DataArray, spacing: float | None = None) -> npt.NDArray: + """ + Recover spatial coordinates from the frequency coordinates. + + Shifts the coordinates in the spatial domain if the ``freq`` has a *shift* + attribute. + + Parameters + ---------- + freq : xr.DataArray + DataArray containing the frequency coordinates. + spacing : float or None, optional + Precomputed spacing of the ``freq`` array. + Pass only if the spacing for the coordinates has been already computed. + If None, the spacing of the coordinates will be computed. + + Returns + ------- + array + 1D array with the spatial coordinates associated with the frequency coordinates. + """ + if freq.ndim != 1: + msg = ( + f"Invalid frequency coordinate with '{freq.ndim}' dimensions. " + "It must be 1D." + ) + raise ValueError(msg) + if spacing is None: + spacing = get_spacing(freq) + coordinate = np.fft.fftshift(np.fft.fftfreq(freq.size, spacing)) + + # Apply static shift if any + if "shift" in freq.attrs: + coordinate += freq.attrs["shift"] - coordinate.min() + + return coordinate diff --git a/src/harmonica/filters/_filters.py b/src/harmonica/filters/_filters.py index c078a17e2..dcf17a052 100644 --- a/src/harmonica/filters/_filters.py +++ b/src/harmonica/filters/_filters.py @@ -11,6 +11,119 @@ import numpy as np from .._utils import magnetic_angles_to_vec +from . import _padding +from ._fft import fft, ifft +from ._utils import grid_sanity_checks + + +def apply_filter( + grid, + fft_filter, + *, + filter_kwargs=None, + pad=True, + pad_kwargs=None, + drop_coords=False, +): + """ + Apply a filter to a grid and return the transformed grid in spatial domain. + + Computes the Fourier transform of the given grid, builds the filter, + applies it and returns the inverse Fourier transform of the filtered grid. + + .. note:: + + Any non-dimensional coordinates in the original grid will be dropped + from the filtered grid. This is because we can't know if the filter + invalidates the coordinate values (for example, upward continuation + would invalidate any height coordinates). So it's safer to drop them. + + Parameters + ---------- + grid : :class:`xarray.DataArray` + A two dimensional :class:`xarray.DataArray` whose coordinates are + evenly spaced (regular grid). Its dimensions should be in the following + order: *northing*, *easting*. Its coordinates should be defined in the + same units. + fft_filter : func + Callable that builds the filter in the frequency domain. + filter_kwargs : dict or None, optional + Any additional keyword argument that should be passed to the + ``fft_filter`` in the form of a dictionary. + pad : bool, optional + If True, will add padding to the grid before taking the Fourier Transform + and applying the filter and remove it after the inverse Fourier Transform. + Adding padding usually helps reduce edge effects from signal truncation. + Default is True. + pad_kwargs : dict or None, optional + Any additional keyword arguments that should be passed to the + :meth:`xarray.DataArray.pad` function. If none are given, the default + padding of 25% the dimensions of the grid will be added using the + "edge" method. + drop_coords : bool, optional + If True, non-dimensional coordinates of the grid will be dropped after + filtering. This is useful if the filter could move the grid, like in upward + continuation, which could make these coordinates incorrect. + + Returns + ------- + filtered_grid : :class:`xarray.DataArray` + A :class:`xarray.DataArray` with the filtered version of the passed + ``grid``. Defined are in the spatial domain. + """ + if filter_kwargs is None: + filter_kwargs = {} + if pad_kwargs is None: + pad_kwargs = {} + grid_sanity_checks(grid) + dims = grid.dims + + # Need to remove non-dimensional coordinates before padding and FFT because + # the padding and fft functions don't know what to do with them. + non_dim_coords = {c: grid[c] for c in grid.coords if c not in grid.indexes} + grid = grid.drop_vars(non_dim_coords.keys()) + + if pad: + # By default, use a padding width of 25% of each grid dimension. + # Fedi et al. (2012; doi:10.1111/j.1365-246X.2011.05259.x) suggest + # a padding of 100% but that seems exaggerated. + if "pad_width" not in pad_kwargs: + pad_kwargs["pad_width"] = {d: int(0.25 * grid[d].size) for d in dims} + if "mode" not in pad_kwargs: + pad_kwargs["mode"] = "edge" + if "constant_values" not in pad_kwargs: + # Has to be included explicitly as None since the pad function always + # passes it to xarray.DataArray.pad. + pad_kwargs["constant_values"] = None + fft_grid = fft(_padding.pad(grid, **pad_kwargs)) + else: + fft_grid = fft(grid) + + # The filter convolution in the frequency domain is a multiplication + filtered_fft_grid = fft_grid * fft_filter(fft_grid, **filter_kwargs) + + # Keep only the real part since the inverse transform returns complex + # number by default + filtered_grid = ifft(filtered_fft_grid).real + if pad: + filtered_grid = _padding.unpad(filtered_grid, pad_kwargs["pad_width"]) + + # Restore the original coordinates to the grid because the inverse + # transform calculates coordinates from the frequencies, which can lead to + # rounding errors and coordinates that are slightly off. This causes errors + # when doing operations with the transformed grids. Restoring the original + # coordinates avoids these issues. + filtered_grid = filtered_grid.assign_coords( + {dims[1]: grid[dims[1]], dims[0]: grid[dims[0]]} + ) + + # Restore the non-dimensional coordinates if desired + if not drop_coords: + filtered_grid = filtered_grid.assign_coords( + {name: non_dim_coords[name] for name in non_dim_coords} + ) + + return filtered_grid def derivative_upward_kernel(fft_grid, order=1): @@ -35,8 +148,8 @@ def derivative_upward_kernel(fft_grid, order=1): Array with the Fourier transform of the original grid. Its dimensions should be in the following order: *freq_northing*, *freq_easting*. - Use :func:`xrft.xrft.fft` and :func:`xrft.xrft.ifft` functions to - compute the Fourier Transform and its inverse, respectively. + Use :func:`harmonica.filters.fft` and :func:`harmonica.filters.ifft` functions + to compute the Fourier Transform and its inverse, respectively. order : int The order of the derivative. Default to 1. @@ -91,8 +204,8 @@ def derivative_easting_kernel(fft_grid, order=1): Array with the Fourier transform of the original grid. Its dimensions should be in the following order: *freq_northing*, *freq_easting*. - Use :func:`xrft.xrft.fft` and :func:`xrft.xrft.ifft` functions to - compute the Fourier Transform and its inverse, respectively. + Use :func:`harmonica.filters.fft` and :func:`harmonica.filters.ifft` functions + to compute the Fourier Transform and its inverse, respectively. order : int The order of the derivative. Default to 1. @@ -145,8 +258,8 @@ def derivative_northing_kernel(fft_grid, order=1): Array with the Fourier transform of the original grid. Its dimensions should be in the following order: *freq_northing*, *freq_easting*. - Use :func:`xrft.xrft.fft` and :func:`xrft.xrft.ifft` functions to - compute the Fourier Transform and its inverse, respectively. + Use :func:`harmonica.filters.fft` and :func:`harmonica.filters.ifft` functions + to compute the Fourier Transform and its inverse, respectively. order : int The order of the derivative. Default to 1. @@ -198,8 +311,8 @@ def upward_continuation_kernel(fft_grid, height_displacement): Array with the Fourier transform of the original grid. Its dimensions should be in the following order: *freq_northing*, *freq_easting*. - Use :func:`xrft.xrft.fft` and :func:`xrft.xrft.ifft` functions to - compute the Fourier Transform and its inverse, respectively. + Use :func:`harmonica.filters.fft` and :func:`harmonica.filters.ifft` functions + to compute the Fourier Transform and its inverse, respectively. height_displacement : float The height displacement of upward continuation. For upward continuation, the height displacement should be positive. @@ -259,8 +372,8 @@ def gaussian_lowpass_kernel(fft_grid, wavelength): Array with the Fourier transform of the original grid. Its dimensions should be in the following order: *freq_northing*, *freq_easting*. - Use :func:`xrft.xrft.fft` and :func:`xrft.xrft.ifft` functions to - compute the Fourier Transform and its inverse, respectively. + Use :func:`harmonica.filters.fft` and :func:`harmonica.filters.ifft` functions + to compute the Fourier Transform and its inverse, respectively. wavelength : float The cutoff wavelength for the low-pass filter. Its units should be the inverse units of the coordinates in @@ -322,8 +435,8 @@ def gaussian_highpass_kernel(fft_grid, wavelength): Array with the Fourier transform of the original grid. Its dimensions should be in the following order: *freq_northing*, *freq_easting*. - Use :func:`xrft.xrft.fft` and :func:`xrft.xrft.ifft` functions to - compute the Fourier Transform and its inverse, respectively. + Use :func:`harmonica.filters.fft` and :func:`harmonica.filters.ifft` functions + to compute the Fourier Transform and its inverse, respectively. wavelength : float The cutoff wavelength for the high-pass filter. Its units should be the inverse units of the coordinates in @@ -401,8 +514,8 @@ def reduction_to_pole_kernel( Array with the Fourier transform of the original grid. Its dimensions should be in the following order: *freq_northing*, *freq_easting*. - Use :func:`xrft.xrft.fft` and :func:`xrft.xrft.ifft` functions to - compute the Fourier Transform and its inverse, respectively. + Use :func:`harmonica.filters.fft` and :func:`harmonica.filters.ifft` functions + to compute the Fourier Transform and its inverse, respectively. inclination : float in degrees The inclination of the inducing Geomagnetic field. declination : float in degrees diff --git a/src/harmonica/filters/_padding.py b/src/harmonica/filters/_padding.py new file mode 100644 index 000000000..3c1c459b8 --- /dev/null +++ b/src/harmonica/filters/_padding.py @@ -0,0 +1,510 @@ +# Copyright (c) 2018 The Harmonica Developers. +# Distributed under the terms of the BSD 3-Clause License. +# SPDX-License-Identifier: BSD-3-Clause +# +# This code is part of the Fatiando a Terra project (https://www.fatiando.org) +# +# The padding functions available in this file are modified versions of the ones +# available in `xrft` (https://github.com/xgcm/xrft/), released under the +# MIT License: +# +# > The MIT License (MIT) +# > +# > Copyright (c) 2017 Ryan Abernathey, Columbia University +# > +# > Permission is hereby granted, free of charge, to any person obtaining a copy +# > of this software and associated documentation files (the "Software"), to deal +# > in the Software without restriction, including without limitation the rights +# > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# > copies of the Software, and to permit persons to whom the Software is +# > furnished to do so, subject to the following conditions: +# > +# > The above copyright notice and this permission notice shall be included in all +# > copies or substantial portions of the Software. +# > +# > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# > SOFTWARE. +""" +Functions to pad and unpad a 2D regular grid. +""" + +import numpy as np +from xarray.core.utils import either_dict_or_kwargs + +from ._utils import get_spacing + + +def pad( + da, + pad_width=None, + mode="constant", + stat_length=None, + constant_values=0, + end_values=None, + reflect_type=None, + **pad_width_kwargs, +): + """ + Pad array with evenly spaced coordinates. + + Wraps the :meth:`xarray.DataArray.pad` method but also pads the evenly + spaced coordinates by extrapolation using the same coordinate spacing. + The ``pad_width`` used for each coordinate is stored as one of its + attributes. + + Parameters + ---------- + da : :class:`xarray.DataArray` + Array to be padded. The coordinates along which the array will be + padded must be evenly spaced. + pad_width : mapping of hashable to tuple of int + Mapping with the form of ``{dim: (pad_before, pad_after)}`` + describing the number of values padded along each dimension. + ``{dim: pad}`` is a shortcut for ``pad_before = pad_after = pad``. + mode : str, default: "constant" + One of the following string values (taken from numpy docs). + - constant: Pads with a constant value. + - edge: Pads with the edge values of array. + - linear_ramp: Pads with the linear ramp between end_value and the + array edge value. + - maximum: Pads with the maximum value of all or part of the + vector along each axis. + - mean: Pads with the mean value of all or part of the + vector along each axis. + - median: Pads with the median value of all or part of the + vector along each axis. + - minimum: Pads with the minimum value of all or part of the + vector along each axis. + - reflect: Pads with the reflection of the vector mirrored on + the first and last values of the vector along each axis. + - symmetric: Pads with the reflection of the vector mirrored + along the edge of the array. + - wrap: Pads with the wrap of the vector along the axis. + The first values are used to pad the end and the + end values are used to pad the beginning. + stat_length : int, tuple or mapping of hashable to tuple, default: None + Used in 'maximum', 'mean', 'median', and 'minimum'. Number of + values at edge of each axis used to calculate the statistic value. + {dim_1: (before_1, after_1), ... dim_N: (before_N, after_N)} unique + statistic lengths along each dimension. + ((before, after),) yields same before and after statistic lengths + for each dimension. + (stat_length,) or int is a shortcut for before = after = statistic + length for all axes. + Default is ``None``, to use the entire axis. + constant_values : scalar, tuple or mapping of hashable to tuple, default: 0 + Used in 'constant'. The values to set the padded values for each + axis. + ``{dim_1: (before_1, after_1), ... dim_N: (before_N, after_N)}`` unique + pad constants along each dimension. + ``((before, after),)`` yields same before and after constants for each + dimension. + ``(constant,)`` or ``constant`` is a shortcut for ``before = after = constant`` + for all dimensions. + Default is 0. + end_values : scalar, tuple or mapping of hashable to tuple, default: 0 + Used in 'linear_ramp'. The values used for the ending value of the + linear_ramp and that will form the edge of the padded array. + ``{dim_1: (before_1, after_1), ... dim_N: (before_N, after_N)}`` unique + end values along each dimension. + ``((before, after),)`` yields same before and after end values for each + axis. + ``(constant,)`` or ``constant`` is a shortcut for ``before = after = constant`` + for all axes. + Default is 0. + reflect_type : {"even", "odd"}, optional + Used in "reflect", and "symmetric". The "even" style is the + default with an unaltered reflection around the edge value. For + the "odd" style, the extended part of the array is created by + subtracting the reflected values from two times the edge value. + **pad_width_kwargs + The keyword arguments form of ``pad_width``. + One of ``pad_width`` or ``pad_width_kwargs`` must be provided. + + Returns + ------- + da_padded : :class:`xarray.DataArray` + + See Also + -------- + :func:`harmonica.filters.unpad` + + Examples + -------- + >>> import xarray as xr + + >>> import pytest + >>> from packaging.version import Version + >>> if Version(xr.__version__) < Version("2025.10.1"): + ... pytest.skip("This doctest works only in Xarray >= 2025.10.1") + + >>> da = xr.DataArray( + ... [[1, 2, 3], [4, 5, 6], [7, 8, 9]], + ... coords={"x": [0, 1, 2], "y": [-5, -4, -3]}, + ... dims=("y", "x"), + ... ) + >>> da_padded = pad(da, x=2, y=1) + >>> da_padded + Size: 280B + array([[0, 0, 0, 0, 0, 0, 0], + [0, 0, 1, 2, 3, 0, 0], + [0, 0, 4, 5, 6, 0, 0], + [0, 0, 7, 8, 9, 0, 0], + [0, 0, 0, 0, 0, 0, 0]]) + Coordinates: + * y (y) int64 40B -6 -5 -4 -3 -2 + * x (x) int64 56B -2 -1 0 1 2 3 4 + >>> da_padded.x + Size: 56B + array([-2, -1, 0, 1, 2, 3, 4]) + Coordinates: + * x (x) int64 56B -2 -1 0 1 2 3 4 + Attributes: + pad_width: 2 + >>> da_padded.y + Size: 40B + array([-6, -5, -4, -3, -2]) + Coordinates: + * y (y) int64 40B -6 -5 -4 -3 -2 + Attributes: + pad_width: 1 + + Asymmetric padding + + >>> da_padded = pad(da, x=(1, 4)) + >>> da_padded + Size: 192B + array([[0, 1, 2, 3, 0, 0, 0, 0], + [0, 4, 5, 6, 0, 0, 0, 0], + [0, 7, 8, 9, 0, 0, 0, 0]]) + Coordinates: + * y (y) int64 24B -5 -4 -3 + * x (x) int64 64B -1 0 1 2 3 4 5 6 + >>> da_padded.x + Size: 64B + array([-1, 0, 1, 2, 3, 4, 5, 6]) + Coordinates: + * x (x) int64 64B -1 0 1 2 3 4 5 6 + Attributes: + pad_width: (1, 4) + + """ + # Redefine pad_width if pad_width_kwargs were passed + pad_width = either_dict_or_kwargs(pad_width, pad_width_kwargs, "pad") + + # Check for bad coordinates + _check_bad_coords(da, pad_width.keys()) + + # Pad the array using the xarray.DataArray.pad method + padded_da = da.pad( + pad_width, + mode, + stat_length, + constant_values, + end_values, + reflect_type, + ) + + # Pad the coordinates selected in pad_width + padded_coords = _pad_coordinates(da.coords, pad_width) + + # Assign the padded coordinates to the padded array + padded_da = padded_da.assign_coords(padded_coords) + + # Edit the attributes of the padded array + for dim in pad_width: + # Add attrs of the original coords to the padded array + padded_da.coords[dim].attrs.update(da.coords[dim].attrs) + # Add the pad_width used for this coordinate + padded_da.coords[dim].attrs.update({"pad_width": pad_width[dim]}) + + return padded_da + + +def _check_bad_coords(da, padding_coordinates): + """ + Check if the DataArray contains bad coordinates. + + A bad coordinate is defined as an additional coordinate that shares at + least one of the dimensions along which the grid will be padded. + + Parameters + ---------- + da : :class:`xarray.DataArray` + Array to be padded. + padding_coordinates : list of str + List containing the coordinates along which the grid will be padded. + + Raises + ------ + ValueError + If any bad coordinate is found in the DataArray. + """ + # Initialize empty list for bad coords + bad_coords = [] + + # Start checking for bad coordinates in the array + for coord in padding_coordinates: + # Get dimension of the current padding coordinate + dim = da[coord].dims[0] + bad_coords += [c for c in da.coords if dim in da[c].dims and c != coord] + + if bad_coords: + bad_coords = "'" + "', '".join(bad_coords) + "'" + msg = ( + "Please, drop the following coordinates from the passed DataArray " + + f"before trying to pad it: {bad_coords}." + ) + raise ValueError(msg) + + +def _pad_coordinates(coords, pad_width): + """ + Pad coordinates arrays according to the passed width. + + Parameters + ---------- + coords : dict-like object + Dictionary with coordinates as :class:`xarray.DataArray`. + Only the coordinates specified through ``pad_width`` will be padded. + Every coordinate that will be padded should be evenly spaced. + pad_width : dict-like object + Dictionary with the same keys as ``coords``. The coordinates specified + through ``pad_width`` are returned as padded. + + Returns + ------- + padded_coords : dict-like object + Dictionary with 1d-arrays corresponding to the padded coordinates. + + Examples + -------- + >>> import numpy as np + >>> import xarray as xr + + >>> import pytest + >>> from packaging.version import Version + >>> if Version(xr.__version__) < Version("2025.10.1"): + ... pytest.skip("This doctest works only in Xarray >= 2025.10.1") + + >>> x = np.linspace(-4, -1, 4) + >>> y = np.linspace(-1, 4, 6) + >>> coords = { + ... "x": xr.DataArray(x, coords={"x": x}, dims=("x",)), + ... "y": xr.DataArray(y, coords={"y": y}, dims=("y",)), + ... } + >>> pad_width = {"x": 2} + >>> padded_coords = _pad_coordinates(coords, pad_width) + >>> padded_coords["x"] + array([-6., -5., -4., -3., -2., -1., 0., 1.]) + >>> padded_coords["y"] + Size: 48B + array([-1., 0., 1., 2., 3., 4.]) + Coordinates: + * y (y) float64 48B -1.0 0.0 1.0 2.0 3.0 4.0 + + """ + # Generate a dictionary with the original coordinates + padded_coords = {dim: coords[dim] for dim in coords} + + # Start padding the coordinates that appear in pad_width + for dim in pad_width: + # Get the spacing of the selected coordinate + # (raises an error if not evenly spaced) + spacing = get_spacing(padded_coords[dim]) + # Pad the coordinates using numpy.pad with the _pad_coordinates_callback + padded_coords[dim] = np.pad( + padded_coords[dim], + pad_width=pad_width[dim], + mode=_pad_coordinates_callback, + spacing=spacing, # spacing is passed as a kwarg to the callback + ) + + return padded_coords + + +def _pad_coordinates_callback(vector, iaxis_pad_width, iaxis, kwargs): + """ + Padd coordinate array. + + .. important:: + + This function is not intended to be called, but to be passed as the + ``mode`` method to the :func:`numpy.pad` + + Parameters + ---------- + vector : 1d-array + A rank 1 array already padded with zeros. Padded values are + ``vector[:iaxis_pad_width[0]]`` and ``vector[-iaxis_pad_width[1]:]``. + iaxis_pad_width : tuple + A 2-tuple of ints, ``iaxis_pad_width[0]`` represents the number of + values padded at the beginning of vector where ``iaxis_pad_width[1]`` + represents the number of values padded at the end of vector. + iaxis : int + The axis currently being calculated. This parameter is not used, but + the function will check if it's equal to zero. It exists for + compatibility with the ``padding_func`` callback that :func:`numpy.pad` + needs. + kwargs : dict + Any keyword arguments the function requires. The kwargs are ignored in + this function, they exist for compatibility with the ``padding_func`` + callback that :func:`numpy.pad` needs. + + Returns + ------- + vector : 1d-array + Padded vector. + """ + assert iaxis == 0 + spacing = kwargs["spacing"] + n_start, n_end = iaxis_pad_width[:] + vmin, vmax = vector[n_start], vector[-(n_end + 1)] + vector[:n_start] = ( + vmin - n_start * spacing + np.linspace(0, spacing * (n_start - 1), n_start) + ) + vector[len(vector) - n_end :] = ( + vmax + spacing + np.linspace(0, spacing * (n_end - 1), n_end) + ) + return vector + + +def unpad(da, pad_width=None, **pad_width_kwargs): + """ + Unpad an array and its coordinates. + + Undo the padding process of the :func:`harmonica.filters.pad` function by slicing + the passed :class:`xarray.DataArray` and its coordinates. + + Parameters + ---------- + da : :class:`xarray.DataArray` + Padded array. The coordinates along which the array will be + padded must be evenly spaced. + + Returns + ------- + da_unpaded : :class:`xarray.DataArray` + Unpadded array. + pad_width : mapping of hashable to tuple of int (optional) + Mapping with the form of {dim: (pad_before, pad_after)} + describing the number of values padded along each dimension. + {dim: pad} is a shortcut for pad_before = pad_after = pad. + If ``None``, then the *pad_width* for each coordinate is read from + their ``pad_width`` attribute. + **pad_width_kwargs (optional) + The keyword arguments form of ``pad_width``. + Pass ``pad_width`` or ``pad_width_kwargs``. + + See Also + -------- + :func:`harmonica.filters.pad` + + Examples + -------- + >>> import xarray as xr + + >>> import pytest + >>> from packaging.version import Version + >>> if Version(xr.__version__) < Version("2025.10.1"): + ... pytest.skip("This doctest works only in Xarray >= 2025.10.1") + + >>> da = xr.DataArray( + ... [[1, 2, 3], [4, 5, 6], [7, 8, 9]], + ... coords={"x": [0, 1, 2], "y": [-5, -4, -3]}, + ... dims=("y", "x"), + ... ) + >>> da_padded = pad(da, x=2, y=1) + >>> da_padded + Size: 280B + array([[0, 0, 0, 0, 0, 0, 0], + [0, 0, 1, 2, 3, 0, 0], + [0, 0, 4, 5, 6, 0, 0], + [0, 0, 7, 8, 9, 0, 0], + [0, 0, 0, 0, 0, 0, 0]]) + Coordinates: + * y (y) int64 40B -6 -5 -4 -3 -2 + * x (x) int64 56B -2 -1 0 1 2 3 4 + >>> unpad(da_padded) + Size: 72B + array([[1, 2, 3], + [4, 5, 6], + [7, 8, 9]]) + Coordinates: + * y (y) int64 24B -5 -4 -3 + * x (x) int64 24B 0 1 2 + + Custom ``pad_width`` + + >>> unpad(da_padded, x=1, y=1) + Size: 120B + array([[0, 1, 2, 3, 0], + [0, 4, 5, 6, 0], + [0, 7, 8, 9, 0]]) + Coordinates: + * y (y) int64 24B -5 -4 -3 + * x (x) int64 40B -1 0 1 2 3 + + """ + # Generate the pad_width dictionary + if pad_width is None and not pad_width_kwargs: + # Read the pad_width from each coordinate if pad_width is None and + # no pad_width_kwargs has been passed + pad_width = { + dim: coord.attrs["pad_width"] + for dim, coord in da.coords.items() + if "pad_width" in coord.attrs + } + # Raise error if there's no pad_width attribute in the coordinates + if not pad_width: + raise ValueError( + "The passed array doesn't seem to be a padded one: the 'pad_width' " + + "attribute was missing on every one of its coordinates. " + ) + else: + # Redefine pad_width if pad_width_kwargs were passed + pad_width = either_dict_or_kwargs(pad_width, pad_width_kwargs, "pad") + + # Transform every pad_width into a tuple with indices + slices = {} + for dim in pad_width: + slices[dim] = _pad_width_to_slice(pad_width[dim], da.coords[dim].size) + + # Slice the padded array + unpadded_da = da.isel(indexers=slices) + + # Remove the pad_width attribute from coords since it's no longer necessary + for dim in pad_width: + if "pad_width" in unpadded_da.coords[dim].attrs: + unpadded_da.coords[dim].attrs.pop("pad_width") + + return unpadded_da + + +def _pad_width_to_slice(pad_width, size): + """ + Create a slice for removing the padded elements of a coordinate array. + + Parameters + ---------- + pad_width : int or tuple + Integer or tuples with the width of the padding at each side of the + coordinate array. An integer means an equal padding at each side equal + to its value. + size : int + Number of elements of the coordinates array. + + Returns + ------- + coord_slice : slice + A slice object for removing the padded elements of the coordinate + array. + """ + if isinstance(pad_width, int): + pad_width = (pad_width, pad_width) + return slice(pad_width[0], size - pad_width[1]) diff --git a/src/harmonica/filters/_utils.py b/src/harmonica/filters/_utils.py index 5971e4558..983969e60 100644 --- a/src/harmonica/filters/_utils.py +++ b/src/harmonica/filters/_utils.py @@ -9,112 +9,7 @@ """ import numpy as np -import xrft - -from ._fft import fft, ifft - - -def apply_filter( - grid, - fft_filter, - *, - filter_kwargs=None, - pad=True, - pad_kwargs=None, - drop_coords=False, -): - """ - Apply a filter to a grid and return the transformed grid in spatial domain. - - Computes the Fourier transform of the given grid, builds the filter, - applies it and returns the inverse Fourier transform of the filtered grid. - - .. note:: - - Any non-dimensional coordinates in the original grid will be dropped - from the filtered grid. This is because we can't know if the filter - invalidates the coordinate values (for example, upward continuation - would invalidate any height coordinates). So it's safer to drop them. - - Parameters - ---------- - grid : :class:`xarray.DataArray` - A two dimensional :class:`xarray.DataArray` whose coordinates are - evenly spaced (regular grid). Its dimensions should be in the following - order: *northing*, *easting*. Its coordinates should be defined in the - same units. - fft_filter : func - Callable that builds the filter in the frequency domain. - filter_kwargs : dict or None, optional - Any additional keyword argument that should be passed to the - ``fft_filter`` in the form of a dictionary. - pad : bool, optional - If True, will add padding to the grid before taking the Fourier Transform - and applying the filter and remove it after the inverse Fourier Transform. - Adding padding usually helps reduce edge effects from signal truncation. - Default is True. - pad_kwargs : dict or None, optional - Any additional keyword arguments that should be passed to the - :meth:`xarray.DataArray.pad` function. If none are given, the default - padding of 25% the dimensions of the grid will be added using the - "edge" method. - drop_coords : bool, optional - If True, non-dimensional coordinates of the grid will be dropped after - filtering. This is useful if the filter could move the grid, like in upward - continuation, which could make these coordinates incorrect. - - Returns - ------- - filtered_grid : :class:`xarray.DataArray` - A :class:`xarray.DataArray` with the filtered version of the passed - ``grid``. Defined are in the spatial domain. - """ - if filter_kwargs is None: - filter_kwargs = {} - if pad_kwargs is None: - pad_kwargs = {} - grid_sanity_checks(grid) - dims = grid.dims - # Need to remove non-dimensional coordinates before padding and FFT because - # xrft doesn't know what to do with them. - non_dim_coords = {c: grid[c] for c in grid.coords if c not in grid.indexes} - grid = grid.drop_vars(non_dim_coords.keys()) - if pad: - # By default, use a padding width of 25% of each grid dimension. - # Fedi et al. (2012; doi:10.1111/j.1365-246X.2011.05259.x) suggest - # a padding of 100% but that seems exaggerated. - if "pad_width" not in pad_kwargs: - pad_kwargs["pad_width"] = {d: int(0.25 * grid[d].size) for d in dims} - if "mode" not in pad_kwargs: - pad_kwargs["mode"] = "edge" - if "constant_values" not in pad_kwargs: - # Has to be included explicitly as None since xrft always passes - # it to xarray.DataArray.pad. - pad_kwargs["constant_values"] = None - fft_grid = fft(xrft.pad(grid, **pad_kwargs)) - else: - fft_grid = fft(grid) - # The filter convolution in the frequency domain is a multiplication - filtered_fft_grid = fft_grid * fft_filter(fft_grid, **filter_kwargs) - # Keep only the real part since the inverse transform returns complex - # number by default - filtered_grid = ifft(filtered_fft_grid).real - if pad: - filtered_grid = xrft.unpad(filtered_grid, pad_kwargs["pad_width"]) - # Restore the original coordinates to the grid because the inverse - # transform calculates coordinates from the frequencies, which can lead to - # rounding errors and coordinates that are slightly off. This causes errors - # when doing operations with the transformed grids. Restoring the original - # coordinates avoids these issues. - filtered_grid = filtered_grid.assign_coords( - {dims[1]: grid[dims[1]], dims[0]: grid[dims[0]]} - ) - # Restore the non-dimensional coordinates if desired - if not drop_coords: - filtered_grid = filtered_grid.assign_coords( - {name: non_dim_coords[name] for name in non_dim_coords} - ) - return filtered_grid +import xarray as xr def grid_sanity_checks(grid): @@ -147,3 +42,30 @@ def grid_sanity_checks(grid): + "The grid must not have missing values before computing the " + "Fast Fourier Transform." ) + + +def get_spacing(coordinate: xr.DataArray) -> float: + """ + Return spacing of a grid coordinate. + + Parameters + ---------- + coordinate : xarray.DataArray + DataArray containing the coordinate. + coordinate : str + Coordinate name. + + Returns + ------- + spacing : float + """ + spacing = coordinate.values[1] - coordinate.values[0] + if not np.allclose(spacing, coordinate.values[1:] - coordinate.values[:-1]): + msg = f"Invalid '{coordinate.name}' coordinates: they must be evenly spaced." + raise ValueError(msg) + if spacing <= 0: + msg = ( + f"Invalid coordinate '{coordinate.name}': it must be increasingly ordered." + ) + raise ValueError(msg) + return spacing diff --git a/test/filters/__init__.py b/test/filters/__init__.py new file mode 100644 index 000000000..b348c0fa7 --- /dev/null +++ b/test/filters/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) 2018 The Harmonica Developers. +# Distributed under the terms of the BSD 3-Clause License. +# SPDX-License-Identifier: BSD-3-Clause +# +# This code is part of the Fatiando a Terra project (https://www.fatiando.org) +# diff --git a/test/filters/test_fft.py b/test/filters/test_fft.py new file mode 100644 index 000000000..4ec1d5551 --- /dev/null +++ b/test/filters/test_fft.py @@ -0,0 +1,271 @@ +# Copyright (c) 2018 The Harmonica Developers. +# Distributed under the terms of the BSD 3-Clause License. +# SPDX-License-Identifier: BSD-3-Clause +# +# This code is part of the Fatiando a Terra project (https://www.fatiando.org) +# +""" +Test FFT functions. +""" + +import re + +import bordado as bd +import numpy as np +import pytest +import xarray as xr +import xrft + +from harmonica.filters import fft, ifft +from harmonica.filters._fft import _fftfreq, _get_dimensional_coordinate, _ifftfreq + + +@pytest.fixture +def synthetic_grid(): + """ + Synthetic 2D grid in space domain. + """ + # Create easting and northing coordinates with a static shift + easting = np.linspace(-2, 8, 21) + northing = np.linspace(4, 15, 41) + xx, yy = np.meshgrid(easting, northing) + z = np.cos(xx) * np.sin(yy) + dims = ("northing", "easting") + coords = {"easting": easting, "northing": northing} + return xr.DataArray(z, coords=coords, dims=dims) + + +def rename_coordinate(da: xr.DataArray, renaming_dict: dict[str, str]) -> xr.DataArray: + """ + Rename coordinate of xarray.DataArray without modifying the dimension. + """ + for old_coord, new_coord in renaming_dict.items(): + (dim,) = getattr(da, old_coord).dims + da = da.assign_coords({new_coord: (dim, getattr(da, old_coord).values)}) + da = da.drop_vars(old_coord) + return da + + +def test_round_loop(synthetic_grid): + """ + Test if applying FFT and iFFT results in the original array. + + Test the normal FFT and iFFT operations considering true phase and true amplitude. + It also checks that the static shift in the original coordiantes is restored. + """ + recovered = ifft(fft(synthetic_grid)) + xr.testing.assert_allclose(synthetic_grid, recovered) + + +class TestPrefix: + """ + Test if prefix of frequency coordinates is correctly set. + """ + + def test_fft_prefix(self, synthetic_grid): + """ + Test custom prefix to fft. + """ + prefix = "new_prefix_" + fft_grid = fft(synthetic_grid, prefix=prefix) + assert fft_grid.dims == (f"{prefix}northing", f"{prefix}easting") + assert f"{prefix}easting" in fft_grid.coords + assert f"{prefix}northing" in fft_grid.coords + + def test_ifft_prefix(self, synthetic_grid): + """ + Test custom prefix to ifft. + """ + prefix = "new_prefix_" + fft_grid = fft(synthetic_grid, prefix=prefix) + ifft_grid = ifft(fft_grid, prefix=prefix) + assert ifft_grid.dims == ("northing", "easting") + assert "easting" in ifft_grid.coords + assert "northing" in ifft_grid.coords + + +class TestErrors: + """ + Test sanity errors raised by ``fft`` and ``ifft``. + """ + + def test_ifft_prefix_error_dimension(self, synthetic_grid): + """ + Test error raised after prefix in dimension not found. + """ + prefix = "new_prefix_" + fft_grid = fft(synthetic_grid, prefix=prefix) + msg = re.escape("Invalid frequency dimension") + with pytest.raises(ValueError, match=msg): + ifft(fft_grid) + + @pytest.mark.parametrize("coordinate", ["easting", "northing"]) + def test_ifft_prefix_error_coordiante(self, synthetic_grid, coordinate): + """ + Test error raised after prefix in frequency coordinates not found. + """ + fft_grid = fft(synthetic_grid) + fft_grid = rename_coordinate( + fft_grid, {f"freq_{coordinate}": f"blah_{coordinate}"} + ) + msg = re.escape(f"Invalid dimensional coordinate 'blah_{coordinate}'") + with pytest.raises(ValueError, match=msg): + ifft(fft_grid) + + @pytest.mark.parametrize("fft_func", [fft, ifft]) + def test_not_xarray(self, fft_func): + """Test error if passed grid is not a ``xarray.DataArray``.""" + grid = np.arange(25).reshape(5, 5) + msg = re.escape( + f"Invalid 'grid' of type '{type(grid).__name__}'. " + "It must be an xarray.DataArray." + ) + with pytest.raises(TypeError, match=msg): + fft_func(grid) + + @pytest.mark.parametrize("fft_func", [fft, ifft]) + def test_not_2d_grid(self, fft_func): + """Test error if passed grid is not 2D.""" + x = np.arange(10) + z = np.random.default_rng(seed=42).uniform(size=x.size) + da = xr.DataArray(z, coords={"x": x}) + msg = re.escape("Invalid grid array with '1' dimension. It must be a 2D array.") + with pytest.raises(ValueError, match=msg): + fft_func(da) + + +class TestAgainstXRFT: + """ + Compare our FFT results against the ones obtained with ``xrft``. + + .. note:: + + We should replace these tests with proper tests against analytical solutions to + stop depending in ``xrft`` also to run tests. + """ + + def test_fft(self, synthetic_grid): + """ + Test FFT results. + """ + fft_hm = fft(synthetic_grid) + fft_xrft = xrft.fft(synthetic_grid) + xr.testing.assert_allclose(fft_hm, fft_xrft) + + @pytest.mark.filterwarnings("ignore:Default ifft's behaviour") + def test_ifft(self, synthetic_grid): + """ + Test FFT results. + """ + recovered_hm = ifft(fft(synthetic_grid)) + recovered_xrft = xrft.ifft(xrft.fft(synthetic_grid)) + xr.testing.assert_allclose(recovered_hm, recovered_xrft) + + +class TestDimensionalCoordinate: + """Test the ``_get_dimensional_coordinate`` private function.""" + + @pytest.mark.parametrize("dim", ["easting", "northing"]) + def test_get_dimensional_coordinate(self, synthetic_grid, dim): + """Test getting the dimensional coordinate assigned with a particular dim.""" + # Rename the coordiante to make the test less trivial + new_coord_name = "blah" + grid = rename_coordinate(synthetic_grid, {dim: new_coord_name}) + assert _get_dimensional_coordinate(grid, dim) == new_coord_name + + def test_no_dimensional_coordinate(self, synthetic_grid): + """Test error if no dimensional coordinate is found.""" + dim = "blah" + msg = re.escape(f"Couldn't find dimensional coordinate for dimension '{dim}'.") + with pytest.raises(ValueError, match=msg): + _get_dimensional_coordinate(synthetic_grid, dim) + + @pytest.mark.parametrize("dim", ["easting", "northing"]) + def test_multiple_dimensional_coordinates(self, synthetic_grid, dim): + """Test error multiple dimensional coordinates are found.""" + bad_coord = "bad-coord" + synthetic_grid = synthetic_grid.assign_coords( + {bad_coord: (dim, getattr(synthetic_grid, dim).values)} + ) + bad_coords = f"{dim}, {bad_coord}" + msg = re.escape( + f"Multiple dimensional coordinates ({bad_coords}) found " + f"for the '{dim}' dimension. " + "Leave only one dimensional coordinate per dimension." + ) + with pytest.raises(ValueError, match=msg): + _get_dimensional_coordinate(synthetic_grid, dim) + + +class TestFFTFreq: + """Test the ``_fftfreq`` and ``_ifftfreq`` private functions.""" + + default_spacing = 0.5 + + @pytest.mark.parametrize("spacing", [None, default_spacing]) + def test_fftfreq(self, spacing): + coord = xr.DataArray( + bd.line_coordinates( + -4.0, 11.0, spacing=self.default_spacing, adjust="region" + ) + ) + freq = _fftfreq(coord, spacing=spacing) + # Check if frequencies are evenly spaced + freq_spacing = freq[1] - freq[0] + np.testing.assert_allclose(freq_spacing, freq[1:] - freq[:-1]) + # Check if frequencies are sorted + assert np.all(freq[1:] > freq[:-1]) + + @pytest.mark.parametrize("spacing", [None, default_spacing]) + def test_ifftfreq(self, spacing): + freq = xr.DataArray( + bd.line_coordinates( + -4.0, 11.0, spacing=self.default_spacing, adjust="region" + ) + ) + coord = _ifftfreq(freq, spacing=spacing) + # Check if frequencies are evenly spaced + coord_spacing = coord[1] - coord[0] + np.testing.assert_allclose(coord_spacing, coord[1:] - coord[:-1]) + # Check if frequencies are sorted + assert np.all(coord[1:] > coord[:-1]) + + def test_roundtrip(self): + # Define spatial coordinates + coord = xr.DataArray( + bd.line_coordinates( + 22.0, 32.0, spacing=self.default_spacing, adjust="region" + ) + ) + # Define frequencies and add shift + freq = xr.DataArray(_fftfreq(coord)) + freq.attrs.update({"shift": coord.values.min()}) + # Check that the recovered spatial coordinates are close to the coords + recovered = _ifftfreq(freq) + np.testing.assert_allclose(recovered, coord) + + def test_ifftfreq_no_shift(self): + """ + Test ``_ifftfreq`` when the frequency coordinates have no **shift** attr. + """ + # Define spatial coordinates + coord = xr.DataArray( + bd.line_coordinates( + 22.0, 32.0, spacing=self.default_spacing, adjust="region" + ) + ) + # Define frequencies without shift + freq = xr.DataArray(_fftfreq(coord)) + recovered = _ifftfreq(freq) + # Check that the recovered doesn't match the original coord + with pytest.raises(AssertionError): + np.testing.assert_allclose(recovered, coord) + # Check that the recovered are centered around zero + assert recovered[0] == -recovered[-1] + + @pytest.mark.parametrize("func", [_fftfreq, _ifftfreq]) + def test_invalid_coordiante(self, func): + """Test error if coordinate is not 1D.""" + coord = xr.DataArray(np.arange(25).reshape(5, 5)) + with pytest.raises(ValueError, match="It must be 1D"): + func(coord) diff --git a/test/test_filters.py b/test/filters/test_filters.py similarity index 99% rename from test/test_filters.py rename to test/filters/test_filters.py index 132db4652..0a840345d 100644 --- a/test/test_filters.py +++ b/test/filters/test_filters.py @@ -20,6 +20,7 @@ from harmonica.filters._fft import fft, ifft from harmonica.filters._filters import ( + apply_filter, derivative_easting_kernel, derivative_northing_kernel, derivative_upward_kernel, @@ -28,7 +29,6 @@ reduction_to_pole_kernel, upward_continuation_kernel, ) -from harmonica.filters._utils import apply_filter # ------------------------------- # Fixtures @@ -145,7 +145,7 @@ def fixture_invalid_grid_with_nans(sample_grid): def test_fft_round_trip(sample_grid): """ - Test if the wrapped fft and ifft functions satisfy a round trip. + Test if the fft and ifft functions satisfy a round trip. """ xrt.assert_allclose(sample_grid, ifft(fft(sample_grid))) diff --git a/test/filters/test_padding.py b/test/filters/test_padding.py new file mode 100644 index 000000000..01d2d9601 --- /dev/null +++ b/test/filters/test_padding.py @@ -0,0 +1,305 @@ +# Copyright (c) 2018 The Harmonica Developers. +# Distributed under the terms of the BSD 3-Clause License. +# SPDX-License-Identifier: BSD-3-Clause +# +# This code is part of the Fatiando a Terra project (https://www.fatiando.org) +# +""" +Unit tests for padding functions +""" + +import re + +import numpy as np +import numpy.testing as npt +import pytest +import xarray as xr +import xarray.testing as xrt + +from harmonica.filters import fft, ifft, pad, unpad +from harmonica.filters._padding import _pad_coordinates, _pad_width_to_slice + + +@pytest.fixture +def sample_da_2d(): + """ + Defines a 2D sample xarray.DataArray + """ + x = np.linspace(0, 10, 11) + y = np.linspace(-4, 4, 17) + z = np.arange(11 * 17, dtype=float).reshape(17, 11) + # Create one xr.DataArray for each coordinate and add spacing and + # direct_lag attributes to them + dx, dy = x[1] - x[0], y[1] - y[0] + x = xr.DataArray( + x, coords={"x": x}, dims=("x",), attrs={"direct_lag": 3.0, "spacing": dx} + ) + y = xr.DataArray( + y, coords={"y": y}, dims=("y",), attrs={"direct_lag": -2.1, "spacing": dy} + ) + return xr.DataArray(z, coords={"x": x, "y": y}, dims=("y", "x")) + + +def test_pad_coordinates(sample_da_2d): + """ + Test pad_coordinates function + """ + coords = sample_da_2d.coords + # Pad a single coordinate + padded_coords = _pad_coordinates(coords, {"x": 3}) + npt.assert_allclose(padded_coords["x"], np.linspace(-3, 13, 17)) + npt.assert_allclose(padded_coords["y"], coords["y"]) + # Pad two coordinates + padded_coords = _pad_coordinates(coords, {"x": 2, "y": 3}) + npt.assert_allclose(padded_coords["x"], np.linspace(-2, 12, 15)) + npt.assert_allclose(padded_coords["y"], np.linspace(-5.5, 5.5, 23)) + # Pad a single coordinate asymmetrically + padded_coords = _pad_coordinates(coords, {"x": (3, 2)}) + npt.assert_allclose(padded_coords["x"], np.linspace(-3, 12, 16)) + npt.assert_allclose(padded_coords["y"], coords["y"]) + # Pad two coordinates asymmetrically + padded_coords = _pad_coordinates(coords, {"x": (2, 1), "y": (3, 4)}) + npt.assert_allclose(padded_coords["x"], np.linspace(-2, 11, 14)) + npt.assert_allclose(padded_coords["y"], np.linspace(-5.5, 6, 24)) + + +def test_pad_coordinates_invalid(sample_da_2d): + """ + Test if pad_coordinates raises error after unevenly spaced coords + """ + x = sample_da_2d.coords["x"].values.copy() + x[3] += 0.1 + sample_da_2d = sample_da_2d.assign_coords({"x": x}) + msg = re.escape("Invalid 'x' coordinates: they must be evenly spaced.") + with pytest.raises(ValueError, match=msg): + _pad_coordinates(sample_da_2d.coords, pad_width={"x": 2}) + + +def test_pad_with_kwargs(sample_da_2d): + """ + Test pad function by passing pad_width as kwargs + """ + padded_da = pad(sample_da_2d, x=2, y=1) + assert padded_da.shape == (19, 15) + npt.assert_allclose(padded_da.values[:1, :], 0) + npt.assert_allclose(padded_da.values[-1:, :], 0) + npt.assert_allclose(padded_da.values[:, :2], 0) + npt.assert_allclose(padded_da.values[:, -2:], 0) + npt.assert_allclose(padded_da.values[1:-1, 2:-2], sample_da_2d) + npt.assert_allclose(padded_da.x, np.linspace(-2, 12, 15)) + npt.assert_allclose(padded_da.y, np.linspace(-4.5, 4.5, 19)) + + +def test_pad_with_pad_width(sample_da_2d): + """ + Test pad function by passing pad_width as argument + """ + pad_width = {"x": (2, 3), "y": (1, 3)} + padded_da = pad(sample_da_2d, pad_width) + assert padded_da.shape == (21, 16) + npt.assert_allclose(padded_da.values[:1, :], 0) + npt.assert_allclose(padded_da.values[-3:, :], 0) + npt.assert_allclose(padded_da.values[:, :2], 0) + npt.assert_allclose(padded_da.values[:, -3:], 0) + npt.assert_allclose(padded_da.values[1:-3, 2:-3], sample_da_2d) + npt.assert_allclose(padded_da.x, np.linspace(-2, 13, 16)) + npt.assert_allclose(padded_da.y, np.linspace(-4.5, 5.5, 21)) + + +@pytest.mark.parametrize( + "pad_width", + [ + {"x": 2, "y": 3}, + {"x": 2}, + {"y": 3}, + {"x": (2, 3), "y": 3}, + {"x": (2, 3), "y": (1, 3)}, + {"x": (2, 3)}, + {"y": (1, 3)}, + ], +) +def test_coordinates_attrs_after_pad(sample_da_2d, pad_width): + """ + Test if the attributes of the coordinates are preserved after padding + and if the pad_width has been added + """ + padded_da = pad(sample_da_2d, pad_width) + # Check if the attrs in sample_da_2d is a subset of the attrs in padded_da + assert sample_da_2d.x.attrs.items() <= padded_da.x.attrs.items() + assert sample_da_2d.y.attrs.items() <= padded_da.y.attrs.items() + # Check if pad_width has been added to the attrs of each coordinate + for coord, width in pad_width.items(): + assert padded_da.coords[coord].attrs["pad_width"] == width + + +@pytest.mark.parametrize( + "pad_width", + [ + {"x": 2, "y": 3}, + {"x": 2}, + {"y": 3}, + {"x": (2, 3), "y": 3}, + {"x": (2, 3), "y": (1, 3)}, + {"x": (2, 3)}, + {"y": (1, 3)}, + ], +) +def test_pad_unpad_round_trip(sample_da_2d, pad_width): + """ + Test if applying pad and then unpad returns the original array + """ + unpadded = unpad(pad(sample_da_2d, pad_width)) + xrt.assert_allclose(sample_da_2d, unpadded) + + +def test_unpad_invalid_array(sample_da_2d): + """ + Test if error is raised when a not padded array is passed to unpad + """ + msg = ( + "The passed array doesn't seem to be a padded one: the 'pad_width' " + + "attribute was missing on every one of its coordinates. " + ) + with pytest.raises(ValueError, match=msg): + unpad(sample_da_2d) + + +@pytest.mark.parametrize( + ("pad_width", "size", "expected_slice"), + [ + ((1, 1), 4, slice(1, 3)), + ((1, 2), 5, slice(1, 3)), + ((2, 3), 10, slice(2, 7)), + (2, 10, slice(2, 8)), + ], +) +def test_pad_width_to_slice(pad_width, size, expected_slice): + """ + Test if _pad_width_to_slice work as expected + """ + assert _pad_width_to_slice(pad_width, size) == expected_slice + + +@pytest.mark.parametrize( + "kwargs", + [{"x": 1, "y": 1}, {"pad_width": {"x": 1, "y": 1}}], + ids=["pad_width_as_kwargs", "pad_width_as_argument"], +) +def test_unpad_custom_path_width(sample_da_2d, kwargs): + """ + Test the behaviour of unpad when passing a custom pad_width + """ + unpadded = unpad(sample_da_2d, **kwargs) + assert unpadded.shape == (15, 9) + npt.assert_allclose(unpadded.x, np.linspace(1, 9, 9)) + npt.assert_allclose(unpadded.y, np.linspace(-3.5, 3.5, 15)) + + +@pytest.mark.parametrize( + "pad_width_arg", + [None, "argument", "kwargs"], + ids=["pad_width_none", "pad_width_as_arg", "pad_width_as_kwargs"], +) +def test_unpad_pop_pad_width_attributes(sample_da_2d, pad_width_arg): + """ + Check if the unpadded array has no pad_width attributes + """ + pad_width = {"x": 2, "y": 1} + padded = pad(sample_da_2d, pad_width) + if pad_width_arg is None: + unpadded = unpad(padded) + elif pad_width_arg == "argument": + unpadded = unpad(padded, pad_width=pad_width) + else: + unpadded = unpad(padded, **pad_width) + # Check if unpadded doesn't have the pad_width attributes + for dim in unpadded.coords: + assert "pad_width" not in unpadded.coords[dim].attrs + + +@pytest.mark.parametrize( + "pad_width", + [ + {"x": 4, "y": 3}, + {"x": 4}, + {"x": 0}, + {"y": 3}, + {"x": 4, "y": 0}, + {"x": (4, 3), "y": 3}, + {"x": (4, 3), "y": (5, 3)}, + {"x": (4, 3)}, + {"y": (5, 3)}, + {"x": (0, 3), "y": (5, 0)}, + ], +) +def test_unpad_ifft_fft_pad_round_trip(sample_da_2d, pad_width): + """ + Test if the round trip with padding and unpadding works + + This test passes a custom ``pad_width`` to the ``unpad`` function because + the ``fft`` doesn't support keeping the ``pad_width`` attribute on the + coordinates (at least for now). + """ + da_padded = pad(sample_da_2d, pad_width, constant_values=0) + da_fft = fft(da_padded) + da_ifft = ifft(da_fft) + da_unpadded = unpad(da_ifft, pad_width=pad_width) + xrt.assert_allclose(sample_da_2d, da_unpadded) + + +@pytest.fixture +def sample_no_round_coords(): + """ + Define sample coordinates with no round floats + """ + x = np.linspace(0, 10, 7) + y = np.linspace(-4, 4, 13) + coords = { + "x": xr.DataArray(x, coords={"x": x}, dims=("x",)), + "y": xr.DataArray(y, coords={"y": y}, dims=("y",)), + } + return coords + + +def test_pad_coordinates_no_round_coords(sample_no_round_coords): + """ + Test _pad_coordinates on no round coordinates + """ + padded_coords = _pad_coordinates(sample_no_round_coords, pad_width={"x": 3, "y": 4}) + assert padded_coords["x"].size == 13 + npt.assert_allclose(padded_coords["x"], np.linspace(-5, 15, 13)) + assert padded_coords["y"].size == 21 + npt.assert_allclose(padded_coords["y"], np.linspace(-4 - 8 / 3, 4 + 8 / 3, 21)) + + +@pytest.fixture +def sample_da_2d_with_bad_coord(): + """ + Defines a 2D sample xarray.DataArray with a bad coordinate + + A bad coordinate is an additional coordinate that holds the same dimensions + that will be used for padding. + """ + x = np.linspace(0, 10, 11) + y = np.linspace(-4, 4, 17) + z = np.arange(11 * 17, dtype=float).reshape(17, 11) + height = z / 2 # bad coordinate + dims = ("y", "x") + coords = {"x": ("x", x), "y": ("y", y), "height": (dims, height)} + return xr.DataArray(z, coords=coords, dims=dims) + + +@pytest.mark.parametrize( + "pad_width", + [{"x": 3}, {"y": 5}, {"x": 2, "y": 4}], +) +def test_pad_with_extra_1d_coordinate(pad_width, sample_da_2d_with_bad_coord): + """ + Test pad on a grid that has an extra dimension + """ + msg = ( + "Please, drop the following coordinates from the passed DataArray " + + "before trying to pad it: 'height'." + ) + with pytest.raises(ValueError, match=msg): + pad(sample_da_2d_with_bad_coord, pad_width=pad_width) diff --git a/test/filters/test_utils.py b/test/filters/test_utils.py new file mode 100644 index 000000000..5623c9e9d --- /dev/null +++ b/test/filters/test_utils.py @@ -0,0 +1,46 @@ +# Copyright (c) 2018 The Harmonica Developers. +# Distributed under the terms of the BSD 3-Clause License. +# SPDX-License-Identifier: BSD-3-Clause +# +# This code is part of the Fatiando a Terra project (https://www.fatiando.org) +# +""" +Test utility functions for the filters submodule. +""" + +import re + +import bordado as bd +import numpy as np +import pytest +import xarray as xr + +from harmonica.filters._utils import get_spacing + + +class TestGetSpacing: + """Test the ``get_spacing`` private function.""" + + def test_get_spacing(self): + spacing = 2.3 + x = bd.line_coordinates(-2.0, 8.0, spacing=spacing, adjust="region") + coordinate = xr.DataArray(x) + np.testing.assert_allclose(spacing, get_spacing(coordinate)) + + def test_not_evenly_spaced(self): + coordinate = xr.DataArray([1.0, 2.0, 4.0, 5.0]) + msg = re.escape( + f"Invalid '{coordinate.name}' coordinates: they must be evenly spaced." + ) + with pytest.raises(ValueError, match=msg): + get_spacing(coordinate) + + def test_not_ordered(self): + spacing = 2.3 + x = bd.line_coordinates(-2.0, 8.0, spacing=spacing, adjust="region")[::-1] + coordinate = xr.DataArray(x) + msg = re.escape( + f"Invalid coordinate '{coordinate.name}': it must be increasingly ordered." + ) + with pytest.raises(ValueError, match=msg): + get_spacing(coordinate)