From 80dd30ef50112e24371221ef90ab84faf5795947 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 6 Dec 2018 19:02:20 +0000 Subject: [PATCH] Added support for cftime types (#2728) --- holoviews/core/ndmapping.py | 2 +- holoviews/core/util.py | 64 ++++++++++++++----- holoviews/operation/datashader.py | 47 ++++++++------ holoviews/operation/element.py | 6 +- holoviews/plotting/bokeh/element.py | 12 ++-- holoviews/plotting/bokeh/plot.py | 52 +++++++++++---- holoviews/plotting/bokeh/util.py | 41 ++++++++---- holoviews/plotting/mpl/element.py | 5 +- holoviews/plotting/mpl/util.py | 37 ++++++++++- holoviews/tests/core/testutils.py | 6 +- holoviews/tests/operation/testoperation.py | 42 +++++++----- .../tests/plotting/bokeh/testelementplot.py | 37 ++++++++++- .../tests/plotting/bokeh/testhistogramplot.py | 20 ++++-- 13 files changed, 275 insertions(+), 96 deletions(-) diff --git a/holoviews/core/ndmapping.py b/holoviews/core/ndmapping.py index 067b44068d..121899fde4 100644 --- a/holoviews/core/ndmapping.py +++ b/holoviews/core/ndmapping.py @@ -650,7 +650,7 @@ def __getitem__(self, indexslice): return self.data[()] elif indexslice in [Ellipsis, ()]: return self - elif Ellipsis in wrap_tuple(indexslice): + elif any(Ellipsis is sl for sl in wrap_tuple(indexslice)): indexslice = process_ellipses(self, indexslice) map_slice, data_slice = self._split_index(indexslice) diff --git a/holoviews/core/util.py b/holoviews/core/util.py index c85c2096b7..29573568f4 100644 --- a/holoviews/core/util.py +++ b/holoviews/core/util.py @@ -92,6 +92,14 @@ def __cmp__(self, other): except ImportError: pd = None +try: + import cftime + cftime_types = (cftime.datetime,) + datetime_types += cftime_types +except: + cftime_types = () +_STANDARD_CALENDARS = set(['standard', 'gregorian', 'proleptic_gregorian']) + class VersionError(Exception): "Raised when there is a library version mismatch." @@ -1805,26 +1813,28 @@ def dt_to_int(value, time_unit='us'): if isinstance(value, pd.Period): value = value.to_timestamp() if isinstance(value, pd.Timestamp): - value = value.to_pydatetime() - value = np.datetime64(value) + try: + value = value.to_datetime64() + except: + value = np.datetime64(value.to_pydatetime()) + elif isinstance(value, cftime_types): + return cftime_to_timestamp(value, time_unit) + # Handle datetime64 separately if isinstance(value, np.datetime64): - value = np.datetime64(value, 'ns') - if time_unit == 'ns': - tscale = 1 - else: - tscale = (np.timedelta64(1, time_unit)/np.timedelta64(1, 'ns')) * 1000. - elif time_unit == 'ns': - tscale = 1000. + try: + value = np.datetime64(value, 'ns') + tscale = (np.timedelta64(1, time_unit)/np.timedelta64(1, 'ns')) + return value.tolist()/tscale + except: + # If it can't handle ns precision fall back to datetime + value = value.tolist() + + if time_unit == 'ns': + tscale = 1e9 else: tscale = 1./np.timedelta64(1, time_unit).tolist().total_seconds() - if isinstance(value, np.datetime64): - value = value.tolist() - if isinstance(value, (int, long)): - # Handle special case of nanosecond precision which cannot be - # represented by python datetime - return value * 10**-(np.log10(tscale)-3) try: # Handle python3 return int(value.timestamp() * tscale) @@ -1833,6 +1843,30 @@ def dt_to_int(value, time_unit='us'): return (time.mktime(value.timetuple()) + value.microsecond / 1e6) * tscale +def cftime_to_timestamp(date, time_unit='us'): + """Converts cftime to timestamp since epoch in milliseconds + + Non-standard calendars (e.g. Julian or no leap calendars) + are converted to standard Gregorian calendar. This can cause + extra space to be added for dates that don't exist in the original + calendar. In order to handle these dates correctly a custom bokeh + model with support for other calendars would have to be defined. + + Args: + date: cftime datetime object (or array) + + Returns: + Milliseconds since 1970-01-01 00:00:00 + """ + import cftime + utime = cftime.utime('microseconds since 1970-01-01') + if time_unit == 'us': + tscale = 1 + else: + tscale = (np.timedelta64(1, 'us')/np.timedelta64(1, time_unit)) + return utime.date2num(date)*tscale + + def search_indices(values, source): """ Given a set of values returns the indices of each of those values diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py index af671ec549..760f0fea44 100644 --- a/holoviews/operation/datashader.py +++ b/holoviews/operation/datashader.py @@ -23,7 +23,9 @@ CompositeOverlay, Dataset, Overlay) from ..core.data import PandasInterface, XArrayInterface from ..core.sheetcoords import BoundingBox -from ..core.util import LooseVersion, get_param_values, basestring, datetime_types, dt_to_int +from ..core.util import ( + LooseVersion, basestring, cftime_types, cftime_to_timestamp, + datetime_types, dt_to_int, get_param_values) from ..element import (Image, Path, Curve, RGB, Graph, TriMesh, QuadMesh, Contours) from ..streams import RangeXY, PlotSize @@ -321,11 +323,18 @@ def get_agg_data(cls, obj, category=None): if category and df[category].dtype.name != 'category': df[category] = df[category].astype('category') - if any(df[d.name].dtype.kind == 'M' for d in (x, y)): + if any(df[d.name].dtype.kind == 'M' or isinstance(df[d.name].values[0], cftime_types) + for d in (x, y)): df = df.copy() for d in (x, y): - if df[d.name].dtype.kind == 'M': - df[d.name] = df[d.name].astype('datetime64[ns]').astype('int64') * 1000. + vals = df[d.name].values + if len(vals) and isinstance(vals[0], cftime_types): + vals = cftime_to_timestamp(vals, 'ns') + elif df[d.name].dtype.kind == 'M': + vals = vals.astype('datetime64[ns]') + else: + continue + df[d.name] = vals.astype('int64') return x, y, Dataset(df, kdims=kdims, vdims=vdims), glyph @@ -344,9 +353,9 @@ def _aggregate_ndoverlay(self, element, agg_fn): info = self._get_sampling(element, x, y) (x_range, y_range), (xs, ys), (width, height), (xtype, ytype) = info if xtype == 'datetime': - x_range = tuple((np.array(x_range)/10e5).astype('datetime64[us]')) + x_range = tuple((np.array(x_range)/1e3).astype('datetime64[us]')) if ytype == 'datetime': - y_range = tuple((np.array(y_range)/10e5).astype('datetime64[us]')) + y_range = tuple((np.array(y_range)/1e3).astype('datetime64[us]')) agg_params = dict({k: v for k, v in dict(self.get_param_values(), **self.p).items() if k in aggregate.params()}, x_range=x_range, y_range=y_range) @@ -433,11 +442,11 @@ def _process(self, element, key=None): (x0, x1), (y0, y1) = x_range, y_range if xtype == 'datetime': - x0, x1 = (np.array([x0, x1])/10e5).astype('datetime64[us]') - xs = (xs/10e5).astype('datetime64[us]') + x0, x1 = (np.array([x0, x1])/1e3).astype('datetime64[us]') + xs = (xs/1e3).astype('datetime64[us]') if ytype == 'datetime': - y0, y1 = (np.array([y0, y1])/10e5).astype('datetime64[us]') - ys = (ys/10e5).astype('datetime64[us]') + y0, y1 = (np.array([y0, y1])/1e3).astype('datetime64[us]') + ys = (ys/1e3).astype('datetime64[us]') bounds = (x0, y0, x1, y1) params = dict(get_param_values(element), kdims=[x, y], datatype=['xarray'], bounds=bounds) @@ -483,9 +492,9 @@ def _process(self, element, key=None): if 'x_axis' in agg.coords and 'y_axis' in agg.coords: agg = agg.rename({'x_axis': x, 'y_axis': y}) if xtype == 'datetime': - agg[x.name] = (agg[x.name]/10e5).astype('datetime64[us]') + agg[x.name] = (agg[x.name]/1e3).astype('datetime64[us]') if ytype == 'datetime': - agg[y.name] = (agg[y.name]/10e5).astype('datetime64[us]') + agg[y.name] = (agg[y.name]/1e3).astype('datetime64[us]') if agg.ndim == 2: # Replacing x and y coordinates to avoid numerical precision issues @@ -606,11 +615,11 @@ def _process(self, element, key=None): # Compute bounds (converting datetimes) if xtype == 'datetime': - xstart, xend = (np.array([xstart, xend])/10e5).astype('datetime64[us]') - xs = (xs/10e5).astype('datetime64[us]') + xstart, xend = (np.array([xstart, xend])/1e3).astype('datetime64[us]') + xs = (xs/1e3).astype('datetime64[us]') if ytype == 'datetime': - ystart, yend = (np.array([ystart, yend])/10e5).astype('datetime64[us]') - ys = (ys/10e5).astype('datetime64[us]') + ystart, yend = (np.array([ystart, yend])/1e3).astype('datetime64[us]') + ys = (ys/1e3).astype('datetime64[us]') bbox = BoundingBox(points=[(xstart, ystart), (xend, yend)]) params = dict(bounds=bbox) @@ -632,9 +641,9 @@ def _process(self, element, key=None): # Convert datetime coordinates if xtype == "datetime": - rarray[x.name] = (rarray[x.name]/10e5).astype('datetime64[us]') + rarray[x.name] = (rarray[x.name]/1e3).astype('datetime64[us]') if ytype == "datetime": - rarray[y.name] = (rarray[y.name]/10e5).astype('datetime64[us]') + rarray[y.name] = (rarray[y.name]/1e3).astype('datetime64[us]') regridded[vd] = rarray regridded = xr.Dataset(regridded) @@ -957,7 +966,7 @@ def _process(self, element, key=None): for d in kdims: if array[d.name].dtype.kind == 'M': - array[d.name] = array[d.name].astype('datetime64[ns]').astype('int64') * 10e-4 + array[d.name] = array[d.name].astype('datetime64[us]').astype('int64') with warnings.catch_warnings(): warnings.filterwarnings('ignore', r'invalid value encountered in true_divide') diff --git a/holoviews/operation/element.py b/holoviews/operation/element.py index 270c382153..c052609d7c 100644 --- a/holoviews/operation/element.py +++ b/holoviews/operation/element.py @@ -589,9 +589,9 @@ def _process(self, element, key=None): if data.dtype.kind == 'M' or (data.dtype.kind == 'O' and isinstance(data[0], datetime_types)): start, end = dt_to_int(start, 'ns'), dt_to_int(end, 'ns') datetimes = True - data = data.astype('datetime64[ns]').astype('int64') * 1000. + data = data.astype('datetime64[ns]').astype('int64') if bins is not None: - bins = bins.astype('datetime64[ns]').astype('int64') * 1000. + bins = bins.astype('datetime64[ns]').astype('int64') else: hist_range = start, end @@ -622,7 +622,7 @@ def _process(self, element, key=None): hist = np.zeros(self.p.num_bins) hist[np.isnan(hist)] = 0 if datetimes: - edges = (edges/10e5).astype('datetime64[us]') + edges = (edges/1e3).astype('datetime64[us]') params = {} if self.p.weight_dimension: diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index b0e3364771..f0b3f488c9 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -40,7 +40,7 @@ from .util import ( bokeh_version, decode_bytes, get_tab_title, glyph_order, py2js_tickformatter, recursive_model_update, theme_attr_json, - cds_column_replace, hold_policy, match_dim_specs + cds_column_replace, hold_policy, match_dim_specs, date_to_integer ) @@ -301,7 +301,7 @@ def _axes_props(self, plots, subplots, element, ranges): xtype = el.nodes.get_dimension_type(xdims[0]) else: xtype = el.get_dimension_type(xdims[0]) - if ((xtype is np.object_ and type(l) in util.datetime_types) or + if ((xtype is np.object_ and issubclass(type(l), util.datetime_types)) or xtype in util.datetime_types): x_axis_type = 'datetime' @@ -315,7 +315,7 @@ def _axes_props(self, plots, subplots, element, ranges): ytype = el.nodes.get_dimension_type(ydims[0]) else: ytype = el.get_dimension_type(ydims[0]) - if ((ytype is np.object_ and type(b) in util.datetime_types) + if ((ytype is np.object_ and issubclass(type(b), util.datetime_types)) or ytype in util.datetime_types): y_axis_type = 'datetime' @@ -606,7 +606,9 @@ def _update_ranges(self, element, ranges): def _update_range(self, axis_range, low, high, factors, invert, shared, log, streaming=False): if isinstance(axis_range, (Range1d, DataRange1d)) and self.apply_ranges: - if (low == high and low is not None): + if isinstance(low, util.cftime_types): + pass + elif (low == high and low is not None): if isinstance(low, util.datetime_types): offset = np.timedelta64(500, 'ms') low -= offset @@ -634,6 +636,8 @@ def _update_range(self, axis_range, low, high, factors, invert, shared, log, str if reset_supported: updates['reset_end'] = updates['end'] for k, (old, new) in updates.items(): + if isinstance(new, util.cftime_types): + new = date_to_integer(new) axis_range.update(**{k:new}) if streaming and not k.startswith('reset_'): axis_range.trigger(k, old, new) diff --git a/holoviews/plotting/bokeh/plot.py b/holoviews/plotting/bokeh/plot.py index ab1d675d6d..f7c745ee88 100644 --- a/holoviews/plotting/bokeh/plot.py +++ b/holoviews/plotting/bokeh/plot.py @@ -12,20 +12,27 @@ from bokeh.models.widgets import Panel, Tabs from bokeh.plotting.helpers import _known_tools as known_tools -from ...core import (OrderedDict, Store, AdjointLayout, NdLayout, Layout, - Empty, GridSpace, HoloMap, Element, DynamicMap) +from ...core import ( + OrderedDict, Store, AdjointLayout, NdLayout, Layout, Empty, + GridSpace, HoloMap, Element, DynamicMap +) from ...core.options import SkipRendering -from ...core.util import (basestring, wrap_tuple, unique_iterator, - get_method_owner, wrap_tuple_streams) +from ...core.util import ( + basestring, cftime_to_timestamp, cftime_types, get_method_owner, + unique_iterator, wrap_tuple, wrap_tuple_streams, _STANDARD_CALENDARS) from ...streams import Stream from ..links import Link -from ..plot import (DimensionedPlot, GenericCompositePlot, GenericLayoutPlot, - GenericElementPlot, GenericOverlayPlot) +from ..plot import ( + DimensionedPlot, GenericCompositePlot, GenericLayoutPlot, + GenericElementPlot, GenericOverlayPlot +) from ..util import attach_streams, displayable, collate from .callbacks import LinkCallback -from .util import (layout_padding, pad_plots, filter_toolboxes, make_axis, - update_shared_sources, empty_plot, decode_bytes, - theme_attr_json, cds_column_replace) +from .util import ( + layout_padding, pad_plots, filter_toolboxes, make_axis, + update_shared_sources, empty_plot, decode_bytes, theme_attr_json, + cds_column_replace +) TOOLS = {name: tool if isinstance(tool, basestring) else type(tool()) for name, tool in known_tools.items()} @@ -226,10 +233,33 @@ def _init_datasource(self, data): """ Initializes a data source to be passed into the bokeh glyph. """ - data = {k: decode_bytes(vs) for k, vs in data.items()} + data = self._postprocess_data(data) return ColumnDataSource(data=data) + def _postprocess_data(self, data): + """ + Applies necessary type transformation to the data before + it is set on a ColumnDataSource. + """ + new_data = {} + for k, values in data.items(): + values = decode_bytes(values) # Bytes need decoding to strings + + # Certain datetime types need to be converted + if len(values) and isinstance(values[0], cftime_types): + if any(v.calendar not in _STANDARD_CALENDARS for v in values): + self.param.warning( + 'Converting cftime.datetime from a non-standard ' + 'calendar (%s) to a standard calendar for plotting. ' + 'This may lead to subtle errors in formatting ' + 'dates, for accurate tick formatting switch to ' + 'the matplotlib backend.' % values[0].calendar) + values = cftime_to_timestamp(values, 'ms') + new_data[k] = values + return new_data + + def _update_datasource(self, source, data): """ Update datasource with data for a new frame. @@ -237,7 +267,7 @@ def _update_datasource(self, source, data): if not self.document: return - data = {k: decode_bytes(vs) for k, vs in data.items()} + data = self._postprocess_data(data) empty = all(len(v) == 0 for v in data.values()) if (self.streaming and self.streaming[0].data is self.current_frame.data and self._stream_data and not empty): diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index 145bd9a75e..94063666d9 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -3,6 +3,7 @@ import re import time import sys +import calendar import datetime as dt from collections import defaultdict @@ -31,8 +32,9 @@ Chart = type(None) # Create stub for isinstance check from ...core.overlay import Overlay -from ...core.util import (LooseVersion, _getargspec, basestring, - callable_name, dt64_to_dt, pd, unique_array) +from ...core.util import ( + LooseVersion, _getargspec, basestring, callable_name, cftime_types, + cftime_to_timestamp, pd, unique_array) from ...core.spaces import get_nested_dmaps, DynamicMap from ..util import dim_axis_label @@ -208,10 +210,6 @@ def make_axis(axis, size, factors, dim, flip=False, rotation=0, return p -def convert_datetime(time): - return time.astype('datetime64[s]').astype(float)*1000 - - def hsv_to_rgb(hsv): """ Vectorized HSV to RGB conversion, adapted from: @@ -537,15 +535,32 @@ def append_refresh(dmap): def date_to_integer(date): + """Converts support date types to milliseconds since epoch + + Attempts highest precision conversion of different datetime + formats to milliseconds since the epoch (1970-01-01 00:00:00). + If datetime is a cftime with a non-standard calendar the + caveats described in hv.core.util.cftime_to_timestamp apply. + + Args: + date: Date- or datetime-like object + + Returns: + Milliseconds since 1970-01-01 00:00:00 """ - Converts datetime types to bokeh's integer format. - """ - if isinstance(date, np.datetime64): - date = dt64_to_dt(date) if pd and isinstance(date, pd.Timestamp): - dt_int = date.timestamp()*1000 - elif isinstance(date, (dt.datetime, dt.date)): - dt_int = time.mktime(date.timetuple())*1000 + try: + date = date.to_datetime64() + except: + date = date.to_datetime() + + if isinstance(date, np.datetime64): + return time.astype('datetime64[ms]').astype(float) + elif isinstance(date, cftime_types): + return cftime_to_timestamp(date, 'ms') + + if hasattr(date, 'timetuple'): + dt_int = calendar.timegm(date.timetuple())*1000 else: raise ValueError('Datetime type not recognized') return dt_int diff --git a/holoviews/plotting/mpl/element.py b/holoviews/plotting/mpl/element.py index 0e59114239..87689a7924 100644 --- a/holoviews/plotting/mpl/element.py +++ b/holoviews/plotting/mpl/element.py @@ -331,7 +331,8 @@ def _set_axis_limits(self, axis, view, subplots, ranges): if self.invert_xaxis or any(p.invert_xaxis for p in subplots): r, l = l, r - if l != r: + + if isinstance(l, util.cftime_types) or l != r: lims = {} if valid_lim(l): lims['left'] = l @@ -343,7 +344,7 @@ def _set_axis_limits(self, axis, view, subplots, ranges): axis.set_xlim(**lims) if self.invert_yaxis or any(p.invert_yaxis for p in subplots): t, b = b, t - if b != t: + if isinstance(b, util.cftime_types) or b != t: lims = {} if valid_lim(b): lims['bottom'] = b diff --git a/holoviews/plotting/mpl/util.py b/holoviews/plotting/mpl/util.py index 6b37961211..701744afa6 100644 --- a/holoviews/plotting/mpl/util.py +++ b/holoviews/plotting/mpl/util.py @@ -5,6 +5,7 @@ import numpy as np import matplotlib +from matplotlib import units as munits from matplotlib import ticker from matplotlib.colors import cnames from matplotlib.lines import Line2D @@ -14,7 +15,17 @@ from matplotlib.rcsetup import ( validate_capstyle, validate_fontsize, validate_fonttype, validate_hatch, validate_joinstyle) -from ...core.util import LooseVersion, _getargspec, basestring, is_number + +try: + from nc_time_axis import NetCDFTimeConverter, CalendarDateTime + nc_axis_available = True +except: + from matplotlib.dates import DateConverter + NetCDFTimeConverter = DateConverter + nc_axis_available = False + +from ...core.util import ( + LooseVersion, _getargspec, basestring, cftime_types, is_number) from ...element import Raster, RGB, Polygons from ..util import COLOR_ALIASES, RGB_HEX_REGEX @@ -334,3 +345,27 @@ def polygons_to_path_patches(element): subpath.append(PathPatch(Path(vertices, codes))) mpl_paths.append(subpath) return mpl_paths + + +class CFTimeConverter(NetCDFTimeConverter): + """ + Defines conversions for cftime types by extending nc_time_axis. + """ + + @classmethod + def convert(cls, value, unit, axis): + if not nc_axis_available: + raise ValueError('In order to display cftime types with ' + 'matplotlib install the nc_time_axis ' + 'library using pip or from conda-forge ' + 'using:\n\tconda install -c conda-forge ' + 'nc_time_axis') + if isinstance(value, cftime_types): + value = CalendarDateTime(value.datetime, value.calendar) + elif isinstance(value, np.ndarray): + value = np.array([CalendarDateTime(v.datetime, v.calendar) for v in value]) + return super(CFTimeConverter, cls).convert(value, unit, axis) + + +for cft in cftime_types: + munits.registry[cft] = CFTimeConverter() diff --git a/holoviews/tests/core/testutils.py b/holoviews/tests/core/testutils.py index bd37d5d93f..097a1dceb3 100644 --- a/holoviews/tests/core/testutils.py +++ b/holoviews/tests/core/testutils.py @@ -584,15 +584,15 @@ def test_datetime_to_us_int(self): def test_datetime64_s_to_ns_int(self): dt = np.datetime64(datetime.datetime(2017, 1, 1), 's') - self.assertEqual(dt_to_int(dt, 'ns'), 1483228800000000000000.0) + self.assertEqual(dt_to_int(dt, 'ns'), 1483228800000000000.0) def test_datetime64_us_to_ns_int(self): dt = np.datetime64(datetime.datetime(2017, 1, 1), 'us') - self.assertEqual(dt_to_int(dt, 'ns'), 1483228800000000000000.0) + self.assertEqual(dt_to_int(dt, 'ns'), 1483228800000000000.0) def test_datetime64_to_ns_int(self): dt = np.datetime64(datetime.datetime(2017, 1, 1)) - self.assertEqual(dt_to_int(dt, 'ns'), 1483228800000000000000.0) + self.assertEqual(dt_to_int(dt, 'ns'), 1483228800000000000.0) def test_datetime64_us_to_us_int(self): dt = np.datetime64(datetime.datetime(2017, 1, 1), 'us') diff --git a/holoviews/tests/operation/testoperation.py b/holoviews/tests/operation/testoperation.py index 1f1ed744ca..e76770f5e2 100644 --- a/holoviews/tests/operation/testoperation.py +++ b/holoviews/tests/operation/testoperation.py @@ -168,22 +168,30 @@ def test_points_histogram_not_normed(self): def test_histogram_operation_datetime(self): dates = np.array([dt.datetime(2017, 1, i) for i in range(1, 5)]) op_hist = histogram(Dataset(dates, 'Date'), num_bins=4) - hist_data = {'Date': np.array(['2017-01-01T00:00:00.000000', '2017-01-01T17:59:59.999999', - '2017-01-02T12:00:00.000000', '2017-01-03T06:00:00.000000', - '2017-01-04T00:00:00.000000'], dtype='datetime64[us]'), - 'Date_frequency': np.array([ 3.85802469e-18, 3.85802469e-18, 3.85802469e-18, - 3.85802469e-18])} + hist_data = { + 'Date': np.array([ + '2017-01-01T00:00:00.000000', '2017-01-01T18:00:00.000000', + '2017-01-02T12:00:00.000000', '2017-01-03T06:00:00.000000', + '2017-01-04T00:00:00.000000'], dtype='datetime64[us]'), + 'Date_frequency': np.array([ + 3.85802469e-18, 3.85802469e-18, 3.85802469e-18, + 3.85802469e-18]) + } hist = Histogram(hist_data, kdims='Date', vdims=('Date_frequency', 'Frequency')) self.assertEqual(op_hist, hist) def test_histogram_operation_datetime64(self): dates = np.array([dt.datetime(2017, 1, i) for i in range(1, 5)]).astype('M') op_hist = histogram(Dataset(dates, 'Date'), num_bins=4) - hist_data = {'Date': np.array(['2017-01-01T00:00:00.000000', '2017-01-01T17:59:59.999999', - '2017-01-02T12:00:00.000000', '2017-01-03T06:00:00.000000', - '2017-01-04T00:00:00.000000'], dtype='datetime64[us]'), - 'Date_frequency': np.array([ 3.85802469e-18, 3.85802469e-18, 3.85802469e-18, - 3.85802469e-18])} + hist_data = { + 'Date': np.array([ + '2017-01-01T00:00:00.000000', '2017-01-01T18:00:00.000000', + '2017-01-02T12:00:00.000000', '2017-01-03T06:00:00.000000', + '2017-01-04T00:00:00.000000'], dtype='datetime64[us]'), + 'Date_frequency': np.array([ + 3.85802469e-18, 3.85802469e-18, 3.85802469e-18, + 3.85802469e-18]) + } hist = Histogram(hist_data, kdims='Date', vdims=('Date_frequency', 'Frequency')) self.assertEqual(op_hist, hist) @@ -191,11 +199,15 @@ def test_histogram_operation_datetime64(self): def test_histogram_operation_pd_period(self): dates = pd.date_range('2017-01-01', '2017-01-04', freq='D').to_period('D') op_hist = histogram(Dataset(dates, 'Date'), num_bins=4) - hist_data = {'Date': np.array(['2017-01-01T00:00:00.000000', '2017-01-01T17:59:59.999999', - '2017-01-02T12:00:00.000000', '2017-01-03T06:00:00.000000', - '2017-01-04T00:00:00.000000'], dtype='datetime64[us]'), - 'Date_frequency': np.array([ 3.85802469e-18, 3.85802469e-18, 3.85802469e-18, - 3.85802469e-18])} + hist_data = { + 'Date': np.array([ + '2017-01-01T00:00:00.000000', '2017-01-01T18:00:00.000000', + '2017-01-02T12:00:00.000000', '2017-01-03T06:00:00.000000', + '2017-01-04T00:00:00.000000'], dtype='datetime64[us]'), + 'Date_frequency': np.array([ + 3.85802469e-18, 3.85802469e-18, 3.85802469e-18, + 3.85802469e-18]) + } hist = Histogram(hist_data, kdims='Date', vdims=('Date_frequency', 'Frequency')) self.assertEqual(op_hist, hist) diff --git a/holoviews/tests/plotting/bokeh/testelementplot.py b/holoviews/tests/plotting/bokeh/testelementplot.py index 62112e4bad..ac8bece0fe 100644 --- a/holoviews/tests/plotting/bokeh/testelementplot.py +++ b/holoviews/tests/plotting/bokeh/testelementplot.py @@ -10,6 +10,7 @@ from holoviews.plotting.util import process_cmap from .testplot import TestBokehPlot, bokeh_renderer +from ...utils import LoggingComparisonTestCase try: from bokeh.document import Document @@ -19,7 +20,7 @@ -class TestElementPlot(TestBokehPlot): +class TestElementPlot(LoggingComparisonTestCase, TestBokehPlot): def test_element_show_frame_disabled(self): curve = Curve(range(10)).opts(plot=dict(show_frame=False)) @@ -287,7 +288,6 @@ def test_categorical_axis_fontsize(self): curve = Curve([('A', 1), ('B', 2)]).options(fontsize={'minor_xticks': '6pt', 'xticks': 18}) plot = bokeh_renderer.get_plot(curve) xaxis = plot.handles['xaxis'] - print(xaxis.properties_with_values()) self.assertEqual(xaxis.major_label_text_font_size, '6pt') self.assertEqual(xaxis.group_text_font_size, {'value': '18pt'}) @@ -298,6 +298,39 @@ def test_categorical_axis_fontsize_both(self): self.assertEqual(xaxis.major_label_text_font_size, {'value': '18pt'}) self.assertEqual(xaxis.group_text_font_size, {'value': '18pt'}) + def test_cftime_transform_gregorian_no_warn(self): + try: + import cftime + except: + raise SkipTest('Test requires cftime library') + gregorian_dates = [cftime.DatetimeGregorian(2000, 2, 28), + cftime.DatetimeGregorian(2000, 3, 1), + cftime.DatetimeGregorian(2000, 3, 2)] + curve = Curve((gregorian_dates, [1, 2, 3])) + plot = bokeh_renderer.get_plot(curve) + xs = plot.handles['cds'].data['x'] + self.assertEqual(xs.astype('int'), + np.array([951696000000, 951868800000, 951955200000])) + + def test_cftime_transform_noleap_warn(self): + try: + import cftime + except: + raise SkipTest('Test requires cftime library') + gregorian_dates = [cftime.DatetimeNoLeap(2000, 2, 28), + cftime.DatetimeNoLeap(2000, 3, 1), + cftime.DatetimeNoLeap(2000, 3, 2)] + curve = Curve((gregorian_dates, [1, 2, 3])) + plot = bokeh_renderer.get_plot(curve) + xs = plot.handles['cds'].data['x'] + self.assertEqual(xs.astype('int'), + np.array([951696000000, 951868800000, 951955200000])) + substr = ( + "Converting cftime.datetime from a non-standard calendar " + "(noleap) to a standard calendar for plotting. This may " + "lead to subtle errors in formatting dates, for accurate " + "tick formatting switch to the matplotlib backend.") + self.log_handler.assertEndsWith('WARNING', substr) class TestColorbarPlot(TestBokehPlot): diff --git a/holoviews/tests/plotting/bokeh/testhistogramplot.py b/holoviews/tests/plotting/bokeh/testhistogramplot.py index c1f5a28f64..e405daa790 100644 --- a/holoviews/tests/plotting/bokeh/testhistogramplot.py +++ b/holoviews/tests/plotting/bokeh/testhistogramplot.py @@ -59,13 +59,19 @@ def test_histogram_datetime64_plot(self): hist = histogram(Dataset(dates, 'Date'), num_bins=4) plot = bokeh_renderer.get_plot(hist) source = plot.handles['source'] - data = {'top': np.array([ 3.85802469e-18, 3.85802469e-18, 3.85802469e-18, 3.85802469e-18]), - 'left': np.array(['2017-01-01T00:00:00.000000', '2017-01-01T17:59:59.999999', - '2017-01-02T12:00:00.000000', '2017-01-03T06:00:00.000000'], - dtype='datetime64[us]'), - 'right': np.array(['2017-01-01T17:59:59.999999', '2017-01-02T12:00:00.000000', - '2017-01-03T06:00:00.000000', '2017-01-04T00:00:00.000000'], - dtype='datetime64[us]')} + print(source.data) + data = { + 'top': np.array([ + 3.85802469e-18, 3.85802469e-18, 3.85802469e-18, 3.85802469e-18]), + 'left': np.array([ + '2017-01-01T00:00:00.000000', '2017-01-01T18:00:00.000000', + '2017-01-02T12:00:00.000000', '2017-01-03T06:00:00.000000'], + dtype='datetime64[us]'), + 'right': np.array([ + '2017-01-01T18:00:00.000000', '2017-01-02T12:00:00.000000', + '2017-01-03T06:00:00.000000', '2017-01-04T00:00:00.000000'], + dtype='datetime64[us]') + } for k, v in data.items(): self.assertEqual(source.data[k], v) xaxis = plot.handles['xaxis']