Multichannel Timeseries Viewer#

Download this notebook from GitHub (right-click to download).


This example demonstrates advanced visualization techniques using HoloViews with the Bokeh plotting backend. You’ll learn how to:

  1. Display multiple timeseries from different data groups in a single plot using subcoordinate_y.

  2. Normalize the timeseries per data group.

  3. Create and link a minimap to the main plot with RangeToolLink.

Specifically, we’ll simulate Electroencephalography (EEG) and position data, plot it, and then create a minimap based on the z-score of the data for easier navigation.

import numpy as np
import holoviews as hv
from bokeh.models import HoverTool
from holoviews.operation.normalization import subcoordinate_group_ranges
from holoviews.operation.datashader import rasterize
from holoviews.plotting.links import RangeToolLink
from scipy.stats import zscore
import colorcet as cc

hv.extension('bokeh')

Generating data#

Let’s start by EEG and position (POS) data. We’ll create a timeseries for each EEG channel using sine waves with varying frequencies, and random data for three position channels. We’ll set these two data groups to have different amplitudes and units.

import numpy as np
import pandas as pd
import holoviews as hv
from holoviews.operation.normalization import subcoordinate_group_ranges
from holoviews.plotting.links import RangeToolLink
from scipy.stats import zscore

hv.extension('bokeh')

GROUP_EEG = 'EEG'
GROUP_POS = 'Position'
N_CHANNELS_EEG = 10
N_CHANNELS_POS = 3
N_SECONDS = 5
SAMPLING_RATE_EEG = 200
SAMPLING_RATE_POS = 25
INIT_FREQ = 2  # Initial frequency in Hz
FREQ_INC = 5  # Frequency increment
AMPLITUDE_EEG = 1000  # EEG amplitude multiplier
AMPLITUDE_POS = 2  # Position amplitude multiplier

# Generate time for EEG and position data
total_samples_eeg = N_SECONDS * SAMPLING_RATE_EEG
total_samples_pos = N_SECONDS * SAMPLING_RATE_POS
time_eeg = np.linspace(0, N_SECONDS, total_samples_eeg)
time_pos = np.linspace(0, N_SECONDS, total_samples_pos)

# Generate EEG timeseries data
def generate_eeg_data(index):
    return AMPLITUDE_EEG * np.sin(2 * np.pi * (INIT_FREQ + index * FREQ_INC) * time_eeg)

eeg_channels = [str(i) for i in np.arange(N_CHANNELS_EEG)]
eeg_data = np.array([generate_eeg_data(i) for i in np.arange(N_CHANNELS_EEG)])
eeg_df = pd.DataFrame(eeg_data.T, index=time_eeg, columns=eeg_channels)
eeg_df.index.name = 'Time'

# Generate position data
pos_channels = ['X', 'Y', 'Z'] # avoid lowercase 'x' and 'y' as channel/dimension names
pos_data = AMPLITUDE_POS * np.random.randn(N_CHANNELS_POS, total_samples_pos).cumsum(axis=1)
pos_df = pd.DataFrame(pos_data.T, index=time_pos, columns=pos_channels)
pos_df.index.name = 'Time'

Visualizing EEG Data#

Next, let’s dive into visualizing the data. We construct each timeseries using a Curve element, assigning it a group, a label and setting subcoordinate_y=True. All these curves are then aggregated into a list per data group, which serves as the input for an Overlay element. Rendering this Overlay produces a plot where the timeseries are stacked vertically.

Additionally, we’ll enhance user interaction by implementing a custom hover tool. This will display key information about the group, channel, time, and amplitude value when you hover over any of the curves.

# Create a Curve per data series
def df_to_curves(df, kdim, vdim, color='black', group='EEG'):
    curves = []
    for i, (channel, channel_data) in enumerate(df.items()):
        ds = hv.Dataset((channel_data.index, channel_data), [kdim, vdim])
        curve = hv.Curve(ds, kdim, vdim, group=group, label=str(channel))
        curve.opts(
            subcoordinate_y=True, color=color if isinstance(color, str) else color[i], line_width=1, 
            hover_tooltips=hover_tooltips, tools=['xwheel_zoom'], line_alpha=.8,
        )
        curves.append(curve)
    return curves

hover_tooltips = [("Group", "$group"), ("Channel", "$label"), ("Time"), ("Value")]

vdim_EEG = hv.Dimension("Value", unit="µV")
vdim_POS = hv.Dimension("Value", unit="cm")
time_dim = hv.Dimension("Time", unit="s")

eeg_curves = df_to_curves(eeg_df, time_dim, vdim_EEG, color='black', group='EEG')
pos_curves = df_to_curves(pos_df, time_dim, vdim_POS, color=cc.glasbey_cool, group='POS')

# Combine EEG and POS curves into an Overlay
eeg_curves_overlay = hv.Overlay(eeg_curves, kdims="Channel")
pos_curves_overlay = hv.Overlay(pos_curves, kdims="Channel")
curves_overlay = (eeg_curves_overlay * pos_curves_overlay).opts(
    xlabel=time_dim.pprint_label, ylabel="Channel", show_legend=False, aspect=3, responsive=True,
)
curves_overlay

Note that the overlay above has multiple wheel-zoom tools in the toolbar, you can hover over the icons in the toolbar to reveal each of the first two control the Y-axis zoom of their respective data group within each curve’s subcoordinate range, and the third wheel zoom tool controls the X-axis scale of all the curves together.

By default, all the curves, including across data groups, have the same y-axis range that is computed from the min and max across all channels. As a consequence, the position curves in blue, which have a much smaller amplitude than timeseries in the EEG data group, appear to be quite flat and are hard to inspect. To deal with this situation, we can transform the Overlay with the subcoordinate_group_ranges operation that will apply a min-max normalization of the timeseries per group.

# Apply group-wise normalization
normalized_overlay = subcoordinate_group_ranges(curves_overlay)
normalized_overlay

Creating the Minimap#

A minimap can provide a quick overview of the data and help you navigate through it. We’ll compute the z-score for each channel and represent it as an image; the z-score will normalize the data and bring out the patterns more clearly. To enable linking in the next step between the timeseries Overlay and the minimap Image, we ensure they share the same y-axis range. We will also leverage rasterization in case the full image resolution is too large to render on the screen.

y_positions = range(N_CHANNELS_EEG + N_CHANNELS_POS)

# Reindex the lower frequency DataFrame to match the higher frequency index
pos_df_interp = pos_df.reindex(eeg_df.index).interpolate(method='index')

# concatenate the EEG and interpolated POS data and z-score the full data array
z_data = zscore(np.concatenate((eeg_df.values, pos_df_interp.values), axis=1), axis=0).T

minimap = rasterize(hv.Image((time_eeg, y_positions , z_data), [time_dim, "Channel"], "Value"))
minimap = minimap.opts(
    cmap="RdBu_r", xlabel='', alpha=.7,
    yticks=[(y_positions[0], f'EEG {eeg_channels[0]}'), (y_positions[-1], f'POS {pos_channels[-1]}')],
    height=120, responsive=True, toolbar='disable', cnorm='eq_hist'
)
minimap

Building the dashboard#

Finally, we use RangeToolLink to connect the minimap Image and the timeseries Overlay, setting bounds for the initially viewable area with boundsx and boundsy, and finally demonstrate setting an upper max zoom range of 3 seconds with intervalsx. Once the plots are linked and assembled into a unified dashboard, you can interact with it. Experiment by dragging the selection box on the minimap or resizing it by clicking and dragging its edges.

RangeToolLink(
    minimap, normalized_overlay, axes=["x", "y"],
    boundsx=(.5, 3), boundsy=(1.5, 12.5),
    intervalsx=(None, 3),
)

dashboard = (normalized_overlay + minimap).cols(1).opts(shared_axes=False)
dashboard