Repository: creare-com/podpac

Issue:
Algorithm Convolution node kernel should be a labelled array
**Description**
Right now the kernel for the convolution algorithm nodes is very difficult to use, and the results depend on the order of the coords used to evaluate the Node. 

**Steps to Reproduce**
```
lat = [0, 1, 2, 3]
lon = [0, 1, 2]
coords = podpac.Coordinates([lat, lon], ['lat', 'lon'])
coords2 = coords.transpose('lon', 'lat')
source = podpac.data.Array(source=-np.arange(12).reshape(4, 3)**2, coordinates=coords, force_eval=True)
conv = podpac.algorithm.Convolution(source=source, kernel=[[-1, -1, 1]], force_eval=True)
assert np.all(conv.eval(coords) == conv.eval(coords2))
```

**Expected Behavior**
I'd expect the results to not have to depend on the order of the input coordinates

**Observed Behavior**
The results DO depend on the input coordinates



Discussion:

Patch:
diff --git a/podpac/algorithm.py b/podpac/algorithm.py
index 54e3d3a6..ec8be6a0 100644
--- a/podpac/algorithm.py
+++ b/podpac/algorithm.py
@@ -28,4 +28,4 @@ from podpac.core.algorithm.coord_select import (
     YearSubstituteCoordinates,
     TransformTimeUnits,
 )
-from podpac.core.algorithm.signal import Convolution, SpatialConvolution, TimeConvolution
+from podpac.core.algorithm.signal import Convolution
diff --git a/podpac/core/algorithm/signal.py b/podpac/core/algorithm/signal.py
index a5e10a61..e09f2bca 100644
--- a/podpac/core/algorithm/signal.py
+++ b/podpac/core/algorithm/signal.py
@@ -63,12 +63,15 @@ class Convolution(UnaryAlgorithm):
     ----------
     source : podpac.Node
         Source node on which convolution will be performed. 
-    kernel : np.ndarray
-        The convolution kernel
-    kernel_ndim : int
-        Number of dimensions of the kernel
+    kernel : np.ndarray, optional
+        The convolution kernel. This kernel must have the same number of dimensions as the source data outputs
+    kernel_dims : list, optional
+        A list of the dimensions for the kernel axes. The dimensions in this list must match the 
+        coordinates in the source, or contain additional dimensions, and the order does not need to match.
+        Any extra dimensions are summed out. 
     kernel_type : str, optional
-        If kernel is not defined, kernel_type will create a kernel based on the inputs. 
+        If kernel is not defined, kernel_type will create a kernel based on the inputs, and it will have the
+        same number of axes as kernel_ndim.
         The format for the created  kernels is '<kernel_type>, <kernel_size>, <kernel_params>'.
         Any kernel defined in `scipy.signal` as well as `mean` can be used. For example:
         kernel_type = 'mean, 8' or kernel_type = 'gaussian,16,8' are both valid. 
@@ -76,21 +79,29 @@ class Convolution(UnaryAlgorithm):
     """
 
     kernel = ArrayTrait(dtype=float).tag(attr=True)
+    kernel_dims = tl.List().tag(attr=True)
 
-    def _first_init(self, kernel=None, kernel_type=None, kernel_ndim=None, **kwargs):
-        if kernel is not None:
-            if kernel_type is not None:
-                raise TypeError("Convolution expected 'kernel' or 'kernel_type', not both")
+    def _first_init(self, kernel=None, kernel_dims=None, kernel_type=None, kernel_ndim=None, **kwargs):
+        if kernel_dims is None:
+            raise TypeError("Convolution expected 'kernel_dims' to be specified when giving a 'kernel' array")
+
+        if kernel is not None and kernel_type is not None:
+            raise TypeError("Convolution expected 'kernel' or 'kernel_type', not both")
 
         if kernel is None:
             if kernel_type is None:
                 raise TypeError("Convolution requires 'kernel' array or 'kernel_type' string")
-            if kernel_ndim is None:
-                raise TypeError("Convolution requires 'kernel_ndim' when supplying a 'kernel_type' string")
+            kernel = self._make_kernel(kernel_type, len(kernel_dims))
 
-            kernel = self._make_kernel(kernel_type, kernel_ndim)
+        if len(kernel_dims) != len(np.array(kernel).shape):
+            raise TypeError(
+                "The kernel_dims should contain the same number of dimensions as the number of axes in 'kernel', but len(kernel_dims) {} != len(kernel.shape) {}".format(
+                    len(kernel_dims), len(np.array(kernel).shape)
+                )
+            )
 
         kwargs["kernel"] = kernel
+        kwargs["kernel_dims"] = kernel_dims
         return super(Convolution, self)._first_init(**kwargs)
 
     @common_doc(COMMON_DOC)
@@ -109,21 +120,20 @@ class Convolution(UnaryAlgorithm):
         -------
         {eval_return}
         """
-        # This should be aligned with coordinates' dimension order
         # The size of this kernel is used to figure out the expanded size
         full_kernel = self._get_full_kernel(coordinates)
 
-        if full_kernel.ndim != coordinates.ndim:
-            raise ValueError(
-                "Cannot evaluate coordinates, kernel and coordinates ndims mismatch (%d != %d)"
-                % (full_kernel.ndim, coordinates.ndim)
-            )
-
         # expand the coordinates
+        # The next line effectively drops extra coordinates, so we have to add those later in case the
+        # source is some sort of reduction Node.
+        kernel_dims = [kd for kd in coordinates.dims if kd in self.kernel_dims]
+        missing_dims = [kd for kd in coordinates.dims if kd not in self.kernel_dims]
+
         exp_coords = []
         exp_slice = []
-        for dim, s in zip(coordinates.dims, full_kernel.shape):
+        for dim in kernel_dims:
             coord = coordinates[dim]
+            s = full_kernel.shape[self.kernel_dims.index(dim)]
             if s == 1 or not isinstance(coord, UniformCoordinates1d):
                 exp_coords.append(coord)
                 exp_slice.append(slice(None))
@@ -142,6 +152,12 @@ class Convolution(UnaryAlgorithm):
                 )
             )
             exp_slice.append(slice(-s_start, -s_end))
+
+        # Add missing dims back in -- this is needed in case the source is a reduce node.
+        exp_coords += [coordinates[d] for d in missing_dims]
+        # exp_slice += [slice(None) for d in missing_dims]
+
+        # Create expanded coordinates
         exp_slice = tuple(exp_slice)
         expanded_coordinates = Coordinates(exp_coords, crs=coordinates.crs, validate_crs=False)
 
@@ -151,29 +167,41 @@ class Convolution(UnaryAlgorithm):
         # evaluate source using expanded coordinates, convolve, and then slice out original coordinates
         source = self.source.eval(expanded_coordinates)
 
-        # The source may not contain all of the same dimensions as the requested coordinates, which means
-        # The kernel could be too big. So, let's detect this and fix the problem.
-        extra_dims = [d for d in expanded_coordinates.dims if d not in source.dims]
-        if extra_dims:
-            coordinates = coordinates.drop(extra_dims)
+        # Check dimensions
+        if any([d not in kernel_dims for d in source.dims if d != "output"]):
+            raise ValueError(
+                "Kernel dims must contain all of the dimensions in source but not all of {} is in kernel_dims={}".format(
+                    source.dims, kernel_dims
+                )
+            )
 
-            extra_dims_ind = [expanded_coordinates.dims.index(d) for d in extra_dims]
-            for ind in extra_dims_ind:
-                full_kernel = full_kernel.sum(axis=ind)
-                exp_list = list(exp_slice)
-                exp_list.pop(ind)
-                exp_slice = tuple(exp_list)
+        full_kernel = self._get_full_kernel(coordinates)
+        kernel_dims = self.kernel_dims
+        sum_dims = [d for d in kernel_dims if d not in source.dims]
+        # Sum out the extra dims
+        full_kernel = full_kernel.sum(axis=tuple([kernel_dims.index(d) for d in sum_dims]))
+        kernel_dims = [d for d in kernel_dims if d in source.dims]
+
+        # Put the kernel axes in the correct order
+        # The (if d in kernel_dims) takes care of "output", which can be optionally present
+        full_kernel = full_kernel.transpose([kernel_dims.index(d) for d in source.dims if (d in kernel_dims)])
 
         if np.any(np.isnan(source)):
             method = "direct"
         else:
             method = "auto"
 
-        if "output" not in source.dims:
+        if ("output" not in source.dims) or ("output" in source.dims and "output" in kernel_dims):
             result = scipy.signal.convolve(source, full_kernel, mode="same", method=method)
         else:
             # source with multiple outputs
-            result = np.array([scipy.signal.convolve(src, full_kernel, mode="same", method=method) for src in source])
+            result = np.stack(
+                [
+                    scipy.signal.convolve(source.sel(output=output), full_kernel, mode="same", method=method)
+                    for output in source.coords["output"]
+                ],
+                axis=source.dims.index("output"),
+            )
         result = result[exp_slice]
 
         if output is None:
@@ -203,69 +231,3 @@ class Convolution(UnaryAlgorithm):
         """{full_kernel}
         """
         return self.kernel
-
-
-class TimeConvolution(Convolution):
-    """Compute a temporal convolution over a source node.
-    
-    Attributes
-    ----------
-    kernel_ndim : int
-        Value is 1. Should not be modified.
-    """
-
-    kernel = ArrayTrait(dtype=float, ndim=1).tag(attr=True)
-
-    def _first_init(self, kernel=None, kernel_type=None, kernel_ndim=1, **kwargs):
-        return super(TimeConvolution, self)._first_init(
-            kernel=kernel, kernel_type=kernel_type, kernel_ndim=kernel_ndim, **kwargs
-        )
-
-    def _get_full_kernel(self, coordinates):
-        """{full_kernel}
-        
-        Raises
-        ------
-        ValueError
-            If source data doesn't have time dimension.
-        """
-        if "time" not in coordinates.dims:
-            raise ValueError("cannot compute time convolution with time-independent coordinates")
-        if "lat" not in coordinates.dims and "lon" not in coordinates.dims:
-            return self.kernel
-
-        kernel = np.array([[self.kernel]])
-        kernel = xr.DataArray(kernel, dims=("lat", "lon", "time"))
-        kernel = kernel.transpose(*coordinates.dims)
-        return kernel.data
-
-
-class SpatialConvolution(Convolution):
-    """Compute a lat-lon convolution over a source node.
-    
-    Attributes
-    ----------
-    kernel_ndim : int
-        Value is 2. Should not be modified.
-    """
-
-    kernel = ArrayTrait(dtype=float, ndim=2).tag(attr=True)
-
-    def _first_init(self, kernel=None, kernel_type=None, kernel_ndim=2, **kwargs):
-        return super(SpatialConvolution, self)._first_init(
-            kernel=kernel, kernel_type=kernel_type, kernel_ndim=kernel_ndim, **kwargs
-        )
-
-    def _get_full_kernel(self, coordinates):
-        """{full_kernel}
-        """
-        if "lat" not in coordinates.dims or "lon" not in coordinates.dims:
-            raise ValueError("cannot compute spatial convolution with coordinate dims %s" % coordinates.dims)
-        if "time" not in coordinates.dims:
-            return self.kernel
-
-        kernel = np.array([self.kernel]).T
-        kernel = xr.DataArray(kernel, dims=("lat", "lon", "time"))
-        kernel = kernel.transpose(*coordinates.dims)
-
-        return kernel.data
diff --git a/setup.cfg b/setup.cfg
index c395494a..a7643c55 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
 [tool:pytest]
-addopts = -v --color=yes -m "not integration"
+#addopts = -v --color=yes -m "not integration"
 testpaths = podpac
 filterwarnings =
   ignore::FutureWarning:traitlets


Test patch:
diff --git a/podpac/core/algorithm/test/test_signal.py b/podpac/core/algorithm/test/test_signal.py
index 38362067..6fe1a679 100644
--- a/podpac/core/algorithm/test/test_signal.py
+++ b/podpac/core/algorithm/test/test_signal.py
@@ -10,37 +10,46 @@ import podpac
 from podpac import Coordinates, clinspace, crange
 from podpac.algorithm import Arange
 from podpac.data import Array
-from podpac.core.algorithm.signal import Convolution, TimeConvolution, SpatialConvolution
+from podpac.core.algorithm.signal import Convolution
 
 
 class TestConvolution(object):
     def test_init_kernel(self):
-        node = Convolution(source=Arange(), kernel=[1, 2, 1])
+        node = Convolution(source=Arange(), kernel=[1, 2, 1], kernel_dims=["lat"])
         assert_equal(node.kernel, [1, 2, 1])
 
-        node = Convolution(source=Arange(), kernel_type="mean, 5", kernel_ndim=2)
+        node = Convolution(source=Arange(), kernel_type="mean, 5", kernel_dims=["lat", "lon"])
         assert node.kernel.shape == (5, 5)
         assert np.all(node.kernel == 0.04)
 
-        node = Convolution(source=Arange(), kernel_type="mean, 5", kernel_ndim=3)
+        node = Convolution(source=Arange(), kernel_type="mean, 5", kernel_dims=["lat", "lon", "time"])
         assert node.kernel.shape == (5, 5, 5)
         assert np.all(node.kernel == 0.008)
 
-        node = Convolution(source=Arange(), kernel_type="gaussian, 3, 1", kernel_ndim=2)
+        node = Convolution(source=Arange(), kernel_type="gaussian, 3, 1", kernel_dims=["lat", "lon"])
         assert node.kernel.shape == (3, 3)
 
         # kernel and kernel_type invalid
         with pytest.raises(TypeError, match="Convolution expected 'kernel' or 'kernel_type', not both"):
-            Convolution(source=Arange(), kernel=[1, 2, 1], kernel_type="mean, 5")
+            Convolution(source=Arange(), kernel=[1, 2, 1], kernel_type="mean, 5", kernel_dims=["lat", "lon"])
 
         # kernel or kernel_type required
         with pytest.raises(TypeError, match="Convolution requires 'kernel' array or 'kernel_type' string"):
-            Convolution(source=Arange())
+            Convolution(source=Arange(), kernel_dims=["lat", "lon"])
 
-        # ndim required
-        with pytest.raises(TypeError, match="Convolution requires 'kernel_ndim'"):
+        # kernel_dims required
+        with pytest.raises(
+            TypeError, match="Convolution expected 'kernel_dims' to be specified when giving a 'kernel' array"
+        ):
             Convolution(source=Arange(), kernel_type="mean, 5")
 
+        # kernel_dims correct number of entries
+        with pytest.raises(
+            TypeError,
+            match="The kernel_dims should contain the same number of dimensions as the number of axes in 'kernel', but ",
+        ):
+            Convolution(source=Arange(), kernel=[[[1, 2]]], kernel_dims=["lat"])
+
     def test_eval(self):
         lat = clinspace(45, 66, 30, name="lat")
         lon = clinspace(-80, 70, 40, name="lon")
@@ -50,17 +59,24 @@ class TestConvolution(object):
         kernel2d = [[1, 2, 1]]
         kernel3d = [[[1, 2, 1]]]
 
-        node1d = Convolution(source=Arange(), kernel=kernel1d)
-        node2d = Convolution(source=Arange(), kernel=kernel2d)
-        node3d = Convolution(source=Arange(), kernel=kernel3d)
+        node1d = Convolution(source=Arange(), kernel=kernel1d, kernel_dims=["time"])
+        node2d = Convolution(source=Arange(), kernel=kernel2d, kernel_dims=["lat", "lon"])
+        node3d = Convolution(source=Arange(), kernel=kernel3d, kernel_dims=["lon", "lat", "time"])
 
         o = node1d.eval(Coordinates([time]))
         o = node2d.eval(Coordinates([lat, lon]))
         o = node3d.eval(Coordinates([lat, lon, time]))
 
-        with pytest.raises(ValueError, match="Cannot evaluate coordinates, kernel and coordinates ndims mismatch"):
+        with pytest.raises(
+            ValueError, match="Kernel dims must contain all of the dimensions in source but not all of "
+        ):
             node2d.eval(Coordinates([lat, lon, time]))
 
+        with pytest.raises(
+            ValueError, match="Kernel dims must contain all of the dimensions in source but not all of "
+        ):
+            node2d.eval(Coordinates([lat, time]))
+
     def test_eval_multiple_outputs(self):
 
         lat = clinspace(45, 66, 30, name="lat")
@@ -68,8 +84,18 @@ class TestConvolution(object):
         kernel = [[1, 2, 1]]
         coords = Coordinates([lat, lon])
         multi = Array(source=np.random.random(coords.shape + (2,)), coordinates=coords, outputs=["a", "b"])
-        node = Convolution(source=multi, kernel=kernel)
-        o = node.eval(Coordinates([lat, lon]))
+        node = Convolution(source=multi, kernel=kernel, kernel_dims=["lat", "lon"])
+        o1 = node.eval(Coordinates([lat, lon]))
+
+        kernel = [[[1, 2]]]
+        coords = Coordinates([lat, lon])
+        multi = Array(source=np.random.random(coords.shape + (2,)), coordinates=coords, outputs=["a", "b"])
+        node1 = Convolution(source=multi, kernel=kernel, kernel_dims=["lat", "lon", "output"], force_eval=True)
+        node2 = Convolution(source=multi, kernel=kernel[0], kernel_dims=["lat", "lon"], force_eval=True)
+        o1 = node1.eval(Coordinates([lat, lon]))
+        o2 = node2.eval(Coordinates([lat, lon]))
+
+        assert np.any(o2.data != o1.data)
 
     def test_eval_nan(self):
         lat = clinspace(45, 66, 30, name="lat")
@@ -79,7 +105,7 @@ class TestConvolution(object):
         data = np.ones(coords.shape)
         data[10, 10] = np.nan
         source = Array(source=data, coordinates=coords)
-        node = Convolution(source=source, kernel=[[1, 2, 1]])
+        node = Convolution(source=source, kernel=[[1, 2, 1]], kernel_dims=["lat", "lon"])
 
         o = node.eval(coords[8:12, 7:13])
 
@@ -88,7 +114,7 @@ class TestConvolution(object):
         lon = clinspace(-80, 70, 40, name="lon")
         coords = Coordinates([lat, lon])
 
-        node = Convolution(source=Arange(), kernel=[[1, 2, 1]], kernel_ndim=2)
+        node = Convolution(source=Arange(), kernel=[[1, 2, 1]], kernel_dims=["lat", "lon"])
 
         a = node.create_output_array(coords)
         o = node.eval(coords, output=a)
@@ -103,7 +129,7 @@ class TestConvolution(object):
 
             # normal version
             a = Arange()
-            node = Convolution(source=a, kernel=[[1, 2, 1]], kernel_ndim=2)
+            node = Convolution(source=a, kernel=[[1, 2, 1]], kernel_dims=["lat", "lon"])
             node.eval(coords)
 
             assert node.source is a
@@ -112,7 +138,7 @@ class TestConvolution(object):
             podpac.settings["DEBUG"] = True
 
             a = Arange()
-            node = Convolution(source=a, kernel=[[1, 2, 1]], kernel_ndim=2)
+            node = Convolution(source=a, kernel=[[1, 2, 1]], kernel_dims=["lat", "lon"])
             node.eval(coords)
 
             assert node.source is not a
@@ -121,82 +147,26 @@ class TestConvolution(object):
             assert node.source._requested_coordinates != coords
             assert a._requested_coordinates is None
 
+    def test_extra_kernel_dims(self):
+        lat = clinspace(45, 66, 8, name="lat")
+        lon = clinspace(-80, 70, 16, name="lon")
+        coords = Coordinates([lat, lon])
 
-class TestSpatialConvolution(object):
-    def test_init_kernel(self):
-        node = SpatialConvolution(source=Arange(), kernel=[[1, 2, 1]])
-        assert_equal(node.kernel, [[1, 2, 1]])
-
-        node = SpatialConvolution(source=Arange(), kernel_type="mean, 5")
-        assert node.kernel.shape == (5, 5)
-        assert np.all(node.kernel == 0.04)
-
-        node = SpatialConvolution(source=Arange(), kernel_type="gaussian, 3, 1")
-        assert node.kernel.shape == (3, 3)
-
-        # kernel and kernel_type invalid
-        with pytest.raises(TypeError, match="Convolution expected 'kernel' or 'kernel_type', not both"):
-            SpatialConvolution(source=Arange(), kernel=[[1, 2, 1]], kernel_type="mean, 5")
-
-        # kernel or kernel_type required
-        with pytest.raises(TypeError, match="Convolution requires 'kernel' array or 'kernel_type' string"):
-            SpatialConvolution(source=Arange())
-
-        # kernel ndim must be 2
-        with pytest.raises(tl.TraitError):
-            SpatialConvolution(source=Arange(), kernel=[1, 2, 1])
-
-        with pytest.raises(tl.TraitError):
-            SpatialConvolution(source=Arange(), kernel=[[[1, 2, 1]]])
-
-    def test_eval(self):
-        lat = clinspace(45, 66, 30, name="lat")
-        lon = clinspace(-80, 70, 40, name="lon")
-        time = crange("2017-09-01", "2017-10-31", "1,D", name="time")
-
-        node = SpatialConvolution(source=Arange(), kernel=[[1, 2, 1]])
-        o = node.eval(Coordinates([lat, lon]))
-        o = node.eval(Coordinates([lat, lon, time]))
-
-        with pytest.raises(ValueError, match="cannot compute spatial convolution with coordinate dims"):
-            node.eval(Coordinates([time]))
-
-
-class TestTimeConvolution(object):
-    def test_init_kernel(self):
-        node = TimeConvolution(source=Arange(), kernel=[1, 2, 1])
-        assert_equal(node.kernel, [1, 2, 1])
-
-        node = TimeConvolution(source=Arange(), kernel_type="mean, 5")
-        assert node.kernel.shape == (5,)
-        assert np.all(node.kernel == 0.2)
-
-        node = TimeConvolution(source=Arange(), kernel_type="gaussian, 3, 1")
-        assert node.kernel.shape == (3,)
-
-        # kernel and kernel_type invalid
-        with pytest.raises(TypeError, match="Convolution expected 'kernel' or 'kernel_type', not both"):
-            TimeConvolution(source=Arange(), kernel=[1, 2, 1], kernel_type="mean, 5")
-
-        # kernel or kernel_type required
-        with pytest.raises(TypeError, match="Convolution requires 'kernel' array or 'kernel_type' string"):
-            TimeConvolution(source=Arange())
-
-        # kernel ndim must be 2
-        with pytest.raises(tl.TraitError):
-            TimeConvolution(source=Arange(), kernel=[[1, 2, 1]])
-
-        with pytest.raises(tl.TraitError):
-            TimeConvolution(source=Arange(), kernel=[[[1, 2, 1]]])
+        node = Convolution(source=Arange(), kernel=[[[1, 2, 1]]], kernel_dims=["time", "lat", "lon"])
+        o = node.eval(coords)
 
-    def test_eval(self):
-        lat = clinspace(45, 66, 30, name="lat")
-        lon = clinspace(-80, 70, 40, name="lon")
-        time = crange("2017-09-01", "2017-10-31", "1,D", name="time")
+    def test_coords_order(self):
+        lat = clinspace(-0.25, 1.25, 7, name="lat")
+        lon = clinspace(-0.125, 1.125, 11, name="lon")
+        coords = Coordinates([lat, lon])
 
-        node = TimeConvolution(source=Arange(), kernel=[1, 2, 1])
-        o = node.eval(Coordinates([time]))
-        o = node.eval(Coordinates([lat, lon, time]))
+        lat = clinspace(0, 1, 5, name="lat")
+        lon = clinspace(0, 1, 9, name="lon")
+        coords1 = Coordinates([lat, lon])
+        coords2 = Coordinates([lon, lat])
 
-        with pytest.raises(ValueError, match="cannot compute time convolution with time-independent coordinates"):
-            node.eval(Coordinates([lat, lon]))
+        source = Array(source=np.random.random(coords.shape), coordinates=coords)
+        node = Convolution(source=source, kernel=[[-1, 2, -1]], kernel_dims=["lat", "lon"], force_eval=True)
+        o1 = node.eval(coords1)
+        o2 = node.eval(coords2)
+        assert np.all(o2.data == o1.data.T)
diff --git a/podpac/core/test/test_node.py b/podpac/core/test/test_node.py
index c9c426b4..cbdf04a6 100644
--- a/podpac/core/test/test_node.py
+++ b/podpac/core/test/test_node.py
@@ -1037,9 +1037,9 @@ class TestUserDefinition(object):
                 "node": "algorithm.Arange"
             },
             "mean": {
-                "node": "algorithm.SpatialConvolution",
+                "node": "algorithm.Convolution",
                 "lookup_attrs": {"source": "a"},
-                "attrs": {"kernel_type": "mean,3"}
+                "attrs": {"kernel_type": "mean,3", "kernel_dims": ["lat", "lon"]}
             },
             "c": {
                 "node": "algorithm.Arithmetic",