Repository: fatiando/verde
Pull request #13 (2018-05-02 01:19:23)

Base class for gridders and synthetic data generation
Add the `verde.base.BaseGridder` class that implements methods for 
predicting data on a grid, a profile, and a random scatter.
Add the `verde.datasets` subpackage with the `CheckerBoard` class 
to generate synthetic data. It inherits from `BaseGridder` and uses it 
to evaluate grids, etc.



Patch:
diff --git a/.codacy.yml b/.codacy.yml
index c111b12..807a14c 100644
--- a/.codacy.yml
+++ b/.codacy.yml
@@ -3,3 +3,4 @@ exclude_paths:
   - 'versioneer.py'
   - '**/_version.py'
   - '**/__init__.py'
+  - '**/tests/**'
diff --git a/.pylintrc b/.pylintrc
index 24891b5..a3e4f57 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -50,7 +50,7 @@ confidence=
 # --enable=similarities". If you want to run only the classes checker, but have
 # no Warning level messages displayed, use"--disable=all --enable=classes
 # --disable=W"
-disable=print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call
+disable=print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,attribute-defined-outside-init
 
 # Enable the message, report, category or checker with the given id(s). You can
 # either give multiple identifier separated by comma (,) or put this option
diff --git a/doc/_templates/autosummary/module.rst b/doc/_templates/autosummary/module.rst
index 54c8c15..3b1fbde 100644
--- a/doc/_templates/autosummary/module.rst
+++ b/doc/_templates/autosummary/module.rst
@@ -18,7 +18,7 @@
 .. autosummary::
   :toctree: ./
 {% for item in classes %}
-  {{ item }}
+  {{ fullname }}.{{ item }}
 {% endfor %}
 {% endif %}
 {% endblock %}
@@ -44,7 +44,7 @@
 .. autosummary::
   :toctree: ./
 {% for item in exceptions %}
-  {{ item }}
+  {{ fullname }}.{{ item }}
 {% endfor %}
 {% endif %}
 {% endblock %}
diff --git a/doc/api/_generate_api.rst b/doc/api/_generate_api.rst
index 4644f01..f1cfce1 100644
--- a/doc/api/_generate_api.rst
+++ b/doc/api/_generate_api.rst
@@ -11,3 +11,5 @@ To include a new package/module, list it below and include it in api.index.rst
    :toctree: ./
 
    verde
+   verde.datasets
+   verde.base
diff --git a/doc/api/index.rst b/doc/api/index.rst
index 83b4322..02e3bbf 100644
--- a/doc/api/index.rst
+++ b/doc/api/index.rst
@@ -4,3 +4,5 @@ API Reference
 =============
 
 .. include:: verde.rst
+.. include:: verde.datasets.rst
+.. include:: verde.base.rst
diff --git a/doc/conf.py b/doc/conf.py
index 6276d3f..9acfc8f 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -18,7 +18,7 @@ extensions = [
     'sphinx.ext.doctest',
     'sphinx.ext.viewcode',
     'sphinx.ext.extlinks',
-    'sphinx.ext.intersphinx',
+    'matplotlib.sphinxext.plot_directive',
     'numpydoc',
     'nbsphinx',
 ]
@@ -28,6 +28,10 @@ autosummary_generate = False
 
 numpydoc_class_members_toctree = False
 
+# Always show the source code that generates a plot
+plot_include_source = True
+plot_formats = ['png']
+
 # Sphinx project configuration
 templates_path = ['_templates']
 exclude_patterns = ['_build', '**.ipynb_checkpoints']
@@ -81,12 +85,6 @@ html_context = {
     'github_version': 'master',
 }
 
-# intersphinx
-# intersphinx_mapping = {
-    # 'numpy': ('https://docs.scipy.org/doc/numpy/', None),
-    # 'pandas': ('http://pandas.pydata.org/pandas-docs/stable/', None),
-# }
-
 # Load the custom CSS files (needs sphinx >= 1.6 for this to work)
 def setup(app):
     app.add_stylesheet("style.css")
diff --git a/verde/__init__.py b/verde/__init__.py
index 56154fb..3f462ce 100644
--- a/verde/__init__.py
+++ b/verde/__init__.py
@@ -5,6 +5,7 @@ from ._version import get_versions as _get_versions
 
 # Import functions/classes to make the API
 from .utils import scatter_points, grid_coordinates, profile_coordinates
+from . import datasets
 
 
 # Get the version number through versioneer
diff --git a/verde/base/__init__.py b/verde/base/__init__.py
new file mode 100644
index 0000000..0c2d96e
--- /dev/null
+++ b/verde/base/__init__.py
@@ -0,0 +1,4 @@
+"""
+Base classes for all gridders.
+"""
+from .gridder import BaseGridder
diff --git a/verde/base/gridder.py b/verde/base/gridder.py
new file mode 100644
index 0000000..db757b7
--- /dev/null
+++ b/verde/base/gridder.py
@@ -0,0 +1,373 @@
+"""
+Base classes for all gridders.
+"""
+import xarray as xr
+import pandas as pd
+from sklearn.base import BaseEstimator
+
+from .. import grid_coordinates, profile_coordinates, scatter_points
+
+
+def get_dims(instance, dims):
+    """
+    Get default dimension names for an instance if not given as arguments.
+
+    Examples
+    --------
+
+    >>> grd = BaseGridder()
+    >>> get_dims(grd, dims=None)
+    ('northing', 'easting')
+    >>> grd.coordinate_system = 'geographic'
+    >>> get_dims(grd, dims=None)
+    ('latitude', 'longitude')
+    >>> grd.coordinate_system = 'cartesian'
+    >>> get_dims(grd, dims=('john', 'paul'))
+    ('john', 'paul')
+
+    """
+    if dims is not None:
+        return dims
+    valid_coords = ['cartesian', 'geographic']
+    coords = getattr(instance, 'coordinate_system', 'cartesian')
+    if coords not in valid_coords:
+        raise ValueError(
+            "Invalid coordinate system for {}: '{}'. Must be one of {}."
+            .format(instance.__class__.__name__, coords, str(valid_coords)))
+    if coords == 'geographic':
+        return ('latitude', 'longitude')
+    return ('northing', 'easting')
+
+
+def get_data_names(instance, data_names):
+    """
+    Get default names for data fields for an instance if not given as arguments
+
+    Examples
+    --------
+
+    >>> grd = BaseGridder()
+    >>> get_data_names(grd, data_names=None)
+    ('scalars',)
+    >>> grd.data_type = 'vector2d'
+    >>> get_data_names(grd, data_names=None)
+    ('east_component', 'north_component')
+    >>> grd.data_type = 'vector3d'
+    >>> get_data_names(grd, data_names=None)
+    ('east_component', 'north_component', 'vertical_component')
+    >>> get_data_names(grd, data_names=('ringo', 'george'))
+    ('ringo', 'george')
+
+    """
+    if data_names is not None:
+        return data_names
+    valid_types = ['scalar', 'vector2d', 'vector3d']
+    data_type = getattr(instance, 'data_type', 'scalar')
+    if data_type not in valid_types:
+        raise ValueError(
+            "Invalid data type for {}: '{}'. Must be one of {}."
+            .format(instance.__class__.__name__, data_type, str(valid_types)))
+    if data_type == 'vector2d':
+        return ('east_component', 'north_component')
+    elif data_type == 'vector3d':
+        return ('east_component', 'north_component', 'vertical_component')
+    return ('scalars',)
+
+
+def get_region(instance, region):
+    """
+    Get the region attribute stored in instance if one is not provided.
+    """
+    if region is None:
+        if not hasattr(instance, 'region_'):
+            raise ValueError(
+                "No default region found. Argument must be supplied.")
+        region = getattr(instance, 'region_')
+    return region
+
+
+def check_data(data):
+    """
+    Check the data returned by predict.
+    If the data is a single array, return it as a tuple with a single element.
+
+    Examples
+    --------
+
+    >>> check_data([1, 2, 3])
+    ([1, 2, 3],)
+    >>> check_data(([1, 2], [3, 4]))
+    ([1, 2], [3, 4])
+    """
+    if not isinstance(data, tuple):
+        data = (data,)
+    return data
+
+
+class BaseGridder(BaseEstimator):
+    """
+    Base class for gridders.
+
+    Requires the implementation of the ``predict(easting, northing)`` method.
+    The data returned by it should be a 1d or 2d numpy array for scalar data or
+    a tuple with 1d or 2d numpy arrays for each component of vector data.
+
+    Doesn't define any new attributes.
+
+    This is a subclass of :class:`sklearn.base.BaseEstimator` and must abide by
+    the same rules of the scikit-learn classes. Mainly:
+
+    * ``__init__`` must **only** assign values to attributes based on the
+      parameters it receives. All parameters must have default values.
+      Parameter checking should be done in ``fit``.
+    * Estimated parameters should be stored as attributes with names ending in
+      ``_``.
+
+    The child class can define the following attributes to control the names of
+    coordinates and data values in the output ``xarray.Dataset`` and
+    ``pandas.DataFrame`` and how distances are calculated:
+
+    * ``coordinate_system``: either ``'cartesian'`` or ``'geographic'``. Will
+      influence dimension names and distance calculations. Defaults to
+      ``'cartesian'``.
+    * ``data_type``: one of ``'scalar'``, ``'vector2d'``, or ``'vector3d'``.
+      Defaults to ``'scalar'``.
+
+    Examples
+    --------
+
+    Let's create a class that interpolates by attributing the mean value of the
+    data to every single point (it's not a very good interpolator).
+
+    >>> import verde as vd
+    >>> import numpy as np
+    >>> from sklearn.utils.validation import check_is_fitted
+    >>> class MeanGridder(vd.base.BaseGridder):
+    ...     "Gridder that always produces the mean of all data values"
+    ...     def __init__(self, multiplier=1):
+    ...         # Init should only assign the parameters to attributes
+    ...         self.multiplier = multiplier
+    ...     def fit(self, easting, northing, data):
+    ...         # Argument checking should be done in fit
+    ...         if self.multiplier <= 0:
+    ...             raise ValueError('Invalid multiplier {}'
+    ...                              .format(self.multiplier))
+    ...         self.mean_ = data.mean()*self.multiplier
+    ...         # fit should return self so that we can chain operations
+    ...         return self
+    ...     def predict(self, easting, northing):
+    ...         # We know the gridder has been fitted if it has the mean
+    ...         check_is_fitted(self, ['mean_'])
+    ...         return np.ones_like(easting)*self.mean_
+    >>> # Try it on some synthetic data
+    >>> synthetic = vd.datasets.CheckerBoard().fit(region=(0, 5, -10, 8))
+    >>> data = synthetic.scatter(random_state=0)
+    >>> print('{:.4f}'.format(data.scalars.mean()))
+    -32.2182
+    >>> # Fit the gridder to our synthetic data
+    >>> grd = MeanGridder().fit(data.easting, data.northing, data.scalars)
+    >>> grd
+    MeanGridder(multiplier=1)
+    >>> # Interpolate on a regular grid
+    >>> grid = grd.grid(region=(0, 5, -10, -8), shape=(40, 40))
+    >>> type(grid)
+    <class 'xarray.core.dataset.Dataset'>
+    >>> np.allclose(grid.scalars, -32.2182)
+    True
+    >>> # Interpolate along a profile
+    >>> profile = grd.profile(point1=(0, -10), point2=(5, -8), size=10)
+    >>> type(profile)
+    <class 'pandas.core.frame.DataFrame'>
+    >>> print(', '.join(['{:.2f}'.format(i) for i in profile.distance]))
+    0.00, 0.60, 1.20, 1.80, 2.39, 2.99, 3.59, 4.19, 4.79, 5.39
+    >>> print(', '.join(['{:.1f}'.format(i) for i in profile.scalars]))
+    -32.2, -32.2, -32.2, -32.2, -32.2, -32.2, -32.2, -32.2, -32.2, -32.2
+
+
+    """
+
+    def predict(self, easting, northing):
+        """
+        Interpolate data on the given set of points.
+
+        This method is required for all other methods related to interpolation.
+
+        Parameters
+        ----------
+        easting : array
+            The values of the West-East coordinates of each point.
+        northing : array
+            The values of the South-North coordinates of each point.
+
+        Returns
+        -------
+        data : array or tuple of arrays
+            The data values interpolated on the given points. If the data are
+            scalars, then it should be a single array. If data are vectors,
+            then should be a tuple with a separate array for each vector
+            component. The order of components should be: east, north,
+            vertical.
+
+        """
+        raise NotImplementedError()
+
+    def grid(self, region=None, shape=(101, 101), dims=None, data_names=None):
+        """
+        Interpolate the data onto a regular grid.
+
+        If the interpolator collected the input data region, then it will be
+        used if ``region=None``. Otherwise, you must specify the grid region.
+
+        Use the *dims* and *data_names* arguments to set custom names for the
+        dimensions and the data field(s) in the output :class:`xarray.Dataset`.
+        Default names are provided.
+
+        Parameters
+        ----------
+        region : list
+            The boundaries (``[W, E, S, N]``) of a given region in Cartesian or
+            geographic coordinates.
+        shape : tuple
+            The number of points in South-North and West-East directions,
+            respectively.
+        dims : list or None
+            The names of the northing and easting data dimensions,
+            respectively, in the output grid. Defaults to
+            ``['northing', 'easting']`` for Cartesian grids and
+            ``['latitude', 'longitude']`` for geographic grids.
+        data_names : list of None
+            The name(s) of the data variables in the output grid. Defaults to
+            ``['scalars']`` for scalar data,
+            ``['east_component', 'north_component']`` for 2D vector data, and
+            ``['east_component', 'north_component', 'vertical_component']`` for
+            3D vector data.
+
+        Returns
+        -------
+        grid : xarray.Dataset
+            The interpolated grid. Metadata about the interpolator is written
+            to the ``attrs`` attribute.
+
+        """
+        dims = get_dims(self, dims)
+        data_names = get_data_names(self, data_names)
+        region = get_region(self, region)
+        easting, northing = grid_coordinates(region, shape)
+        data = check_data(self.predict(easting, northing))
+        coords = {dims[0]: easting[0, :], dims[1]: northing[:, 0]}
+        attrs = {'metadata': 'Generated by: {}.'.format(repr(self))}
+        data_vars = {}
+        for data_array, data_name in zip(data, data_names):
+            data_vars[data_name] = (dims, data_array, attrs)
+        return xr.Dataset(data_vars, coords=coords, attrs=attrs)
+
+    def scatter(self, region=None, size=300, random_state=None, dims=None,
+                data_names=None):
+        """
+        Interpolate values onto a random scatter of points.
+
+        If the interpolator collected the input data region, then it will be
+        used if ``region=None``. Otherwise, you must specify the grid region.
+
+        Use the *dims* and *data_names* arguments to set custom names for the
+        dimensions and the data field(s) in the output
+        :class:`pandas.DataFrame`. Default names are provided.
+
+        Parameters
+        ----------
+        region : list
+            The boundaries (``[W, E, S, N]``) of a given region in Cartesian or
+            geographic coordinates.
+        size : int
+            The number of points to generate.
+        random_state : numpy.random.RandomState or an int seed
+            A random number generator used to define the state of the random
+            permutations. Use a fixes seed to make sure computations are
+            reproducible.
+        dims : list or None
+            The names of the northing and easting data dimensions,
+            respectively, in the output dataframe. Defaults to
+            ``['northing', 'easting']`` for Cartesian grids and
+            ``['latitude', 'longitude']`` for geographic grids.
+        data_names : list of None
+            The name(s) of the data variables in the output dataframe. Defaults
+            to ``['scalars']`` for scalar data,
+            ``['east_component', 'north_component']`` for 2D vector data, and
+            ``['east_component', 'north_component', 'vertical_component']`` for
+            3D vector data.
+
+        Returns
+        -------
+        table : pandas.DataFrame
+            The interpolated values on a random set of points.
+
+        """
+        dims = get_dims(self, dims)
+        data_names = get_data_names(self, data_names)
+        region = get_region(self, region)
+        east, north = scatter_points(region, size, random_state)
+        data = check_data(self.predict(east, north))
+        column_names = [dim for dim in dims]
+        column_names.extend(data_names)
+        columns = [north, east]
+        columns.extend(data)
+        table = pd.DataFrame(
+            {name: value for name, value in zip(column_names, columns)})
+        return table
+
+    def profile(self, point1, point2, size, dims=None, data_names=None):
+        """
+        Interpolate data along a profile between two points.
+
+        Generates the profile using a straight line if the interpolator assumes
+        Cartesian data or a great circle if geographic data.
+
+        Use the *dims* and *data_names* arguments to set custom names for the
+        dimensions and the data field(s) in the output
+        :class:`pandas.DataFrame`. Default names are provided.
+
+        Includes the calculated distance to *point1* for each data point in the
+        profile.
+
+        Parameters
+        ----------
+        point1 : tuple
+            The easting and northing coordinates, respectively, of the first
+            point.
+        point2 : tuple
+            The easting and northing coordinates, respectively, of the second
+            point.
+        size : int
+            The number of points to generate.
+        dims : list or None
+            The names of the northing and easting data dimensions,
+            respectively, in the output dataframe. Defaults to
+            ``['northing', 'easting']`` for Cartesian grids and
+            ``['latitude', 'longitude']`` for geographic grids.
+        data_names : list of None
+            The name(s) of the data variables in the output dataframe. Defaults
+            to ``['scalars']`` for scalar data,
+            ``['east_component', 'north_component']`` for 2D vector data, and
+            ``['east_component', 'north_component', 'vertical_component']`` for
+            3D vector data.
+
+        Returns
+        -------
+        table : pandas.DataFrame
+            The interpolated values along the profile.
+
+        """
+        coordsys = getattr(self, 'coordinate_system', 'cartesian')
+        dims = get_dims(self, dims)
+        data_names = get_data_names(self, data_names)
+        east, north, distances = profile_coordinates(
+            point1, point2, size, coordinate_system=coordsys)
+        data = check_data(self.predict(east, north))
+        column_names = [dim for dim in dims]
+        column_names.append('distance')
+        column_names.extend(data_names)
+        columns = [north, east, distances]
+        columns.extend(data)
+        table = pd.DataFrame(
+            {name: value for name, value in zip(column_names, columns)})
+        return table
diff --git a/verde/datasets/__init__.py b/verde/datasets/__init__.py
new file mode 100644
index 0000000..3253b02
--- /dev/null
+++ b/verde/datasets/__init__.py
@@ -0,0 +1,4 @@
+"""
+Loading test data and generating synthetic datasets.
+"""
+from .synthetic import CheckerBoard
diff --git a/verde/datasets/synthetic.py b/verde/datasets/synthetic.py
new file mode 100644
index 0000000..be0ad3a
--- /dev/null
+++ b/verde/datasets/synthetic.py
@@ -0,0 +1,133 @@
+"""
+Generators of synthetic datasets.
+"""
+import numpy as np
+from sklearn.utils.validation import check_is_fitted
+
+from ..base import BaseGridder
+from ..utils import check_region
+
+
+class CheckerBoard(BaseGridder):
+    r"""
+    Generate synthetic data in a checkerboard pattern.
+
+    The mathematical model is:
+
+    .. math::
+
+        f(e, n) = a
+        \sin\left(\frac{2\pi}{w_e} e\right)
+        \cos\left(\frac{2\pi}{w_n} n\right)
+
+    in which :math:`e` is the easting coordinate, :math:`n` is the northing
+    coordinate, :math:`a` is the amplitude, and :math:`w_e` and :math:`w_n` are
+    the wavelengths in the east and north directions, respectively.
+
+    The model will evaluated on random points or on a regular grid depending on
+    the value of *scatter*.
+
+    Parameters
+    ----------
+    amplitude : float
+        The amplitude of the checkerboard undulations.
+    w_east : float
+        The wavelength in the east direction. Defaults to half of the West-East
+        size of the evaluating region.
+    w_north : float
+        The wavelength in the north direction. Defaults to half of the
+        South-North size of the evaluating region.
+
+    Examples
+    --------
+
+    >>> # Lower printing precision to shorten this example
+    >>> import pandas as pd; pd.set_option('precision', 1)
+    >>> import xarray as xr; _ = xr.set_options(display_width=60)
+    >>> synth = CheckerBoard().fit()
+    >>> # Default values for the region and wavelengths are selected by fit()
+    >>> print(synth)
+    CheckerBoard(amplitude=1000, w_east=2500.0, w_north=2500.0)
+    >>> # grid produces an xarray.Dataset with data on a regular grid
+    >>> grid = synth.grid()
+    >>> print(grid)
+    <xarray.Dataset>
+    Dimensions:   (easting: 101, northing: 101)
+    Coordinates:
+      * northing  (northing) float64 0.0 50.0 100.0 150.0 ...
+      * easting   (easting) float64 0.0 50.0 100.0 150.0 ...
+    Data variables:
+        scalars   (northing, easting) float64 0.0 125.3 ...
+    Attributes:
+        metadata:  Generated by: CheckerBoard(amplitude=1000,...
+    >>> # Use the random_state argument to seed the random number generator
+    >>> table = synth.scatter(random_state=0)
+    >>> # scatter and profile generate pandas.DataFrame
+    >>> print(table.head())
+       easting  northing  scalars
+    0   2744.1    4532.8    222.3
+    1   3575.9    3870.2   -404.4
+    2   3013.8    1665.7   -482.6
+    3   2724.4     405.5    280.2
+    4   2118.3    2036.2   -322.8
+    >>> profile = synth.profile(point1=(0, 0), point2=(2500, 2500), size=100)
+    >>> print(profile.head())
+       distance  easting  northing  scalars
+    0       0.0      0.0       0.0      0.0
+    1      35.7     25.3      25.3     63.3
+    2      71.4     50.5      50.5    125.6
+    3     107.1     75.8      75.8    185.8
+    4     142.8    101.0     101.0    243.1
+
+    """
+
+    def __init__(self, amplitude=1000, w_east=None, w_north=None):
+        self.amplitude = amplitude
+        self.w_east = w_east
+        self.w_north = w_north
+
+    def fit(self, region=(0, 5000, 0, 5000)):
+        """
+        Set the region in which the checkerboard will be evaluated.
+
+        If the wavelengths are not supplied, will set them to half of the
+        region extent in each dimension.
+
+        Parameters
+        ----------
+        region : list
+            The boundaries (``[W, E, S, N]``) of a given region in Cartesian or
+            geographic coordinates.
+
+        """
+        check_region(region)
+        w, e, s, n = region
+        if self.w_east is None:
+            self.w_east = (e - w)/2
+        if self.w_north is None:
+            self.w_north = (n - s)/2
+        self.region_ = region
+        return self
+
+    def predict(self, easting, northing):
+        """
+        Evaluate the checkerboard function on a given set of points.
+
+        Parameters
+        ----------
+        easting : array
+            The values of the West-East coordinates of each point.
+        northing : array
+            The values of the South-North coordinates of each point.
+
+        Returns
+        -------
+        data : array
+            The evaluated checkerboard function.
+
+        """
+        check_is_fitted(self, ['region_'])
+        data = (self.amplitude *
+                np.sin((2*np.pi/self.w_east)*easting) *
+                np.cos((2*np.pi/self.w_north)*northing))
+        return data


Test patch:
diff --git a/verde/tests/test_base.py b/verde/tests/test_base.py
new file mode 100644
index 0000000..5d73f48
--- /dev/null
+++ b/verde/tests/test_base.py
@@ -0,0 +1,137 @@
+# pylint: disable=unused-argument
+"""
+Test the base classes and their utility functions.
+"""
+import pytest
+import numpy as np
+import numpy.testing as npt
+
+from ..base import BaseGridder
+from ..base.gridder import get_dims, get_data_names, get_region
+from .. import grid_coordinates
+
+
+def test_get_dims():
+    "Tests that get_dims returns the expected results"
+    grd = BaseGridder()
+    assert get_dims(grd, dims=None) == ('northing', 'easting')
+    assert get_dims(grd, dims=('john', 'paul')) == ('john', 'paul')
+
+    grd.coordinate_system = 'geographic'
+    assert get_dims(grd, dims=None) == ('latitude', 'longitude')
+    assert get_dims(grd, dims=('john', 'paul')) == ('john', 'paul')
+
+    grd.coordinate_system = 'cartesian'
+    assert get_dims(grd, dims=None) == ('northing', 'easting')
+    assert get_dims(grd, dims=('john', 'paul')) == ('john', 'paul')
+
+    # Make sure the given dims is returned no matter what
+    grd.coordinate_system = 'an invalid system'
+    assert get_dims(grd, dims=('john', 'paul')) == ('john', 'paul')
+
+
+def test_get_dims_fails():
+    "Check if fails for invalid coordinate system"
+    grd = BaseGridder()
+    with pytest.raises(ValueError):
+        grd.coordinate_system = 'Cartesian'
+        get_dims(grd, dims=None)
+    with pytest.raises(ValueError):
+        grd.coordinate_system = 'Geographic'
+        get_dims(grd, dims=None)
+    with pytest.raises(ValueError):
+        grd.coordinate_system = 'some totally not valid name'
+        get_dims(grd, dims=None)
+
+
+def test_get_data_names():
+    "Tests that get_data_names returns the expected results"
+    grd = BaseGridder()
+    assert get_data_names(grd, data_names=None) == ('scalars',)
+    assert get_data_names(grd, data_names=('a', 'b')) == ('a', 'b')
+
+    grd.data_type = 'scalar'
+    assert get_data_names(grd, data_names=None) == ('scalars',)
+    assert get_data_names(grd, data_names=('a', 'b')) == ('a', 'b')
+
+    grd.data_type = 'vector2d'
+    assert get_data_names(grd, data_names=None) == ('east_component',
+                                                    'north_component')
+    assert get_data_names(grd, data_names=('a', 'b')) == ('a', 'b')
+
+    grd.data_type = 'vector3d'
+    assert get_data_names(grd, data_names=None) == ('east_component',
+                                                    'north_component',
+                                                    'vertical_component')
+    assert get_data_names(grd, data_names=('a', 'b')) == ('a', 'b')
+
+    # Make sure the given dims is returned no matter what
+    grd.data_type = 'an invalid type'
+    assert get_data_names(grd, data_names=('a', 'b')) == ('a', 'b')
+
+
+def test_get_data_names_fails():
+    "Check if fails for invalid data types"
+    grd = BaseGridder()
+    with pytest.raises(ValueError):
+        grd.data_type = 'Scalars'
+        get_data_names(grd, data_names=None)
+    with pytest.raises(ValueError):
+        grd.data_type = 'Vector3d'
+        get_data_names(grd, data_names=None)
+    with pytest.raises(ValueError):
+        grd.data_type = 'some totally not valid name'
+        get_data_names(grd, data_names=None)
+
+
+def test_get_region():
+    "Check if get_region finds the correct region"
+    grd = BaseGridder()
+    assert get_region(grd, region=(1, 2, 3, 4)) == (1, 2, 3, 4)
+    with pytest.raises(ValueError):
+        get_region(grd, region=None)
+    grd.region_ = (5, 6, 7, 8)
+    assert get_region(grd, region=None) == (5, 6, 7, 8)
+    assert get_region(grd, region=(1, 2, 3, 4)) == (1, 2, 3, 4)
+
+
+def test_basegridder():
+    "Test basic functionality of BaseGridder"
+
+    with pytest.raises(NotImplementedError):
+        BaseGridder().predict(None, None)
+
+    class TestGridder(BaseGridder):
+        "A test gridder"
+
+        def __init__(self, constant=0):
+            self.constant = constant
+
+        def fit(self, easting, northing, data):
+            "Get the data mean"
+            self.mean_ = data.mean()
+            return self
+
+        def predict(self, easting, northing):
+            "Predict the data mean"
+            return np.ones_like(easting)*self.mean_ + self.constant
+
+    grd = TestGridder()
+    assert repr(grd) == 'TestGridder(constant=0)'
+    grd.constant = 1000
+    assert repr(grd) == 'TestGridder(constant=1000)'
+
+    region = (0, 10, -10, -5)
+    shape = (50, 50)
+    east, north = grid_coordinates(region, shape)
+    data = np.ones_like(east)
+    grd = TestGridder().fit(east, north, data)
+
+    with pytest.raises(ValueError):
+        # A region should be given because it hasn't been assigned by
+        # TestGridder
+        grd.grid()
+
+    npt.assert_allclose(grd.grid(region, shape).scalars.values, data)
+    npt.assert_allclose(grd.scatter(region, 100).scalars, 1)
+    npt.assert_allclose(grd.profile((0, 100), (20, 10), 100).scalars, 1)