Repository: holoviz/holoviews
Pull request #6318 (2024-07-09 13:17:49)

Improve following behavior when streaming
- Automatically stops following when the user zooms in and starts following when the user resets
- Ensures that if the stream is out-of-sync with the plot the full data is synced

~~There still appears to be a race condition in bokeh where if a stream event arrives while the frontend is rendering it can cause "backtracking" glitches.~~

TODO:
- [x] Recompute range based on every new data point
- [x] Fix backtracking (fixed in Panel)

<details> <summary> Code </summary>

```python
import asyncio
import pandas as pd
import numpy as np

import holoviews as hv
hv.extension('bokeh')

nchannels = 25
buffer = hv.streams.Buffer(data=pd.DataFrame(np.random.randn(10, nchannels).cumsum(axis=1), columns=[chr(65+i) for i in range(nchannels)]))

def out(data):
    return hv.NdOverlay(
        {chr(65+i): hv.Curve(data, 'index', (chr(65+i), 'Amplitude')).opts(subcoordinate_y=True) for i in range(nchannels)}
    )

dmap = hv.DynamicMap(out, streams=[buffer]).opts(legend_position='right', legend_cols=2, responsive=True, min_height=600)

pane = pn.pane.HoloViews(dmap)

async def stream(event):
    for i in range(1, 1000):
        buffer.event(data=pd.DataFrame(np.random.randn(10, nchannels).cumsum(axis=1), columns=[chr(65+i) for i in range(nchannels)], index=np.arange(i*10, i*10+10)))
        await asyncio.sleep(0.01)

pn.Column(pn.widgets.Button(on_click=stream, name='Stream'), pane).servable()
```

</details>


![ezgif-3-bde2bf8b98](https://github.com/holoviz/holoviews/assets/1550771/e576fa6a-b84c-455b-b097-43ab6dcd9cf0)


philippjfr: The backtracking behavior was fixed in https://github.com/holoviz/panel/pull/7128
philippjfr: With range handling:

![ezgif-6-90d086a92b](https://github.com/user-attachments/assets/ab2db0f3-ef0a-4628-a999-b89715cbb2f1)

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/holoviz/holoviews/pull/6318?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) Report
Attention: Patch coverage is `76.40449%` with `21 lines` in your changes missing coverage. Please review.
> Project coverage is 88.49%. Comparing base [(`3b3f10f`)](https://app.codecov.io/gh/holoviz/holoviews/commit/3b3f10f1ec196fee3278058cba58183fd8cd2ebb?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) to head [(`e62581c`)](https://app.codecov.io/gh/holoviz/holoviews/commit/e62581cc01d0d1283756ff85749218427656d6b1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/holoviz/holoviews/pull/6318?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) | Patch % | Lines |
|---|---|---|
| [holoviews/plotting/bokeh/element.py](https://app.codecov.io/gh/holoviz/holoviews/pull/6318?src=pr&el=tree&filepath=holoviews%2Fplotting%2Fbokeh%2Felement.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Bsb3R0aW5nL2Jva2VoL2VsZW1lbnQucHk=) | 65.71% | [12 Missing :warning: ](https://app.codecov.io/gh/holoviz/holoviews/pull/6318?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) |
| [holoviews/plotting/bokeh/plot.py](https://app.codecov.io/gh/holoviz/holoviews/pull/6318?src=pr&el=tree&filepath=holoviews%2Fplotting%2Fbokeh%2Fplot.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Bsb3R0aW5nL2Jva2VoL3Bsb3QucHk=) | 0.00% | [9 Missing :warning: ](https://app.codecov.io/gh/holoviz/holoviews/pull/6318?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #6318      +/-   ##
==========================================
- Coverage   88.49%   88.49%   -0.01%     
==========================================
  Files         323      323              
  Lines       68304    68402      +98     
==========================================
+ Hits        60448    60529      +81     
- Misses       7856     7873      +17     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/holoviz/holoviews/pull/6318?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz).


Patch:
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 4bfa6b7b8..bef067ee9 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -12,7 +12,7 @@ on:
 
 defaults:
   run:
-    shell: bash -el {0}
+    shell: bash -l {0}
 
 env:
   PYTHON_VERSION: "3.11"
@@ -33,14 +33,14 @@ jobs:
     name: Pixi lock
     runs-on: ubuntu-latest
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_lock@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_lock@v0
 
   conda_build:
     name: Build Conda
     needs: [pixi_lock]
     runs-on: "ubuntu-latest"
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_install@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_install@v0
         with:
           environments: "build"
           download-data: false
@@ -88,7 +88,7 @@ jobs:
     needs: [pixi_lock]
     runs-on: "ubuntu-latest"
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_install@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_install@v0
         with:
           environments: "build"
           download-data: false
diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml
index 660cbf350..5dedd6b75 100644
--- a/.github/workflows/docs.yaml
+++ b/.github/workflows/docs.yaml
@@ -22,14 +22,14 @@ on:
 
 defaults:
   run:
-    shell: bash -el {0}
+    shell: bash -l {0}
 
 jobs:
   pixi_lock:
     name: Pixi lock
     runs-on: ubuntu-latest
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_lock@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_lock@v0
 
   docs_build:
     name: Build Documentation
@@ -41,7 +41,7 @@ jobs:
     env:
       GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_install@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_install@v0
         with:
           environments: docs
       - name: Build documentation
diff --git a/.github/workflows/nightly_lock.yaml b/.github/workflows/nightly_lock.yaml
index 9b93b85a2..00e670c88 100644
--- a/.github/workflows/nightly_lock.yaml
+++ b/.github/workflows/nightly_lock.yaml
@@ -13,7 +13,7 @@ jobs:
     runs-on: ubuntu-latest
     timeout-minutes: 5
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_lock@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_lock@v0
       - name: Upload lock-file to S3
         if: "!github.event.pull_request.head.repo.fork"
         env:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 75de2eb85..6a3131a02 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -20,7 +20,7 @@ repos:
       - id: check-json
       - id: detect-private-key
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.6.2
+    rev: v0.6.7
     hooks:
       - id: ruff
         files: holoviews/|scripts/
diff --git a/holoviews/__init__.py b/holoviews/__init__.py
index 1ca017cdf..a8b3f5868 100644
--- a/holoviews/__init__.py
+++ b/holoviews/__init__.py
@@ -73,9 +73,9 @@ In a notebook or ipython environment the usual
 To ask the community go to https://discourse.holoviz.org/.
 To report issues go to https://github.com/holoviz/holoviews.
 """
+import builtins
 import os
 import sys
-import warnings
 
 import param
 
@@ -114,12 +114,7 @@ from .util.warnings import (  # noqa: F401
     HoloviewsUserWarning,
 )
 
-# Suppress warnings generated by NumPy in matplotlib
-# Expected to be fixed in next matplotlib release
-warnings.filterwarnings("ignore",
-                        message="elementwise comparison failed; returning scalar instead")
-
-if "IPython" in sys.modules:
+if hasattr(builtins, "__IPYTHON__"):
     from .ipython import notebook_extension
     extension = notebook_extension
 else:
@@ -181,7 +176,7 @@ def help(obj, visualization=True, ansi=True, backend=None,
         pydoc.help(obj)
 
 
-del os, sys, rcfile, warnings
+del builtins, os, rcfile, sys
 
 def __getattr__(name):
     if name == "annotate":
diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py
index a725520b9..96a519a5d 100644
--- a/holoviews/operation/datashader.py
+++ b/holoviews/operation/datashader.py
@@ -1802,9 +1802,10 @@ class inspect_mask(Operation):
     operation.
     """
 
-    pixels = param.Integer(default=3, doc="""
+    pixels = param.ClassSelector(default=3, class_=(int, tuple), doc="""
        Size of the mask that should match the pixels parameter used in
-       the associated inspection operation.""")
+       the associated inspection operation. Pixels can be provided as
+       integer or x/y-tuple to perform asymmetric masking.""")
 
     streams = param.ClassSelector(default=[PointerXY], class_=(dict, list))
     x = param.Number(default=0)
@@ -1813,9 +1814,13 @@ class inspect_mask(Operation):
     @classmethod
     def _distance_args(cls, element, x_range, y_range,  pixels):
         ycount, xcount =  element.interface.shape(element, gridded=True)
+        if isinstance(pixels, tuple):
+            xpixels, ypixels = pixels
+        else:
+            xpixels = ypixels = pixels
         x_delta = abs(x_range[1] - x_range[0]) / xcount
         y_delta = abs(y_range[1] - y_range[0]) / ycount
-        return (x_delta*pixels, y_delta*pixels)
+        return (x_delta*xpixels, y_delta*ypixels)
 
     def _process(self, raster, key=None):
         if isinstance(raster, RGB):
@@ -1838,10 +1843,11 @@ class inspect(Operation):
     type.
     """
 
-    pixels = param.Integer(default=3, doc="""
+    pixels = param.ClassSelector(default=3, class_=(int, tuple), doc="""
        Number of pixels in data space around the cursor point to search
        for hits in. The hit within this box mask that is closest to the
-       cursor's position is displayed.""")
+       cursor's position is displayed. Pixels can be provided as
+       integer or x/y-tuple to perform asymmetric masking.""")
 
     null_value = param.Number(default=0, doc="""
        Value of raster which indicates no hits. For instance zero for
@@ -1874,9 +1880,9 @@ class inspect(Operation):
                                                y=PointerXY.param.y),
                                   class_=(dict, list))
 
-    x = param.Number(default=0, doc="x-position to inspect.")
+    x = param.Number(default=None, doc="x-position to inspect.")
 
-    y = param.Number(default=0, doc="y-position to inspect.")
+    y = param.Number(default=None, doc="y-position to inspect.")
 
     _dispatch = {}
 
@@ -1927,33 +1933,40 @@ class inspect_base(inspect):
 
     def _process(self, raster, key=None):
         self._validate(raster)
-        if isinstance(raster, RGB):
-            raster = raster[..., raster.vdims[-1]]
-        x_range, y_range = raster.range(0), raster.range(1)
-        xdelta, ydelta = self._distance_args(raster, x_range, y_range, self.p.pixels)
         x, y = self.p.x, self.p.y
-        val = raster[x-xdelta:x+xdelta, y-ydelta:y+ydelta].reduce(function=np.nansum)
-        if np.isnan(val):
-            val = self.p.null_value
-
-        if ((self.p.value_bounds and
-             not (self.p.value_bounds[0] < val < self.p.value_bounds[1]))
-             or val == self.p.null_value):
-            result = self._empty_df(raster.dataset)
-        else:
-            masked = self._mask_dataframe(raster, x, y, xdelta, ydelta)
-            result = self._sort_by_distance(raster, masked, x, y)
+        if x is not None and y is not None:
+            if isinstance(raster, RGB):
+                raster = raster[..., raster.vdims[-1]]
+            x_range, y_range = raster.range(0), raster.range(1)
+            xdelta, ydelta = self._distance_args(raster, x_range, y_range, self.p.pixels)
+            val = raster[x-xdelta:x+xdelta, y-ydelta:y+ydelta].reduce(function=np.nansum)
+            if np.isnan(val):
+                val = self.p.null_value
+
+            if ((self.p.value_bounds and
+                 not (self.p.value_bounds[0] < val < self.p.value_bounds[1]))
+                or val == self.p.null_value):
+                result = self._empty_df(raster.dataset)
+            else:
+                masked = self._mask_dataframe(raster, x, y, xdelta, ydelta)
+                result = self._sort_by_distance(raster, masked, x, y)
 
-        self.hits = result
+            self.hits = result
+        else:
+            self.hits = result = self._empty_df(raster.dataset)
         df = self.p.transform(result)
         return self._element(raster, df.iloc[:self.p.max_indicators])
 
     @classmethod
-    def _distance_args(cls, element, x_range, y_range,  pixels):
-        ycount, xcount =  element.interface.shape(element, gridded=True)
+    def _distance_args(cls, element, x_range, y_range, pixels):
+        ycount, xcount = element.interface.shape(element, gridded=True)
+        if isinstance(pixels, tuple):
+            xpixels, ypixels = pixels
+        else:
+            xpixels = ypixels = pixels
         x_delta = abs(x_range[1] - x_range[0]) / xcount
         y_delta = abs(y_range[1] - y_range[0]) / ycount
-        return (x_delta*pixels, y_delta*pixels)
+        return (x_delta*xpixels, y_delta*ypixels)
 
     @classmethod
     def _empty_df(cls, dataset):
@@ -2008,9 +2021,21 @@ class inspect_points(inspect_base):
         ds = raster.dataset.clone(df)
         xs, ys = (ds.dimension_values(kd) for kd in raster.kdims)
         dx, dy = xs - x, ys - y
-        distances = pd.Series(dx*dx + dy*dy)
-        return df.iloc[distances.argsort().values]
-
+        xtype, ytype = dx.dtype.kind, dy.dtype.kind
+        if xtype in 'Mm':
+            dx = dx.astype('int64')
+        if ytype in 'Mm':
+            dy = dx.astype('int64')
+        # If coordinate types don't match normalize
+        # coordinate space to ensure that distance
+        # in both direction is handled the same.
+        if xtype != ytype and len(dx) and len(dy):
+            with warnings.catch_warnings():
+                warnings.filterwarnings('ignore', r'invalid value encountered in (divide)')
+                dx = (dx - dx.min()) / (dx.max() - dx.min())
+                dy = (dy - dy.min()) / (dy.max() - dy.min())
+        distances = pd.Series(dx**2 + dy**2)
+        return df.iloc[distances.fillna(0).argsort().values]
 
 
 class inspect_polygons(inspect_base):
@@ -2044,7 +2069,7 @@ class inspect_polygons(inspect_base):
                 xs.append((np.min(gxs)+np.max(gxs))/2)
                 ys.append((np.min(gys)+np.max(gys))/2)
         dx, dy = np.array(xs) - x, np.array(ys) - y
-        distances = pd.Series(dx*dx + dy*dy)
+        distances = pd.Series(dx**2 + dy**2)
         return df.iloc[distances.argsort().values]
 
 
diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py
index a8edb5a0f..89e31d2d9 100644
--- a/holoviews/plotting/bokeh/element.py
+++ b/holoviews/plotting/bokeh/element.py
@@ -368,6 +368,8 @@ class ElementPlot(BokehPlot, GenericElementPlot):
 
         # Flag to check whether plot has been updated
         self._updated = False
+        # Counter to keep track of last stream update
+        self._stream_count = None
 
     def _hover_opts(self, element):
         if self.batched:
@@ -989,6 +991,13 @@ class ElementPlot(BokehPlot, GenericElementPlot):
         fig.xaxis[0].update(**axis_props['x'])
         fig.yaxis[0].update(**axis_props['y'])
 
+        # Set up handlers to configure following behavior on streaming plots
+        if self.streaming:
+            fig.on_event('rangesupdate', self._disable_follow)
+            fig.on_event('reset', self._reset_follow)
+            code = "export default (_, cb_obj) => { cb_obj.origin.hold_render = false }"
+            fig.js_on_event('reset', CustomJS(code=code))
+
         # Do not add the extra axes to the layout if subcoordinates are used
         if self._subcoord_overlaid:
             return fig
@@ -1008,6 +1017,20 @@ class ElementPlot(BokehPlot, GenericElementPlot):
             fig.add_layout(ax, axis_position)
         return fig
 
+    def _disable_follow(self, event):
+        hold = self.state.hold_render
+        for stream in self.streaming:
+            stream.following = False
+        if not hold:
+            stream.trigger(self.streaming)
+            self.state.hold_render = True
+
+    def _reset_follow(self, event):
+        self.state.hold_render = False
+        for stream in self.streaming:
+            stream.following = True
+        stream.trigger(self.streaming)
+
     def _plot_properties(self, key, element):
         """
         Returns a dictionary of plot properties.
@@ -1319,7 +1342,7 @@ class ElementPlot(BokehPlot, GenericElementPlot):
                 self._shared.get(extra_y_range.name, False), log, streaming
             )
 
-    def _update_main_ranges(self, element, x_range, y_range, ranges):
+    def _update_main_ranges(self, element, x_range, y_range, ranges, subcoord=False):
         plot = self.handles['plot']
 
         l, b, r, t = None, None, None, None
@@ -1343,11 +1366,11 @@ class ElementPlot(BokehPlot, GenericElementPlot):
         framewise = self.framewise
         streaming = (self.streaming and any(stream._triggering and stream.following
                                             for stream in self.streaming))
-        xupdate = ((not (self.model_changed(x_range) or self.model_changed(plot))
+        xupdate = not subcoord and ((not (self.model_changed(x_range) or self.model_changed(plot))
                     and (framewise or streaming))
                    or xfactors is not None)
-        yupdate = ((not (self.model_changed(x_range) or self.model_changed(plot))
-                    and (framewise or streaming) or yfactors is not None) and not self.subcoordinate_y)
+        yupdate = (not (self.model_changed(x_range) or self.model_changed(plot))
+                    and (framewise or streaming) or yfactors is not None)
 
         options = self._traverse_options(element, 'plot', ['width', 'height'], defaults=False)
         fixed_width = (self.frame_width or options.get('width'))
@@ -1459,7 +1482,27 @@ class ElementPlot(BokehPlot, GenericElementPlot):
         if not self.drawn or xupdate:
             self._update_range(x_range, l, r, xfactors, self.invert_xaxis,
                                self._shared['x-main-range'], self.logx, streaming)
-        if not (self.drawn or self.subcoordinate_y) or yupdate:
+
+        # If subcoordinate_y is enabled we iterate over each of the
+        # subcoordinate ranges and let the subplot handle the update
+        if self.subcoordinate_y and yupdate and not subcoord:
+            updated = set()
+            for sp in (self.subplots or {}).values():
+                if isinstance(sp, GenericOverlayPlot):
+                    subcoord = False
+                    el_ranges = ranges
+                else:
+                    sp_range = sp.handles.get('y_range')
+                    if not sp_range or sp_range.name in updated:
+                        continue
+                    el_ranges = util.match_spec(sp.current_frame, ranges)
+                    updated.add(sp_range.name)
+                    subcoord = True
+                sp._update_main_ranges(
+                    sp.current_frame, sp.handles['x_range'], sp.handles['y_range'],
+                    el_ranges, subcoord=subcoord
+                )
+        elif (not self.drawn or yupdate) and (not self.subcoordinate_y or subcoord):
             self._update_range(
                 y_range, b, t, yfactors, self._get_tag(y_range, 'invert_yaxis'),
                 self._shared['y-main-range'], self.logy, streaming
diff --git a/holoviews/plotting/bokeh/plot.py b/holoviews/plotting/bokeh/plot.py
index 77658f43d..91a72967b 100644
--- a/holoviews/plotting/bokeh/plot.py
+++ b/holoviews/plotting/bokeh/plot.py
@@ -177,10 +177,15 @@ class BokehPlot(DimensionedPlot, CallbackPlot):
         if (self.streaming and self.streaming[0].data is self.current_frame.data
             and self._stream_data and not empty):
             stream = self.streaming[0]
-            if stream._triggering:
-                data = {k: v[-stream._chunk_length:] for k, v in data.items()}
-                source.stream(data, stream.length)
-            return
+            if stream._count == ((self._stream_count or 0) + 1):
+                if stream._triggering and stream.following:
+                    data = {k: v[-stream._chunk_length:] for k, v in data.items()}
+                    source.stream(data, stream.length)
+                    self._stream_count = stream._count
+                return
+            elif not stream.following:
+                return
+            self._stream_count = stream._count
 
         if cds_column_replace(source, data):
             source.data = data
diff --git a/holoviews/plotting/mpl/plot.py b/holoviews/plotting/mpl/plot.py
index f166544d8..d8208709c 100644
--- a/holoviews/plotting/mpl/plot.py
+++ b/holoviews/plotting/mpl/plot.py
@@ -120,12 +120,18 @@ class MPLPlot(DimensionedPlot):
         Allows labeling the subaxes in each plot with various formatters
         including {Alpha}, {alpha}, {numeric} and {roman}.""")
 
+    sublabel_offset = param.Number(default=0, bounds=(0, None), doc="""
+        Allows offsetting the sublabel index.""")
+
     sublabel_position = param.NumericTuple(default=(-0.35, 0.85), doc="""
          Position relative to the plot for placing the optional subfigure label.""")
 
     sublabel_size = param.Number(default=18, doc="""
          Size of optional subfigure label.""")
 
+    sublabel_skip = param.List(default=None, item_type=int, doc="""
+        List of elements to skip when labeling subplots. Numbering starts at 1.""")
+
     projection = param.Parameter(default=None, doc="""
         The projection of the plot axis, default of None is equivalent to
         2D plot, '3d' and 'polar' are also supported by matplotlib by default.
@@ -207,20 +213,29 @@ class MPLPlot(DimensionedPlot):
 
 
     def _subplot_label(self, axis):
+        if self.sublabel_skip and self.layout_num in self.sublabel_skip:
+            return
         layout_num = self.layout_num if self.subplot else 1
-        if self.sublabel_format and not self.adjoined and layout_num > 0:
+        if self.sublabel_skip:
+            if any(n < 1 for n in self.sublabel_skip):
+                raise ValueError('sublabel_skip values must be greater than 0')
+            sublabel_num = len(set(range(layout_num)) - set(self.sublabel_skip))
+        else:
+            sublabel_num = layout_num
+        sublabel_num += self.sublabel_offset
+        if self.sublabel_format and not self.adjoined and sublabel_num > 0:
             from matplotlib.offsetbox import AnchoredText
             labels = {}
             if '{Alpha}' in self.sublabel_format:
-                labels['Alpha'] = int_to_alpha(layout_num-1)
+                labels['Alpha'] = int_to_alpha(sublabel_num-1)
             elif '{alpha}' in self.sublabel_format:
-                labels['alpha'] = int_to_alpha(layout_num-1, upper=False)
+                labels['alpha'] = int_to_alpha(sublabel_num-1, upper=False)
             elif '{numeric}' in self.sublabel_format:
-                labels['numeric'] = self.layout_num
+                labels['numeric'] = sublabel_num
             elif '{Roman}' in self.sublabel_format:
-                labels['Roman'] = int_to_roman(layout_num)
+                labels['Roman'] = int_to_roman(sublabel_num)
             elif '{roman}' in self.sublabel_format:
-                labels['roman'] = int_to_roman(layout_num).lower()
+                labels['roman'] = int_to_roman(sublabel_num).lower()
             at = AnchoredText(self.sublabel_format.format(**labels), loc=3,
                               bbox_to_anchor=self.sublabel_position, frameon=False,
                               prop=dict(size=self.sublabel_size, weight='bold'),
diff --git a/holoviews/streams.py b/holoviews/streams.py
index c866f4447..e129c8b43 100644
--- a/holoviews/streams.py
+++ b/holoviews/streams.py
@@ -565,8 +565,6 @@ class Buffer(Pipe):
             data.stream.sink(self.send)
             self.sdf = data
 
-        if index and isinstance(example, pd.DataFrame):
-            example = example.reset_index()
         params['data'] = example
         super().__init__(**params)
         self.length = length
@@ -656,9 +654,6 @@ class Buffer(Pipe):
         """
         data = kwargs.get('data')
         if data is not None:
-            if (isinstance(data, pd.DataFrame) and
-                list(data.columns) != list(self.data.columns) and self._index):
-                data = data.reset_index()
             self.verify(data)
             kwargs['data'] = self._concat(data)
             self._count += 1
@@ -667,7 +662,7 @@ class Buffer(Pipe):
 
     @property
     def hashkey(self):
-        return {'hash': self._count}
+        return {'hash': (self._count, self._memoize_counter)}
 
 
 


Test patch:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 2f5e4f208..6f2db511b 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -30,7 +30,7 @@ concurrency:
 
 defaults:
   run:
-    shell: bash -el {0}
+    shell: bash -l {0}
 
 env:
   DISPLAY: ":99.0"
@@ -107,7 +107,7 @@ jobs:
     name: Pixi lock
     runs-on: ubuntu-latest
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_lock@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_lock@v0
         with:
           cache: ${{ github.event.inputs.cache == 'true' || github.event.inputs.cache == '' }}
 
@@ -120,7 +120,7 @@ jobs:
       matrix: ${{ fromJson(needs.setup.outputs.matrix) }}
     timeout-minutes: 120
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_install@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_install@v0
         if: needs.setup.outputs.code_change == 'true'
         with:
           environments: ${{ matrix.environment }}
@@ -154,7 +154,7 @@ jobs:
     env:
       PANEL_LOG_LEVEL: info
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_install@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_install@v0
         if: needs.setup.outputs.code_change == 'true'
         with:
           environments: ${{ matrix.environment }}
@@ -188,7 +188,7 @@ jobs:
         environment: ["test-core"]
     timeout-minutes: 120
     steps:
-      - uses: holoviz-dev/holoviz_tasks/pixi_install@pixi
+      - uses: holoviz-dev/holoviz_tasks/pixi_install@v0
         if: needs.setup.outputs.code_change == 'true'
         with:
           environments: ${{ matrix.environment }}
diff --git a/holoviews/tests/operation/test_datashader.py b/holoviews/tests/operation/test_datashader.py
index 235eb05f4..97f518ff4 100644
--- a/holoviews/tests/operation/test_datashader.py
+++ b/holoviews/tests/operation/test_datashader.py
@@ -1458,14 +1458,25 @@ class GraphBundlingTests(ComparisonTestCase):
         direct = directly_connect_edges(self.graph)._split_edgepaths
         self.assertEqual(direct, self.graph.edgepaths)
 
+
 class InspectorTests(ComparisonTestCase):
     """
     Tests for inspector operations
     """
     def setUp(self):
         points = Points([(0.2, 0.3), (0.4, 0.7), (0, 0.99)])
-        self.pntsimg = rasterize(points, dynamic=False,
-                             x_range=(0, 1), y_range=(0, 1), width=4, height=4)
+        self.pntsimg = rasterize(
+            points, dynamic=False, height=4, width=4,
+            x_range=(0, 1), y_range=(0, 1)
+        )
+        date_pts = Points([
+            (np.datetime64('2024-09-25 11:00'), 0.3),
+            (np.datetime64('2024-09-25 11:01'), 0.7),
+            (np.datetime64('2024-09-25 11:04'), 0.99)])
+        self.datesimg = rasterize(
+            date_pts, dynamic=False, height=4, width=4,
+            x_range=(np.datetime64('2024-09-25 11:00'), np.datetime64('2024-09-25 11:04')), y_range=(0, 1)
+        )
         if spatialpandas is None:
             return
 
@@ -1480,7 +1491,6 @@ class InspectorTests(ComparisonTestCase):
     def tearDown(self):
         Tap.x, Tap.y = None, None
 
-
     def test_inspect_points_or_polygons(self):
         if spatialpandas is None:
             raise SkipTest('Polygon inspect tests require spatialpandas')
@@ -1537,6 +1547,18 @@ class InspectorTests(ComparisonTestCase):
         self.assertEqual(points.streams[0].x, 0.2)
         self.assertEqual(points.streams[0].y, 0.3)
 
+    def test_points_with_dates_inspection_1px_mask(self):
+        points = inspect_points(self.datesimg, max_indicators=3, dynamic=False, pixels=1,
+                                x=np.datetime64('2024-09-25 11:01'), y=-0.1)
+        self.assertEqual(points.dimension_values('x'), np.array([]))
+        self.assertEqual(points.dimension_values('y'), np.array([]))
+
+    def test_points_with_dates_inspection_2px_mask(self):
+        points = inspect_points(self.datesimg, max_indicators=3, dynamic=False, pixels=2,
+                                x=np.datetime64('2024-09-25 11:01'), y=-0.1)
+        self.assertEqual(points.dimension_values('x'), np.array([np.datetime64('2024-09-25 11:00')]))
+        self.assertEqual(points.dimension_values('y'), np.array([0.3]))
+
     def test_polys_inspection_1px_mask_hit(self):
         if spatialpandas is None:
             raise SkipTest('Polygon inspect tests require spatialpandas')
@@ -1556,7 +1578,6 @@ class InspectorTests(ComparisonTestCase):
         self.assertEqual(inspector.hits.iloc[0].geometry,
                          spatialpandas.geometry.polygon.Polygon(data))
 
-
     def test_polys_inspection_1px_mask_miss(self):
         if spatialpandas is None:
             raise SkipTest('Polygon inspect tests require spatialpandas')
diff --git a/holoviews/tests/plotting/bokeh/test_overlayplot.py b/holoviews/tests/plotting/bokeh/test_overlayplot.py
index 8f1e989eb..d2e65d1c4 100644
--- a/holoviews/tests/plotting/bokeh/test_overlayplot.py
+++ b/holoviews/tests/plotting/bokeh/test_overlayplot.py
@@ -15,7 +15,7 @@ from holoviews.element import (
     VLine,
 )
 from holoviews.plotting.bokeh.util import property_to_dict
-from holoviews.streams import Stream, Tap
+from holoviews.streams import Pipe, Stream, Tap
 from holoviews.util import Dynamic
 
 from ...utils import LoggingComparisonTestCase
@@ -307,6 +307,43 @@ class TestOverlayPlot(LoggingComparisonTestCase, TestBokehPlot):
             assert sp.handles['y_range'].start == 0
             assert sp.handles['y_range'].end == 27
 
+    def test_ndoverlay_subcoordinate_y_ranges_update(self):
+        def lines(data):
+            return NdOverlay({
+                'A': Curve(data, 'x', ('A', 'y1')).opts(subcoordinate_y=True, framewise=True),
+                'B': Curve(data, 'x', ('B', 'y2')).opts(subcoordinate_y=True, framewise=True),
+                'C': Curve(data, 'x', ('C', 'y3')).opts(subcoordinate_y=True, framewise=True),
+            })
+        data = {
+            'x': np.arange(10),
+            'A': np.arange(10),
+            'B': np.arange(10)*2,
+            'C': np.arange(10)*3
+        }
+        stream = Pipe(data=data)
+        dmap = DynamicMap(lines, streams=[stream])
+        plot = bokeh_renderer.get_plot(dmap)
+
+        assert plot.state.y_range.start == -0.5
+        assert plot.state.y_range.end == 2.5
+        for sp in plot.subplots.values():
+            y_range = sp.handles['y_range']
+            assert y_range.start == 0
+            assert y_range.end == data[y_range.name].max()
+
+        new_data = {
+            'x': np.arange(10),
+            'A': data['A'] + 1,
+            'B': data['B'] + 2,
+            'C': data['C'] + 3
+        }
+        stream.event(data=new_data)
+        for sp in plot.subplots.values():
+            y_range = sp.handles['y_range']
+            ydata = new_data[y_range.name]
+            assert y_range.start == ydata.min()
+            assert y_range.end == ydata.max()
+
     def test_overlay_subcoordinate_y_ranges(self):
         data = {
             'x': np.arange(10),
@@ -327,6 +364,43 @@ class TestOverlayPlot(LoggingComparisonTestCase, TestBokehPlot):
             assert sp.handles['y_range'].start == 0
             assert sp.handles['y_range'].end == 27
 
+    def test_overlay_subcoordinate_y_ranges_update(self):
+        def lines(data):
+            return Overlay([
+                Curve(data, 'x', ('A', 'y1'), label='A').opts(subcoordinate_y=True, framewise=True),
+                Curve(data, 'x', ('B', 'y2'), label='B').opts(subcoordinate_y=True, framewise=True),
+                Curve(data, 'x', ('C', 'y3'), label='C').opts(subcoordinate_y=True, framewise=True),
+            ])
+        data = {
+            'x': np.arange(10),
+            'A': np.arange(10),
+            'B': np.arange(10)*2,
+            'C': np.arange(10)*3
+        }
+        stream = Pipe(data=data)
+        dmap = DynamicMap(lines, streams=[stream])
+        plot = bokeh_renderer.get_plot(dmap)
+
+        assert plot.state.y_range.start == -0.5
+        assert plot.state.y_range.end == 2.5
+        for sp in plot.subplots.values():
+            y_range = sp.handles['y_range']
+            assert y_range.start == 0
+            assert y_range.end == data[y_range.name].max()
+
+        new_data = {
+            'x': np.arange(10),
+            'A': data['A'] + 1,
+            'B': data['B'] + 2,
+            'C': data['C'] + 3
+        }
+        stream.event(data=new_data)
+        for sp in plot.subplots.values():
+            y_range = sp.handles['y_range']
+            ydata = new_data[y_range.name]
+            assert y_range.start == ydata.min()
+            assert y_range.end == ydata.max()
+
 
 class TestLegends(TestBokehPlot):
 
diff --git a/holoviews/tests/plotting/matplotlib/test_layoutplot.py b/holoviews/tests/plotting/matplotlib/test_layoutplot.py
index 97ed2ab41..5c32d7395 100644
--- a/holoviews/tests/plotting/matplotlib/test_layoutplot.py
+++ b/holoviews/tests/plotting/matplotlib/test_layoutplot.py
@@ -58,3 +58,25 @@ class TestLayoutPlot(LoggingComparisonTestCase, TestMPLPlot):
         cp1, cp2 = plot.traverse(lambda x: x, [CurvePlot])
         self.assertTrue(cp1.handles['axis'].get_ylim(), (1, 3))
         self.assertTrue(cp2.handles['axis'].get_ylim(), (10, 30))
+
+    def test_layout_sublabel_offset(self):
+        from holoviews.plotting.mpl import CurvePlot
+        layout = Curve([]) + Curve([]) + Curve([]) + Curve([])
+        layout.opts(sublabel_offset=1)
+        plot = mpl_renderer.get_plot(layout)
+        cps = plot.traverse(lambda x: x, [CurvePlot])
+        assert cps[0].handles["sublabel"].get_text() == "B"
+        assert cps[1].handles["sublabel"].get_text() == "C"
+        assert cps[2].handles["sublabel"].get_text() == "D"
+        assert cps[3].handles["sublabel"].get_text() == "E"
+
+    def test_layout_sublabel_skip(self):
+        from holoviews.plotting.mpl import CurvePlot
+        layout = Curve([]) + Curve([]) + Curve([]) + Curve([])
+        layout.opts(sublabel_skip=[1, 3])
+        plot = mpl_renderer.get_plot(layout)
+        cps = plot.traverse(lambda x: x, [CurvePlot])
+        assert "sublabel" not in cps[0].handles
+        assert cps[1].handles["sublabel"].get_text() == "A"
+        assert "sublabel" not in cps[2].handles
+        assert cps[3].handles["sublabel"].get_text() == "B"
diff --git a/holoviews/tests/test_streams.py b/holoviews/tests/test_streams.py
index 2bc957f67..3f7124a85 100644
--- a/holoviews/tests/test_streams.py
+++ b/holoviews/tests/test_streams.py
@@ -938,7 +938,7 @@ class TestBufferDataFrameStream(ComparisonTestCase):
     def test_init_buffer_dframe_with_index(self):
         data = pd.DataFrame({'x': np.array([1]), 'y': np.array([2])})
         buff = Buffer(data)
-        self.assertEqual(buff.data, data.reset_index())
+        self.assertEqual(buff.data, data)
 
     def test_buffer_dframe_send(self):
         data = pd.DataFrame({'x': np.array([0]), 'y': np.array([1])})
@@ -952,7 +952,7 @@ class TestBufferDataFrameStream(ComparisonTestCase):
         buff = Buffer(data)
         buff.send(pd.DataFrame({'x': np.array([1]), 'y': np.array([2])}))
         dframe = pd.DataFrame({'x': np.array([0, 1]), 'y': np.array([1, 2])}, index=[0, 0])
-        self.assertEqual(buff.data.values, dframe.reset_index().values)
+        pd.testing.assert_frame_equal(buff.data, dframe)
 
     def test_buffer_dframe_larger_than_length(self):
         data = pd.DataFrame({'x': np.array([0]), 'y': np.array([1])})
@@ -980,7 +980,7 @@ class TestBufferDataFrameStream(ComparisonTestCase):
         data = pd.DataFrame({'a': [1, 2, 3]})
         buff = Buffer(data)
         buff.clear()
-        self.assertEqual(buff.data, data.iloc[:0, :].reset_index())
+        pd.testing.assert_frame_equal(buff.data, data.iloc[:0, :])
 
 
 class Sum(Derived):