Repository: fatiando/verde

Issue:
Add support for generating N-dimensional grids
**Description of the desired feature**

Grids are generated by the `BaseGridder.grid` method, which relies on `coordinates.grid_coordinates` to generate the coordinates for prediction. There is a heavy assumption of only 2 dimensions for the grid in the code: `region` is assumed to be `[W, E, S, N]`, assigning coords to `xarray.Dataset` is hard-coded, etc. This is fine for gridders in this package because none of them support predicting data in 3D from 2D observations. 

This is not the case for harmonic functions, like gravity and magnetic data. Because of Green's identities, you can predict these data anywhere in space from observations on a surface. This is know as the equivalent layer technique and it will be implemented in [harmonica](https://github.com/fatiando/harmonica) using Verde as a basis. So it would be good to:

* Add support for `grid_coordinates` to take N-dimensional `region`, `spacing`, and `shape`. For example, `grid_coordinates(region=[0, 10, 0, 20, 0, 30], shape=(10, 11, 12))` should produce three 3D arrays.
* Change `BaseGridder.grid` to set the `Dataset` `coords` attribute dynamically depending on the shape of the coordinates produced by `grid_coordinates`.


Comments:

Patch:
diff --git a/doc/api/index.rst b/doc/api/index.rst
index 334d095..f3a9fc3 100644
--- a/doc/api/index.rst
+++ b/doc/api/index.rst
@@ -57,6 +57,7 @@ Coordinate Manipulation
 .. autosummary::
    :toctree: generated/
 
+    line_coordinates
     grid_coordinates
     scatter_points
     profile_coordinates
diff --git a/verde/__init__.py b/verde/__init__.py
index eb456d3..661d38a 100644
--- a/verde/__init__.py
+++ b/verde/__init__.py
@@ -15,6 +15,7 @@ from .coordinates import (
     get_region,
     grid_coordinates,
     inside,
+    line_coordinates,
     longitude_continuity,
     pad_region,
     profile_coordinates,
diff --git a/verde/coordinates.py b/verde/coordinates.py
index b46b504..f9a2a9c 100644
--- a/verde/coordinates.py
+++ b/verde/coordinates.py
@@ -188,6 +188,105 @@ def scatter_points(region, size, random_state=None, extra_coords=None):
     return tuple(coordinates)
 
 
+def line_coordinates(
+    start, stop, size=None, spacing=None, adjust="spacing", pixel_register=False
+):
+    """
+    Generate evenly spaced points between two values.
+
+    Able to handle either specifying the number of points required (*size*) or
+    the size of the interval between points (*spacing*). If using *size*, the
+    output will be similar to using :func:`numpy.linspace`. When using
+    *spacing*, if the interval is not divisible by the desired spacing, either
+    the interval or the spacing will have to be adjusted. By default, the
+    spacing will be rounded to the nearest multiple. Optionally, the *stop*
+    value can be adjusted to fit the exact spacing given.
+
+    Parameters
+    ----------
+    start : float
+        The starting value of the sequence.
+    stop : float
+        The end value of the sequence.
+    num : int or None
+        The number of points in the sequence. If None, *spacing* must be
+        provided.
+    spacing : float or None
+        The step size (interval) between points in the sequence. If None,
+        *size* must be provided.
+    adjust : {'spacing', 'region'}
+        Whether to adjust the spacing or the interval/region if required.
+        Ignored if *size* is given instead of *spacing*. Defaults to adjusting
+        the spacing.
+    pixel_register : bool
+        If True, the points will refer to the center of each interval (pixel)
+        instead of the boundaries. In practice, this means that there will be
+        one less element in the sequence if *spacing* is provided. If *size* is
+        provided, the requested number of elements is respected. Default is
+        False.
+
+    Returns
+    -------
+    sequence : array
+        The generated sequence of values.
+
+    Examples
+    --------
+
+    >>> # Lower printing precision to shorten this example
+    >>> import numpy as np; np.set_printoptions(precision=2, suppress=True)
+
+    >>> values = line_coordinates(0, 5, spacing=2.5)
+    >>> print(values.shape)
+    (3,)
+    >>> print(values)
+    [0.  2.5 5. ]
+    >>> print(line_coordinates(0, 10, size=5))
+    [ 0.   2.5  5.   7.5 10. ]
+    >>> print(line_coordinates(0, 10, spacing=2.5))
+    [ 0.   2.5  5.   7.5 10. ]
+
+    The spacing is adjusted to fit the interval by default but this can be
+    changed to adjusting the interval/region instead:
+
+    >>> print(line_coordinates(0, 10, spacing=2.6))
+    [ 0.   2.5  5.   7.5 10. ]
+    >>> print(line_coordinates(0, 10, spacing=2.6, adjust="region"))
+    [ 0.   2.6  5.2  7.8 10.4]
+
+    Optionally, return values at the center of the intervals instead of their
+    boundaries:
+
+    >>> print(line_coordinates(0, 10, spacing=2.5, pixel_register=True))
+    [1.25 3.75 6.25 8.75]
+
+    Notice that this produces one value less than the non-pixel registered
+    version. If using *size* instead of *spacing*, the number of values will be
+    *size* regardless and the spacing will therefore be different from the
+    non-pixel registered version:
+
+    >>> print(line_coordinates(0, 10, size=5, pixel_register=True))
+    [1. 3. 5. 7. 9.]
+
+    """
+    if size is not None and spacing is not None:
+        raise ValueError("Both size and spacing provided. Only one is allowed.")
+    if size is None and spacing is None:
+        raise ValueError("Either a size or a spacing must be provided.")
+    if spacing is not None:
+        size, stop = spacing_to_size(start, stop, spacing, adjust)
+    elif pixel_register:
+        # Starts by generating grid-line registered coordinates and shifting
+        # them to the center of the pixel. Need 1 more point if given a shape
+        # so that we can do that because we discard the last point when
+        # shifting the coordinates.
+        size = size + 1
+    values = np.linspace(start, stop, size)
+    if pixel_register:
+        values = values[:-1] + (values[1] - values[0]) / 2
+    return values
+
+
 def grid_coordinates(
     region,
     shape=None,
@@ -445,6 +544,7 @@ def grid_coordinates(
     --------
     scatter_points : Generate the coordinates for a random scatter of points
     profile_coordinates : Coordinates for a profile between two points
+    line_coordinates: Generate evenly spaced points between two values
 
     """
     check_region(region)
@@ -452,23 +552,36 @@ def grid_coordinates(
         raise ValueError("Both grid shape and spacing provided. Only one is allowed.")
     if shape is None and spacing is None:
         raise ValueError("Either a grid shape or a spacing must be provided.")
-    if spacing is not None:
-        shape, region = spacing_to_shape(region, spacing, adjust)
-    elif pixel_register:
-        # Starts by generating grid-line registered coordinates and shifting
-        # them to the center of the pixel. Need 1 more point if given a shape
-        # so that we can do that because we discard the last point when
-        # shifting the coordinates.
-        shape = tuple(i + 1 for i in shape)
-    east_lines = np.linspace(region[0], region[1], shape[1])
-    north_lines = np.linspace(region[2], region[3], shape[0])
-    if pixel_register:
-        east_lines = east_lines[:-1] + (east_lines[1] - east_lines[0]) / 2
-        north_lines = north_lines[:-1] + (north_lines[1] - north_lines[0]) / 2
-    if meshgrid:
-        coordinates = list(np.meshgrid(east_lines, north_lines))
+    if shape is None:
+        shape = (None, None)
+        # Make sure the spacing is a tuple of 2 numbers
+        spacing = np.atleast_1d(spacing)
+        if len(spacing) == 1:
+            spacing = (spacing[0], spacing[0])
+        elif len(spacing) > 2:
+            raise ValueError(f"Only two values allowed for grid spacing: {spacing}")
     else:
-        coordinates = (east_lines, north_lines)
+        spacing = (None, None)
+
+    east = line_coordinates(
+        region[0],
+        region[1],
+        size=shape[1],
+        spacing=spacing[1],
+        adjust=adjust,
+        pixel_register=pixel_register,
+    )
+    north = line_coordinates(
+        region[2],
+        region[3],
+        size=shape[0],
+        spacing=spacing[0],
+        adjust=adjust,
+        pixel_register=pixel_register,
+    )
+    coordinates = [east, north]
+    if meshgrid:
+        coordinates = list(np.meshgrid(east, north))
     if extra_coords is not None:
         if not meshgrid:
             raise ValueError(
@@ -479,32 +592,30 @@ def grid_coordinates(
     return tuple(coordinates)
 
 
-def spacing_to_shape(region, spacing, adjust):
+def spacing_to_size(start, stop, spacing, adjust):
     """
-    Convert the grid spacing to a grid shape.
+    Convert a spacing to the number of points between start and stop.
 
-    Adjusts the spacing or the region if the desired spacing is not a multiple
-    of the grid dimensions.
+    Takes into account if the spacing or the interval needs to be adjusted.
 
     Parameters
     ----------
-    region : list = [W, E, S, N]
-        The boundaries of a given region in Cartesian or geographic
-        coordinates.
-    spacing : float, tuple = (s_north, s_east), or None
-        The grid spacing in the South-North and West-East directions,
-        respectively. A single value means that the spacing is equal in both
-        directions.
+    start : float
+        The starting value of the sequence.
+    stop : float
+        The end value of the sequence.
+    spacing : float
+        The step size (interval) between points in the sequence.
     adjust : {'spacing', 'region'}
-        Whether to adjust the spacing or the region if required. Ignored if
-        *shape* is given instead of *spacing*. Defaults to adjusting the
-        spacing.
+        Whether to adjust the spacing or the interval/region if required.
+        Defaults to adjusting the spacing.
 
     Returns
     -------
-    shape, region : tuples
-        The calculated shape and region that best fits the desired spacing.
-        Spacing or region may be adjusted.
+    size : int
+        The number of points between start and stop.
+    stop : float
+        The end of the interval, which may or may not have been adjusted.
 
     """
     if adjust not in ["spacing", "region"]:
@@ -513,27 +624,13 @@ def spacing_to_shape(region, spacing, adjust):
                 adjust
             )
         )
-
-    spacing = np.atleast_1d(spacing)
-    if len(spacing) > 2:
-        raise ValueError(
-            "Only two values allowed for grid spacing: {}".format(str(spacing))
-        )
-    elif len(spacing) == 1:
-        deast = dnorth = spacing[0]
-    elif len(spacing) == 2:
-        dnorth, deast = spacing
-
-    w, e, s, n = region
     # Add 1 to get the number of nodes, not segments
-    nnorth = int(round((n - s) / dnorth)) + 1
-    neast = int(round((e - w) / deast)) + 1
+    size = int(round((stop - start) / spacing)) + 1
     if adjust == "region":
-        # The shape is the same but we adjust the region so that the spacing
+        # The size is the same but we adjust the interval so that the spacing
         # isn't altered when we do the linspace.
-        n = s + (nnorth - 1) * dnorth
-        e = w + (neast - 1) * deast
-    return (nnorth, neast), (w, e, s, n)
+        stop = start + (size - 1) * spacing
+    return size, stop
 
 
 def shape_to_spacing(region, shape, pixel_register=False):


Test patch:
diff --git a/verde/tests/test_coordinates.py b/verde/tests/test_coordinates.py
index b61f29e..86cceae 100644
--- a/verde/tests/test_coordinates.py
+++ b/verde/tests/test_coordinates.py
@@ -16,10 +16,11 @@ import pytest
 from ..coordinates import (
     check_region,
     grid_coordinates,
+    line_coordinates,
     longitude_continuity,
     profile_coordinates,
     rolling_window,
-    spacing_to_shape,
+    spacing_to_size,
 )
 
 
@@ -98,43 +99,48 @@ def test_rolling_window_oversized_window():
             rolling_window(coords, size=oversize, spacing=2)
 
 
-def test_spacing_to_shape():
-    "Check that correct spacing and region are returned"
-    region = (-10, 0, 0, 5)
+def test_spacing_to_size():
+    "Check that correct size and stop are returned"
+    start, stop = -10, 0
 
-    shape, new_region = spacing_to_shape(region, spacing=2.5, adjust="spacing")
-    npt.assert_allclose(shape, (3, 5))
-    npt.assert_allclose(new_region, region)
+    size, new_stop = spacing_to_size(start, stop, spacing=2.5, adjust="spacing")
+    npt.assert_allclose(size, 5)
+    npt.assert_allclose(new_stop, stop)
 
-    shape, new_region = spacing_to_shape(region, spacing=(2.5, 2), adjust="spacing")
-    npt.assert_allclose(shape, (3, 6))
-    npt.assert_allclose(new_region, region)
+    size, new_stop = spacing_to_size(start, stop, spacing=2, adjust="spacing")
+    npt.assert_allclose(size, 6)
+    npt.assert_allclose(new_stop, stop)
 
-    shape, new_region = spacing_to_shape(region, spacing=2.6, adjust="spacing")
-    npt.assert_allclose(shape, (3, 5))
-    npt.assert_allclose(new_region, region)
+    size, new_stop = spacing_to_size(start, stop, spacing=2.6, adjust="spacing")
+    npt.assert_allclose(size, 5)
+    npt.assert_allclose(new_stop, stop)
 
-    shape, new_region = spacing_to_shape(region, spacing=2.4, adjust="spacing")
-    npt.assert_allclose(shape, (3, 5))
-    npt.assert_allclose(new_region, region)
+    size, new_stop = spacing_to_size(start, stop, spacing=2.4, adjust="spacing")
+    npt.assert_allclose(size, 5)
+    npt.assert_allclose(new_stop, stop)
 
-    shape, new_region = spacing_to_shape(region, spacing=(2.4, 1.9), adjust="spacing")
-    npt.assert_allclose(shape, (3, 6))
-    npt.assert_allclose(new_region, region)
+    size, new_stop = spacing_to_size(start, stop, spacing=2.6, adjust="region")
+    npt.assert_allclose(size, 5)
+    npt.assert_allclose(new_stop, 0.4)
 
-    shape, new_region = spacing_to_shape(region, spacing=2.6, adjust="region")
-    npt.assert_allclose(shape, (3, 5))
-    npt.assert_allclose(new_region, (-10, 0.4, 0, 5.2))
+    size, new_stop = spacing_to_size(start, stop, spacing=2.4, adjust="region")
+    npt.assert_allclose(size, 5)
+    npt.assert_allclose(new_stop, -0.4)
 
-    shape, new_region = spacing_to_shape(region, spacing=(2.6, 2.4), adjust="region")
-    npt.assert_allclose(shape, (3, 5))
-    npt.assert_allclose(new_region, (-10, -0.4, 0, 5.2))
 
+def test_line_coordinates_fails():
+    "Check failures for invalid arguments"
+    start, stop = 0, 1
+    size = 10
+    spacing = 0.1
+    # Make sure it doesn't fail for these parameters
+    line_coordinates(start, stop, size=size)
+    line_coordinates(start, stop, spacing=spacing)
 
-def test_spacing_to_shape_fails():
-    "Should fail if more than 2 spacings are given"
     with pytest.raises(ValueError):
-        spacing_to_shape((0, 1, 0, 1), (1, 2, 3), adjust="region")
+        line_coordinates(start, stop)
+    with pytest.raises(ValueError):
+        line_coordinates(start, stop, size=size, spacing=spacing)
 
 
 def test_grid_coordinates_fails():
@@ -152,6 +158,8 @@ def test_grid_coordinates_fails():
         grid_coordinates(region, shape=None, spacing=None)
     with pytest.raises(ValueError):
         grid_coordinates(region, spacing=spacing, adjust="invalid adjust")
+    with pytest.raises(ValueError):
+        grid_coordinates(region, spacing=(1, 2, 3))
 
 
 def test_check_region():