diff --git a/holoviews/__init__.py b/holoviews/__init__.py index 5ff09d0fe3..308fccc0b9 100644 --- a/holoviews/__init__.py +++ b/holoviews/__init__.py @@ -24,6 +24,7 @@ from .interface import * # noqa (API import) from .operation import ElementOperation, MapOperation, TreeOperation # noqa (API import) from .element import * # noqa (API import) +from . import util # noqa (API import) # Surpress warnings generated by NumPy in matplotlib # Expected to be fixed in next matplotlib release diff --git a/holoviews/core/dimension.py b/holoviews/core/dimension.py index b1495be019..5905075ddd 100644 --- a/holoviews/core/dimension.py +++ b/holoviews/core/dimension.py @@ -344,13 +344,15 @@ def relabel(self, label=None, group=None, depth=0): Assign a new label and/or group to an existing LabelledData object, creating a clone of the object with the new settings. """ - keywords = [('label',label), ('group',group)] - obj = self.clone(self.data, - **{k:v for k,v in keywords if v is not None}) - if (depth > 0) and getattr(obj, '_deep_indexable', False): - for k, v in obj.items(): - obj[k] = v.relabel(group=group, label=label, depth=depth-1) - return obj + new_data = self.data + if (depth > 0) and getattr(self, '_deep_indexable', False): + new_data = [] + for k, v in self.data.items(): + relabelled = v.relabel(group=group, label=label, depth=depth-1) + new_data.append((k, relabelled)) + keywords = [('label', label), ('group', group)] + kwargs = {k: v for k, v in keywords if v is not None} + return self.clone(new_data, **kwargs) def matches(self, spec): @@ -417,6 +419,7 @@ def map(self, map_fn, specs=None, clone=True): Recursively replaces elements using a map function when the specification applies. """ + if specs and not isinstance(specs, list): specs = [specs] applies = specs is None or any(self.matches(spec) for spec in specs) if self._deep_indexable: @@ -760,6 +763,8 @@ def select(self, selection_specs=None, **kwargs): # Check selection_spec applies if selection_specs is not None: + if not isinstance(selection_specs, (list, tuple)): + selection_specs = [selection_specs] matches = any(self.matches(spec) for spec in selection_specs) else: @@ -767,8 +772,9 @@ def select(self, selection_specs=None, **kwargs): # Apply selection to self if local_kwargs and matches: - ndims = (len(self.dimensions()) if any(d in self.vdims for d in kwargs) - else self.ndims) + ndims = self.ndims + if any(d in self.vdims for d in kwargs): + ndims = len(self.kdims+self.vdims) select = [slice(None) for _ in range(ndims)] for dim, val in local_kwargs.items(): if dim == 'value': diff --git a/holoviews/core/ndmapping.py b/holoviews/core/ndmapping.py index 62eddf8ef3..79fda801c6 100644 --- a/holoviews/core/ndmapping.py +++ b/holoviews/core/ndmapping.py @@ -219,6 +219,9 @@ def _split_index(self, key): """ if not isinstance(key, tuple): key = (key,) + elif key == (): + return (), () + if key[0] is Ellipsis: num_pad = self.ndims - len(key) + 1 key = (slice(None),) * num_pad + key[1:] diff --git a/holoviews/core/spaces.py b/holoviews/core/spaces.py index 3724a76263..d2c2872de8 100644 --- a/holoviews/core/spaces.py +++ b/holoviews/core/spaces.py @@ -360,7 +360,6 @@ def relabel(self, label=None, group=None, depth=1): return super(HoloMap, self).relabel(label=label, group=group, depth=depth) - def hist(self, num_bins=20, bin_range=None, adjoin=True, individually=True, **kwargs): histmaps = [self.clone(shared_data=False) for _ in kwargs.get('dimension', range(1))] @@ -655,7 +654,7 @@ def reset(self): return self - def _cross_product(self, tuple_key, cache): + def _cross_product(self, tuple_key, cache, data_slice): """ Returns a new DynamicMap if the key (tuple form) expresses a cross product, otherwise returns None. The cache argument is a @@ -664,7 +663,10 @@ def _cross_product(self, tuple_key, cache): Each key inside the cross product is looked up in the cache (self.data) to check if the appropriate element is - available. Oherwise the element is computed accordingly. + available. Otherwise the element is computed accordingly. + + The data_slice may specify slices into each value in the + the cross-product. """ if self.mode != 'bounded': return None if not any(isinstance(el, (list, set)) for el in tuple_key): @@ -683,47 +685,73 @@ def _cross_product(self, tuple_key, cache): val = cache[key] else: val = self._execute_callback(*key) + if data_slice: + val = self._dataslice(val, data_slice) data.append((key, val)) - return self.clone(data) + product = self.clone(data) + + if data_slice: + from ..util import Dynamic + return Dynamic(product, operation=lambda obj: obj[data_slice], + shared_data=True) + return product - def _slice_bounded(self, tuple_key): + def _slice_bounded(self, tuple_key, data_slice): """ - Slices bounded DynamicMaps by setting the soft_ranges on key dimensions. + Slices bounded DynamicMaps by setting the soft_ranges on + key dimensions and applies data slice to cached and dynamic + values. """ - cloned = self.clone(self) + slices = [el for el in tuple_key if isinstance(el, slice)] + if any(el.step for el in slices): + raise Exception("Slices cannot have a step argument " + "in DynamicMap bounded mode ") + elif len(slices) not in [0, len(tuple_key)]: + raise Exception("Slices must be used exclusively or not at all") + elif not slices: + return None + + sliced = self.clone(self) for i, slc in enumerate(tuple_key): (start, stop) = slc.start, slc.stop - if start is not None and start < cloned.kdims[i].range[0]: + if start is not None and start < sliced.kdims[i].range[0]: raise Exception("Requested slice below defined dimension range.") - if stop is not None and stop > cloned.kdims[i].range[1]: + if stop is not None and stop > sliced.kdims[i].range[1]: raise Exception("Requested slice above defined dimension range.") - cloned.kdims[i].soft_range = (start, stop) - return cloned + sliced.kdims[i].soft_range = (start, stop) + if data_slice: + if not isinstance(sliced, DynamicMap): + return self._dataslice(sliced, data_slice) + else: + from ..util import Dynamic + if len(self): + slices = [slice(None) for _ in range(self.ndims)] + list(data_slice) + sliced = super(DynamicMap, sliced).__getitem__(tuple(slices)) + return Dynamic(sliced, operation=lambda obj: obj[data_slice], + shared_data=True) + return sliced def __getitem__(self, key): """ Return an element for any key chosen key (in'bounded mode') or for a previously generated key that is still in the cache - (for one of the 'open' modes) + (for one of the 'open' modes). Also allows for usual deep + slicing semantics by slicing values in the cache and applying + the deep slice to newly generated values. """ - tuple_key = util.wrap_tuple_streams(key, self.kdims, self.streams) + # Split key dimensions and data slices + if key is Ellipsis: + return self + map_slice, data_slice = self._split_index(key) + tuple_key = util.wrap_tuple_streams(map_slice, self.kdims, self.streams) # Validation for bounded mode if self.mode == 'bounded': - # DynamicMap(...)[:] returns a new DynamicMap with the same cache - if key == slice(None, None, None): - return self.clone(self) - - slices = [el for el in tuple_key if isinstance(el, slice)] - if any(el.step for el in slices): - raise Exception("Slices cannot have a step argument " - "in DynamicMap bounded mode ") - if len(slices) not in [0, len(tuple_key)]: - raise Exception("Slices must be used exclusively or not at all") - if slices: - return self._slice_bounded(tuple_key) + sliced = self._slice_bounded(tuple_key, data_slice) + if sliced is not None: + return sliced # Cache lookup try: @@ -742,7 +770,7 @@ def __getitem__(self, key): "available cache in open interval mode.") # If the key expresses a cross product, compute the elements and return - product = self._cross_product(tuple_key, cache.data if cache else {}) + product = self._cross_product(tuple_key, cache.data if cache else {}, data_slice) if product is not None: return product @@ -752,10 +780,44 @@ def __getitem__(self, key): if self.call_mode == 'counter': val = val[1] + if data_slice: + val = self._dataslice(val, data_slice) self._cache(tuple_key, val) return val + def select(self, selection_specs=None, **kwargs): + """ + Allows slicing or indexing into the DynamicMap objects by + supplying the dimension and index/slice as key value + pairs. Select descends recursively through the data structure + applying the key dimension selection and applies to dynamically + generated items by wrapping the callback. + + The selection may also be selectively applied to specific + objects by supplying the selection_specs as an iterable of + type.group.label specs, types or functions. + """ + if selection_specs is not None and not isinstance(selection_specs, (list, tuple)): + selection_specs = [selection_specs] + selection = super(DynamicMap, self).select(selection_specs, **kwargs) + def dynamic_select(obj): + if selection_specs is not None: + matches = any(obj.matches(spec) for spec in selection_specs) + else: + matches = True + if matches: + return obj.select(**kwargs) + return obj + + if not isinstance(selection, DynamicMap): + return dynamic_select(selection) + else: + from ..util import Dynamic + return Dynamic(selection, operation=dynamic_select, + shared_data=True) + + def _cache(self, key, val): """ Request that a key/value pair be considered for caching. @@ -795,6 +857,35 @@ def next(self): return val + def relabel(self, label=None, group=None, depth=1): + """ + Assign a new label and/or group to an existing LabelledData + object, creating a clone of the object with the new settings. + """ + relabelled = super(DynamicMap, self).relabel(label, group, depth) + if depth > 0: + from ..util import Dynamic + def dynamic_relabel(obj): + return obj.relabel(group=group, label=label, depth=depth-1) + return Dynamic(relabelled, shared_data=True, operation=dynamic_relabel) + return relabelled + + + def redim(self, specs=None, **dimensions): + """ + Replaces existing dimensions in an object with new dimensions + or changing specific attributes of a dimensions. Dimension + mapping should map between the old dimension name and a + dictionary of the new attributes, a completely new dimension + or a new string name. + """ + redimmed = super(DynamicMap, self).redim(specs, **dimensions) + from ..util import Dynamic + def dynamic_redim(obj): + return obj.redim(specs, **dimensions) + return Dynamic(redimmed, shared_data=True, operation=dynamic_redim) + + def groupby(self, dimensions=None, container_type=None, group_type=None, **kwargs): """ Implements a dynamic version of a groupby, which will diff --git a/holoviews/util.py b/holoviews/util.py index 3961b3c084..71487e03a6 100644 --- a/holoviews/util.py +++ b/holoviews/util.py @@ -28,6 +28,9 @@ class Dynamic(param.ParameterizedFunction): kwargs = param.Dict(default={}, doc=""" Keyword arguments passed to the function.""") + shared_data = param.Boolean(default=False, doc=""" + Whether the cloned DynamicMap will share the same cache.""") + streams = param.List(default=[], doc=""" List of streams to attach to the returned DynamicMap""") @@ -35,7 +38,7 @@ def __call__(self, map_obj, **params): self.p = param.ParamOverrides(self, params) callback = self._dynamic_operation(map_obj) if isinstance(map_obj, DynamicMap): - dmap = map_obj.clone(callback=callback, shared_data=False, + dmap = map_obj.clone(callback=callback, shared_data=self.p.shared_data, streams=[]) else: dmap = self._make_dynamic(map_obj, callback) diff --git a/tests/testdynamic.py b/tests/testdynamic.py index 20c27bf09f..1930e09dd4 100644 --- a/tests/testdynamic.py +++ b/tests/testdynamic.py @@ -1,5 +1,5 @@ import numpy as np -from holoviews import Dimension, DynamicMap, Image, HoloMap +from holoviews import Dimension, DynamicMap, Image, HoloMap, Scatter, Curve from holoviews.util import Dynamic from holoviews.element.comparison import ComparisonTestCase @@ -11,6 +11,77 @@ def sine_array(phase, freq): return np.sin(phase + (freq*x**2+freq*y**2)) +class DynamicMethods(ComparisonTestCase): + + def test_deep_relabel_label(self): + fn = lambda i: Image(sine_array(0,i)) + dmap = DynamicMap(fn).relabel(label='Test') + self.assertEqual(dmap[0].label, 'Test') + + def test_deep_relabel_group(self): + fn = lambda i: Image(sine_array(0,i)) + dmap = DynamicMap(fn).relabel(group='Test') + self.assertEqual(dmap[0].group, 'Test') + + def test_redim_dimension_name(self): + fn = lambda i: Image(sine_array(0,i)) + dmap = DynamicMap(fn).redim(Default='New') + self.assertEqual(dmap.kdims[0].name, 'New') + + def test_deep_redim_dimension_name(self): + fn = lambda i: Image(sine_array(0,i)) + dmap = DynamicMap(fn).redim(x='X') + self.assertEqual(dmap[0].kdims[0].name, 'X') + + def test_deep_redim_dimension_name_with_spec(self): + fn = lambda i: Image(sine_array(0,i)) + dmap = DynamicMap(fn).redim(Image, x='X') + self.assertEqual(dmap[0].kdims[0].name, 'X') + + def test_deep_getitem_bounded_kdims(self): + fn = lambda i: Curve(np.arange(i)) + dmap = DynamicMap(fn, kdims=[Dimension('Test', range=(10, 20))]) + self.assertEqual(dmap[:, 5:10][10], fn(10)[5:10]) + + def test_deep_getitem_bounded_kdims_and_vdims(self): + fn = lambda i: Curve(np.arange(i)) + dmap = DynamicMap(fn, kdims=[Dimension('Test', range=(10, 20))]) + self.assertEqual(dmap[:, 5:10, 0:5][10], fn(10)[5:10, 0:5]) + + def test_deep_getitem_cross_product_and_slice(self): + fn = lambda i: Curve(np.arange(i)) + dmap = DynamicMap(fn, kdims=[Dimension('Test', range=(10, 20))]) + self.assertEqual(dmap[[10, 11, 12], 5:10], + dmap.clone([(i, fn(i)[5:10]) for i in range(10, 13)])) + + def test_deep_getitem_index_and_slice(self): + fn = lambda i: Curve(np.arange(i)) + dmap = DynamicMap(fn, kdims=[Dimension('Test', range=(10, 20))]) + self.assertEqual(dmap[10, 5:10], fn(10)[5:10]) + + def test_deep_getitem_cache_sliced(self): + fn = lambda i: Curve(np.arange(i)) + dmap = DynamicMap(fn, kdims=[Dimension('Test', range=(10, 20))]) + dmap[10] # Add item to cache + self.assertEqual(dmap[:, 5:10][10], fn(10)[5:10]) + + def test_deep_select_slice_kdim(self): + fn = lambda i: Curve(np.arange(i)) + dmap = DynamicMap(fn, kdims=[Dimension('Test', range=(10, 20))]) + self.assertEqual(dmap.select(x=(5, 10))[10], fn(10)[5:10]) + + def test_deep_select_slice_kdim_and_vdims(self): + fn = lambda i: Curve(np.arange(i)) + dmap = DynamicMap(fn, kdims=[Dimension('Test', range=(10, 20))]) + self.assertEqual(dmap.select(x=(5, 10), y=(0, 5))[10], fn(10)[5:10, 0:5]) + + def test_deep_select_slice_kdim_no_match(self): + fn = lambda i: Curve(np.arange(i)) + dmap = DynamicMap(fn, kdims=[Dimension('Test', range=(10, 20))]) + self.assertEqual(dmap.select(DynamicMap, x=(5, 10))[10], fn(10)) + + + class DynamicTestGeneratorOpen(ComparisonTestCase):