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)