Spiral Motion to Velocity-Modulated LFP#

This guide walks through the spiral_to_dynamic_pink_outlet.py example, which generates a simulated cursor moving in a spiral pattern and encodes its velocity into LFP-like colored noise streamed over Lab Streaming Layer (LSL).

Overview#

The example demonstrates velocity-to-LFP encoding with a predictable, synthetic input:

Clock -> SpiralGenerator -> Diff -> CART2POL -> Velocity2LFP -> LSLOutlet

The spiral motion produces smoothly varying velocity vectors that sweep through all directions while also varying in magnitude, providing known ground truth for testing decoders.

Prerequisites#

Install the required packages:

uv add ezmsg-simbiophys ezmsg-lsl

Running the Example#

Basic usage:

cd examples
uv run python spiral_to_dynamic_pink_outlet.py

With custom parameters:

uv run python spiral_to_dynamic_pink_outlet.py \
    --cursor-fs 100 \
    --output-fs 30000 \
    --output-ch 256

Command-Line Arguments#

--graph-addr

Address for the ezmsg graph server (ip:port). Set empty to disable. Default: 127.0.0.1:25978

--cursor-fs

Simulated cursor update rate in Hz. Default: 100.0

--output-fs

Output sampling rate in Hz. Default: 30000.0

--output-ch

Number of output channels. Default: 256

--seed

Random seed for reproducibility. Default: 6767

Pipeline Components#

Clock#

Generates timing signals at the specified rate (default 100 Hz).

SpiralGenerator#

Generates spiral 2-dimensional motion where both radius and angle vary over time:

SpiralGenerator(SpiralGeneratorSettings(
    r_mean=150.0,        # Mean radius
    r_amp=50.0,          # Amplitude of radial oscillation
    radial_freq=0.1,     # Radial oscillation frequency (Hz)
    angular_freq=0.25,   # Angular rotation frequency (Hz)
))

The parametric equations are:

  • r(t) = r_mean + r_amp * sin(2*pi*radial_freq*t)

  • theta(t) = 2*pi*angular_freq*t

  • x(t) = r(t) * cos(theta(t))

  • y(t) = r(t) * sin(theta(t))

This creates a “breathing” spiral where the cursor rotates while moving in and out from the center.

Diff#

Differentiates position to get velocity. With scale_by_fs=True, the output is in pixels per second.

CART2POL (CoordinateSpaces)#

Converts Cartesian velocity (vx, vy) to polar coordinates (magnitude, angle). This transformation is done once upstream and shared if both spike and LFP encoding are used (via VelocityEncoder).

Velocity2LFP#

Encodes polar velocity into LFP-like colored noise using a cosine tuning model:

  1. Cosine encoder: Each of n_lfp_sources (default 8) has a random preferred direction. The spectral exponent beta is computed as: beta = baseline + modulation * magnitude * cos(angle - pd)

  2. Clip: Ensures beta values stay within valid range [0, 2]

  3. Colored noise: Generate 1/f^β noise with β dynamically modulated per source

  4. Spatial mixing: Project n_lfp_sources onto output_ch channels using sinusoidal mixing patterns

The result is multi-channel colored noise where spectral properties vary with cursor velocity direction and magnitude.

LSLOutlet#

Streams the output over LSL with name SpiralModulatedPinkNoise and type EEG.

Understanding the Encoding#

Cosine Tuning Model#

Each of the n_lfp_sources (default 8) has a randomly assigned preferred direction. The spectral exponent beta for each source is computed using a cosine tuning model:

beta = baseline + modulation * magnitude * cos(angle - pd)

With default settings:

  • baseline = 1.0 (pink noise at rest)

  • modulation = 1.0 / max_velocity (scales with velocity)

  • max_velocity = 315.0 (pixels/second)

When moving at maximum velocity in a source’s preferred direction, beta reaches 2.0 (brown noise). When moving opposite to the preferred direction, beta reaches 0.0 (white noise). The output is clipped to [0, 2].

Spectral Exponent Effects#

The spectral exponent β controls the noise color:

  • β = 0: White noise (flat spectrum)

  • β = 1: Pink noise (1/f, equal power per octave)

  • β = 2: Brown noise (1/f², random walk)

Each source responds differently to velocity direction based on its preferred direction, creating a rich mixture of spectral characteristics.

Spatial Mixing#

The n_lfp_sources noise sources are projected onto output_ch channels using a mixing matrix based on sinusoidal weights at different spatial frequencies, plus random perturbations:

weights = np.zeros((n_sources, output_ch))
for i in range(n_sources):
    freq = (i + 1) / n_sources
    phase = 2 * np.pi * i / n_sources
    weights[i, :] = np.sin(2 * np.pi * freq * ch_idx / output_ch + phase)
weights += 0.3 * rng.standard_normal((n_sources, output_ch))

This creates spatially-varying patterns where different channels have different mixtures of the velocity-modulated sources, mimicking the spatial spread of LFP signals across electrode arrays.

Verifying the Output#

The spiral motion provides predictable ground truth:

  1. Varying velocity magnitude: Oscillates due to radial breathing

  2. Linearly varying angle: Rotates at angular_freq Hz

  3. Periodic behavior: Angular period = 1/angular_freq seconds (4s default), radial period = 1/radial_freq seconds (10s default)

You can verify the encoding by:

  1. Recording the LSL stream

  2. Computing spectral features from the output

  3. Checking that spectral properties correlate with the known velocity pattern

Example Analysis#

import numpy as np
from pylsl import StreamInlet, resolve_stream

# Capture one angular period (4 seconds with default settings)
streams = resolve_stream('name', 'SpiralModulatedPinkNoise')
inlet = StreamInlet(streams[0])

samples = []
for _ in range(int(4 * 30000)):  # 4 seconds at 30 kHz
    sample, _ = inlet.pull_sample()
    samples.append(sample)

data = np.array(samples)

# Compute spectrum for each second
from scipy import signal
for i in range(4):
    segment = data[i*30000:(i+1)*30000, 0]  # First channel
    f, psd = signal.welch(segment, fs=30000)
    # Compare spectral slope across segments

See Also#