Skip to content

SWR-triggered current source density tutorial

Compute a channel-by-time CSD average around pre-computed sharp-wave ripple (SWR) events. The session path matches lfp_loader_tutorial.ipynb.

%reload_ext autoreload
%autoreload 2
%matplotlib inline
%config InlineBackend.figure_format = 'retina'

import matplotlib.colors as mcolors
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
import nelpy as nel
import numpy as np

import neuro_py as npy

npy.plotting.set_plotting_defaults()

1. What CSD shows

Current-source density (CSD) summarizes how voltage changes across probe depth. A current sink is net inward transmembrane current and a current source is net outward current; neither identifies a single neuron or synapse. In this tutorial, negative CSD is blue (sink) and positive CSD is red (source). Because CSD is a spatial derivative, the first and last probe contacts are less reliable: there is no electrode beyond the probe boundary.

This synthetic example begins with a known, balanced source-sink pattern. It generates the LFP that would be measured along one shank, then recovers CSD from that LFP with the same npy.lfp.get_csd function used below for recorded data.

# Simulate a balanced source-sink pair on an evenly spaced 24-contact shank.
n_contacts = 24
spacing_mm = 0.05
depth_mm = np.arange(n_contacts) * spacing_mm
time_s = np.linspace(-0.1, 0.1, 251)

sink = -np.exp(-0.5 * ((depth_mm - 0.40) / 0.07) ** 2)
source = np.exp(-0.5 * ((depth_mm - 0.75) / 0.10) ** 2)
source *= -sink.sum() / source.sum()  # zero net current across depth
event_timecourse = np.exp(-0.5 * (time_s / 0.015) ** 2)
true_csd = (sink + source)[:, np.newaxis] * event_timecourse

# Solve -d²V/dz² = CSD with zero-potential boundaries to obtain a synthetic LFP.
laplacian = (
    np.diag(np.full(n_contacts - 2, 2.0))
    + np.diag(np.full(n_contacts - 3, -1.0), 1)
    + np.diag(np.full(n_contacts - 3, -1.0), -1)
) / spacing_mm**2
synthetic_lfp = np.zeros_like(true_csd)
synthetic_lfp[1:-1] = np.linalg.solve(laplacian, true_csd[1:-1])

recovered_csd = npy.lfp.get_csd(
    "synthetic", synthetic_lfp, shank=0, method="StandardCSD", coords=depth_mm
)
assert recovered_csd.shape == synthetic_lfp.shape

# Only the visualization omits unreliable derivative estimates at the two edges.
recovered_csd_display = recovered_csd.copy()
recovered_csd_display[[0, -1]] = np.nan

depth_um = depth_mm * 1000
csd_limit = np.max(np.abs(true_csd))
csd_norm = mcolors.TwoSlopeNorm(vcenter=0, vmin=-csd_limit, vmax=csd_limit)
fig, axes = plt.subplots(
    1,
    3,
    figsize=npy.plotting.set_size("nature_double", subplots=(2, 3)),
    sharex=True,
    sharey=True,
    dpi=200,
    layout="constrained",
)

trace_scale = 35 / np.max(np.abs(synthetic_lfp))
axes[0].plot(time_s, synthetic_lfp.T * trace_scale + depth_um, color="black", lw=0.8)
axes[0].set_title("Simulated LFP")
axes[0].set_ylabel("depth (µm)")

for axis, csd, title in zip(
    axes[1:],
    (true_csd, recovered_csd_display),
    ("Known source-sink CSD", "StandardCSD recovered from LFP"),
):
    image = axis.pcolormesh(
        time_s, depth_um, csd, cmap="RdBu_r", norm=csd_norm, shading="nearest"
    )
    axis.set_title(title)

axes[1].annotate(
    "sink",
    xy=(0, 400),
    xytext=(0.045, 250),
    arrowprops={"arrowstyle": "->"},
    ha="center",
)
axes[1].annotate(
    "source",
    xy=(0, 750),
    xytext=(0.045, 930),
    arrowprops={"arrowstyle": "->"},
    ha="center",
)
for axis in axes:
    axis.set_xlabel("time (s)")
axes[0].invert_yaxis()
fig.colorbar(image, ax=axes[1:], label="CSD (arbitrary units)", shrink=0.8, pad=0.03)
<matplotlib.colorbar.Colorbar at 0x217257b42c0>

png

2. Set the session path

This session is expected to contain the pre-computed basename.ripples.events.mat file read by npy.io.load_ripples_events.

basepath = r"Z:\Data\AYAold\AB4\day03"

3. Load SWR epochs

The event file supplies each SWR's start, stop, and peak time. Peaks provide a common alignment point for averaging the LFP and CSD.

ripples = npy.io.load_ripples_events(basepath)
if ripples.empty:
    raise FileNotFoundError("No ripples.events.mat file was found for this session.")

ripples = nel.EventArray(abscissa_vals=ripples.peaks.to_numpy(), fs=1250)

# restrict to nrem sleep
nrem = npy.io.load_SleepState_states(basepath, return_epoch_array=True).get("NREMstate")
ripples = ripples[nrem]

4. Load all channels from all shank

CSD requires several depth-ordered channels. The probe layout selects one shank and supplies its physical channel order and spacing.

probe_layout = npy.io.load_probe_layout(basepath)

# remove last shank here as it contains accelerometer channels (***specific for this basepath***)
shank_layout = probe_layout.query("shank < 8")

bad_channels = npy.io.load_channel_tags(basepath).get("Bad", [])
if bad_channels:
    if bad_channels:
        bad_channels = bad_channels.get("channels", [])

    # normalize to a list for pandas .query / isin semantics
    if isinstance(bad_channels, np.ndarray):
        bad_channels = np.array(bad_channels) - 1
    elif isinstance(bad_channels, (np.integer, int)):
        bad_channels = [int(np.array(bad_channels) - 1)]
    elif bad_channels is None:
        bad_channels = []

    shank_layout = shank_layout.query("channels not in @bad_channels")

channels = shank_layout.channels.to_list()
lfp = npy.io.LFPLoader(
    basepath,
    channels=channels,
    ext="lfp",
    epoch=[ripples.data[0, :] - 0.15, ripples.data[0, :] + 0.15],
)
times = lfp.abscissa_vals
lfp
<LFPLoader at 0x217259a2c60: 250 signals> for a total of 5:02:05:299 hours

5. Average LFP around SWR peaks

Extracting identical windows around valid ripple peaks produces a channel-by-time average that suppresses unrelated activity while retaining the ripple-associated laminar pattern.

avg_signal = npy.process.peth(data=lfp, events=ripples.data[0, :], window=[-0.15, 0.15])

# peth returns positional columns. Restore the physical channel IDs used by LFPLoader
# so they align with the probe-layout channel IDs below.
if avg_signal.shape[1] != len(channels):
    raise ValueError("The averaged LFP does not contain every requested channel.")
avg_signal.columns = channels

6. Compute and plot average CSD

The native standard CSD estimator takes the second spatial derivative across depth for each time point. Probe-layout depths are stored and plotted in micrometres, then converted to millimetres for CSD estimation. The heatmap highlights sinks and sources aligned to SWR peaks.

StandardCSD is a direct spatial derivative, DeltaiCSD is an inverse model, and KCSD1D uses a spatially regularized kernel estimate. They can differ in amplitude and detail, so compare their spatial pattern rather than their absolute scale. For additional estimators or more control over kernel-CSD parameters, install and use the optional external kCSD-python package separately; it is not a neuro_py dependency.

def plot_csd_by_shank(
    basepath,
    avg_signal,
    shank_layout,
    method="StandardCSD",
    scale_factor=0.5,
    cmap="RdBu_r",
    figsize=None,
    dpi=200,
    get_csd_kwargs=None,
):
    """
    Plot average CSD heatmaps with overlaid LFP traces, one subplot per shank,
    aligned to real channel depth.

    Parameters
    ----------
    basepath : str
        Path passed through to npy.lfp.get_csd.
    avg_signal : pd.DataFrame
        Averaged LFP, channels as columns, time as index.
    shank_layout : pd.DataFrame
        Must contain columns: shank, channels, y (depth coordinate).
    method : str, default "StandardCSD"
        CSD method passed to npy.lfp.get_csd (e.g. "StandardCSD", "DeltaiCSD", "KCSD1D").
    scale_factor : float, default 0.5
        Controls trace amplitude scaling relative to channel spacing.
        Smaller = taller traces.
    cmap : str, default "RdBu_r"
        Diverging colormap for the CSD heatmap.
    figsize : tuple, optional
        Passed to plt.subplots. Defaults to npy.plotting.set_size("nature_double") if None.
    dpi : int, default 200
        Figure DPI.
    get_csd_kwargs : dict, optional
        Extra keyword arguments forwarded to npy.lfp.get_csd (e.g. method-specific params).

    Returns
    -------
    fig, ax : matplotlib Figure and array of Axes
    """
    get_csd_kwargs = get_csd_kwargs or {}
    if figsize is None:
        figsize = npy.plotting.set_size("nature_double")

    fig, ax = plt.subplots(
        1,
        shank_layout.shank.nunique(),
        figsize=figsize,
        dpi=dpi,
    )
    # space between subplots
    fig.subplots_adjust(wspace=0.05)

    # ensure ax is always iterable (in case of a single shank)
    ax = np.atleast_1d(ax)

    norm = mcolors.CenteredNorm()

    all_channels = shank_layout.channels.to_list()
    global_max_amp = avg_signal.loc[:, all_channels].abs().to_numpy().max()

    min_spacing = (
        shank_layout.groupby("shank")
        .y.apply(lambda y: np.diff(np.sort(y.abs().to_numpy())).min())
        .min()
    )

    trace_scale = abs(min_spacing / (global_max_amp * scale_factor))

    im = None
    for i, shank in enumerate(shank_layout.shank.unique()):
        shank_df = shank_layout.query("shank == @shank").copy()
        shank_df["depth_um"] = shank_df.y.abs()
        shank_df = shank_df.sort_values(
            "depth_um", ascending=True
        )  # required ascending for get_csd

        channels = shank_df.channels.to_list()
        coords_mm = shank_df.depth_um.to_numpy(dtype=float) / 1000.0

        assert np.all(
            np.diff(coords_mm) > 0
        ), "coords must be strictly increasing for get_csd"

        mean_csd = npy.lfp.get_csd(
            basepath,
            avg_signal.loc[:, channels].to_numpy().T,
            shank=shank,
            method=method,
            coords=coords_mm,
            **get_csd_kwargs,
        )

        # The second derivative has no electrode outside either boundary, so
        # StandardCSD boundary estimates are not interpretable. Keep the LFP
        # traces, but mask those two heatmap rows.
        plot_csd = mean_csd.copy()
        if method == "StandardCSD":
            plot_csd[[0, -1]] = np.nan

        # negate coords only for display positioning — shallow near 0, deep more negative,
        # so shallow renders near the top with a normal (non-inverted) y-axis.
        plot_coords = -shank_df.depth_um.to_numpy(dtype=float)

        im = ax[i].pcolormesh(
            avg_signal.index,
            plot_coords,
            plot_csd,
            cmap=cmap,
            norm=norm,
            shading="nearest",
        )

        ax[i].plot(
            avg_signal.index,
            avg_signal.loc[:, channels] * trace_scale + plot_coords,
            color="k",
            linewidth=0.6,
        )

        ax[i].set_title(f"shank {shank}")
        ax[i].set_xlabel("time (s)")
        ax[i].set_yticks([])
        ax[i].spines["left"].set_visible(False)

        ax[i].yaxis.set_major_formatter(
            mticker.FuncFormatter(lambda val, pos: f"{-val:.0f}")
        )

    ax[0].set_ylabel("depth (μm)")
    fig.colorbar(im, ax=ax[-1], label="CSD (a.u.)", shrink=0.6)

    return fig, ax
fig, ax = plot_csd_by_shank(
    basepath,
    avg_signal,
    shank_layout,
    method="StandardCSD",
)
# suplot title
fig.suptitle("StandardCSD", fontsize=12, y=0.95)

fig, ax = plot_csd_by_shank(
    basepath,
    avg_signal,
    shank_layout,
    method="DeltaiCSD",
)
fig.suptitle("DeltaiCSD", fontsize=12, y=0.95)

fig, ax = plot_csd_by_shank(
    basepath,
    avg_signal,
    shank_layout,
    method="KCSD1D",
)
fig.suptitle("KCSD1D", fontsize=12, y=0.95)
Text(0.5, 0.95, 'KCSD1D')

png

png

png

7. Theta-cycle-triggered CSD

Theta cycles provide a slower rhythmic alignment than SWR peaks. This section loads the saved theta-cycle peak times, keeps only peaks during the session's theta state, and averages a wider 400 ms LFP window around each peak. The same per-shank CSD plot then shows how laminar current flow changes over the theta cycle.

theta_cycles = npy.io.load_events(basepath, epoch_name="thetacycles", load_pandas=True)
theta_cycles = nel.EventArray(abscissa_vals=theta_cycles.peaks.to_numpy(), fs=1250)
theta = npy.io.load_SleepState_states(basepath, return_epoch_array=True).get("THETA")

theta_cycles = theta_cycles[theta]
theta_cycles
<EventArray at 0x21725b63b60: 1 series (203 segments)> at 1250 Hz
lfp = npy.io.LFPLoader(
    basepath,
    channels=channels,
    ext="lfp",
    epoch=[theta_cycles.data[0, :] - 0.2, theta_cycles.data[0, :] + 0.2],
)
times = lfp.abscissa_vals
lfp
<LFPLoader at 0x21725e11e80: 250 signals> for a total of 5:02:05:299 hours
avg_signal = npy.process.peth(
    data=lfp, events=theta_cycles.data[0, :], window=[-0.2, 0.2]
)

# peth returns positional columns. Restore the physical channel IDs used by LFPLoader
# so they align with the probe-layout channel IDs below.
if avg_signal.shape[1] != len(channels):
    raise ValueError("The averaged LFP does not contain every requested channel.")
avg_signal.columns = channels
fig, ax = plot_csd_by_shank(
    basepath,
    avg_signal,
    shank_layout,
    method="StandardCSD",
)
# suplot title
fig.suptitle("StandardCSD", fontsize=12, y=0.95)

fig, ax = plot_csd_by_shank(
    basepath,
    avg_signal,
    shank_layout,
    method="DeltaiCSD",
)
fig.suptitle("DeltaiCSD", fontsize=12, y=0.95)

fig, ax = plot_csd_by_shank(
    basepath,
    avg_signal,
    shank_layout,
    method="KCSD1D",
)
fig.suptitle("KCSD1D", fontsize=12, y=0.95)
Text(0.5, 0.95, 'KCSD1D')

png

png

png

8. Dentate-spike-triggered CSD

Dentate spikes are brief dentate-gyrus population events. Here we load the saved DS2 detector peaks, retain events occurring during NREM sleep, and average 200 ms LFP windows around them. Comparing this CSD pattern with the SWR and theta results helps identify event-specific laminar sinks and sources.

dentate_spikes = npy.io.load_events(basepath, epoch_name="DS2", load_pandas=True)
dentate_spikes = nel.EventArray(abscissa_vals=dentate_spikes.peaks.to_numpy(), fs=1250)
dentate_spikes = dentate_spikes[nrem]
dentate_spikes
<EventArray at 0x2172640d6a0: 1 series (61 segments)> at 1250 Hz
lfp = npy.io.LFPLoader(
    basepath,
    channels=channels,
    ext="lfp",
    epoch=[dentate_spikes.data[0, :] - 0.1, dentate_spikes.data[0, :] + 0.1],
)
times = lfp.abscissa_vals
lfp
<LFPLoader at 0x2172650c320: 250 signals> for a total of 5:02:05:299 hours
avg_signal = npy.process.peth(
    data=lfp, events=dentate_spikes.data[0, :], window=[-0.1, 0.1]
)

# peth returns positional columns. Restore the physical channel IDs used by LFPLoader
# so they align with the probe-layout channel IDs below.
if avg_signal.shape[1] != len(channels):
    raise ValueError("The averaged LFP does not contain every requested channel.")
avg_signal.columns = channels
fig, ax = plot_csd_by_shank(
    basepath,
    avg_signal,
    shank_layout,
    method="StandardCSD",
)
# suplot title
fig.suptitle("StandardCSD", fontsize=12, y=0.95)

fig, ax = plot_csd_by_shank(
    basepath,
    avg_signal,
    shank_layout,
    method="DeltaiCSD",
)
fig.suptitle("DeltaiCSD", fontsize=12, y=0.95)

fig, ax = plot_csd_by_shank(
    basepath,
    avg_signal,
    shank_layout,
    method="KCSD1D",
)
fig.suptitle("KCSD1D", fontsize=12, y=0.95)
Text(0.5, 0.95, 'KCSD1D')

png

png

png