diff --git a/holoviews/core/operation.py b/holoviews/core/operation.py index aa06051ec4..17a0bbd381 100644 --- a/holoviews/core/operation.py +++ b/holoviews/core/operation.py @@ -5,12 +5,18 @@ from functools import reduce import param +try: + from itertools import izip as zip +except: + pass + from .dimension import ViewableElement from .element import Element, HoloMap, GridSpace, Collator from .layout import Layout from .overlay import NdOverlay, Overlay +from .spaces import DynamicMap from .traversal import unique_dimkeys - +from . import util class Operation(param.ParameterizedFunction): @@ -64,6 +70,79 @@ def get_overlay_bounds(cls, overlay): raise ValueError("Extents across the overlay are inconsistent") +class DynamicOperation(Operation): + """ + Dynamically applies an operation to the elements of a HoloMap + or DynamicMap. Will return a DynamicMap wrapping the original + map object, which will lazily evaluate when a key is requested. + The _process method should be overridden in subclasses to apply + a specific operation, DynamicOperation itself applies a no-op, + making the DynamicOperation baseclass useful for converting + existing HoloMaps to a DynamicMap. + """ + + def __call__(self, map_obj, **params): + self.p = param.ParamOverrides(self, params) + callback = self._dynamic_operation(map_obj) + if isinstance(map_obj, DynamicMap): + return map_obj.clone(callback=callback, shared_data=False) + else: + return self._make_dynamic(map_obj, callback) + + + def _process(self, element): + return element + + + def _dynamic_operation(self, map_obj): + """ + Generate function to dynamically apply the operation. + Wraps an existing HoloMap or DynamicMap. + """ + if not isinstance(map_obj, DynamicMap): + def dynamic_operation(*key): + return self._process(map_obj[key]) + return dynamic_operation + + def dynamic_operation(*key): + key = key[0] if map_obj.mode == 'open' else key + _, el = util.get_dynamic_item(map_obj, map_obj.kdims, key) + return self._process(el) + + return dynamic_operation + + + def _make_dynamic(self, hmap, dynamic_fn): + """ + Accepts a HoloMap and a dynamic callback function creating + an equivalent DynamicMap from the HoloMap. + """ + dim_values = zip(*hmap.data.keys()) + params = util.get_param_values(hmap) + kdims = [d(values=list(values)) for d, values in zip(hmap.kdims, dim_values)] + return DynamicMap(dynamic_fn, **dict(params, kdims=kdims)) + + + +class DynamicFunction(DynamicOperation): + """ + Dynamically applies a function to the Elements in a DynamicMap + or HoloMap. Must supply a HoloMap or DynamicMap type and will + return another DynamicMap type, which will apply the supplied + function with the supplied kwargs whenever a value is requested + from the map. + """ + + function = param.Callable(default=lambda x: x, doc=""" + Function to apply to DynamicMap items dynamically.""") + + kwargs = param.Dict(default={}, doc=""" + Keyword arguments passed to the function.""") + + def _process(self, element): + return self.p.function(element, **self.p.kwargs) + + class ElementOperation(Operation): """ @@ -74,6 +153,13 @@ class ElementOperation(Operation): ElementOperation may turn overlays in new elements or vice versa. """ + dynamic = param.ObjectSelector(default='default', + objects=['default', True, False], doc=""" + Whether the operation should be applied dynamically when a + specific frame is requested, specified as a Boolean. If set to + 'default' the mode will be determined based on the input type, + i.e. if the data is a DynamicMap it will stay dynamic.""") + input_ranges = param.ClassSelector(default={}, class_=(dict, tuple), doc=""" Ranges to be used for input normalization (if applicable) in a @@ -106,16 +192,27 @@ def process_element(self, element, key, **params): def __call__(self, element, **params): self.p = param.ParamOverrides(self, params) + dynamic = ((self.p.dynamic == 'default' and + isinstance(element, DynamicMap)) + or self.p.dynamic is True) if isinstance(element, ViewableElement): processed = self._process(element) elif isinstance(element, GridSpace): # Initialize an empty axis layout - processed = GridSpace(None, label=element.label, + grid_data = ((pos, self(cell, **params)) + for pos, cell in element.items()) + processed = GridSpace(grid_data, label=element.label, kdims=element.kdims) - # Populate the axis layout - for pos, cell in element.items(): - processed[pos] = self(cell, **params) + elif dynamic: + processed = DynamicFunction(element, function=self, kwargs=params) + elif isinstance(element, DynamicMap): + if any((not d.values) for d in element.kdims): + raise ValueError('Applying a non-dynamic operation requires ' + 'all DynamicMap key dimensions to define ' + 'the sampling by specifying values.') + samples = tuple(d.values for d in element.kdims) + processed = self(element[samples], **params) elif isinstance(element, HoloMap): mapped_items = [(k, self._process(el, key=k)) for k, el in element.items()] diff --git a/holoviews/core/overlay.py b/holoviews/core/overlay.py index 69e0fbf21c..0c2b425323 100644 --- a/holoviews/core/overlay.py +++ b/holoviews/core/overlay.py @@ -23,6 +23,11 @@ class Overlayable(object): """ def __mul__(self, other): + if type(other).__name__ == 'DynamicMap': + from .operation import DynamicFunction + def dynamic_mul(element): + return self * element + return DynamicFunction(other, function=dynamic_mul) if isinstance(other, UniformNdMapping) and not isinstance(other, CompositeOverlay): items = [(k, self * v) for (k, v) in other.items()] return other.clone(items) diff --git a/holoviews/core/spaces.py b/holoviews/core/spaces.py index eb119e23d4..852d2cd373 100644 --- a/holoviews/core/spaces.py +++ b/holoviews/core/spaces.py @@ -1,5 +1,6 @@ from numbers import Number import itertools +from itertools import groupby import numpy as np import param @@ -97,6 +98,56 @@ def _dimension_keys(self): for k in self.keys()] + def _dynamic_mul(self, dimensions, other, keys): + """ + Implements dynamic version of overlaying operation overlaying + DynamicMaps and HoloMaps where the key dimensions of one is + a strict superset of the other. + """ + # If either is a HoloMap compute Dimension values + if not isinstance(self, DynamicMap) or not isinstance(other, DynamicMap): + keys = sorted((d, v) for k in keys for d, v in k) + grouped = dict([(g, [v for _, v in group]) + for g, group in groupby(keys, lambda x: x[0])]) + dimensions = [d(values=grouped[d.name]) for d in dimensions] + mode = 'bounded' + map_obj = None + elif (isinstance(self, DynamicMap) and (other, DynamicMap) and + self.mode != other.mode): + raise ValueEror("Cannot overlay DynamicMaps with mismatching mode.") + else: + map_obj = self if isinstance(self, DynamicMap) else other + mode = map_obj.mode + + def dynamic_mul(*key): + key = key[0] if mode == 'open' else key + layers = [] + try: + if isinstance(self, DynamicMap): + _, self_el = util.get_dynamic_item(self, dimensions, key) + if self_el is not None: + layers.append(self_el) + else: + layers.append(self[key]) + except KeyError: + pass + try: + if isinstance(other, DynamicMap): + _, other_el = util.get_dynamic_item(other, dimensions, key) + if other_el is not None: + layers.append(other_el) + else: + layers.append(other[key]) + except KeyError: + pass + return Overlay(layers) + if map_obj: + return map_obj.clone(callback=dynamic_mul, shared_data=False, + kdims=dimensions) + else: + return DynamicMap(callback=dynamic_mul, kdims=dimensions) + + def __mul__(self, other): """ The mul (*) operator implements overlaying of different Views. @@ -108,7 +159,7 @@ def __mul__(self, other): will try to match up the dimensions, making sure that items with completely different dimensions aren't overlaid. """ - if isinstance(other, self.__class__): + if isinstance(other, HoloMap): self_set = {d.name for d in self.kdims} other_set = {d.name for d in other.kdims} @@ -117,8 +168,10 @@ def __mul__(self, other): self_in_other = self_set.issubset(other_set) other_in_self = other_set.issubset(self_set) dimensions = self.kdims + if self_in_other and other_in_self: # superset of each other - super_keys = sorted(set(self._dimension_keys() + other._dimension_keys())) + keys = self._dimension_keys() + other._dimension_keys() + super_keys = util.unique_iterator(keys) elif self_in_other: # self is superset dimensions = other.kdims super_keys = other._dimension_keys() @@ -127,6 +180,9 @@ def __mul__(self, other): else: # neither is superset raise Exception('One set of keys needs to be a strict subset of the other.') + if isinstance(self, DynamicMap) or isinstance(other, DynamicMap): + return self._dynamic_mul(dimensions, other, super_keys) + items = [] for dim_keys in super_keys: # Generate keys for both subset and superset and sort them by the dimension index. @@ -146,6 +202,11 @@ def __mul__(self, other): items.append((new_key, Overlay([other[other_key]]))) return self.clone(items, kdims=dimensions, label=self._label, group=self._group) elif isinstance(other, self.data_type): + if isinstance(self, DynamicMap): + from .operation import DynamicFunction + def dynamic_mul(element): + return element * other + return DynamicFunction(self, function=dynamic_mul) items = [(k, v * other) for (k, v) in self.data.items()] return self.clone(items, label=self._label, group=self._group) else: diff --git a/holoviews/core/util.py b/holoviews/core/util.py index b861519568..08a4ba6e02 100644 --- a/holoviews/core/util.py +++ b/holoviews/core/util.py @@ -886,3 +886,27 @@ def arglexsort(arrays): recarray['f%s' % i] = array return recarray.argsort() + +def get_dynamic_item(map_obj, dimensions, key): + """ + Looks up an item in a DynamicMap given a list of dimensions + and a corresponding key. The dimensions must be a subset + of the map_obj key dimensions. + """ + if isinstance(key, tuple): + dims = {d.name: k for d, k in zip(dimensions, key) + if d in map_obj.kdims} + key = tuple(dims.get(d.name) for d in map_obj.kdims) + el = map_obj.select([lambda x: type(x).__name__ == 'DynamicMap'], + **dims) + elif key < map_obj.counter: + key_offset = max([key-map_obj.cache_size, 0]) + key = map_obj.keys()[min([key-key_offset, + len(map_obj)-1])] + el = map_obj[key] + elif key >= map_obj.counter: + el = next(map_obj) + key = list(map_obj.keys())[-1] + else: + el = None + return key, el diff --git a/holoviews/plotting/plot.py b/holoviews/plotting/plot.py index 52557504e1..621a8242a4 100644 --- a/holoviews/plotting/plot.py +++ b/holoviews/plotting/plot.py @@ -515,19 +515,7 @@ def _get_frame(self, key): self.current_key = key return self.current_frame elif self.dynamic: - if isinstance(key, tuple): - dims = {d.name: k for d, k in zip(self.dimensions, key) - if d in self.hmap.kdims} - frame = self.hmap.select([DynamicMap], **dims) - elif key < self.hmap.counter: - key_offset = max([key-self.hmap.cache_size, 0]) - key = self.hmap.keys()[min([key-key_offset, len(self.hmap)-1])] - frame = self.hmap[key] - elif key >= self.hmap.counter: - frame = next(self.hmap) - key = self.hmap.keys()[-1] - else: - frame = None + key, frame = util.get_dynamic_item(self.hmap, self.dimensions, key) if not isinstance(key, tuple): key = (key,) if not key in self.keys: self.keys.append(key) diff --git a/tests/testdynamic.py b/tests/testdynamic.py index cc7c446205..162203c5db 100644 --- a/tests/testdynamic.py +++ b/tests/testdynamic.py @@ -1,5 +1,6 @@ import numpy as np -from holoviews import Dimension, DynamicMap, Image +from holoviews import Dimension, DynamicMap, Image, HoloMap +from holoviews.core.operation import DynamicFunction from holoviews.element.comparison import ComparisonTestCase frequencies = np.linspace(0.5,2.0,5) @@ -78,3 +79,55 @@ def test_sampled_bounded_resample(self): dmap=DynamicMap(fn, sampled=True) self.assertEqual(dmap[{0, 1, 2}].keys(), [0, 1, 2]) + +class DynamicTestOperation(ComparisonTestCase): + + def test_dynamic_function(self): + fn = lambda i: Image(sine_array(0,i)) + dmap=DynamicMap(fn, sampled=True) + dmap_with_fn = DynamicFunction(dmap, function=lambda x: x.clone(x.data*2)) + self.assertEqual(dmap_with_fn[5], Image(sine_array(0,5)*2)) + + + def test_dynamic_function_with_kwargs(self): + fn = lambda i: Image(sine_array(0,i)) + dmap=DynamicMap(fn, sampled=True) + def fn(x, multiplier=2): + return x.clone(x.data*multiplier) + dmap_with_fn = DynamicFunction(dmap, function=fn, kwargs=dict(multiplier=3)) + self.assertEqual(dmap_with_fn[5], Image(sine_array(0,5)*3)) + + + +class DynamicTestOverlay(ComparisonTestCase): + + def test_dynamic_element_overlay(self): + fn = lambda i: Image(sine_array(0,i)) + dmap=DynamicMap(fn, sampled=True) + dynamic_overlay = dmap * Image(sine_array(0,10)) + overlaid = Image(sine_array(0,5)) * Image(sine_array(0,10)) + self.assertEqual(dynamic_overlay[5], overlaid) + + def test_dynamic_element_underlay(self): + fn = lambda i: Image(sine_array(0,i)) + dmap=DynamicMap(fn, sampled=True) + dynamic_overlay = Image(sine_array(0,10)) * dmap + overlaid = Image(sine_array(0,10)) * Image(sine_array(0,5)) + self.assertEqual(dynamic_overlay[5], overlaid) + + def test_dynamic_dynamicmap_overlay(self): + fn = lambda i: Image(sine_array(0,i)) + dmap=DynamicMap(fn, sampled=True) + fn2 = lambda i: Image(sine_array(0,i*2)) + dmap2=DynamicMap(fn2, sampled=True) + dynamic_overlay = dmap * dmap2 + overlaid = Image(sine_array(0,5)) * Image(sine_array(0,10)) + self.assertEqual(dynamic_overlay[5], overlaid) + + def test_dynamic_holomap_overlay(self): + fn = lambda i: Image(sine_array(0,i)) + dmap = DynamicMap(fn, sampled=True) + hmap = HoloMap({i: Image(sine_array(0,i*2)) for i in range(10)}) + dynamic_overlay = dmap * hmap + overlaid = Image(sine_array(0,5)) * Image(sine_array(0,10)) + self.assertEqual(dynamic_overlay[5], overlaid)