Repository: holoviz/holoviews
Pull request #6271 (2024-06-10 09:48:50)

Ensure downsample works with non-contiguous arrays
Many of the downsampling algorithms do not work with non-contiguous arrays. Therefore we ensure they are contiguous before passing them to the downsampling operation.


Patch:
diff --git a/holoviews/operation/downsample.py b/holoviews/operation/downsample.py
index 50cdd7ff6..77259b581 100644
--- a/holoviews/operation/downsample.py
+++ b/holoviews/operation/downsample.py
@@ -100,6 +100,13 @@ def _lttb_inner(x, y, n_out, sampled_x, offset):
     )
 
 
+def _ensure_contiguous(x, y):
+    """
+    Ensures the arrays are contiguous in memory (required by tsdownsample).
+    """
+    return np.ascontiguousarray(x), np.ascontiguousarray(y)
+
+
 def _lttb(x, y, n_out, **kwargs):
     """
     Downsample the data using the LTTB algorithm.
@@ -115,6 +122,7 @@ def _lttb(x, y, n_out, **kwargs):
     """
     try:
         from tsdownsample import LTTBDownsampler
+        x, y = _ensure_contiguous(x, y)
         return LTTBDownsampler().downsample(x, y, n_out=n_out, **kwargs)
     except ModuleNotFoundError:
         pass
@@ -168,6 +176,7 @@ def _min_max(x, y, n_out, **kwargs):
             'The min-max downsampling algorithm requires the tsdownsample '
             'library to be installed.'
         ) from None
+    x, y = _ensure_contiguous(x, y)
     return MinMaxDownsampler().downsample(x, y, n_out=n_out, **kwargs)
 
 def _min_max_lttb(x, y, n_out, **kwargs):
@@ -178,6 +187,7 @@ def _min_max_lttb(x, y, n_out, **kwargs):
             'The minmax-lttb downsampling algorithm requires the tsdownsample '
             'library to be installed.'
         ) from None
+    x, y = _ensure_contiguous(x, y)
     return MinMaxLTTBDownsampler().downsample(x, y, n_out=n_out, **kwargs)
 
 def _m4(x, y, n_out, **kwargs):
@@ -188,6 +198,7 @@ def _m4(x, y, n_out, **kwargs):
             'The m4 downsampling algorithm requires the tsdownsample '
             'library to be installed.'
         ) from None
+    x, y = _ensure_contiguous(x, y)
     n_out = n_out - (n_out % 4)  # n_out must be a multiple of 4
     return M4Downsampler().downsample(x, y, n_out=n_out, **kwargs)
 
diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py
index ae500ffb1..8015bd1e6 100644
--- a/holoviews/plotting/bokeh/element.py
+++ b/holoviews/plotting/bokeh/element.py
@@ -3004,6 +3004,10 @@ class OverlayPlot(GenericOverlayPlot, LegendPlot):
                 return v._y_range_type
         return self._y_range_type
 
+    @property
+    def _is_batched(self):
+        return super()._is_batched and not self.subcoordinate_y
+
     def _process_legend(self, overlay):
         plot = self.handles['plot']
         subplots = self.traverse(lambda x: x, [lambda x: x is not self])
diff --git a/holoviews/plotting/plot.py b/holoviews/plotting/plot.py
index 774842989..6ac6ef052 100644
--- a/holoviews/plotting/plot.py
+++ b/holoviews/plotting/plot.py
@@ -1797,10 +1797,14 @@ class GenericOverlayPlot(GenericElementPlot):
             collapsed = Compositor.collapse(holomap, (ranges, frame_ranges.keys()), mode='display')
         return collapsed
 
+    @property
+    def _is_batched(self):
+        return self.batched and type(self.hmap.last) is NdOverlay
+
     def _create_subplots(self, ranges):
         # Check if plot should be batched
         ordering = util.layer_sort(self.hmap)
-        batched = self.batched and type(self.hmap.last) is NdOverlay
+        batched = self._is_batched
         if batched:
             backend = self.renderer.backend
             batchedplot = Store.registry[backend].get(self.hmap.last.type)


Test patch:
diff --git a/holoviews/tests/core/test_layers.py b/holoviews/tests/core/test_layers.py
index 35a21e261..5eb0acb69 100644
--- a/holoviews/tests/core/test_layers.py
+++ b/holoviews/tests/core/test_layers.py
@@ -2,7 +2,7 @@ import unittest
 
 import numpy as np
 
-from holoviews import Element, NdOverlay
+from holoviews import Curve, Element, NdOverlay, Overlay
 
 
 class CompositeTest(unittest.TestCase):
@@ -29,6 +29,12 @@ class OverlayTest(CompositeTest):
         for el, v in zip(overlay, views):
             self.assertEqual(el, v)
 
+    def test_overlay_iterable(self):
+        # Related to https://github.com/holoviz/holoviews/issues/5315
+        c1 = Curve([0, 1])
+        c2 = Curve([10, 20])
+        Overlay({'a': c1, 'b': c2}.values())
+
     def test_overlay_integer_indexing(self):
         overlay = NdOverlay(list(enumerate([self.view1, self.view2, self.view3])))
         self.assertEqual(overlay[0], self.view1)
diff --git a/holoviews/tests/operation/test_downsample.py b/holoviews/tests/operation/test_downsample.py
index 3ca6a4c92..e030696ff 100644
--- a/holoviews/tests/operation/test_downsample.py
+++ b/holoviews/tests/operation/test_downsample.py
@@ -2,7 +2,8 @@ import numpy as np
 import pandas as pd
 import pytest
 
-import holoviews as hv
+from holoviews.core.overlay import NdOverlay, Overlay
+from holoviews.element import Curve
 from holoviews.operation.downsample import _ALGORITHMS, downsample1d
 
 try:
@@ -20,9 +21,9 @@ def test_downsample1d_multi(plottype):
     assert N > downsample1d.width
 
     if plottype == "overlay":
-        figure = hv.Overlay([hv.Curve(range(N)), hv.Curve(range(N))])
+        figure = Overlay([Curve(range(N)), Curve(range(N))])
     elif plottype == "ndoverlay":
-        figure = hv.NdOverlay({"A": hv.Curve(range(N)), "B": hv.Curve(range(N))})
+        figure = NdOverlay({"A": Curve(range(N)), "B": Curve(range(N))})
 
     figure_values = downsample1d(figure, dynamic=False).data.values()
     for n in figure_values:
@@ -30,6 +31,16 @@ def test_downsample1d_multi(plottype):
             assert value.size == downsample1d.width
 
 
+@pytest.mark.skipif(not tsdownsample, reason="tsdownsample not installed")
+@pytest.mark.parametrize("algorithm", algorithms)
+def test_downsample1d_non_contiguous(algorithm):
+    x = np.arange(20)
+    y = np.arange(40).reshape(1, 40)[0, ::2]
+
+    downsampled = downsample1d(Curve((x, y), datatype=['array']), dynamic=False, width=10, algorithm=algorithm)
+    assert len(downsampled)
+
+
 def test_downsample1d_shared_data():
     runs = [0]
 
@@ -42,7 +53,7 @@ def test_downsample1d_shared_data():
 
     N = 1000
     df = pd.DataFrame({c: range(N) for c in "xyz"})
-    figure = hv.Overlay([hv.Curve(df, kdims="x", vdims=c) for c in "yz"])
+    figure = Overlay([Curve(df, kdims="x", vdims=c) for c in "yz"])
 
     # We set x_range to trigger _compute_mask
     mocksample(figure, dynamic=False, x_range=(0, 500))
@@ -61,7 +72,7 @@ def test_downsample1d_shared_data_index():
 
     N = 1000
     df = pd.DataFrame({c: range(N) for c in "xyz"})
-    figure = hv.Overlay([hv.Curve(df, kdims="index", vdims=c) for c in "xyz"])
+    figure = Overlay([Curve(df, kdims="index", vdims=c) for c in "xyz"])
 
     # We set x_range to trigger _compute_mask
     mocksample(figure, dynamic=False, x_range=(0, 500))
diff --git a/holoviews/tests/plotting/bokeh/test_overlayplot.py b/holoviews/tests/plotting/bokeh/test_overlayplot.py
index 1678ce53e..bfca27b19 100644
--- a/holoviews/tests/plotting/bokeh/test_overlayplot.py
+++ b/holoviews/tests/plotting/bokeh/test_overlayplot.py
@@ -278,11 +278,15 @@ class TestOverlayPlot(LoggingComparisonTestCase, TestBokehPlot):
         _, vline_plot = plot.subplots.values()
         assert vline_plot.handles['glyph'].location == 1
 
-    def test_overlay_iterable(self):
-        # Related to https://github.com/holoviz/holoviews/issues/5315
-        c1 = Curve([0, 1])
-        c2 = Curve([10, 20])
-        Overlay({'a': c1, 'b': c2}.values())
+    def test_ndoverlay_subcoordinate_y_no_batching(self):
+        overlay = NdOverlay({
+            i: Curve(np.arange(10)*i).opts(subcoordinate_y=True) for i in range(10)
+        }).opts(legend_limit=1)
+        plot = bokeh_renderer.get_plot(overlay)
+
+        assert plot.batched == False
+        assert len(plot.subplots) == 10
+
 
 
 class TestLegends(TestBokehPlot):