diff --git a/datashader/__init__.py b/datashader/__init__.py index da620d0a9..d328f8c3a 100644 --- a/datashader/__init__.py +++ b/datashader/__init__.py @@ -12,6 +12,7 @@ from . import transfer_functions as tf # noqa (API import) from . import pandas # noqa (build backend dispatch) +from . import xarray # noqa (build backend dispatch) try: from . import dask # noqa (build backend dispatch) except ImportError: diff --git a/datashader/core.py b/datashader/core.py index 13e9d16ea..1cd5902a1 100644 --- a/datashader/core.py +++ b/datashader/core.py @@ -11,7 +11,8 @@ from collections import OrderedDict from datashader.spatial.points import SpatialPointsFrame -from .utils import Dispatcher, ngjit, calc_res, calc_bbox, orient_array, compute_coords +from .utils import Dispatcher, ngjit, calc_res, calc_bbox, orient_array, \ + compute_coords, dshape_from_xarray_dataset from .utils import get_indices, dshape_from_pandas, dshape_from_dask from .utils import Expr # noqa (API import) from .resampling import resample_2d, resample_2d_distributed @@ -564,6 +565,75 @@ def area(self, source, x, y, agg=None, axis=0, y_stack=None): return bypixel(source, self, glyph, agg) + def quadmesh(self, source, x=None, y=None, agg=None): + """Samples a recti- or curvi-linear quadmesh by canvas size and bounds. + Parameters + ---------- + source : xarray.DataArray or Dataset + The input datasource. + x, y : str + Column names for the x and y coordinates of each point. + agg : Reduction, optional + Reduction to compute. Default is ``mean()``. + Returns + ------- + data : xarray.DataArray + """ + from .glyphs import QuadMeshRectilinear, QuadMeshCurvialinear + + # Determine reduction operation + from .reductions import mean as mean_rnd + + if isinstance(source, Dataset): + if agg is None or agg.column is None: + name = list(source.data_vars)[0] + else: + name = agg.column + # Keep as dataset so that source[agg.column] works + source = source[[name]] + elif isinstance(source, DataArray): + # Make dataset so that source[agg.column] works + name = source.name + source = source.to_dataset() + else: + raise ValueError("Invalid input type") + + if agg is None: + agg = mean_rnd(name) + + if x is None and y is None: + y, x = source[name].dims + elif not x or not y: + raise ValueError("Either specify both x and y coordinates" + "or allow them to be inferred.") + yarr, xarr = source[y], source[x] + + if (yarr.ndim > 1 or xarr.ndim > 1) and xarr.dims != yarr.dims: + raise ValueError("Ensure that x- and y-coordinate arrays " + "share the same dimensions. x-coordinates " + "are indexed by %s dims while " + "y-coordinates are indexed by %s dims." % + (xarr.dims, yarr.dims)) + + if (name is not None + and agg.column is not None + and agg.column != name): + raise ValueError('DataArray name %r does not match ' + 'supplied reduction %s.' % + (source.name, agg)) + + if xarr.ndim == 1: + glyph = QuadMeshRectilinear(x, y, name) + elif xarr.ndim == 2: + glyph = QuadMeshCurvialinear(x, y, name) + else: + raise ValueError("""\ +x- and y-coordinate arrays must have 1 or 2 dimensions. + Received arrays with dimensions: {dims}""".format( + dims=list(xarr.dims))) + + return bypixel(source, self, glyph, agg) + # TODO re 'untested', below: Consider replacing with e.g. a 3x3 # array in the call to Canvas (plot_height=3,plot_width=3), then # show the output as a numpy array that has a compact @@ -910,11 +980,13 @@ def bypixel(source, canvas, glyph, agg): glyph : Glyph agg : Reduction """ - if isinstance(source, DataArray): + + # Convert 1D xarray DataArrays and DataSets into Dask DataFrames + if isinstance(source, DataArray) and source.ndim == 1: if not source.name: source.name = 'value' source = source.reset_coords() - if isinstance(source, Dataset): + if isinstance(source, Dataset) and len(source.dims) == 1: columns = list(source.coords.keys()) + list(source.data_vars.keys()) cols_to_keep = _cols_to_keep(columns, glyph, agg) source = source.drop([col for col in columns if col not in cols_to_keep]) @@ -931,6 +1003,9 @@ def bypixel(source, canvas, glyph, agg): dshape = dshape_from_pandas(source) elif isinstance(source, dd.DataFrame): dshape = dshape_from_dask(source) + elif isinstance(source, Dataset): + # Multi-dimensional Dataset + dshape = dshape_from_xarray_dataset(source) else: raise ValueError("source must be a pandas or dask DataFrame") schema = dshape.measure diff --git a/datashader/dask.py b/datashader/dask.py index b57cbdc9f..853fee6b8 100644 --- a/datashader/dask.py +++ b/datashader/dask.py @@ -3,6 +3,7 @@ import dask import pandas as pd import dask.dataframe as dd +from collections import OrderedDict from dask.base import tokenize, compute from .core import bypixel @@ -51,7 +52,7 @@ def shape_bounds_st_and_axis(df, canvas, glyph): x_axis = canvas.x_axis.compute_index(x_st, width) y_axis = canvas.y_axis.compute_index(y_st, height) - axis = [y_axis, x_axis] + axis = OrderedDict([(glyph.x_label, x_axis), (glyph.y_label, y_axis)]) return shape, bounds, st, axis diff --git a/datashader/glyphs/__init__.py b/datashader/glyphs/__init__.py index 7c2c550a0..8abcb83c3 100644 --- a/datashader/glyphs/__init__.py +++ b/datashader/glyphs/__init__.py @@ -23,4 +23,7 @@ AreaToLineAxis1Ragged, ) from .trimesh import Triangles # noqa (API import) +from .quadmesh import ( # noqa (API import) + QuadMeshRectilinear, QuadMeshCurvialinear +) from .glyph import Glyph # noqa (API import) diff --git a/datashader/glyphs/glyph.py b/datashader/glyphs/glyph.py index 324b0700f..4427eff2b 100644 --- a/datashader/glyphs/glyph.py +++ b/datashader/glyphs/glyph.py @@ -62,6 +62,22 @@ def _compute_y_bounds(ys): return minval, maxval + @staticmethod + @ngjit + def _compute_bounds_2d(vals): + minval = np.inf + maxval = -np.inf + for i in range(vals.shape[0]): + for j in range(vals.shape[1]): + v = vals[i][j] + if not np.isnan(v): + if v < minval: + minval = v + if v > maxval: + maxval = v + + return minval, maxval + def expand_aggs_and_cols(self, append): """ Create a decorator that can be used on functions that accept diff --git a/datashader/glyphs/quadmesh.py b/datashader/glyphs/quadmesh.py new file mode 100644 index 000000000..098704eca --- /dev/null +++ b/datashader/glyphs/quadmesh.py @@ -0,0 +1,314 @@ +from toolz import memoize +import numpy as np + +from datashader.glyphs.glyph import Glyph +from datashader.resampling import infer_interval_breaks +from datashader.utils import isreal, ngjit + + +class _QuadMeshLike(Glyph): + def __init__(self, x, y, name): + self.x = x + self.y = y + self.name = name + + @property + def ndims(self): + return 2 + + @property + def inputs(self): + return (self.x, self.y, self.name) + + def validate(self, in_dshape): + if not isreal(in_dshape.measure[str(self.x)]): + raise ValueError('x must be real') + elif not isreal(in_dshape.measure[str(self.y)]): + raise ValueError('y must be real') + elif not isreal(in_dshape.measure[str(self.name)]): + raise ValueError('aggregate value must be real') + + @property + def x_label(self): + return self.x + + @property + def y_label(self): + return self.y + + +class QuadMeshRectilinear(_QuadMeshLike): + def _compute_bounds_from_1d_centers(self, xr_ds, dim): + vals = xr_ds[dim].values + + # Assume dimension is sorted in ascending or descending order + v0, v1, v_nm1, v_n = [ + vals[i] for i in [0, 1, -2, -1] + ] + + # Check if we should swap order + if v_n < v0: + v0, v1, v_nm1, v_n = v_n, v_nm1, v1, v0 + + bounds = (v0 - 0.5 * (v1 - v0), v_n + 0.5 * (v_n - v_nm1)) + return self.maybe_expand_bounds(bounds) + + def compute_x_bounds(self, xr_ds): + return self._compute_bounds_from_1d_centers(xr_ds, self.x) + + def compute_y_bounds(self, xr_ds): + return self._compute_bounds_from_1d_centers(xr_ds, self.y) + + @memoize + def _build_extend(self, x_mapper, y_mapper, info, append): + x_name = self.x + y_name = self.y + + @ngjit + @self.expand_aggs_and_cols(append) + def _extend(xs, ys, *aggs_and_cols): + for i in range(len(xs) - 1): + x0i, x1i = xs[i], xs[i + 1] + + # Make sure x0 <= x1 + if x0i > x1i: + x0i, x1i = x1i, x0i + + # Make sure single pixel quads are represented + if x0i == x1i: + x1i += 1 + + for j in range(len(ys) - 1): + + y0i, y1i = ys[j], ys[j + 1] + + # Make sure y0 <= y1 + if y0i > y1i: + y0i, y1i = y1i, y0i + + # Make sure single pixel quads are represented + if y0i == y1i: + y1i += 1 + + # x1i and y1i are not included in the iteration. this + # serves to avoid overlapping quads and it avoids the need + # for special handling of quads that end on exactly on the + # upper bound. + for xi in range(x0i, x1i): + for yi in range(y0i, y1i): + append(j, i, xi, yi, *aggs_and_cols) + + def extend(aggs, xr_ds, vt, bounds): + # Convert from bin centers to interval edges + x_breaks = infer_interval_breaks(xr_ds[x_name].values) + y_breaks = infer_interval_breaks(xr_ds[y_name].values) + + x0, x1, y0, y1 = bounds + xspan = x1 - x0 + yspan = y1 - y0 + xscaled = (x_mapper(x_breaks) - x0) / xspan + yscaled = (y_mapper(y_breaks) - y0) / yspan + + xmask = np.where((xscaled >= 0) & (xscaled <= 1)) + ymask = np.where((yscaled >= 0) & (yscaled <= 1)) + xm0, xm1 = max(xmask[0].min() - 1, 0), xmask[0].max() + 1 + ym0, ym1 = max(ymask[0].min() - 1, 0), ymask[0].max() + 1 + + plot_height, plot_width = aggs[0].shape[:2] + + # Downselect xs and ys and convert to int + xs = (xscaled[xm0:xm1 + 1] * plot_width).astype(int).clip(0, plot_width) + ys = (yscaled[ym0:ym1 + 1] * plot_height).astype(int).clip(0, plot_height) + + # For input "column", down select to valid range + cols_full = info(xr_ds.transpose(y_name, x_name)) + cols = tuple([c[ym0:ym1, xm0:xm1] for c in cols_full]) + + aggs_and_cols = aggs + cols + + _extend(xs, ys, *aggs_and_cols) + + return extend + + +class QuadMeshCurvialinear(_QuadMeshLike): + def compute_x_bounds(self, xr_ds): + xs = xr_ds[self.x].values + xs = infer_interval_breaks(xs, axis=1) + xs = infer_interval_breaks(xs, axis=0) + bounds = Glyph._compute_bounds_2d(xs) + return self.maybe_expand_bounds(bounds) + + def compute_y_bounds(self, xr_ds): + ys = xr_ds[self.y].values + ys = infer_interval_breaks(ys, axis=1) + ys = infer_interval_breaks(ys, axis=0) + bounds = Glyph._compute_bounds_2d(ys) + return self.maybe_expand_bounds(bounds) + + @memoize + def _build_extend(self, x_mapper, y_mapper, info, append): + x_name = self.x + y_name = self.y + + @ngjit + @self.expand_aggs_and_cols(append) + def _extend(plot_height, plot_width, xs, ys, *aggs_and_cols): + y_len, x_len, = xs.shape + + # For performance, we initialize all arrays once before the loop + + # xverts/yverts arrays + xverts = np.zeros(5, dtype=np.int32) + yverts = np.zeros(5, dtype=np.int32) + + # Array holding whether each edge is increasing + # vertically (+1), decreasing vertically (-1), + # or horizontal (0). + yincreasing = np.zeros(4, dtype=np.int8) + + # Array that will hold mask of whether edges are + # eligible for intersection tests + eligible = np.ones(4, dtype=np.int8) + + # Array that will hold a mask of whether edges + # intersect the ray to the right of test point + intersect = np.zeros(4, dtype=np.int8) + + for i in range(x_len - 1): + for j in range(y_len - 1): + + # make array of quad x any vertices + xverts[0] = xs[j, i] + xverts[1] = xs[j, i + 1] + xverts[2] = xs[j + 1, i + 1] + xverts[3] = xs[j + 1, i] + xverts[4] = xs[j, i] + + yverts[0] = ys[j, i] + yverts[1] = ys[j, i + 1] + yverts[2] = ys[j + 1, i + 1] + yverts[3] = ys[j + 1, i] + yverts[4] = ys[j, i] + + # Compute the rectilinear bounding box around the quad and + # skip quad if there is no chance for it to intersect + # viewport + xmin = min(xverts) + if xmin >= plot_width: + continue + xmin = max(xmin, 0) + + xmax = max(xverts) + if xmax < 0: + continue + xmax = min(xmax, plot_width) + + ymin = min(yverts) + if ymin >= plot_height: + continue + ymin = max(ymin, 0) + + ymax = max(yverts) + if ymax < 0: + continue + ymax = min(ymax, plot_height) + + # Handle subpixel quads + if xmin == xmax or ymin == ymax: + # If either dimension is a single pixel, then render it + if xmin == xmax and xmax < plot_width: + xmax += 1 + if ymin == ymax and ymax < plot_height: + ymax += 1 + + for yi in range(ymin, ymax): + for xi in range(xmin, xmax): + append(j, i, xi, yi, *aggs_and_cols) + + continue + + # make yincreasing an array holding whether each edge is + # increasing vertically (+1), decreasing vertically (-1), + # or horizontal (0). + yincreasing.fill(0) + yincreasing[yverts[1:] > yverts[:-1]] = 1 + yincreasing[yverts[1:] < yverts[:-1]] = -1 + + for yi in range(ymin, ymax): + eligible.fill(1) + for xi in range(xmin, xmax): + intersect.fill(0) + # Test edges + for edge_i in range(4): + # Skip if we already know edge is ineligible + if not eligible[edge_i]: + continue + + # Check if edge is fully to left of point. If + # so, we don't need to consider it again for + # this row. + if ((xverts[edge_i] < xi) and + (xverts[edge_i + 1] < xi)): + eligible[edge_i] = 0 + continue + + # Check if edge is fully above or below point. + # If so, we don't need to consider it again + # for this row. + if ((yverts[edge_i] > yi) == + (yverts[edge_i + 1] > yi)): + + eligible[edge_i] = 0 + continue + + # Now check if edge is to the right of point. + # A is vector from point to first vertex + ax = xverts[edge_i] - xi + ay = yverts[edge_i] - yi + + # B is vector from point to second vertex + bx = xverts[edge_i + 1] - xi + by = yverts[edge_i + 1] - yi + + # Compute cross product of B and A + bxa = (bx*ay - by*ax) + + # If cross product has same sign as yincreasing + # then edge intersects to the right + intersect[edge_i] = ( + bxa * yincreasing[edge_i] < 0 + ) + + if intersect.sum() % 2 == 1: + # If odd number of intersections, point + # is inside quad + append(j, i, xi, yi, *aggs_and_cols) + + def extend(aggs, xr_ds, vt, bounds): + # Convert from bin centers to interval edges + x_breaks = xr_ds[x_name].values + x_breaks = infer_interval_breaks(x_breaks, axis=1) + x_breaks = infer_interval_breaks(x_breaks, axis=0) + + y_breaks = xr_ds[y_name].values + y_breaks = infer_interval_breaks(y_breaks, axis=1) + y_breaks = infer_interval_breaks(y_breaks, axis=0) + + # Scale x and y vertices into integer canvas coordinates + x0, x1, y0, y1 = bounds + xspan = x1 - x0 + yspan = y1 - y0 + xscaled = (x_mapper(x_breaks) - x0) / xspan + yscaled = (y_mapper(y_breaks) - y0) / yspan + + plot_height, plot_width = aggs[0].shape[:2] + + xs = (xscaled * plot_width).astype(int) + ys = (yscaled * plot_height).astype(int) + + coord_dims = xr_ds.coords[x_name].dims + aggs_and_cols = aggs + info(xr_ds.transpose(*coord_dims)) + _extend(plot_height, plot_width, xs, ys, *aggs_and_cols) + + return extend diff --git a/datashader/pandas.py b/datashader/pandas.py index fc0c2a036..c3e6ddf3a 100644 --- a/datashader/pandas.py +++ b/datashader/pandas.py @@ -7,6 +7,7 @@ from .glyphs.points import _PointLike from .glyphs.area import _AreaToLineLike from .utils import Dispatcher +from collections import OrderedDict __all__ = () @@ -21,7 +22,7 @@ def pandas_pipeline(df, schema, canvas, glyph, summary): @glyph_dispatch.register(_PointLike) @glyph_dispatch.register(_AreaToLineLike) -def pointlike(glyph, df, schema, canvas, summary): +def default(glyph, df, schema, canvas, summary): create, info, append, _, finalize = compile_components(summary, schema, glyph) x_mapper = canvas.x_axis.mapper y_mapper = canvas.y_axis.mapper @@ -43,5 +44,6 @@ def pointlike(glyph, df, schema, canvas, summary): extend(bases, df, x_st + y_st, x_range + y_range) return finalize(bases, - coords=[y_axis, x_axis], + coords=OrderedDict([(glyph.x_label, x_axis), + (glyph.y_label, y_axis)]), dims=[glyph.y_label, glyph.x_label]) diff --git a/datashader/reductions.py b/datashader/reductions.py index de0a4703c..1487fc49e 100644 --- a/datashader/reductions.py +++ b/datashader/reductions.py @@ -310,7 +310,9 @@ def _build_finalize(self, dshape): def finalize(bases, **kwargs): dims = kwargs['dims'] + [self.column] - coords = kwargs['coords'] + [cats] + + coords = kwargs['coords'] + coords[self.column] = cats return xr.DataArray(bases[0], dims=dims, coords=coords) return finalize diff --git a/datashader/resampling.py b/datashader/resampling.py index 9e0baa02a..bc526f7d4 100644 --- a/datashader/resampling.py +++ b/datashader/resampling.py @@ -26,6 +26,8 @@ from __future__ import absolute_import, division, print_function +import sys +import datetime as dt from itertools import groupby from math import floor, ceil @@ -952,3 +954,25 @@ def _downsample_2d_std_var(src, mask, use_mask, method, fill_value, DS_MODE: _downsample_2d_mode, DS_STD: _downsample_2d_std_var, DS_VAR: _downsample_2d_std_var} + + +def infer_interval_breaks(coord, axis=0): + """ + >>> infer_interval_breaks(np.arange(5)) + array([-0.5, 0.5, 1.5, 2.5, 3.5, 4.5]) + >>> infer_interval_breaks([[0, 1], [3, 4]], axis=1) + array([[-0.5, 0.5, 1.5], + [ 2.5, 3.5, 4.5]]) + """ + coord = np.asarray(coord) + if sys.version_info.major == 2 and len(coord) and isinstance(coord[0], (dt.datetime, dt.date)): + # np.diff does not work on datetimes in python 2 + coord = coord.astype('datetime64') + if len(coord) == 0: + return np.array([], dtype=coord.dtype) + deltas = 0.5 * np.diff(coord, axis=axis) + first = np.take(coord, [0], axis=axis) - np.take(deltas, [0], axis=axis) + last = np.take(coord, [-1], axis=axis) + np.take(deltas, [-1], axis=axis) + trim_last = tuple(slice(None, -1) if n == axis else slice(None) + for n in range(coord.ndim)) + return np.concatenate([first, coord[trim_last] + deltas, last], axis=axis) diff --git a/datashader/tests/test_bokeh_ext.py b/datashader/tests/test_bokeh_ext.py index 3134b54cb..44fc2a2d8 100644 --- a/datashader/tests/test_bokeh_ext.py +++ b/datashader/tests/test_bokeh_ext.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import import numpy as np import pandas as pd import datashader as ds @@ -46,7 +47,7 @@ def test_interactive_image_update(): # Ensure bokeh Document is instantiated img._repr_html_() - assert isinstance(img.doc, Document) + assert isinstance(img.doc, Document) # Ensure image is updated img.update_image({'xmin': 0.5, 'xmax': 1, 'ymin': 0.5, 'ymax': 1, 'w': 1, 'h': 1}) diff --git a/datashader/tests/test_bundling.py b/datashader/tests/test_bundling.py index ab6bf104f..f5a86f6ac 100644 --- a/datashader/tests/test_bundling.py +++ b/datashader/tests/test_bundling.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import import pytest skimage = pytest.importorskip("skimage") diff --git a/datashader/tests/test_colors.py b/datashader/tests/test_colors.py index 8431aa5ad..6d608255e 100644 --- a/datashader/tests/test_colors.py +++ b/datashader/tests/test_colors.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import from datashader.colors import rgb, hex_to_rgb import pytest diff --git a/datashader/tests/test_compatibility.py b/datashader/tests/test_compatibility.py index 233fe6fbc..059af6e06 100644 --- a/datashader/tests/test_compatibility.py +++ b/datashader/tests/test_compatibility.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import from pytest import raises from datashader.compatibility import apply, _exec diff --git a/datashader/tests/test_composite.py b/datashader/tests/test_composite.py index 6c27daff1..5f3217b9e 100644 --- a/datashader/tests/test_composite.py +++ b/datashader/tests/test_composite.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import import numpy as np from datashader.composite import add, saturate, over, source diff --git a/datashader/tests/test_dask.py b/datashader/tests/test_dask.py index 791acc18c..86079c45d 100644 --- a/datashader/tests/test_dask.py +++ b/datashader/tests/test_dask.py @@ -1,4 +1,4 @@ -from __future__ import division +from __future__ import division, absolute_import from dask.context import config import dask.dataframe as dd import numpy as np diff --git a/datashader/tests/test_datatypes.py b/datashader/tests/test_datatypes.py index 859c0d35c..aee0cc9f2 100644 --- a/datashader/tests/test_datatypes.py +++ b/datashader/tests/test_datatypes.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import import pytest import numpy as np import pandas as pd diff --git a/datashader/tests/test_geo.py b/datashader/tests/test_geo.py index e99339378..acb5cfd2f 100644 --- a/datashader/tests/test_geo.py +++ b/datashader/tests/test_geo.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import import datashader as ds import xarray as xr import numpy as np diff --git a/datashader/tests/test_glyphs.py b/datashader/tests/test_glyphs.py index 2e8e1a9ef..05953edad 100644 --- a/datashader/tests/test_glyphs.py +++ b/datashader/tests/test_glyphs.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import from datashape import dshape import pandas as pd import numpy as np diff --git a/datashader/tests/test_layout.py b/datashader/tests/test_layout.py index f8f5e6b4c..f5d4d2f27 100644 --- a/datashader/tests/test_layout.py +++ b/datashader/tests/test_layout.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import import pytest skimage = pytest.importorskip("skimage") diff --git a/datashader/tests/test_pandas.py b/datashader/tests/test_pandas.py index 0ff336500..2d610bfcd 100644 --- a/datashader/tests/test_pandas.py +++ b/datashader/tests/test_pandas.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import import numpy as np import pandas as pd import xarray as xr diff --git a/datashader/tests/test_pipeline.py b/datashader/tests/test_pipeline.py index 084f0226a..364500dbc 100644 --- a/datashader/tests/test_pipeline.py +++ b/datashader/tests/test_pipeline.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import import numpy as np import pandas as pd import datashader as ds diff --git a/datashader/tests/test_quadmesh.py b/datashader/tests/test_quadmesh.py new file mode 100644 index 000000000..7243d7baf --- /dev/null +++ b/datashader/tests/test_quadmesh.py @@ -0,0 +1,296 @@ +from __future__ import absolute_import +import numpy as np +from numpy import nan +import xarray as xr +import datashader as ds +from collections import OrderedDict + + +def test_rect_quadmesh_autorange(): + c = ds.Canvas(plot_width=8, plot_height=4) + da = xr.DataArray( + [[1, 2, 3, 4], + [5, 6, 7, 8]], + coords=[('b', [1, 2]), + ('a', [1, 2, 3, 4])], + name='Z') + + y_coords = np.linspace(0.75, 2.25, 4) + x_coords = np.linspace(0.75, 4.25, 8) + out = xr.DataArray(np.array( + [[1., 1., 2., 2., 3., 3., 4., 4.], + [1., 1., 2., 2., 3., 3., 4., 4.], + [5., 5., 6., 6., 7., 7., 8., 8.], + [5., 5., 6., 6., 7., 7., 8., 8.]], + dtype='i4'), + coords=[('b', y_coords), + ('a', x_coords)] + ) + + res = c.quadmesh(da, x='a', y='b', agg=ds.sum('Z')) + assert res.equals(out) + + # Check transpose gives same answer + res = c.quadmesh(da.transpose('a', 'b'), x='a', y='b', agg=ds.sum('Z')) + assert res.equals(out) + + +def test_rect_quadmesh_autorange_reversed(): + c = ds.Canvas(plot_width=8, plot_height=4) + da = xr.DataArray( + [[1, 2, 3, 4], + [5, 6, 7, 8]], + coords=[('b', [-1, -2]), + ('a', [-1, -2, -3, -4])], + name='Z') + + y_coords = np.linspace(-2.25, -0.75, 4) + x_coords = np.linspace(-4.25, -0.75, 8) + out = xr.DataArray(np.array( + [[8., 8., 7., 7., 6., 6., 5., 5.], + [8., 8., 7., 7., 6., 6., 5., 5.], + [4., 4., 3., 3., 2., 2., 1., 1.], + [4., 4., 3., 3., 2., 2., 1., 1.]], + dtype='i4'), + coords=[('b', y_coords), + ('a', x_coords)] + ) + + res = c.quadmesh(da, x='a', y='b', agg=ds.sum('Z')) + assert res.equals(out) + + # Check transpose gives same answer + res = c.quadmesh(da.transpose('a', 'b'), x='a', y='b', agg=ds.sum('Z')) + assert res.equals(out) + + +def test_rect_quadmesh_manual_range(): + c = ds.Canvas(plot_width=8, plot_height=4, + x_range=[1, 3], + y_range=[-1, 3]) + + da = xr.DataArray( + [[1, 2, 3, 4], + [5, 6, 7, 8]], + coords=[('b', [1, 2]), + ('a', [1, 2, 3, 4])], + name='Z') + + y_coords = np.linspace(-0.5, 2.5, 4) + x_coords = np.linspace(1.125, 2.875, 8) + out = xr.DataArray(np.array( + [[nan, nan, nan, nan, nan, nan, nan, nan], + [1., 1., 2., 2., 2., 2., 3., 3.], + [5., 5., 6., 6., 6., 6., 7., 7.], + [nan, nan, nan, nan, nan, nan, nan, nan]], + dtype='f4'), + coords=[('b', y_coords), + ('a', x_coords)] + ) + + res = c.quadmesh(da, x='a', y='b', agg=ds.sum('Z')) + assert res.equals(out) + + # Check transpose gives same answer + res = c.quadmesh(da.transpose('a', 'b'), x='a', y='b', agg=ds.sum('Z')) + assert res.equals(out) + + +def test_subpixel_quads_represented(): + c = ds.Canvas(plot_width=8, plot_height=4, + x_range=[0, 16], + y_range=[0, 8]) + + da = xr.DataArray( + [[1, 2, 3, 4], + [5, 6, 7, 8]], + coords=[('b', [1, 2]), + ('a', [1, 2, 3, 4])], + name='Z') + + y_coords = np.linspace(1, 7, 4) + x_coords = np.linspace(1, 15, 8) + out = xr.DataArray(np.array( + [[14., 22., nan, nan, nan, nan, nan, nan], + [nan, nan, nan, nan, nan, nan, nan, nan], + [nan, nan, nan, nan, nan, nan, nan, nan], + [nan, nan, nan, nan, nan, nan, nan, nan]], + dtype='f4'), + coords=[('b', y_coords), + ('a', x_coords)] + ) + + res = c.quadmesh(da, x='a', y='b', agg=ds.sum('Z')) + assert res.equals(out) + + # Check transpose gives same answer + res = c.quadmesh(da.transpose('a', 'b'), x='a', y='b', agg=ds.sum('Z')) + assert res.equals(out) + + +def test_curve_quadmesh_rect_autorange(): + c = ds.Canvas(plot_width=8, plot_height=4) + Qx = np.array( + [[1, 2], + [1, 2]] + ) + Qy = np.array( + [[1, 1], + [2, 2]] + ) + Z = np.arange(4, dtype='int32').reshape(2, 2) + da = xr.DataArray( + Z, + coords={'Qx': (['Y', 'X'], Qx), + 'Qy': (['Y', 'X'], Qy)}, + dims=['Y', 'X'], + name='Z', + ) + + y_coords = np.linspace(0.75, 2.25, 4) + x_coords = np.linspace(0.625, 2.375, 8) + out = xr.DataArray(np.array( + [[0., 0., 0., 0., 1., 1., 1., 1.], + [0., 0., 0., 0., 1., 1., 1., 1.], + [2., 2., 2., 2., 3., 3., 3., 3.], + [2., 2., 2., 2., 3., 3., 3., 3.]], + ), + coords=[('Qy', y_coords), + ('Qx', x_coords)] + ) + + res = c.quadmesh(da, x='Qx', y='Qy', agg=ds.sum('Z')) + assert res.equals(out) + + res = c.quadmesh(da.transpose('X', 'Y'), x='Qx', y='Qy', agg=ds.sum('Z')) + assert res.equals(out) + + +def test_curve_quadmesh_autorange(): + c = ds.Canvas(plot_width=4, plot_height=8) + Qx = np.array( + [[1, 2], + [1, 2]] + ) + Qy = np.array( + [[1, 1], + [4, 2]] + ) + Z = np.arange(4, dtype='int32').reshape(2, 2) + da = xr.DataArray( + Z, + coords={'Qx': (['Y', 'X'], Qx), + 'Qy': (['Y', 'X'], Qy)}, + dims=['Y', 'X'], + name='Z', + ) + + x_coords = np.linspace(0.75, 2.25, 4) + y_coords = np.linspace(-0.5, 6.5, 8) + out = xr.DataArray(np.array( + [[nan, nan, nan, nan], + [0., 0., nan, nan], + [0., 0., 1., 1.], + [0., 0., 3., 3.], + [2., 2., 3., nan], + [2., 2., nan, nan], + [2., 2., nan, nan], + [2., nan, nan, nan]] + ), + coords=OrderedDict([ + ('Qx', x_coords), + ('Qy', y_coords)]), + dims=['Qy', 'Qx'] + ) + + res = c.quadmesh(da, x='Qx', y='Qy', agg=ds.sum('Z')) + assert res.equals(out) + + res = c.quadmesh(da.transpose('X', 'Y'), x='Qx', y='Qy', agg=ds.sum('Z')) + assert res.equals(out) + + +def test_curve_quadmesh_manual_range(): + c = ds.Canvas(plot_width=4, plot_height=8, x_range=[1, 2], y_range=[1, 3]) + Qx = np.array( + [[1, 2], + [1, 2]] + ) + Qy = np.array( + [[1, 1], + [4, 2]] + ) + Z = np.arange(4, dtype='int32').reshape(2, 2) + da = xr.DataArray( + Z, + coords={'Qx': (['Y', 'X'], Qx), + 'Qy': (['Y', 'X'], Qy)}, + dims=['Y', 'X'], + name='Z', + ) + + x_coords = np.linspace(1.125, 1.875, 4) + y_coords = np.linspace(1.125, 2.875, 8) + out = xr.DataArray(np.array( + [[0., 0., 1., 1.], + [0., 0., 1., 1.], + [0., 0., 1., 1.], + [0., 0., 1., 3.], + [0., 0., 3., 3.], + [0., 2., 3., 3.], + [2., 2., 3., 3.], + [2., 2., 3., 3.]] + ), + coords=OrderedDict([ + ('Qx', x_coords), + ('Qy', y_coords)]), + dims=['Qy', 'Qx'] + ) + + res = c.quadmesh(da, x='Qx', y='Qy', agg=ds.sum('Z')) + assert res.equals(out) + + res = c.quadmesh(da.transpose('X', 'Y'), x='Qx', y='Qy', agg=ds.sum('Z')) + assert res.equals(out) + + +def test_curve_quadmesh_manual_range_subpixel(): + c = ds.Canvas(plot_width=3, plot_height=5, + x_range=[-150, 150], y_range=[-250, 250]) + Qx = np.array( + [[1, 2], + [1, 2]] + ) + Qy = np.array( + [[1, 1], + [4, 2]] + ) + Z = np.arange(4, dtype='int32').reshape(2, 2) + da = xr.DataArray( + Z, + coords={'Qx': (['Y', 'X'], Qx), + 'Qy': (['Y', 'X'], Qy)}, + dims=['Y', 'X'], + name='Z', + ) + + x_coords = np.linspace(-100, 100, 3) + y_coords = np.linspace(-200, 200, 5) + out = xr.DataArray(np.array( + [[nan, nan, nan], + [nan, nan, nan], + [nan, 6., nan], + [nan, nan, nan], + [nan, nan, nan]] + ), + coords=OrderedDict([ + ('Qx', x_coords), + ('Qy', y_coords)]), + dims=['Qy', 'Qx'] + ) + + res = c.quadmesh(da, x='Qx', y='Qy', agg=ds.sum('Z')) + assert res.equals(out) + + res = c.quadmesh(da.transpose('X', 'Y'), x='Qx', y='Qy', agg=ds.sum('Z')) + assert res.equals(out) diff --git a/datashader/tests/test_raster.py b/datashader/tests/test_raster.py index 0f951146c..7129e3a26 100644 --- a/datashader/tests/test_raster.py +++ b/datashader/tests/test_raster.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import import pytest rasterio = pytest.importorskip("rasterio") diff --git a/datashader/tests/test_spatial.py b/datashader/tests/test_spatial.py index ef9fdb614..3cf48eed7 100644 --- a/datashader/tests/test_spatial.py +++ b/datashader/tests/test_spatial.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import import os import pytest import numpy as np diff --git a/datashader/tests/test_tiles.py b/datashader/tests/test_tiles.py index c822a4d1e..b9c5eac1f 100644 --- a/datashader/tests/test_tiles.py +++ b/datashader/tests/test_tiles.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import import datashader as ds import datashader.transfer_functions as tf diff --git a/datashader/tests/test_transfer_functions.py b/datashader/tests/test_transfer_functions.py index 988c6a213..322521696 100644 --- a/datashader/tests/test_transfer_functions.py +++ b/datashader/tests/test_transfer_functions.py @@ -1,4 +1,4 @@ -from __future__ import division +from __future__ import division, absolute_import from io import BytesIO diff --git a/datashader/tests/test_utils.py b/datashader/tests/test_utils.py index 28dc3469a..5fe53c36e 100644 --- a/datashader/tests/test_utils.py +++ b/datashader/tests/test_utils.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import from datashape import dshape from datashader.utils import Dispatcher, isreal diff --git a/datashader/tests/test_xarray.py b/datashader/tests/test_xarray.py index ce24d22f9..2855b0dc8 100644 --- a/datashader/tests/test_xarray.py +++ b/datashader/tests/test_xarray.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import import numpy as np import xarray as xr diff --git a/datashader/utils.py b/datashader/utils.py index fb660b17b..f125a648c 100644 --- a/datashader/utils.py +++ b/datashader/utils.py @@ -394,6 +394,14 @@ def dshape_from_dask(df): return datashape.var * dshape_from_pandas(df.head()).measure +def dshape_from_xarray_dataset(xr_ds): + """Return a datashape.DataShape object given a xarray Dataset.""" + return datashape.var * datashape.Record([ + (k, dshape_from_pandas_helper(xr_ds[k])) + for k in list(xr_ds.data_vars) + list(xr_ds.coords) + ]) + + def dataframe_from_multiple_sequences(x_values, y_values): """ Converts a set of multiple sequences (eg: time series), stored as a 2 dimensional diff --git a/datashader/xarray.py b/datashader/xarray.py new file mode 100644 index 000000000..d80dd5b37 --- /dev/null +++ b/datashader/xarray.py @@ -0,0 +1,18 @@ +from __future__ import absolute_import +from datashader.glyphs.quadmesh import _QuadMeshLike +from datashader.pandas import default +from .core import bypixel +import xarray as xr +from .utils import Dispatcher + + +glyph_dispatch = Dispatcher() + + +@bypixel.pipeline.register(xr.Dataset) +def xarray_pipeline(df, schema, canvas, glyph, summary): + return glyph_dispatch(glyph, df, schema, canvas, summary) + + +# Default to default pandas implementation +glyph_dispatch.register(_QuadMeshLike)(default)