Claude Skill

alterlab-neuropixels

Analyze Neuropixels 1.0/2.0 extracellular electrophysiology with SpikeInterface — load SpikeGLX/Open Ephys recordings, preprocess and motion-correct, run Kilosort4 spike sorting, compute quality metrics, apply Allen/IBL curation, and do AI-assisted visual inspection. Use when wor

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download alterlab-ieu-alterlab-academic-skills-skills_bioinformatics_alterlab-neuropixels-e4836c0.zip · 55 KB
Part of alterlab-ieu/alterlab-academic-skills — 94 skills

Install

skills CLI npx skills add https://github.com/AlterLab-IEU/AlterLab-Academic-Skills/tree/main/skills/bioinformatics/alterlab-neuropixels
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install alterlab-ieu-alterlab-academic-skills@llmmart
Git git clone https://github.com/AlterLab-IEU/AlterLab-Academic-Skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole alterlab-ieu/alterlab-academic-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Neuropixels Data Analysis

Overview

Comprehensive toolkit for analyzing Neuropixels high-density neural recordings using current best practices from SpikeInterface, Allen Institute, and International Brain Laboratory (IBL). Supports the full workflow from raw data to publication-ready curated units.

When to Use This Skill

This skill should be used when:

  • Working with Neuropixels recordings (.ap.bin, .lf.bin, .meta files)
  • Loading data from SpikeGLX, Open Ephys, or NWB formats
  • Preprocessing neural recordings (filtering, CAR, bad channel detection)
  • Detecting and correcting motion/drift in recordings
  • Running spike sorting (Kilosort4, SpykingCircus2, Mountainsort5)
  • Computing quality metrics (SNR, ISI violations, presence ratio)
  • Curating units using Allen/IBL criteria
  • Creating visualizations of neural data
  • Exporting results to Phy or NWB

Does NOT Trigger

Scenario Use Instead
Single-cell or bulk transcriptomics rather than electrophysiology alterlab-scanpy / alterlab-rnaseq-quant
Flow/mass cytometry FCS files alterlab-flowio
Generic time-series statistics or ML on already-extracted spike features alterlab-statistical-analysis / alterlab-scikit-learn
Plotting an arbitrary figure from data you already have alterlab-matplotlib / alterlab-scientific-viz
Network/graph analysis of a connectivity matrix alterlab-networkx

Supported Hardware & Formats

Probe Electrodes Channels Notes
Neuropixels 1.0 960 384 Requires phase_shift correction
Neuropixels 2.0 (single) 1280 384 Denser geometry
Neuropixels 2.0 (4-shank) 5120 384 Multi-region recording
Format Extension Reader
SpikeGLX .ap.bin, .lf.bin, .meta si.read_spikeglx()
Open Ephys .continuous, .oebin si.read_openephys()
NWB .nwb si.read_nwb()

Quick Start

Basic Import and Setup

import spikeinterface.full as si

# Bundled helper functions live in scripts/neuropixels_pipeline.py
from scripts.neuropixels_pipeline import (
    load_recording, preprocess, check_drift, correct_motion,
    run_spike_sorting, postprocess, curate_units, export_results, run_pipeline,
)

# Configure parallel processing
job_kwargs = dict(n_jobs=-1, chunk_duration='1s', progress_bar=True)

Loading Data

# SpikeGLX (most common)
recording = si.read_spikeglx('/path/to/data', stream_id='imec0.ap')

# Open Ephys (common for many labs)
recording = si.read_openephys('/path/to/Record_Node_101/')

# Check available streams
streams, ids = si.get_neo_streams('spikeglx', '/path/to/data')
print(streams)  # ['imec0.ap', 'imec0.lf', 'nidq']

# For testing with subset of data
recording = recording.frame_slice(0, int(60 * recording.get_sampling_frequency()))

Complete Pipeline (One Command)

# Run full analysis pipeline (writes all outputs under output_path/)
from scripts.neuropixels_pipeline import run_pipeline

run_pipeline(
    data_path='/path/to/data',
    output_path='output/',
    sorter='kilosort4',
    stream_name='imec0.ap',
    apply_motion_correction=True,
    curation_method='allen',
)

# Results are written to disk:
#   output/sorting_output/    spike sorter output
#   output/analyzer/          SortingAnalyzer (waveforms, metrics)
#   output/quality_metrics.csv
#   output/curation_labels.json

Or run it from the command line:

python scripts/neuropixels_pipeline.py /path/to/data output/ --sorter kilosort4 --curation allen

Standard Analysis Workflow

1. Preprocessing

# Recommended preprocessing chain
rec = si.highpass_filter(recording, freq_min=400)
rec = si.phase_shift(rec)  # Required for Neuropixels 1.0
bad_ids, _ = si.detect_bad_channels(rec)
rec = rec.remove_channels(bad_ids)
rec = si.common_reference(rec, operator='median')

# Or use the bundled wrapper (returns the preprocessed recording + bad channel ids)
from scripts.neuropixels_pipeline import preprocess
rec, bad_channels = preprocess(recording)

2. Check and Correct Drift

from scripts.neuropixels_pipeline import check_drift, correct_motion

# Check for drift (always do this!) — detects/localizes peaks and saves
# a drift plot to <output_folder>/drift_check.png, returns a dict with
# 'drift_estimate' (μm range).
drift_info = check_drift(rec, output_folder='output/')

# Apply correction if needed
if drift_info['drift_estimate'] > 20:  # microns
    rec = correct_motion(rec, output_folder='output/', preset='nonrigid_fast_and_accurate')

3. Spike Sorting

# Kilosort4 (recommended, requires GPU)
sorting = si.run_sorter('kilosort4', rec, folder='ks4_output')

# CPU alternatives
sorting = si.run_sorter('tridesclous2', rec, folder='tdc2_output')
sorting = si.run_sorter('spykingcircus2', rec, folder='sc2_output')
sorting = si.run_sorter('mountainsort5', rec, folder='ms5_output')

# Check available sorters
print(si.installed_sorters())

4. Postprocessing

# Create analyzer and compute all extensions
analyzer = si.create_sorting_analyzer(sorting, rec, sparse=True)

analyzer.compute('random_spikes', max_spikes_per_unit=500)
analyzer.compute('waveforms', ms_before=1.0, ms_after=2.0)
analyzer.compute('templates', operators=['average', 'std'])
analyzer.compute('spike_amplitudes')
analyzer.compute('correlograms', window_ms=50.0, bin_ms=1.0)
analyzer.compute('unit_locations', method='monopolar_triangulation')
analyzer.compute('quality_metrics')

metrics = analyzer.get_extension('quality_metrics').get_data()

5. Curation

# Allen Institute criteria (conservative)
good_units = metrics.query("""
    presence_ratio > 0.9 and
    isi_violations_ratio < 0.5 and
    amplitude_cutoff < 0.1
""").index.tolist()

# Or use automated curation (returns {unit_id: 'good'|'mua'|'noise'})
from scripts.neuropixels_pipeline import curate_units
labels = curate_units(metrics, method='allen')  # 'allen', 'ibl', 'strict'

6. AI-Assisted Curation (For Uncertain Units)

When using this skill with Claude Code, Claude can directly analyze waveform plots and provide expert curation decisions. The recommended workflow is to render per-unit summary plots with SpikeInterface and let Claude inspect them:

import spikeinterface.widgets as sw
import matplotlib.pyplot as plt

# Find borderline units worth a visual look
uncertain = metrics.query('snr > 3 and snr < 8').index.tolist()

# Render a summary figure per uncertain unit (waveform + correlogram + amplitudes)
for unit_id in uncertain:
    sw.plot_unit_summary(analyzer, unit_id=unit_id)
    plt.savefig(f'ai_curation/unit_{unit_id}_summary.png', dpi=150, bbox_inches='tight')
    plt.close()

Claude Code Integration: When running within Claude Code, ask Claude to examine the saved waveform/correlogram plots directly - no API setup required.

7. Generate Analysis Report

# The bundled run_pipeline writes a machine-readable summary.json
# (sampling rate, duration, channel count, unit counts) into output_path/.
import json
with open('output/summary.json') as f:
    summary = json.load(f)
print(summary)

# For a browsable HTML report of waveforms/metrics, use SpikeInterface's exporter:
si.export_report(analyzer, output_folder='output/report/')
# Open output/report/index.html for figures and the per-unit table

8. Export Results

# Export to Phy for manual review
si.export_to_phy(analyzer, output_folder='phy_export/',
                 compute_pc_features=True, compute_amplitudes=True)

# Export to NWB (via NeuroConv — SpikeInterface has no native NWB exporter)
# pip install neuroconv
from neuroconv.tools.spikeinterface import write_sorting, write_recording
write_recording(recording=rec, nwbfile_path='output.nwb', overwrite=True)
write_sorting(sorting=sorting, nwbfile_path='output.nwb')

# Save quality metrics
metrics.to_csv('quality_metrics.csv')

Common Pitfalls and Best Practices

  1. Always check drift before spike sorting - drift > 10μm significantly impacts quality
  2. Use phase_shift for Neuropixels 1.0 probes (not needed for 2.0)
  3. Save preprocessed data to avoid recomputing - use rec.save(folder='preprocessed/')
  4. Use GPU for Kilosort4 - it's 10-50x faster than CPU alternatives
  5. Review uncertain units manually - automated curation is a starting point
  6. Combine metrics with AI - use metrics for clear cases, AI for borderline units
  7. Document your thresholds - different analyses may need different criteria
  8. Export to Phy for critical experiments - human oversight is valuable

Key Parameters to Adjust

Preprocessing

  • freq_min: Highpass cutoff (300-400 Hz typical)
  • detect_threshold: Bad channel detection sensitivity

Motion Correction

  • preset: 'kilosort_like' (fast) or 'nonrigid_accurate' (better for severe drift)

Spike Sorting (Kilosort4)

  • batch_size: Samples per batch (30000 default)
  • nblocks: Number of drift blocks (increase for long recordings)
  • Th_learned: Detection threshold (lower = more spikes)

Quality Metrics

  • snr_threshold: Signal-to-noise cutoff (3-5 typical)
  • isi_violations_ratio: Refractory violations (0.01-0.5)
  • presence_ratio: Recording coverage (0.5-0.95)

Bundled Resources

scripts/preprocess_recording.py

Automated preprocessing script:

python scripts/preprocess_recording.py /path/to/data --output preprocessed/

scripts/run_sorting.py

Run spike sorting:

python scripts/run_sorting.py preprocessed/ --sorter kilosort4 --output sorting/

scripts/compute_metrics.py

Compute quality metrics and apply curation:

python scripts/compute_metrics.py sorting/ preprocessed/ --output metrics/ --curation allen

scripts/export_to_phy.py

Export to Phy for manual curation:

python scripts/export_to_phy.py metrics/analyzer --output phy_export/

assets/analysis_template.py

Complete analysis template. Copy and customize:

cp assets/analysis_template.py my_analysis.py
# Edit parameters and run
python my_analysis.py

references/standard_workflow.md

Detailed step-by-step workflow with explanations for each stage.

references/api_reference.md

Quick function reference organized by module.

references/plotting_guide.md

Comprehensive visualization guide for publication-quality figures.

Detailed Reference Guides

Topic Reference
Full workflow references/standard_workflow.md
API reference references/api_reference.md
Plotting guide references/plotting_guide.md
Preprocessing references/PREPROCESSING.md
Spike sorting references/SPIKE_SORTING.md
Motion correction references/MOTION_CORRECTION.md
Quality metrics references/QUALITY_METRICS.md
Automated curation references/AUTOMATED_CURATION.md
AI-assisted curation references/AI_CURATION.md
Waveform analysis references/ANALYSIS.md

Installation

# Core packages
uv pip install "spikeinterface[full]" probeinterface neo

# External sorters (separate packages, run via si.run_sorter)
uv pip install kilosort            # Kilosort4 (CUDA GPU required)
uv pip install mountainsort5       # Mountainsort5 (CPU)

# Internal sorters ship INSIDE spikeinterface — install the extra, not a
# standalone package. There is no `spykingcircus` package for SpykingCircus2.
uv pip install "spikeinterface[spykingcircus2]"
uv pip install "spikeinterface[tridesclous2]"

# Our toolkit ships as local scripts (scripts/) — no pip install needed;
# run them directly or import from scripts.neuropixels_pipeline

# Optional: AI curation
uv pip install anthropic

# Optional: IBL tools
uv pip install ibl-neuropixel ibllib

SpikeInterface 0.105 — breaking changes worth knowing

0.105.0 (2026-09-21) renamed a lot of surface area. Objects saved by older versions still load, but scripts written against 0.104 may not run:

  • peak_sign is gone from the analysis API. The main channel and peak sign are now fixed when the SortingAnalyzer is created and stored as the main_channel_id sorting property, so compute_quality_metrics, compute_template_metrics, compute_spike_amplitudes, compute_spike_locations and compute_unit_locations no longer accept it. compute_snrs(..., peak_sign=, peak_mode=) became compute_snrs(..., method=).
  • get_template_extremum_channel() -> SortingAnalyzer.get_main_channels() (old names remain as deprecated shims).
  • estimate_sparsity() now defaults to peak_sign="both" instead of "neg" — a silent change in results, not an error, so re-check sparsity-dependent output.
  • Probe handling: set_probe()/set_probegroup() act in place and return None; use select_channels_with_probe() to get a new recording. reset_probe() -> remove_probe().
  • ChunkRecordingExecutor -> TimeSeriesChunkExecutor; BaseExtractor.load_from_folder() removed in favour of si.load().
  • New in 0.105: dartsort as an external sorter, SortingAnalyzer lazy and read_only modes, detect_and_remove_artifacts preprocessing, and read_kilosort4_motion() to build a Motion object from a Kilosort4 output folder.

Pin the SpikeInterface version in any analysis you intend to reproduce, and record it in the methods section — metric definitions have changed across minor releases.

Project Structure

project/
├── raw_data/
│   └── recording_g0/
│       └── recording_g0_imec0/
│           ├── recording_g0_t0.imec0.ap.bin
│           └── recording_g0_t0.imec0.ap.meta
├── preprocessed/           # Saved preprocessed recording
├── motion/                 # Motion estimation results
├── sorting_output/         # Spike sorter output
├── analyzer/               # SortingAnalyzer (waveforms, metrics)
├── phy_export/             # For manual curation
├── ai_curation/            # AI analysis reports
└── results/
    ├── quality_metrics.csv
    ├── curation_labels.json
    └── output.nwb

Additional Resources

Files (alterlab-academic-skills)
  • assets
    • analysis_template.py 8.8 KB
      #!/usr/bin/env python
      """
      Neuropixels Analysis Template
      
      Complete analysis workflow from raw data to curated units.
      Copy and customize this template for your analysis.
      
      Usage:
          1. Copy this file to your analysis directory
          2. Update the PARAMETERS section
          3. Run: python analysis_template.py
      """
      
      # =============================================================================
      # PARAMETERS - Customize these for your analysis
      # =============================================================================
      
      # Input/Output paths
      DATA_PATH = '/path/to/your/spikeglx/data/'
      OUTPUT_DIR = 'analysis_output/'
      DATA_FORMAT = 'spikeglx'  # 'spikeglx', 'openephys', or 'nwb'
      STREAM_ID = 'imec0.ap'    # For multi-probe recordings
      
      # Preprocessing parameters
      FREQ_MIN = 300           # Highpass filter (Hz)
      FREQ_MAX = 6000          # Lowpass filter (Hz)
      APPLY_PHASE_SHIFT = True
      APPLY_CMR = True
      DETECT_BAD_CHANNELS = True
      
      # Motion correction
      CORRECT_MOTION = True
      MOTION_PRESET = 'nonrigid_accurate'  # 'kilosort_like', 'nonrigid_fast_and_accurate'
      
      # Spike sorting
      SORTER = 'kilosort4'     # 'kilosort4', 'spykingcircus2', 'mountainsort5'
      SORTER_PARAMS = {
          'batch_size': 30000,
          'nblocks': 1,        # Increase for long recordings with drift
      }
      
      # Quality metrics and curation
      CURATION_METHOD = 'allen'  # 'allen', 'ibl', 'strict'
      
      # Processing
      N_JOBS = -1              # -1 = all cores
      
      # =============================================================================
      # ANALYSIS PIPELINE - Usually no need to modify below
      # =============================================================================
      
      from pathlib import Path
      import json
      
      import spikeinterface.full as si
      from spikeinterface.exporters import export_to_phy
      
      
      def main():
          """Run the full analysis pipeline."""
      
          output_path = Path(OUTPUT_DIR)
          output_path.mkdir(parents=True, exist_ok=True)
      
          # =========================================================================
          # 1. LOAD DATA
          # =========================================================================
          print("=" * 60)
          print("1. LOADING DATA")
          print("=" * 60)
      
          if DATA_FORMAT == 'spikeglx':
              recording = si.read_spikeglx(DATA_PATH, stream_id=STREAM_ID)
          elif DATA_FORMAT == 'openephys':
              recording = si.read_openephys(DATA_PATH)
          elif DATA_FORMAT == 'nwb':
              recording = si.read_nwb(DATA_PATH)
          else:
              raise ValueError(f"Unknown format: {DATA_FORMAT}")
      
          print(f"Recording: {recording.get_num_channels()} channels")
          print(f"Duration: {recording.get_total_duration():.1f} seconds")
          print(f"Sampling rate: {recording.get_sampling_frequency()} Hz")
      
          # =========================================================================
          # 2. PREPROCESSING
          # =========================================================================
          print("\n" + "=" * 60)
          print("2. PREPROCESSING")
          print("=" * 60)
      
          rec = recording
      
          # Bandpass filter
          print(f"Applying bandpass filter ({FREQ_MIN}-{FREQ_MAX} Hz)...")
          rec = si.bandpass_filter(rec, freq_min=FREQ_MIN, freq_max=FREQ_MAX)
      
          # Phase shift correction
          if APPLY_PHASE_SHIFT:
              print("Applying phase shift correction...")
              rec = si.phase_shift(rec)
      
          # Bad channel detection
          if DETECT_BAD_CHANNELS:
              print("Detecting bad channels...")
              bad_ids, _ = si.detect_bad_channels(rec)
              if len(bad_ids) > 0:
                  print(f"  Removing {len(bad_ids)} bad channels")
                  rec = rec.remove_channels(bad_ids)
      
          # Common median reference
          if APPLY_CMR:
              print("Applying common median reference...")
              rec = si.common_reference(rec, operator='median', reference='global')
      
          # Save preprocessed
          print("Saving preprocessed recording...")
          rec.save(folder=output_path / 'preprocessed', n_jobs=N_JOBS)
      
          # =========================================================================
          # 3. MOTION CORRECTION
          # =========================================================================
          if CORRECT_MOTION:
              print("\n" + "=" * 60)
              print("3. MOTION CORRECTION")
              print("=" * 60)
      
              print(f"Estimating and correcting motion (preset: {MOTION_PRESET})...")
              rec = si.correct_motion(
                  rec,
                  preset=MOTION_PRESET,
                  folder=output_path / 'motion',
              )
      
          # =========================================================================
          # 4. SPIKE SORTING
          # =========================================================================
          print("\n" + "=" * 60)
          print("4. SPIKE SORTING")
          print("=" * 60)
      
          print(f"Running {SORTER}...")
          sorting = si.run_sorter(
              SORTER,
              rec,
              folder=output_path / f'{SORTER}_output',  # `folder=` (the old `output_folder=` is deprecated)
              verbose=True,
              **SORTER_PARAMS,
          )
      
          print(f"Found {len(sorting.unit_ids)} units")
      
          # =========================================================================
          # 5. POSTPROCESSING
          # =========================================================================
          print("\n" + "=" * 60)
          print("5. POSTPROCESSING")
          print("=" * 60)
      
          print("Creating SortingAnalyzer...")
          analyzer = si.create_sorting_analyzer(
              sorting,
              rec,
              format='binary_folder',
              folder=output_path / 'analyzer',
              sparse=True,
          )
      
          print("Computing extensions...")
          analyzer.compute('random_spikes', max_spikes_per_unit=500)
          analyzer.compute('waveforms', ms_before=1.0, ms_after=2.0)
          analyzer.compute('templates', operators=['average', 'std'])
          analyzer.compute('noise_levels')
          analyzer.compute('spike_amplitudes')
          analyzer.compute('correlograms', window_ms=50.0, bin_ms=1.0)
          analyzer.compute('unit_locations', method='monopolar_triangulation')
      
          # =========================================================================
          # 6. QUALITY METRICS
          # =========================================================================
          print("\n" + "=" * 60)
          print("6. QUALITY METRICS")
          print("=" * 60)
      
          print("Computing quality metrics...")
          metrics = si.compute_quality_metrics(
              analyzer,
              metric_names=[
                  'snr', 'isi_violations_ratio', 'presence_ratio',
                  'amplitude_cutoff', 'firing_rate', 'amplitude_cv',
              ],
              n_jobs=N_JOBS,
          )
      
          metrics.to_csv(output_path / 'quality_metrics.csv')
          print(f"Saved metrics to: {output_path / 'quality_metrics.csv'}")
      
          # Print summary
          print("\nMetrics summary:")
          for col in ['snr', 'isi_violations_ratio', 'presence_ratio', 'firing_rate']:
              if col in metrics.columns:
                  print(f"  {col}: {metrics[col].median():.4f} (median)")
      
          # =========================================================================
          # 7. CURATION
          # =========================================================================
          print("\n" + "=" * 60)
          print("7. CURATION")
          print("=" * 60)
      
          # Curation criteria
          criteria = {
              'allen': {'snr': 3.0, 'isi_violations_ratio': 0.1, 'presence_ratio': 0.9},
              'ibl': {'snr': 4.0, 'isi_violations_ratio': 0.5, 'presence_ratio': 0.5},
              'strict': {'snr': 5.0, 'isi_violations_ratio': 0.01, 'presence_ratio': 0.95},
          }[CURATION_METHOD]
      
          print(f"Applying {CURATION_METHOD} criteria: {criteria}")
      
          labels = {}
          for unit_id in metrics.index:
              row = metrics.loc[unit_id]
              is_good = (
                  row.get('snr', 0) >= criteria['snr'] and
                  row.get('isi_violations_ratio', 1) <= criteria['isi_violations_ratio'] and
                  row.get('presence_ratio', 0) >= criteria['presence_ratio']
              )
              if is_good:
                  labels[int(unit_id)] = 'good'
              elif row.get('snr', 0) < 2:
                  labels[int(unit_id)] = 'noise'
              else:
                  labels[int(unit_id)] = 'mua'
      
          # Save labels
          with open(output_path / 'curation_labels.json', 'w') as f:
              json.dump(labels, f, indent=2)
      
          # Count
          good_count = sum(1 for v in labels.values() if v == 'good')
          mua_count = sum(1 for v in labels.values() if v == 'mua')
          noise_count = sum(1 for v in labels.values() if v == 'noise')
      
          print(f"\nCuration results:")
          print(f"  Good: {good_count}")
          print(f"  MUA: {mua_count}")
          print(f"  Noise: {noise_count}")
          print(f"  Total: {len(labels)}")
      
          # =========================================================================
          # 8. EXPORT
          # =========================================================================
          print("\n" + "=" * 60)
          print("8. EXPORT")
          print("=" * 60)
      
          print("Exporting to Phy...")
          export_to_phy(
              analyzer,
              output_folder=output_path / 'phy_export',
              copy_binary=True,
          )
      
          print(f"\nAnalysis complete!")
          print(f"Results saved to: {output_path}")
          print(f"\nTo open in Phy:")
          print(f"  phy template-gui {output_path / 'phy_export' / 'params.py'}")
      
      
      if __name__ == '__main__':
          main()
      
  • evals
    • evals.json 4.1 KB
      {
        "skill": "alterlab-neuropixels",
        "evals": [
          {
            "id": "load-spikeglx-preprocess",
            "prompt": "I just got a SpikeGLX recording from a Neuropixels 1.0 probe (.ap.bin / .ap.meta). Walk me through loading it in SpikeInterface and the standard preprocessing chain before sorting.",
            "expected_output": "Triggers the Neuropixels skill. Should use si.read_spikeglx with the imec0.ap stream, then the recommended preprocessing chain: highpass_filter, phase_shift (required for NP 1.0), detect_bad_channels + remove_channels, and common_reference (median CAR). May reference the bundled preprocess() helper.",
            "assertions": [
              {"type": "should_trigger", "value": true},
              {"type": "output_contains", "value": "read_spikeglx"},
              {"type": "behavior", "value": "Includes phase_shift for Neuropixels 1.0 and a common-reference/CAR step in the preprocessing chain."}
            ]
          },
          {
            "id": "drift-check-motion-correct",
            "prompt": "Before I run spike sorting on my probe data I want to check for drift and correct it if it's bad. How do I do that in SpikeInterface, and what threshold means I should bother correcting?",
            "expected_output": "Triggers the Neuropixels skill. Should check drift first (check_drift / peak detection + localization), and apply correct_motion (e.g. preset 'nonrigid_fast_and_accurate' or 'nonrigid_accurate') when drift is significant (>~10-20 microns). Should emphasize always checking drift before sorting.",
            "assertions": [
              {"type": "should_trigger", "value": true},
              {"type": "output_contains", "value": "motion"},
              {"type": "behavior", "value": "Recommends checking drift before sorting and applying motion/drift correction when the estimate exceeds ~10-20 microns."}
            ]
          },
          {
            "id": "kilosort4-quality-metrics-curation",
            "prompt": "I want to run Kilosort4 on my preprocessed recording, then compute quality metrics and keep only good units using Allen Institute criteria. Show me the SpikeInterface steps.",
            "expected_output": "Triggers the Neuropixels skill. Should run si.run_sorter('kilosort4', ...) (noting GPU is needed), build a SortingAnalyzer, compute extensions including quality_metrics, then curate with Allen criteria (presence_ratio > 0.9, isi_violations_ratio < 0.5, amplitude_cutoff < 0.1) or curate_units(method='allen').",
            "assertions": [
              {"type": "should_trigger", "value": true},
              {"type": "output_contains", "value": "kilosort4"},
              {"type": "behavior", "value": "Computes quality_metrics on a SortingAnalyzer and applies Allen-style thresholds (presence_ratio, isi_violations_ratio, amplitude_cutoff) for curation."}
            ]
          },
          {
            "id": "export-to-phy",
            "prompt": "My units are sorted and I have an analyzer with metrics. I want to hand this off for manual curation in Phy. How do I export it from SpikeInterface?",
            "expected_output": "Triggers the Neuropixels skill. Should use si.export_to_phy(analyzer, output_folder='phy_export/', compute_pc_features=True, compute_amplitudes=True) to produce a Phy-compatible export for manual review.",
            "assertions": [
              {"type": "should_trigger", "value": true},
              {"type": "output_contains", "value": "export_to_phy"},
              {"type": "behavior", "value": "Calls export_to_phy on the SortingAnalyzer to produce a Phy-loadable folder for manual curation."}
            ]
          },
          {
            "id": "near-miss-scanpy",
            "prompt": "I have a single-cell RNA-seq count matrix in an AnnData object. I want to cluster the cells with Leiden and make a UMAP colored by cluster. What's the standard workflow?",
            "expected_output": "Should NOT trigger the Neuropixels skill. This is single-cell transcriptomics clustering on an AnnData object, not extracellular electrophysiology / spike sorting. It belongs to the scanpy skill (sc.pp.neighbors, sc.tl.leiden, sc.tl.umap). The response should defer to scanpy rather than SpikeInterface.",
            "assertions": [
              {"type": "should_not_trigger", "value": true},
              {"type": "output_contains", "value": "scanpy"}
            ]
          }
        ]
      }
      
  • references
    • AI_CURATION.md 9.4 KB
      # AI-Assisted Curation Reference
      
      Guide to using AI visual analysis for unit curation, inspired by SpikeAgent's approach.
      
      ## Overview
      
      AI-assisted curation uses vision-language models to analyze spike sorting visualizations,
      providing expert-level quality assessments similar to human curators.
      
      ### Workflow
      
      ```
      Traditional:  Metrics → Threshold → Labels
      AI-Enhanced:  Metrics → AI Visual Analysis → Confidence Score → Labels
      ```
      
      ## Claude Code Integration
      
      When using this skill within Claude Code, Claude can directly analyze waveform plots without requiring API setup. Simply:
      
      1. Generate a unit report or plot
      2. Ask Claude to analyze the visualization
      3. Claude will provide expert-level curation decisions
      
      Example workflow in Claude Code:
      ```python
      # Generate plots for a unit
      npa.plot_unit_summary(analyzer, unit_id=0, output='unit_0_summary.png')
      
      # Then ask Claude: "Please analyze this unit's waveforms and autocorrelogram
      # to determine if it's a well-isolated single unit, multi-unit activity, or noise"
      ```
      
      Claude can assess:
      - Waveform consistency and shape
      - Refractory period violations from autocorrelograms
      - Amplitude stability over time
      - Overall unit isolation quality
      
      ## Quick Start
      
      ### Generate Unit Report
      
      ```python
      # NOTE: the `npa.*` AI-curation helpers shown throughout this file are illustrative
      # only — they are NOT part of the bundled scripts. In practice, render per-unit
      # summary figures with SpikeInterface and have Claude Code inspect them directly
      # (no API client or extra package required):
      import spikeinterface.widgets as sw
      import matplotlib.pyplot as plt
      
      # Create a visual summary figure for a unit
      sw.plot_unit_summary(analyzer, unit_id=0)
      plt.savefig('reports/unit_0_summary.png', dpi=150, bbox_inches='tight')
      plt.close()
      
      # Report includes:
      # - Waveforms, templates, autocorrelogram
      # - Amplitudes over time, ISI histogram
      # - Quality metrics summary
      # - Base64 encoded image for API
      ```
      
      ### AI Visual Analysis
      
      ```python
      import os
      from anthropic import Anthropic
      
      # Setup API client
      client = Anthropic()
      
      # Analyze single unit
      result = npa.analyze_unit_visually(
          analyzer,
          unit_id=0,
          api_client=client,
          # Per the ALTERLAB_MODEL convention (skills/core/shared/model_env.md), never hardcode a
          # bare model ID: read it from the environment with a dated default.
          model=os.environ.get("ALTERLAB_MODEL") or "claude-opus-5-5",  # default reviewed 2026-09-23
          task='quality_assessment'
      )
      
      print(f"Classification: {result['classification']}")
      print(f"Reasoning: {result['reasoning']}")
      ```
      
      ### Batch Analysis
      
      ```python
      # Analyze all units
      results = npa.batch_visual_curation(
          analyzer,
          api_client=client,
          output_dir='ai_curation/',
          progress_callback=lambda i, n: print(f"Progress: {i}/{n}")
      )
      
      # Get labels
      ai_labels = {uid: r['classification'] for uid, r in results.items()}
      ```
      
      ## Interactive Curation Session
      
      For human-in-the-loop curation with AI assistance:
      
      ```python
      # Create session
      session = npa.CurationSession.create(
          analyzer,
          output_dir='curation_session/',
          sort_by_confidence=True  # Show uncertain units first
      )
      
      # Process units
      while True:
          unit = session.current_unit()
          if unit is None:
              break
      
          print(f"Unit {unit.unit_id}:")
          print(f"  Auto: {unit.auto_classification} (conf: {unit.confidence:.2f})")
      
          # Generate report
          report = npa.generate_unit_report(analyzer, unit.unit_id)
      
          # Get AI opinion
          ai_result = npa.analyze_unit_visually(analyzer, unit.unit_id, api_client=client)
          session.set_ai_classification(unit.unit_id, ai_result['classification'])
      
          # Human decision
          decision = input("Decision (good/mua/noise/skip): ")
          if decision != 'skip':
              session.set_decision(unit.unit_id, decision)
      
          session.next_unit()
      
      # Export results
      labels = session.get_final_labels()
      session.export_decisions('final_curation.csv')
      ```
      
      ## Analysis Tasks
      
      ### Quality Assessment (Default)
      
      Analyzes waveform shape, refractory period, amplitude stability.
      
      ```python
      result = npa.analyze_unit_visually(analyzer, uid, task='quality_assessment')
      # Returns: 'good', 'mua', or 'noise'
      ```
      
      ### Merge Candidate Detection
      
      Determines if two units should be merged.
      
      ```python
      result = npa.analyze_unit_visually(analyzer, uid, task='merge_candidate')
      # Returns: 'merge' or 'keep_separate'
      ```
      
      ### Drift Assessment
      
      Evaluates motion/drift in the recording.
      
      ```python
      result = npa.analyze_unit_visually(analyzer, uid, task='drift_assessment')
      # Returns drift magnitude and correction recommendation
      ```
      
      ## Custom Prompts
      
      Create custom analysis prompts:
      
      ```python
      # There is no bundled prompt-builder; just write the instruction text you want
      # Claude to follow when it inspects the unit's summary figure. For example:
      prompt = """
      Assess this unit's quality from its waveform, correlogram, and amplitude plots.
      Focus on waveform amplitude consistency.
      Classify as: GOOD, MUA, or NOISE.
      """
      
      # Or fully custom
      custom_prompt = """
      Analyze this unit and determine if it represents a fast-spiking interneuron.
      
      Look for:
      1. Narrow waveform (peak-to-trough < 0.5ms)
      2. High firing rate
      3. Regular ISI distribution
      
      Classify as: FSI (fast-spiking interneuron) or OTHER
      """
      
      result = npa.analyze_unit_visually(
          analyzer, uid,
          api_client=client,
          custom_prompt=custom_prompt
      )
      ```
      
      ## Combining AI with Metrics
      
      Best practice: use both AI and quantitative metrics:
      
      ```python
      def hybrid_curation(analyzer, metrics, api_client):
          """Combine metrics and AI for robust curation."""
          labels = {}
      
          for unit_id in metrics.index:
              row = metrics.loc[unit_id]
      
              # High confidence from metrics alone
              if row['snr'] > 10 and row['isi_violations_ratio'] < 0.001:
                  labels[unit_id] = 'good'
                  continue
      
              if row['snr'] < 1.5:
                  labels[unit_id] = 'noise'
                  continue
      
              # Uncertain cases: use AI
              result = npa.analyze_unit_visually(
                  analyzer, unit_id, api_client=api_client
              )
              labels[unit_id] = result['classification']
      
          return labels
      ```
      
      ## Session Management
      
      ### Resume Session
      
      ```python
      # Resume interrupted session
      session = npa.CurationSession.load('curation_session/20250101_120000/')
      
      # Check progress
      summary = session.get_summary()
      print(f"Progress: {summary['progress_pct']:.1f}%")
      print(f"Remaining: {summary['remaining']} units")
      
      # Continue from where we left off
      unit = session.current_unit()
      ```
      
      ### Navigate Session
      
      ```python
      # Go to specific unit
      session.go_to_unit(42)
      
      # Previous/next
      session.prev_unit()
      session.next_unit()
      
      # Update decision
      session.set_decision(42, 'good', notes='Clear refractory period')
      ```
      
      ### Export Results
      
      ```python
      # Get final labels (priority: human > AI > auto)
      labels = session.get_final_labels()
      
      # Export detailed results
      df = session.export_decisions('curation_results.csv')
      
      # Summary
      summary = session.get_summary()
      print(f"Good: {summary['decisions'].get('good', 0)}")
      print(f"MUA: {summary['decisions'].get('mua', 0)}")
      print(f"Noise: {summary['decisions'].get('noise', 0)}")
      ```
      
      ## Visual Report Components
      
      The generated report includes 6 panels:
      
      | Panel | Content | What to Look For |
      |-------|---------|------------------|
      | Waveforms | Individual spike waveforms | Consistency, shape |
      | Template | Mean ± std | Clean negative peak, physiological shape |
      | Autocorrelogram | Spike timing | Gap at 0ms (refractory period) |
      | Amplitudes | Amplitude over time | Stability, no drift |
      | ISI Histogram | Inter-spike intervals | Refractory gap < 1.5ms |
      | Metrics | Quality numbers | SNR, ISI violations, presence |
      
      ## API Support
      
      Currently supported APIs:
      
      | Provider | Client | Model Examples |
      |----------|--------|----------------|
      | Anthropic | `anthropic.Anthropic()` | via `ALTERLAB_MODEL` (default `claude-opus-5-5`, reviewed 2026-09-23) |
      | OpenAI | `openai.OpenAI()` | gpt-4o (gpt-4-vision-preview is deprecated) |
      | Google | `google.genai` | Gemini 3.x vision models (e.g. `gemini-3.5-flash`); Gemini 2.5 is still served but is the previous generation |
      
      ### Anthropic Example
      
      ```python
      from anthropic import Anthropic
      
      client = Anthropic(api_key="your-api-key")
      result = npa.analyze_unit_visually(analyzer, uid, api_client=client)
      ```
      
      ### OpenAI Example
      
      ```python
      from openai import OpenAI
      
      client = OpenAI(api_key="your-api-key")
      result = npa.analyze_unit_visually(
          analyzer, uid,
          api_client=client,
          model='gpt-4o'  # gpt-4-vision-preview is deprecated; gpt-4o is its vision-capable successor
      )
      ```
      
      ## Best Practices
      
      1. **Use AI for uncertain cases** - Don't waste API calls on obvious good/noise units
      2. **Combine with metrics** - AI should supplement, not replace, quantitative measures
      3. **Human oversight** - Review AI decisions, especially for important analyses
      4. **Save sessions** - Always use CurationSession to track decisions
      5. **Document reasoning** - Use notes field to record decision rationale
      
      ## Cost Optimization
      
      ```python
      # Only use AI for uncertain units
      uncertain_units = metrics.query("""
          snr > 2 and snr < 8 and
          isi_violations_ratio > 0.001 and isi_violations_ratio < 0.1
      """).index.tolist()
      
      # Batch process only these
      results = npa.batch_visual_curation(
          analyzer,
          unit_ids=uncertain_units,
          api_client=client
      )
      ```
      
      ## References
      
      - [SpikeAgent](https://github.com/SpikeAgent/SpikeAgent) - AI-powered spike sorting assistant
      - [Anthropic Vision API](https://docs.anthropic.com/en/docs/vision)
      - [GPT-4 Vision](https://platform.openai.com/docs/guides/vision)
      
    • ANALYSIS.md 9.7 KB
      # Post-Processing & Analysis Reference
      
      Comprehensive guide to quality metrics, visualization, and analysis of sorted Neuropixels data.
      
      ## Sorting Analyzer
      
      The `SortingAnalyzer` is the central object for post-processing.
      
      ### Create Analyzer
      ```python
      import spikeinterface.full as si
      
      # Create analyzer
      analyzer = si.create_sorting_analyzer(
          sorting,
          recording,
          sparse=True,                    # Use sparse representation
          format='binary_folder',         # Storage format
          folder='analyzer_output'        # Save location
      )
      ```
      
      ### Compute Extensions
      ```python
      # Compute all standard extensions
      analyzer.compute('random_spikes')       # Random spike selection
      analyzer.compute('waveforms')           # Extract waveforms
      analyzer.compute('templates')           # Compute templates
      analyzer.compute('noise_levels')        # Noise estimation
      analyzer.compute('principal_components')  # PCA
      analyzer.compute('spike_amplitudes')    # Amplitude per spike
      analyzer.compute('correlograms')        # Auto/cross correlograms
      analyzer.compute('unit_locations')      # Unit locations
      analyzer.compute('spike_locations')     # Per-spike locations
      analyzer.compute('template_similarity') # Template similarity matrix
      analyzer.compute('quality_metrics')     # Quality metrics
      
      # Or compute multiple at once
      analyzer.compute([
          'random_spikes', 'waveforms', 'templates', 'noise_levels',
          'principal_components', 'spike_amplitudes', 'correlograms',
          'unit_locations', 'quality_metrics'
      ])
      ```
      
      ### Save and Load
      ```python
      # Save
      analyzer.save_as(folder='analyzer_saved', format='binary_folder')
      
      # Load
      analyzer = si.load_sorting_analyzer('analyzer_saved')
      ```
      
      ## Quality Metrics
      
      ### Compute Metrics
      ```python
      analyzer.compute('quality_metrics')
      qm = analyzer.get_extension('quality_metrics').get_data()
      print(qm)
      ```
      
      ### Available Metrics
      
      | Metric | Description | Good Values |
      |--------|-------------|-------------|
      | `snr` | Signal-to-noise ratio | > 5 |
      | `isi_violations_ratio` | ISI violation ratio | < 0.01 (1%) |
      | `isi_violations_count` | ISI violation count | Low |
      | `presence_ratio` | Fraction of recording with spikes | > 0.9 |
      | `firing_rate` | Spikes per second | 0.1-50 Hz |
      | `amplitude_cutoff` | Estimated missed spikes | < 0.1 |
      | `amplitude_median` | Median spike amplitude | - |
      | `amplitude_cv` | Coefficient of variation | < 0.5 |
      | `drift_ptp` | Peak-to-peak drift (um) | < 40 |
      | `drift_std` | Standard deviation of drift | < 10 |
      | `drift_mad` | Median absolute deviation | < 10 |
      | `sliding_rp_violation` | Sliding refractory period | < 0.05 |
      | `sync_spike_2` | Synchrony with other units | < 0.5 |
      | `isolation_distance` | Mahalanobis distance | > 20 |
      | `l_ratio` | L-ratio (isolation) | < 0.1 |
      | `d_prime` | Discriminability | > 5 |
      | `nn_hit_rate` | Nearest neighbor hit rate | > 0.9 |
      | `nn_miss_rate` | Nearest neighbor miss rate | < 0.1 |
      | `silhouette_score` | Cluster silhouette | > 0.5 |
      
      ### Compute Specific Metrics
      ```python
      analyzer.compute(
          'quality_metrics',
          metric_names=['snr', 'isi_violations_ratio', 'presence_ratio', 'firing_rate']
      )
      ```
      
      ### Custom Quality Thresholds
      ```python
      qm = analyzer.get_extension('quality_metrics').get_data()
      
      # Define quality criteria
      quality_criteria = {
          'snr': ('>', 5),
          'isi_violations_ratio': ('<', 0.01),
          'presence_ratio': ('>', 0.9),
          'firing_rate': ('>', 0.1),
          'amplitude_cutoff': ('<', 0.1),
      }
      
      # Filter good units
      good_units = qm.query(
          "(snr > 5) & (isi_violations_ratio < 0.01) & (presence_ratio > 0.9)"
      ).index.tolist()
      
      print(f"Good units: {len(good_units)}/{len(qm)}")
      ```
      
      ## Waveforms & Templates
      
      ### Extract Waveforms
      ```python
      analyzer.compute('waveforms', ms_before=1.5, ms_after=2.5, max_spikes_per_unit=500)
      
      # Get waveforms for a unit
      waveforms = analyzer.get_extension('waveforms').get_waveforms(unit_id=0)
      print(f"Shape: {waveforms.shape}")  # (n_spikes, n_samples, n_channels)
      ```
      
      ### Compute Templates
      ```python
      analyzer.compute('templates', operators=['average', 'std', 'median'])
      
      # Get template
      templates_ext = analyzer.get_extension('templates')
      template = templates_ext.get_unit_template(unit_id=0, operator='average')
      ```
      
      ### Template Similarity
      ```python
      analyzer.compute('template_similarity')
      sim = analyzer.get_extension('template_similarity').get_data()
      # Matrix of cosine similarities between templates
      ```
      
      ## Unit Locations
      
      ### Compute Locations
      ```python
      analyzer.compute('unit_locations', method='monopolar_triangulation')
      locations = analyzer.get_extension('unit_locations').get_data()
      print(locations)  # x, y coordinates per unit
      ```
      
      ### Spike Locations
      ```python
      analyzer.compute('spike_locations', method='center_of_mass')
      spike_locs = analyzer.get_extension('spike_locations').get_data()
      ```
      
      ### Location Methods
      - `'center_of_mass'` - Fast, less accurate
      - `'monopolar_triangulation'` - More accurate, slower
      - `'grid_convolution'` - Good balance
      
      ## Correlograms
      
      ### Auto-correlograms
      ```python
      analyzer.compute('correlograms', window_ms=50, bin_ms=1)
      correlograms, bins = analyzer.get_extension('correlograms').get_data()
      
      # correlograms shape: (n_units, n_units, n_bins)
      # Auto-correlogram for unit i: correlograms[i, i, :]
      # Cross-correlogram units i,j: correlograms[i, j, :]
      ```
      
      ## Visualization
      
      ### Probe Map
      ```python
      si.plot_probe_map(recording, with_channel_ids=True)
      ```
      
      ### Unit Templates
      ```python
      # All units
      si.plot_unit_templates(analyzer)
      
      # Specific units
      si.plot_unit_templates(analyzer, unit_ids=[0, 1, 2])
      ```
      
      ### Waveforms
      ```python
      # Plot waveforms with template
      si.plot_unit_waveforms(analyzer, unit_ids=[0])
      
      # Waveform density
      si.plot_unit_waveforms_density_map(analyzer, unit_id=0)
      ```
      
      ### Raster Plot
      ```python
      si.plot_rasters(sorting, time_range=(0, 10))  # First 10 seconds
      ```
      
      ### Amplitudes
      ```python
      analyzer.compute('spike_amplitudes')
      si.plot_amplitudes(analyzer)
      
      # Distribution
      si.plot_all_amplitudes_distributions(analyzer)
      ```
      
      ### Correlograms
      ```python
      # Auto-correlograms
      si.plot_autocorrelograms(analyzer, unit_ids=[0, 1, 2])
      
      # Cross-correlograms
      si.plot_crosscorrelograms(analyzer, unit_ids=[0, 1])
      ```
      
      ### Quality Metrics
      ```python
      # Summary plot
      si.plot_quality_metrics(analyzer)
      
      # Specific metric distribution
      import matplotlib.pyplot as plt
      qm = analyzer.get_extension('quality_metrics').get_data()
      plt.hist(qm['snr'], bins=50)
      plt.xlabel('SNR')
      plt.ylabel('Count')
      ```
      
      ### Unit Locations on Probe
      ```python
      si.plot_unit_locations(analyzer)
      ```
      
      ### Drift Map
      ```python
      si.plot_drift_raster(sorting, recording)
      ```
      
      ### Summary Plot
      ```python
      # Comprehensive unit summary
      si.plot_unit_summary(analyzer, unit_id=0)
      ```
      
      ## LFP Analysis
      
      ### Load LFP Data
      ```python
      lfp = si.read_spikeglx('/path/to/data', stream_id='imec0.lf')
      print(f"LFP: {lfp.get_sampling_frequency()} Hz")
      ```
      
      ### Basic LFP Processing
      ```python
      # Downsample if needed
      lfp_ds = si.resample(lfp, resample_rate=1000)
      
      # Common average reference
      lfp_car = si.common_reference(lfp_ds, reference='global', operator='median')
      ```
      
      ### Extract LFP Traces
      ```python
      import numpy as np
      
      # Get traces (channels x samples)
      traces = lfp.get_traces(start_frame=0, end_frame=30000)
      
      # Specific channels
      traces = lfp.get_traces(channel_ids=[0, 1, 2])
      ```
      
      ### Spectral Analysis
      ```python
      from scipy import signal
      import matplotlib.pyplot as plt
      
      # Get single channel
      trace = lfp.get_traces(channel_ids=[0]).flatten()
      fs = lfp.get_sampling_frequency()
      
      # Power spectrum
      freqs, psd = signal.welch(trace, fs, nperseg=4096)
      plt.semilogy(freqs, psd)
      plt.xlabel('Frequency (Hz)')
      plt.ylabel('Power')
      plt.xlim(0, 100)
      ```
      
      ### Spectrogram
      ```python
      f, t, Sxx = signal.spectrogram(trace, fs, nperseg=2048, noverlap=1024)
      plt.pcolormesh(t, f, 10*np.log10(Sxx), shading='gouraud')
      plt.ylabel('Frequency (Hz)')
      plt.xlabel('Time (s)')
      plt.ylim(0, 100)
      plt.colorbar(label='Power (dB)')
      ```
      
      ## Export Formats
      
      ### Export to Phy
      ```python
      si.export_to_phy(
          analyzer,
          output_folder='phy_export',
          compute_pc_features=True,
          compute_amplitudes=True,
          copy_binary=True
      )
      # Then: phy template-gui phy_export/params.py
      ```
      
      ### Export to NWB
      ```python
      # SpikeInterface has NO native NWB exporter — use NeuroConv (pip install neuroconv).
      from neuroconv.tools.spikeinterface import write_recording, write_sorting
      
      write_recording(recording=recording, nwbfile_path='output.nwb', overwrite=True)
      write_sorting(sorting=sorting, nwbfile_path='output.nwb')
      ```
      
      ### Export Report
      ```python
      si.export_report(
          analyzer,
          output_folder='report',
          remove_if_exists=True,
          format='html'
      )
      ```
      
      ## Complete Analysis Pipeline
      
      ```python
      import spikeinterface.full as si
      
      def analyze_sorting(recording, sorting, output_dir):
          """Complete post-processing pipeline."""
      
          # Create analyzer
          analyzer = si.create_sorting_analyzer(
              sorting, recording,
              sparse=True,
              folder=f'{output_dir}/analyzer'
          )
      
          # Compute all extensions
          print("Computing extensions...")
          analyzer.compute(['random_spikes', 'waveforms', 'templates', 'noise_levels'])
          analyzer.compute(['principal_components', 'spike_amplitudes'])
          analyzer.compute(['correlograms', 'unit_locations', 'template_similarity'])
          analyzer.compute('quality_metrics')
      
          # Get quality metrics
          qm = analyzer.get_extension('quality_metrics').get_data()
      
          # Filter good units
          good_units = qm.query(
              "(snr > 5) & (isi_violations_ratio < 0.01) & (presence_ratio > 0.9)"
          ).index.tolist()
      
          print(f"Quality filtering: {len(good_units)}/{len(qm)} units passed")
      
          # Export
          si.export_to_phy(analyzer, f'{output_dir}/phy')
          si.export_report(analyzer, f'{output_dir}/report')
      
          # Save metrics
          qm.to_csv(f'{output_dir}/quality_metrics.csv')
      
          return analyzer, qm, good_units
      
      # Usage
      analyzer, qm, good_units = analyze_sorting(recording, sorting, 'output/')
      ```
      
    • api_reference.md 9.1 KB
      # API Reference
      
      > **READ THIS FIRST.** Only **nine** functions are actually bundled, all in
      > `scripts/neuropixels_pipeline.py` and imported as plain functions (there is **no**
      > `npa` module and **no** installable `neuropixels_analysis` package):
      > `load_recording`, `preprocess`, `check_drift`, `correct_motion`,
      > `run_spike_sorting`, `postprocess`, `curate_units`, `export_results`, `run_pipeline`.
      > Their real signatures are in the source file — trust that over the sketches below.
      >
      > Everything documented with an `npa.` prefix (e.g. `npa.estimate_motion`,
      > `npa.compare_sorters`, `npa.CurationSession`, `npa.analyze_unit_visually`,
      > `npa.plot_*`) is **illustrative pseudocode, NOT a real API** — the signatures shown
      > are approximate and several have no bundled equivalent at all. Use the corresponding
      > SpikeInterface call (`si.*` / `sw.*`) instead, or build it yourself. Do not import or
      > call `npa.*` as written.
      
      Quick reference, organized by capability. Import the real helpers with
      `from scripts.neuropixels_pipeline import load_recording, preprocess, ...`.
      
      ## Core Module
      
      ### load_recording
      
      ```python
      npa.load_recording(
          path: str,
          format: str = 'auto',  # 'spikeglx', 'openephys', 'nwb'
          stream_id: str = None,  # e.g., 'imec0.ap'
      ) -> Recording
      ```
      
      Load Neuropixels recording from various formats.
      
      ### run_pipeline
      
      ```python
      npa.run_pipeline(
          recording: Recording,
          output_dir: str,
          sorter: str = 'kilosort4',
          preprocess: bool = True,
          correct_motion: bool = True,
          postprocess: bool = True,
          curate: bool = True,
          curation_method: str = 'allen',
      ) -> dict
      ```
      
      Run complete analysis pipeline. Returns dictionary with all results.
      
      ## Preprocessing Module
      
      ### preprocess
      
      ```python
      npa.preprocess(
          recording: Recording,
          freq_min: float = 300,
          freq_max: float = 6000,
          phase_shift: bool = True,
          common_ref: bool = True,
          bad_channel_detection: bool = True,
      ) -> Recording
      ```
      
      Apply standard preprocessing chain.
      
      ### detect_bad_channels
      
      ```python
      npa.detect_bad_channels(
          recording: Recording,
          method: str = 'coherence+psd',
          **kwargs,
      ) -> list
      ```
      
      Detect and return list of bad channel IDs.
      
      ### apply_filters
      
      ```python
      npa.apply_filters(
          recording: Recording,
          freq_min: float = 300,
          freq_max: float = 6000,
          filter_type: str = 'bandpass',
      ) -> Recording
      ```
      
      Apply frequency filters.
      
      ### common_reference
      
      ```python
      npa.common_reference(
          recording: Recording,
          operator: str = 'median',
          reference: str = 'global',
      ) -> Recording
      ```
      
      Apply common reference (CMR/CAR).
      
      ## Motion Module
      
      ### check_drift
      
      ```python
      npa.check_drift(
          recording: Recording,
          plot: bool = True,
          output: str = None,
      ) -> dict
      ```
      
      Check recording for drift. Returns drift statistics.
      
      ### estimate_motion
      
      ```python
      npa.estimate_motion(
          recording: Recording,
          preset: str = 'kilosort_like',
          **kwargs,
      ) -> dict
      ```
      
      Estimate motion without applying correction.
      
      ### correct_motion
      
      ```python
      npa.correct_motion(
          recording: Recording,
          preset: str = 'nonrigid_accurate',
          folder: str = None,
          **kwargs,
      ) -> Recording
      ```
      
      Apply motion correction.
      
      **Presets:**
      - `'kilosort_like'`: Fast, rigid correction
      - `'nonrigid_accurate'`: Slower, better for severe drift
      - `'nonrigid_fast_and_accurate'`: Balanced option
      
      ## Sorting Module
      
      ### run_sorting
      
      ```python
      npa.run_sorting(
          recording: Recording,
          sorter: str = 'kilosort4',
          output_folder: str = None,
          sorter_params: dict = None,
          **kwargs,
      ) -> Sorting
      ```
      
      Run spike sorter.
      
      **Supported sorters:**
      - `'kilosort4'`: GPU-based, recommended
      - `'kilosort3'`: Legacy, requires MATLAB
      - `'spykingcircus2'`: CPU-based alternative
      - `'mountainsort5'`: Fast, good for short recordings
      
      ### compare_sorters
      
      ```python
      npa.compare_sorters(
          sortings: list,
          delta_time: float = 0.4,  # ms
          match_score: float = 0.5,
      ) -> Comparison
      ```
      
      Compare results from multiple sorters.
      
      ## Postprocessing Module
      
      ### create_analyzer
      
      ```python
      npa.create_analyzer(
          sorting: Sorting,
          recording: Recording,
          output_folder: str = None,
          sparse: bool = True,
      ) -> SortingAnalyzer
      ```
      
      Create SortingAnalyzer for postprocessing.
      
      ### postprocess
      
      ```python
      npa.postprocess(
          sorting: Sorting,
          recording: Recording,
          output_folder: str = None,
          compute_all: bool = True,
          n_jobs: int = -1,
      ) -> tuple[SortingAnalyzer, DataFrame]
      ```
      
      Full postprocessing. Returns (analyzer, metrics).
      
      ### compute_quality_metrics
      
      ```python
      npa.compute_quality_metrics(
          analyzer: SortingAnalyzer,
          metric_names: list = None,  # None = all
          **kwargs,
      ) -> DataFrame
      ```
      
      Compute quality metrics for all units.
      
      **Available metrics:**
      - `snr`: Signal-to-noise ratio
      - `isi_violations_ratio`: ISI violations
      - `presence_ratio`: Recording presence
      - `amplitude_cutoff`: Amplitude distribution cutoff
      - `firing_rate`: Average firing rate
      - `amplitude_cv`: Amplitude coefficient of variation
      - `sliding_rp_violation`: Sliding window refractory violations
      - `d_prime`: Isolation quality
      - `nearest_neighbor`: Nearest-neighbor overlap
      
      ## Curation Module
      
      ### curate
      
      ```python
      npa.curate(
          metrics: DataFrame,
          method: str = 'allen',  # 'allen', 'ibl', 'strict', 'custom'
          **thresholds,
      ) -> dict
      ```
      
      Apply automated curation. Returns {unit_id: label}.
      
      ### auto_classify
      
      ```python
      npa.auto_classify(
          metrics: DataFrame,
          snr_threshold: float = 5.0,
          isi_threshold: float = 0.01,
          presence_threshold: float = 0.9,
      ) -> dict
      ```
      
      Classify units based on custom thresholds.
      
      ### filter_units
      
      ```python
      npa.filter_units(
          sorting: Sorting,
          labels: dict,
          keep: list = ['good'],
      ) -> Sorting
      ```
      
      Filter sorting to keep only specified labels.
      
      ## AI Curation Module
      
      ### generate_unit_report
      
      ```python
      npa.generate_unit_report(
          analyzer: SortingAnalyzer,
          unit_id: int,
          output_dir: str = None,
          figsize: tuple = (16, 12),
      ) -> dict
      ```
      
      Generate visual report for AI analysis.
      
      Returns:
      - `'image_path'`: Path to saved figure
      - `'image_base64'`: Base64 encoded image
      - `'metrics'`: Quality metrics dict
      - `'unit_id'`: Unit ID
      
      ### analyze_unit_visually
      
      ```python
      npa.analyze_unit_visually(
          analyzer: SortingAnalyzer,
          unit_id: int,
          api_client: Any = None,
          model: str = None,  # None -> resolve via ALTERLAB_MODEL (skills/core/shared/model_env.md)
          task: str = 'quality_assessment',
          custom_prompt: str = None,
      ) -> dict
      ```
      
      Analyze unit using vision-language model.
      
      **Tasks:**
      - `'quality_assessment'`: Classify as good/mua/noise
      - `'merge_candidate'`: Check if units should merge
      - `'drift_assessment'`: Assess motion/drift
      
      ### batch_visual_curation
      
      ```python
      npa.batch_visual_curation(
          analyzer: SortingAnalyzer,
          unit_ids: list = None,
          api_client: Any = None,
          model: str = None,  # None -> resolve via ALTERLAB_MODEL (skills/core/shared/model_env.md)
          output_dir: str = None,
          progress_callback: callable = None,
      ) -> dict
      ```
      
      Run visual curation on multiple units.
      
      ### CurationSession
      
      ```python
      session = npa.CurationSession.create(
          analyzer: SortingAnalyzer,
          output_dir: str,
          session_id: str = None,
          unit_ids: list = None,
          sort_by_confidence: bool = True,
      )
      
      # Navigation
      session.current_unit() -> UnitCuration
      session.next_unit() -> UnitCuration
      session.prev_unit() -> UnitCuration
      session.go_to_unit(unit_id: int) -> UnitCuration
      
      # Decisions
      session.set_decision(unit_id, decision, notes='')
      session.set_ai_classification(unit_id, classification)
      
      # Export
      session.get_final_labels() -> dict
      session.export_decisions(output_path) -> DataFrame
      session.get_summary() -> dict
      
      # Persistence
      session.save()
      session = npa.CurationSession.load(session_dir)
      ```
      
      ## Visualization Module
      
      ### plot_drift
      
      ```python
      npa.plot_drift(
          recording: Recording,
          motion: dict = None,
          output: str = None,
          figsize: tuple = (12, 8),
      )
      ```
      
      Plot drift/motion map.
      
      ### plot_quality_metrics
      
      ```python
      npa.plot_quality_metrics(
          analyzer: SortingAnalyzer,
          metrics: DataFrame = None,
          output: str = None,
      )
      ```
      
      Plot quality metrics overview.
      
      ### plot_unit_summary
      
      ```python
      npa.plot_unit_summary(
          analyzer: SortingAnalyzer,
          unit_id: int,
          output: str = None,
      )
      ```
      
      Plot comprehensive unit summary.
      
      ## SpikeInterface Integration
      
      All bundled helper functions work with SpikeInterface objects:
      
      ```python
      import spikeinterface.full as si
      from scripts.neuropixels_pipeline import preprocess
      
      # SpikeInterface recording works with the bundled helpers
      recording = si.read_spikeglx('/path/')
      rec, bad_channels = preprocess(recording)
      
      # Access SpikeInterface directly for advanced usage
      rec_filtered = si.bandpass_filter(recording, freq_min=300, freq_max=6000)
      ```
      
      ## Common Parameters
      
      ### Recording parameters
      - `freq_min`: Highpass cutoff (Hz)
      - `freq_max`: Lowpass cutoff (Hz)
      - `n_jobs`: Parallel jobs (-1 = all cores)
      
      ### Sorting parameters
      - `output_folder`: Where to save results
      - `sorter_params`: Dict of sorter-specific params
      
      ### Quality metric thresholds
      - `snr_threshold`: SNR cutoff (typically 5)
      - `isi_threshold`: ISI violations cutoff (typically 0.01)
      - `presence_threshold`: Presence ratio cutoff (typically 0.9)
      
    • AUTOMATED_CURATION.md 9.8 KB
      # Automated Curation Reference
      
      Guide to automated spike sorting curation using Bombcell, UnitRefine, and other tools.
      
      ## Why Automated Curation?
      
      Manual curation is:
      - **Slow**: Hours per recording session
      - **Subjective**: Inter-rater variability
      - **Non-reproducible**: Hard to standardize
      
      Automated tools provide consistent, reproducible quality classification.
      
      ## Available Tools
      
      | Tool | Classification | Language | Integration |
      |------|---------------|----------|-------------|
      | **Bombcell** | 4-class (single/multi/noise/non-somatic) | Python/MATLAB | SpikeInterface, Phy |
      | **UnitRefine** | Machine learning-based | Python | SpikeInterface |
      | **SpikeInterface QM** | Threshold-based | Python | Native |
      | **UnitMatch** | Cross-session tracking | Python/MATLAB | Kilosort, Bombcell |
      
      ## Bombcell
      
      ### Overview
      
      Bombcell classifies units into 4 categories:
      1. **Single somatic units** - Well-isolated single neurons
      2. **Multi-unit activity (MUA)** - Mixed neuronal signals
      3. **Noise** - Non-neural artifacts
      4. **Non-somatic** - Axonal or dendritic signals
      
      ### Installation
      
      ```bash
      # Python
      pip install bombcell
      
      # Or development version
      git clone https://github.com/Julie-Fabre/bombcell.git
      cd bombcell/py_bombcell
      pip install -e .
      ```
      
      ### Basic Usage (Python)
      
      ```python
      import bombcell as bc
      
      # Load sorted data (Kilosort output)
      kilosort_folder = '/path/to/kilosort/output'
      raw_data_path = '/path/to/recording.ap.bin'
      
      # Run Bombcell
      results = bc.run_bombcell(
          kilosort_folder,
          raw_data_path,
          sample_rate=30000,
          n_channels=384
      )
      
      # Get classifications
      unit_labels = results['unit_labels']
      # 'good' = single unit, 'mua' = multi-unit, 'noise' = noise
      ```
      
      ### Integration with SpikeInterface
      
      ```python
      import spikeinterface.full as si
      
      # After spike sorting
      sorting = si.run_sorter('kilosort4', recording, folder='ks4/')
      
      # Create analyzer and compute required extensions
      analyzer = si.create_sorting_analyzer(sorting, recording, sparse=True)
      analyzer.compute('waveforms')
      analyzer.compute('templates')
      analyzer.compute('spike_amplitudes')
      
      # Export to Phy format (Bombcell can read this)
      si.export_to_phy(analyzer, output_folder='phy_export/')
      
      # Run Bombcell on Phy export
      import bombcell as bc
      results = bc.run_bombcell_phy('phy_export/')
      ```
      
      ### Bombcell Metrics
      
      Bombcell computes specific metrics for classification:
      
      | Metric | Description | Used For |
      |--------|-------------|----------|
      | `peak_trough_ratio` | Waveform shape | Somatic vs non-somatic |
      | `spatial_decay` | Amplitude across channels | Noise detection |
      | `refractory_period_violations` | ISI violations | Single vs multi |
      | `presence_ratio` | Temporal stability | Unit quality |
      | `waveform_duration` | Peak-to-trough time | Cell type |
      
      ### Custom Thresholds
      
      ```python
      # Customize classification thresholds
      custom_params = {
          'isi_threshold': 0.01,          # ISI violation threshold
          'presence_threshold': 0.9,       # Minimum presence ratio
          'amplitude_threshold': 20,       # Minimum amplitude (μV)
          'spatial_decay_threshold': 40,   # Spatial decay (μm)
      }
      
      results = bc.run_bombcell(
          kilosort_folder,
          raw_data_path,
          **custom_params
      )
      ```
      
      ## SpikeInterface Auto-Curation
      
      ### Threshold-Based Curation
      
      ```python
      # Compute quality metrics
      analyzer.compute('quality_metrics')
      qm = analyzer.get_extension('quality_metrics').get_data()
      
      # Define curation function
      def auto_curate(qm):
          labels = {}
          for unit_id in qm.index:
              row = qm.loc[unit_id]
      
              # Classification logic
              if row['snr'] < 2 or row['presence_ratio'] < 0.5:
                  labels[unit_id] = 'noise'
              elif row['isi_violations_ratio'] > 0.1:
                  labels[unit_id] = 'mua'
              elif (row['snr'] > 5 and
                    row['isi_violations_ratio'] < 0.01 and
                    row['presence_ratio'] > 0.9):
                  labels[unit_id] = 'good'
              else:
                  labels[unit_id] = 'unsorted'
      
          return labels
      
      unit_labels = auto_curate(qm)
      
      # Filter by label
      good_unit_ids = [u for u, l in unit_labels.items() if l == 'good']
      sorting_curated = sorting.select_units(good_unit_ids)
      ```
      
      ### Using SpikeInterface Curation Module
      
      ```python
      from spikeinterface.curation import (
          CurationSorting,
          MergeUnitsSorting,
          SplitUnitSorting
      )
      
      # Wrap sorting for curation
      curation = CurationSorting(sorting)
      
      # Remove noise units
      noise_units = qm[qm['snr'] < 2].index.tolist()
      curation.remove_units(noise_units)
      
      # Merge similar units (based on template similarity)
      analyzer.compute('template_similarity')
      similarity = analyzer.get_extension('template_similarity').get_data()
      
      # Find highly similar pairs
      import numpy as np
      threshold = 0.9
      similar_pairs = np.argwhere(similarity > threshold)
      # Merge pairs (careful - requires manual review)
      
      # Get curated sorting
      sorting_curated = curation.to_sorting()
      ```
      
      ## UnitMatch: Cross-Session Tracking
      
      Track the same neurons across recording days.
      
      ### Installation
      
      ```bash
      pip install unitmatch
      # Or from source
      git clone https://github.com/EnnyvanBeest/UnitMatch.git
      ```
      
      ### Usage
      
      ```python
      # After running Bombcell on multiple sessions
      session_folders = [
          '/path/to/session1/kilosort/',
          '/path/to/session2/kilosort/',
          '/path/to/session3/kilosort/',
      ]
      
      from unitmatch import UnitMatch
      
      # Run UnitMatch
      um = UnitMatch(session_folders)
      um.run()
      
      # Get matching results
      matches = um.get_matches()
      # Returns DataFrame with unit IDs matched across sessions
      
      # Assign unique IDs
      unique_ids = um.get_unique_ids()
      ```
      
      ### Integration with Workflow
      
      ```python
      # Typical workflow:
      # 1. Spike sort each session
      # 2. Run Bombcell for quality control
      # 3. Run UnitMatch for cross-session tracking
      
      # Session 1
      sorting1 = si.run_sorter('kilosort4', rec1, folder='session1/ks4/')
      # Run Bombcell
      labels1 = bc.run_bombcell('session1/ks4/', raw1_path)
      
      # Session 2
      sorting2 = si.run_sorter('kilosort4', rec2, folder='session2/ks4/')
      labels2 = bc.run_bombcell('session2/ks4/', raw2_path)
      
      # Track units across sessions
      um = UnitMatch(['session1/ks4/', 'session2/ks4/'])
      matches = um.get_matches()
      ```
      
      ## Semi-Automated Workflow
      
      Combine automated and manual curation:
      
      ```python
      # Step 1: Automated classification
      analyzer.compute('quality_metrics')
      qm = analyzer.get_extension('quality_metrics').get_data()
      
      # Auto-label obvious cases
      auto_labels = {}
      for unit_id in qm.index:
          row = qm.loc[unit_id]
          if row['snr'] < 1.5:
              auto_labels[unit_id] = 'noise'
          elif row['snr'] > 8 and row['isi_violations_ratio'] < 0.005:
              auto_labels[unit_id] = 'good'
          else:
              auto_labels[unit_id] = 'needs_review'
      
      # Step 2: Export uncertain units for manual review
      needs_review = [u for u, l in auto_labels.items() if l == 'needs_review']
      
      # Export only uncertain units to Phy
      sorting_review = sorting.select_units(needs_review)
      analyzer_review = si.create_sorting_analyzer(sorting_review, recording)
      analyzer_review.compute('waveforms')
      analyzer_review.compute('templates')
      si.export_to_phy(analyzer_review, output_folder='phy_review/')
      
      # Manual review in Phy: phy template-gui phy_review/params.py
      
      # Step 3: Load manual labels and merge
      manual_labels = si.read_phy('phy_review/').get_property('quality')
      # Combine auto + manual labels for final result
      ```
      
      ## Comparison of Methods
      
      | Method | Pros | Cons |
      |--------|------|------|
      | **Manual (Phy)** | Gold standard, flexible | Slow, subjective |
      | **SpikeInterface QM** | Fast, reproducible | Simple thresholds only |
      | **Bombcell** | Multi-class, validated | Requires waveform extraction |
      | **UnitRefine** | ML-based, learns from data | Needs training data |
      
      ## Best Practices
      
      1. **Always visualize** - Don't blindly trust automated results
      2. **Document thresholds** - Record exact parameters used
      3. **Validate** - Compare automated vs manual on subset
      4. **Be conservative** - When in doubt, exclude the unit
      5. **Report methods** - Include curation criteria in publications
      
      ## Pipeline Example
      
      ```python
      def curate_sorting(sorting, recording, output_dir):
          """Complete curation pipeline."""
      
          # Create analyzer
          analyzer = si.create_sorting_analyzer(sorting, recording, sparse=True,
                                                folder=f'{output_dir}/analyzer')
      
          # Compute required extensions
          analyzer.compute('random_spikes', max_spikes_per_unit=500)
          analyzer.compute('waveforms')
          analyzer.compute('templates')
          analyzer.compute('noise_levels')
          analyzer.compute('spike_amplitudes')
          analyzer.compute('quality_metrics')
      
          qm = analyzer.get_extension('quality_metrics').get_data()
      
          # Auto-classify
          labels = {}
          for unit_id in qm.index:
              row = qm.loc[unit_id]
      
              if row['snr'] < 2:
                  labels[unit_id] = 'noise'
              elif row['isi_violations_ratio'] > 0.1 or row['presence_ratio'] < 0.8:
                  labels[unit_id] = 'mua'
              elif (row['snr'] > 5 and
                    row['isi_violations_ratio'] < 0.01 and
                    row['presence_ratio'] > 0.9 and
                    row['amplitude_cutoff'] < 0.1):
                  labels[unit_id] = 'good'
              else:
                  labels[unit_id] = 'unsorted'
      
          # Summary
          from collections import Counter
          print("Classification summary:")
          print(Counter(labels.values()))
      
          # Save labels
          import json
          with open(f'{output_dir}/unit_labels.json', 'w') as f:
              json.dump(labels, f)
      
          # Return good units
          good_ids = [u for u, l in labels.items() if l == 'good']
          return sorting.select_units(good_ids), labels
      
      # Usage
      sorting_curated, labels = curate_sorting(sorting, recording, 'output/')
      ```
      
      ## References
      
      - [Bombcell GitHub](https://github.com/Julie-Fabre/bombcell)
      - [UnitMatch GitHub](https://github.com/EnnyvanBeest/UnitMatch)
      - [SpikeInterface Curation](https://spikeinterface.readthedocs.io/en/stable/modules/curation.html)
      - Fabre et al. (2023) "Bombcell: automated curation and cell classification"
      - van Beest et al. (2024) "UnitMatch: tracking neurons across days with high-density probes"
      
    • MOTION_CORRECTION.md 9.1 KB
      # Motion/Drift Correction Reference
      
      Mechanical drift during acute probe insertion is a major challenge for Neuropixels recordings. This guide covers detection, estimation, and correction of motion artifacts.
      
      ## Why Motion Correction Matters
      
      - Neuropixels probes can drift 10-100+ μm during recording
      - Uncorrected drift leads to:
        - Units appearing/disappearing mid-recording
        - Waveform amplitude changes
        - Incorrect spike-unit assignments
        - Reduced unit yield
      
      ## Detection: Check Before Sorting
      
      **Always visualize drift before running spike sorting!**
      
      ```python
      import spikeinterface.full as si
      from spikeinterface.sortingcomponents.peak_detection import detect_peaks
      from spikeinterface.sortingcomponents.peak_localization import localize_peaks
      
      # Preprocess first (don't whiten - affects peak localization)
      rec = si.highpass_filter(recording, freq_min=400.)
      rec = si.common_reference(rec, operator='median', reference='global')
      
      # Detect peaks
      noise_levels = si.get_noise_levels(rec, return_in_uV=False)
      peaks = detect_peaks(
          rec,
          method='locally_exclusive',
          noise_levels=noise_levels,
          detect_threshold=5,
          radius_um=50.,
          n_jobs=8,
          chunk_duration='1s',
          progress_bar=True
      )
      
      # Localize peaks
      peak_locations = localize_peaks(
          rec, peaks,
          method='center_of_mass',
          n_jobs=8,
          chunk_duration='1s'
      )
      
      # Visualize drift
      si.plot_drift_raster_map(
          peaks=peaks,
          peak_locations=peak_locations,
          recording=rec,
          clim=(-200, 0)  # Adjust color limits
      )
      ```
      
      ### Interpreting Drift Plots
      
      | Pattern | Interpretation | Action |
      |---------|---------------|--------|
      | Horizontal bands, stable | No significant drift | Skip correction |
      | Diagonal bands (slow) | Gradual settling drift | Use motion correction |
      | Rapid jumps | Brain pulsation or movement | Use non-rigid correction |
      | Chaotic patterns | Severe instability | Consider discarding segment |
      
      ## Motion Correction Methods
      
      ### Quick Correction (Recommended Start)
      
      ```python
      # Simple one-liner with preset
      rec_corrected = si.correct_motion(
          recording=rec,
          preset='nonrigid_fast_and_accurate'
      )
      ```
      
      ### Available Presets
      
      | Preset | Speed | Accuracy | Best For |
      |--------|-------|----------|----------|
      | `rigid_fast` | Fast | Low | Quick check, small drift |
      | `kilosort_like` | Medium | Good | Kilosort-compatible results |
      | `nonrigid_accurate` | Slow | High | Publication-quality |
      | `nonrigid_fast_and_accurate` | Medium | High | **Recommended default** |
      | `dredge` | Slow | Highest | Best results, complex drift |
      | `dredge_fast` | Medium | High | DREDge with less compute |
      
      ### Full Control Pipeline
      
      ```python
      from spikeinterface.sortingcomponents.motion import (
          estimate_motion,
          interpolate_motion,
      )
      
      # Step 1: Estimate motion. In current SpikeInterface (>= 0.100) estimate_motion
      # returns a single Motion object — NOT the old (motion, temporal_bins, spatial_bins)
      # tuple. The bins are carried inside the Motion object.
      motion = estimate_motion(
          recording=rec,
          peaks=peaks,
          peak_locations=peak_locations,
          method='dredge_ap',   # mature, recommended; 'decentralized' also available
          direction='y',
          rigid=False,          # Non-rigid for Neuropixels
          win_step_um=200,      # Spatial window step
          win_scale_um=300,     # Spatial window scale
          bin_s=1.0,            # Temporal bin size
          progress_bar=True,
      )
      
      # Step 2: Visualize motion estimate (the widget reads bins from the Motion object)
      from spikeinterface.widgets import plot_motion
      plot_motion(motion, recording=rec)
      
      # Step 3: Apply correction via interpolation (pass the Motion object directly)
      rec_corrected = interpolate_motion(
          recording=rec,
          motion=motion,
          border_mode='remove_channels',
      )
      ```
      
      ### Save / Load Motion Estimate
      
      The robust, documented way to persist motion is to let `correct_motion`
      (or `compute_motion`) write a folder, then reload it:
      
      ```python
      # Persist while correcting:
      rec_corrected = si.correct_motion(rec, preset='nonrigid_accurate', folder='motion_output/')
      
      # Reload later (returns a dict with 'motion', 'peaks', 'peak_locations', params):
      motion_info = si.load_motion_info('motion_output/')
      motion = motion_info['motion']  # a Motion object
      ```
      
      ## DREDge: State-of-the-Art Method
      
      DREDge (Decentralized Registration of Electrophysiology Data) is currently the best-performing motion correction method.
      
      ### Using DREDge Preset
      
      ```python
      # AP-band motion estimation
      rec_corrected = si.correct_motion(rec, preset='dredge')
      
      # Or compute explicitly
      motion, motion_info = si.compute_motion(
          rec,
          preset='dredge',
          output_motion_info=True,
          folder='motion_output/',
          **job_kwargs
      )
      ```
      
      ### LFP-Based Motion Estimation
      
      For very fast drift or when AP-band estimation fails:
      
      ```python
      # Load LFP stream
      lfp = si.read_spikeglx('/path/to/data', stream_name='imec0.lf')
      
      # Estimate motion from LFP (faster, handles rapid drift)
      motion_lfp, motion_info = si.compute_motion(
          lfp,
          preset='dredge_lfp',
          output_motion_info=True,
      )
      
      # Apply to AP recording (pass the Motion object directly)
      rec_corrected = interpolate_motion(
          recording=rec,  # AP recording
          motion=motion_lfp,
      )
      ```
      
      ## Integration with Spike Sorting
      
      ### Option 1: Pre-correction (Recommended)
      
      ```python
      # Correct before sorting
      rec_corrected = si.correct_motion(rec, preset='nonrigid_fast_and_accurate')
      
      # Save corrected recording
      rec_corrected = rec_corrected.save(folder='preprocessed_motion_corrected/',
                                          format='binary', n_jobs=8)
      
      # Run spike sorting on corrected data
      sorting = si.run_sorter('kilosort4', rec_corrected, folder='ks4/')
      ```
      
      ### Option 2: Let Kilosort Handle It
      
      Kilosort4 has built-in drift correction. Sorter-specific params (e.g. `nblocks`)
      are passed straight through to `run_sorter` as keyword args:
      
      ```python
      sorting = si.run_sorter(
          'kilosort4',
          rec,  # Not motion corrected
          folder='ks4/',
          nblocks=5,  # >0 enables Kilosort4's internal drift correction (0 disables it)
      )
      ```
      
      ### Option 3: Estimate from Sorted Spikes
      
      For a high-fidelity drift estimate, derive peaks/locations from an existing
      sorting (actual spike times) and feed them to `estimate_motion`:
      
      ```python
      # Sort first
      sorting = si.run_sorter('kilosort4', rec, folder='ks4/')
      
      # Build peaks + locations from the sorting, then estimate motion as usual
      from spikeinterface.core.node_pipeline import spike_vector_to_peaks  # helper
      # (or use detect_peaks/localize_peaks on `rec` as shown above)
      # then: motion = estimate_motion(recording=rec, peaks=..., peak_locations=...)
      ```
      
      ## Parameters Deep Dive
      
      ### Peak Detection
      
      ```python
      peaks = detect_peaks(
          rec,
          method='locally_exclusive',  # Best for dense probes
          noise_levels=noise_levels,
          detect_threshold=5,          # Lower = more peaks (noisier estimate)
          radius_um=50.,               # Exclusion radius
          exclude_sweep_ms=0.1,        # Temporal exclusion
      )
      ```
      
      ### Motion Estimation
      
      ```python
      motion = estimate_motion(
          recording=rec, peaks=peaks, peak_locations=peak_locations,
          method='dredge_ap',          # 'dredge_ap', 'decentralized', 'iterative_template'
          direction='y',               # Along probe axis
          rigid=False,                 # False for non-rigid
          bin_s=1.0,                   # Temporal resolution (seconds)
          win_shape='gaussian',        # Spatial window shape
          win_step_um=200,             # Spatial window step
          win_scale_um=300,            # Spatial window scale
          win_margin_um=None,          # Margin at probe edges (None = auto)
      )
      ```
      
      ## Troubleshooting
      
      ### Over-correction (Wavy Patterns)
      
      ```python
      # Increase temporal smoothing
      motion = estimate_motion(..., bin_s=5.0)  # Larger bins
      
      # Or use rigid correction for small drift
      motion = estimate_motion(..., rigid=True)
      ```
      
      ### Under-correction (Drift Remains)
      
      ```python
      # Decrease spatial window for finer non-rigid estimate
      motion = estimate_motion(..., win_step_um=25, win_sigma_um=75)
      
      # Use more peaks
      peaks = detect_peaks(..., detect_threshold=4)  # Lower threshold
      ```
      
      ### Edge Artifacts
      
      ```python
      rec_corrected = interpolate_motion(
          recording=rec,
          motion=motion,
          border_mode='remove_channels',     # or 'force_extrapolate'
          spatial_interpolation_method='kriging',
      )
      ```
      
      ## Validation
      
      After correction, re-visualize to confirm:
      
      ```python
      # Re-detect peaks on corrected recording
      peaks_corrected = detect_peaks(rec_corrected, ...)
      peak_locations_corrected = localize_peaks(rec_corrected, peaks_corrected, ...)
      
      # Plot before/after comparison
      fig, axes = plt.subplots(1, 2, figsize=(14, 6))
      
      # Before
      si.plot_drift_raster_map(peaks, peak_locations, rec, ax=axes[0])
      axes[0].set_title('Before Correction')
      
      # After
      si.plot_drift_raster_map(peaks_corrected, peak_locations_corrected,
                               rec_corrected, ax=axes[1])
      axes[1].set_title('After Correction')
      ```
      
      ## References
      
      - [SpikeInterface Motion Correction Docs](https://spikeinterface.readthedocs.io/en/stable/modules/motion_correction.html)
      - [Handle Drift Tutorial](https://spikeinterface.readthedocs.io/en/stable/how_to/handle_drift.html)
      - [DREDge GitHub](https://github.com/evarol/DREDge)
      - Windolf et al. (2023) "DREDge: robust motion correction for high-density extracellular recordings"
      
    • plotting_guide.md 10.9 KB
      # Plotting Guide
      
      Comprehensive guide for creating publication-quality visualizations from Neuropixels data.
      
      ## Setup
      
      ```python
      import matplotlib.pyplot as plt
      import numpy as np
      import spikeinterface.full as si
      import spikeinterface.widgets as sw
      # NOTE: the `npa.*` plot helpers shown below are illustrative convenience wrappers,
      # not part of the bundled scripts. Prefer the SpikeInterface widgets (sw.*) directly,
      # e.g. sw.plot_unit_summary, sw.plot_quality_metrics, sw.plot_motion.
      
      # High-quality settings
      plt.rcParams['figure.dpi'] = 150
      plt.rcParams['savefig.dpi'] = 300
      plt.rcParams['font.size'] = 10
      plt.rcParams['font.family'] = 'sans-serif'
      ```
      
      ## Drift and Motion Plots
      
      ### Basic Drift Map
      
      ```python
      # detect_peaks / localize_peaks live in spikeinterface.sortingcomponents,
      # NOT in spikeinterface.preprocessing.
      from spikeinterface.sortingcomponents.peak_detection import detect_peaks
      from spikeinterface.sortingcomponents.peak_localization import localize_peaks
      
      peaks = detect_peaks(recording, method='locally_exclusive')
      peak_locations = localize_peaks(recording, peaks, method='center_of_mass')
      
      sw.plot_drift_raster_map(
          peaks=peaks,
          peak_locations=peak_locations,
          recording=recording,
          clim=(-50, 50),
      )
      plt.savefig('drift_raster.png', bbox_inches='tight')
      ```
      
      ### Motion Estimate Visualization
      
      ```python
      # correct_motion(..., output_motion_info=True) returns motion_info whose
      # ['motion'] is a Motion object. The widget reads time/space bins from it.
      rec_corrected, motion_info = si.correct_motion(
          recording, preset='nonrigid_fast_and_accurate',
          output_motion_info=True, folder='motion_output/',
      )
      
      # plot_motion_info takes the whole motion_info dict and overlays peaks + drift;
      # plot_motion takes the Motion object alone. Either works.
      si.plot_motion_info(motion_info, recording, color_amplitude=True)
      plt.savefig('motion_analysis.png', dpi=300, bbox_inches='tight')
      ```
      
      ## Waveform Plots
      
      ### Single Unit Waveforms
      
      ```python
      unit_id = 0
      
      # Basic waveforms
      sw.plot_unit_waveforms(analyzer, unit_ids=[unit_id])
      plt.savefig(f'unit_{unit_id}_waveforms.png')
      
      # With density map
      sw.plot_unit_waveform_density_map(analyzer, unit_ids=[unit_id])
      plt.savefig(f'unit_{unit_id}_density.png')
      ```
      
      ### Template Comparison
      
      ```python
      # Compare multiple units
      unit_ids = [0, 1, 2, 3]
      sw.plot_unit_templates(analyzer, unit_ids=unit_ids)
      plt.savefig('template_comparison.png')
      ```
      
      ### Waveforms on Probe
      
      ```python
      # Show waveforms spatially on probe
      sw.plot_unit_waveforms_on_probe(
          analyzer,
          unit_ids=[unit_id],
          plot_channels=True,
      )
      plt.savefig(f'unit_{unit_id}_probe.png')
      ```
      
      ## Quality Metrics Visualization
      
      ### Metrics Overview
      
      ```python
      sw.plot_quality_metrics(analyzer)
      plt.savefig('quality_overview.png', dpi=300, bbox_inches='tight')
      ```
      
      ### Metrics Distribution
      
      ```python
      fig, axes = plt.subplots(2, 3, figsize=(12, 8))
      
      metric_names = ['snr', 'isi_violations_ratio', 'presence_ratio',
                      'amplitude_cutoff', 'firing_rate', 'amplitude_cv']
      
      for ax, metric in zip(axes.flat, metric_names):
          if metric in metrics.columns:
              values = metrics[metric].dropna()
              ax.hist(values, bins=30, edgecolor='black', alpha=0.7)
              ax.axvline(values.median(), color='red', linestyle='--', label='median')
              ax.set_xlabel(metric)
              ax.set_ylabel('Count')
              ax.legend()
      
      plt.tight_layout()
      plt.savefig('metrics_distribution.png', dpi=300)
      ```
      
      ### Metrics Scatter Matrix
      
      ```python
      import pandas as pd
      
      key_metrics = ['snr', 'isi_violations_ratio', 'presence_ratio', 'firing_rate']
      pd.plotting.scatter_matrix(
          metrics[key_metrics],
          figsize=(10, 10),
          alpha=0.5,
          diagonal='hist',
      )
      plt.savefig('metrics_scatter.png', dpi=300)
      ```
      
      ### Metrics vs Labels
      
      ```python
      labels_series = pd.Series(labels)
      
      fig, axes = plt.subplots(1, 3, figsize=(12, 4))
      
      for ax, metric in zip(axes, ['snr', 'isi_violations_ratio', 'presence_ratio']):
          for label in ['good', 'mua', 'noise']:
              mask = labels_series == label
              if mask.any():
                  ax.hist(metrics.loc[mask.index[mask], metric],
                         alpha=0.5, label=label, bins=20)
          ax.set_xlabel(metric)
          ax.legend()
      
      plt.tight_layout()
      plt.savefig('metrics_by_label.png', dpi=300)
      ```
      
      ## Correlogram Plots
      
      ### Autocorrelogram
      
      ```python
      sw.plot_autocorrelograms(
          analyzer,
          unit_ids=[unit_id],
          window_ms=50,
          bin_ms=1,
      )
      plt.savefig(f'unit_{unit_id}_acg.png')
      ```
      
      ### Cross-correlograms
      
      ```python
      unit_pairs = [(0, 1), (0, 2), (1, 2)]
      sw.plot_crosscorrelograms(
          analyzer,
          unit_pairs=unit_pairs,
          window_ms=50,
          bin_ms=1,
      )
      plt.savefig('crosscorrelograms.png')
      ```
      
      ### Correlogram Matrix
      
      ```python
      sw.plot_autocorrelograms(
          analyzer,
          unit_ids=analyzer.sorting.unit_ids[:10],  # First 10 units
      )
      plt.savefig('acg_matrix.png')
      ```
      
      ## Spike Train Plots
      
      ### Raster Plot
      
      ```python
      sw.plot_rasters(
          sorting,
          time_range=(0, 30),  # First 30 seconds
          unit_ids=unit_ids[:5],
      )
      plt.savefig('raster.png')
      ```
      
      ### Firing Rate Over Time
      
      ```python
      unit_id = 0
      spike_train = sorting.get_unit_spike_train(unit_id)
      fs = recording.get_sampling_frequency()
      times = spike_train / fs
      
      # Compute firing rate histogram
      bin_width = 1.0  # seconds
      bins = np.arange(0, recording.get_total_duration(), bin_width)
      hist, _ = np.histogram(times, bins=bins)
      firing_rate = hist / bin_width
      
      plt.figure(figsize=(12, 3))
      plt.bar(bins[:-1], firing_rate, width=bin_width, edgecolor='none')
      plt.xlabel('Time (s)')
      plt.ylabel('Firing rate (Hz)')
      plt.title(f'Unit {unit_id} firing rate')
      plt.savefig(f'unit_{unit_id}_firing_rate.png', dpi=300)
      ```
      
      ## Probe and Location Plots
      
      ### Probe Layout
      
      ```python
      sw.plot_probe_map(recording, with_channel_ids=True)
      plt.savefig('probe_layout.png')
      ```
      
      ### Unit Locations on Probe
      
      ```python
      sw.plot_unit_locations(analyzer, with_channel_ids=True)
      plt.savefig('unit_locations.png')
      ```
      
      ### Spike Locations
      
      ```python
      sw.plot_spike_locations(analyzer, unit_ids=[unit_id])
      plt.savefig(f'unit_{unit_id}_spike_locations.png')
      ```
      
      ## Amplitude Plots
      
      ### Amplitudes Over Time
      
      ```python
      sw.plot_amplitudes(
          analyzer,
          unit_ids=[unit_id],
          plot_histograms=True,
      )
      plt.savefig(f'unit_{unit_id}_amplitudes.png')
      ```
      
      ### Amplitude Distribution
      
      ```python
      amplitudes = analyzer.get_extension('spike_amplitudes').get_data()
      spike_vector = sorting.to_spike_vector()
      unit_idx = list(sorting.unit_ids).index(unit_id)
      unit_mask = spike_vector['unit_index'] == unit_idx
      unit_amps = amplitudes[unit_mask]
      
      fig, ax = plt.subplots(figsize=(6, 4))
      ax.hist(unit_amps, bins=50, edgecolor='black', alpha=0.7)
      ax.axvline(np.median(unit_amps), color='red', linestyle='--', label='median')
      ax.set_xlabel('Amplitude (uV)')
      ax.set_ylabel('Count')
      ax.set_title(f'Unit {unit_id} Amplitude Distribution')
      ax.legend()
      plt.savefig(f'unit_{unit_id}_amp_dist.png', dpi=300)
      ```
      
      ## ISI Plots
      
      ### ISI Histogram
      
      ```python
      sw.plot_isi_distribution(
          analyzer,
          unit_ids=[unit_id],
          window_ms=100,
          bin_ms=1,
      )
      plt.savefig(f'unit_{unit_id}_isi.png')
      ```
      
      ### ISI with Refractory Markers
      
      ```python
      spike_train = sorting.get_unit_spike_train(unit_id)
      fs = recording.get_sampling_frequency()
      isis = np.diff(spike_train) / fs * 1000  # ms
      
      fig, ax = plt.subplots(figsize=(8, 4))
      ax.hist(isis[isis < 100], bins=100, edgecolor='black', alpha=0.7)
      ax.axvline(1.5, color='red', linestyle='--', label='1.5ms refractory')
      ax.axvline(3.0, color='orange', linestyle='--', label='3ms threshold')
      ax.set_xlabel('ISI (ms)')
      ax.set_ylabel('Count')
      ax.set_title(f'Unit {unit_id} ISI Distribution')
      ax.legend()
      plt.savefig(f'unit_{unit_id}_isi_detailed.png', dpi=300)
      ```
      
      ## Summary Plots
      
      ### Unit Summary Panel
      
      ```python
      sw.plot_unit_summary(analyzer, unit_id=unit_id)
      plt.savefig(f'unit_{unit_id}_summary.png', dpi=300, bbox_inches='tight')
      ```
      
      ### Manual Multi-Panel Summary
      
      ```python
      fig = plt.figure(figsize=(16, 12))
      
      # Waveforms
      ax1 = fig.add_subplot(2, 3, 1)
      wfs = analyzer.get_extension('waveforms').get_waveforms(unit_id)
      for i in range(min(50, wfs.shape[0])):
          ax1.plot(wfs[i, :, 0], 'k', alpha=0.1, linewidth=0.5)
      template = wfs.mean(axis=0)[:, 0]
      ax1.plot(template, 'b', linewidth=2)
      ax1.set_title('Waveforms')
      
      # Template
      ax2 = fig.add_subplot(2, 3, 2)
      templates_ext = analyzer.get_extension('templates')
      template = templates_ext.get_unit_template(unit_id, operator='average')
      template_std = templates_ext.get_unit_template(unit_id, operator='std')
      x = range(template.shape[0])
      ax2.plot(x, template[:, 0], 'b', linewidth=2)
      ax2.fill_between(x, template[:, 0] - template_std[:, 0],
                       template[:, 0] + template_std[:, 0], alpha=0.3)
      ax2.set_title('Template')
      
      # Autocorrelogram
      ax3 = fig.add_subplot(2, 3, 3)
      correlograms = analyzer.get_extension('correlograms')
      ccg, bins = correlograms.get_data()
      unit_idx = list(sorting.unit_ids).index(unit_id)
      ax3.bar(bins[:-1], ccg[unit_idx, unit_idx, :], width=bins[1]-bins[0], color='gray')
      ax3.axvline(0, color='r', linestyle='--', alpha=0.5)
      ax3.set_title('Autocorrelogram')
      
      # Amplitudes
      ax4 = fig.add_subplot(2, 3, 4)
      amps_ext = analyzer.get_extension('spike_amplitudes')
      amps = amps_ext.get_data()
      spike_vector = sorting.to_spike_vector()
      unit_mask = spike_vector['unit_index'] == unit_idx
      unit_times = spike_vector['sample_index'][unit_mask] / fs
      unit_amps = amps[unit_mask]
      ax4.scatter(unit_times, unit_amps, s=1, alpha=0.3)
      ax4.set_xlabel('Time (s)')
      ax4.set_ylabel('Amplitude')
      ax4.set_title('Amplitudes')
      
      # ISI
      ax5 = fig.add_subplot(2, 3, 5)
      isis = np.diff(sorting.get_unit_spike_train(unit_id)) / fs * 1000
      ax5.hist(isis[isis < 100], bins=50, color='gray', edgecolor='black')
      ax5.axvline(1.5, color='r', linestyle='--')
      ax5.set_xlabel('ISI (ms)')
      ax5.set_title('ISI Distribution')
      
      # Metrics
      ax6 = fig.add_subplot(2, 3, 6)
      unit_metrics = metrics.loc[unit_id]
      text_lines = [f"{k}: {v:.4f}" for k, v in unit_metrics.items() if not np.isnan(v)]
      ax6.text(0.1, 0.9, '\n'.join(text_lines[:8]), transform=ax6.transAxes,
               verticalalignment='top', fontsize=10, family='monospace')
      ax6.axis('off')
      ax6.set_title('Metrics')
      
      plt.tight_layout()
      plt.savefig(f'unit_{unit_id}_full_summary.png', dpi=300)
      ```
      
      ## Publication-Quality Settings
      
      ### Figure Sizes
      
      ```python
      # Single column (3.5 inches)
      fig, ax = plt.subplots(figsize=(3.5, 3))
      
      # Double column (7 inches)
      fig, ax = plt.subplots(figsize=(7, 4))
      
      # Full page
      fig, ax = plt.subplots(figsize=(7, 9))
      ```
      
      ### Font Settings
      
      ```python
      plt.rcParams.update({
          'font.size': 8,
          'axes.titlesize': 9,
          'axes.labelsize': 8,
          'xtick.labelsize': 7,
          'ytick.labelsize': 7,
          'legend.fontsize': 7,
          'font.family': 'Arial',
      })
      ```
      
      ### Export Settings
      
      ```python
      # For publications
      plt.savefig('figure.pdf', format='pdf', bbox_inches='tight')
      plt.savefig('figure.svg', format='svg', bbox_inches='tight')
      
      # High-res PNG
      plt.savefig('figure.png', dpi=600, bbox_inches='tight', facecolor='white')
      ```
      
      ### Color Palettes
      
      ```python
      # Colorblind-friendly
      colors = ['#0072B2', '#E69F00', '#009E73', '#CC79A7', '#F0E442']
      
      # For good/mua/noise
      label_colors = {'good': '#2ecc71', 'mua': '#f39c12', 'noise': '#e74c3c'}
      ```
      
    • PREPROCESSING.md 6.9 KB
      # Neuropixels Preprocessing Reference
      
      Comprehensive preprocessing techniques for Neuropixels neural recordings.
      
      ## Standard Preprocessing Pipeline
      
      ```python
      import spikeinterface.full as si
      
      # Load raw data
      recording = si.read_spikeglx('/path/to/data', stream_id='imec0.ap')
      
      # 1. Phase shift correction (for Neuropixels 1.0)
      rec = si.phase_shift(recording)
      
      # 2. Bandpass filter for spike detection
      rec = si.bandpass_filter(rec, freq_min=300, freq_max=6000)
      
      # 3. Common median reference (removes correlated noise)
      rec = si.common_reference(rec, reference='global', operator='median')
      
      # 4. Remove bad channels (optional)
      rec = si.remove_bad_channels(rec, bad_channel_ids=bad_channels)
      ```
      
      ## Filtering Options
      
      ### Bandpass Filter
      ```python
      # Standard AP band
      rec = si.bandpass_filter(recording, freq_min=300, freq_max=6000)
      
      # Wider band (preserve more waveform shape)
      rec = si.bandpass_filter(recording, freq_min=150, freq_max=7500)
      
      # Filter parameters
      rec = si.bandpass_filter(
          recording,
          freq_min=300,
          freq_max=6000,
          filter_order=5,
          ftype='butter',  # 'butter', 'bessel', or 'cheby1'
          margin_ms=5.0    # Prevent edge artifacts
      )
      ```
      
      ### Highpass Filter Only
      ```python
      rec = si.highpass_filter(recording, freq_min=300)
      ```
      
      ### Notch Filter (Remove Line Noise)
      ```python
      # Remove 60Hz and harmonics
      rec = si.notch_filter(recording, freq=60, q=30)
      rec = si.notch_filter(rec, freq=120, q=30)
      rec = si.notch_filter(rec, freq=180, q=30)
      ```
      
      ## Reference Schemes
      
      ### Common Median Reference (Recommended)
      ```python
      # Global median reference
      rec = si.common_reference(recording, reference='global', operator='median')
      
      # Per-shank reference (multi-shank probes)
      rec = si.common_reference(recording, reference='global', operator='median',
                                groups=recording.get_channel_groups())
      ```
      
      ### Common Average Reference
      ```python
      rec = si.common_reference(recording, reference='global', operator='average')
      ```
      
      ### Local Reference
      ```python
      # Reference by local groups of channels
      rec = si.common_reference(recording, reference='local', local_radius=(30, 100))
      ```
      
      ## Bad Channel Detection & Removal
      
      ### Automatic Detection
      ```python
      # Detect bad channels
      bad_channel_ids, channel_labels = si.detect_bad_channels(
          recording,
          method='coherence+psd',
          dead_channel_threshold=-0.5,
          noisy_channel_threshold=1.0,
          outside_channel_threshold=-0.3,
          n_neighbors=11
      )
      
      print(f"Bad channels: {bad_channel_ids}")
      print(f"Labels: {dict(zip(bad_channel_ids, channel_labels))}")
      ```
      
      ### Remove Bad Channels
      ```python
      rec_clean = si.remove_bad_channels(recording, bad_channel_ids=bad_channel_ids)
      ```
      
      ### Interpolate Bad Channels
      ```python
      rec_interp = si.interpolate_bad_channels(recording, bad_channel_ids=bad_channel_ids)
      ```
      
      ## Motion Correction
      
      ### Estimate and Apply Motion Correction
      
      The simplest path is the all-in-one `correct_motion` with a preset (it detects
      peaks, estimates motion, and interpolates in one call):
      
      ```python
      # One-shot: returns the drift-corrected recording
      rec_corrected = si.correct_motion(recording, preset='nonrigid_fast_and_accurate')
      
      # To inspect the estimate too, ask for motion_info
      rec_corrected, motion_info = si.correct_motion(
          recording,
          preset='nonrigid_fast_and_accurate',
          output_motion_info=True,
          folder='motion_output/',
      )
      ```
      
      ### Motion Visualization
      ```python
      # motion_info['motion'] is a Motion object (bins are carried inside it)
      from spikeinterface.widgets import plot_motion
      plot_motion(motion_info['motion'], recording=recording)
      ```
      
      See `MOTION_CORRECTION.md` for the low-level `estimate_motion` / `interpolate_motion`
      API. Note: `estimate_motion` returns a single `Motion` object in current
      SpikeInterface (not the old `(motion, temporal_bins, spatial_bins)` tuple).
      
      ## Probe-Specific Processing
      
      ### Neuropixels 1.0
      ```python
      # Phase shift correction (different ADC per channel)
      rec = si.phase_shift(recording)
      
      # Then standard pipeline
      rec = si.bandpass_filter(rec, freq_min=300, freq_max=6000)
      rec = si.common_reference(rec, reference='global', operator='median')
      ```
      
      ### Neuropixels 2.0
      ```python
      # No phase shift needed (single ADC)
      rec = si.bandpass_filter(recording, freq_min=300, freq_max=6000)
      rec = si.common_reference(rec, reference='global', operator='median')
      ```
      
      ### Multi-Shank (Neuropixels 2.0 4-shank)
      ```python
      # Reference per shank
      groups = recording.get_channel_groups()  # Returns shank assignments
      rec = si.common_reference(recording, reference='global', operator='median', groups=groups)
      ```
      
      ## Whitening
      
      ```python
      # Whiten data (decorrelate channels)
      rec_whitened = si.whiten(recording, mode='local', local_radius_um=100)
      
      # Global whitening
      rec_whitened = si.whiten(recording, mode='global')
      ```
      
      ## Artifact Removal
      
      ### Remove Stimulation Artifacts
      ```python
      # Define artifact times (in samples)
      triggers = [10000, 20000, 30000]  # Sample indices
      
      rec = si.remove_artifacts(
          recording,
          triggers,
          ms_before=0.5,
          ms_after=3.0,
          mode='cubic'  # 'zeros', 'linear', 'cubic'
      )
      ```
      
      ### Blank Saturation Periods
      ```python
      rec = si.blank_staturation(recording, threshold=0.95, fill_value=0)
      ```
      
      ## Saving Preprocessed Data
      
      ### Binary Format (Recommended)
      ```python
      rec_preprocessed.save(folder='preprocessed/', format='binary', n_jobs=4)
      ```
      
      ### Zarr Format (Compressed)
      ```python
      rec_preprocessed.save(folder='preprocessed.zarr', format='zarr')
      ```
      
      ### Save as Recording Extractor
      ```python
      # Save for later use
      rec_preprocessed.save(folder='preprocessed/', format='binary')
      
      # Load later
      rec_loaded = si.load_extractor('preprocessed/')
      ```
      
      ## Complete Pipeline Example
      
      ```python
      import spikeinterface.full as si
      
      def preprocess_neuropixels(data_path, output_path):
          """Standard Neuropixels preprocessing pipeline."""
      
          # Load data
          recording = si.read_spikeglx(data_path, stream_id='imec0.ap')
          print(f"Loaded: {recording.get_num_channels()} channels, "
                f"{recording.get_total_duration():.1f}s")
      
          # Phase shift (NP 1.0 only)
          rec = si.phase_shift(recording)
      
          # Filter
          rec = si.bandpass_filter(rec, freq_min=300, freq_max=6000)
      
          # Detect and remove bad channels
          bad_ids, _ = si.detect_bad_channels(rec)
          if len(bad_ids) > 0:
              print(f"Removing {len(bad_ids)} bad channels: {bad_ids}")
              rec = si.interpolate_bad_channels(rec, bad_ids)
      
          # Common reference
          rec = si.common_reference(rec, reference='global', operator='median')
      
          # Save
          rec.save(folder=output_path, format='binary', n_jobs=4)
          print(f"Saved to: {output_path}")
      
          return rec
      
      # Usage
      rec_preprocessed = preprocess_neuropixels(
          '/path/to/spikeglx/data',
          '/path/to/preprocessed'
      )
      ```
      
      ## Performance Tips
      
      ```python
      # Use parallel processing
      rec.save(folder='output/', n_jobs=-1)  # Use all cores
      
      # Use job kwargs for memory management
      job_kwargs = dict(n_jobs=8, chunk_duration='1s', progress_bar=True)
      rec.save(folder='output/', **job_kwargs)
      
      # Set global job kwargs
      si.set_global_job_kwargs(n_jobs=8, chunk_duration='1s')
      ```
      
    • QUALITY_METRICS.md 9.5 KB
      # Quality Metrics Reference
      
      Comprehensive guide to unit quality assessment using SpikeInterface metrics and Allen/IBL standards.
      
      ## Overview
      
      Quality metrics assess three aspects of sorted units:
      
      | Category | Question | Key Metrics |
      |----------|----------|-------------|
      | **Contamination** (Type I) | Are spikes from multiple neurons? | ISI violations, SNR |
      | **Completeness** (Type II) | Are we missing spikes? | Amplitude cutoff, presence ratio |
      | **Stability** | Is the unit stable over time? | Drift metrics, amplitude CV |
      
      ## Computing Quality Metrics
      
      ```python
      import spikeinterface.full as si
      
      # Create analyzer with computed waveforms
      analyzer = si.create_sorting_analyzer(sorting, recording, sparse=True)
      analyzer.compute('random_spikes', max_spikes_per_unit=500)
      analyzer.compute('waveforms', ms_before=1.5, ms_after=2.0)
      analyzer.compute('templates')
      analyzer.compute('noise_levels')
      analyzer.compute('spike_amplitudes')
      analyzer.compute('principal_components', n_components=5)
      
      # Compute all quality metrics
      analyzer.compute('quality_metrics')
      
      # Or compute specific metrics
      analyzer.compute('quality_metrics', metric_names=[
          'firing_rate', 'snr', 'isi_violations_ratio',
          'presence_ratio', 'amplitude_cutoff'
      ])
      
      # Get results
      qm = analyzer.get_extension('quality_metrics').get_data()
      print(qm.columns.tolist())  # Available metrics
      ```
      
      ## Metric Definitions & Thresholds
      
      ### Contamination Metrics
      
      #### ISI Violations Ratio
      Fraction of spikes violating refractory period. All neurons have a ~1.5ms refractory period.
      
      ```python
      # Compute with custom refractory period
      analyzer.compute('quality_metrics',
                       metric_names=['isi_violations_ratio'],
                       isi_threshold_ms=1.5,
                       min_isi_ms=0.0)
      ```
      
      | Value | Interpretation |
      |-------|---------------|
      | < 0.01 | Excellent (well-isolated single unit) |
      | 0.01 - 0.1 | Good (minor contamination) |
      | 0.1 - 0.5 | Moderate (multi-unit activity likely) |
      | > 0.5 | Poor (likely multi-unit) |
      
      **Reference:** Hill et al. (2011) J Neurosci 31:8699-8705
      
      #### Signal-to-Noise Ratio (SNR)
      Ratio of peak waveform amplitude to background noise.
      
      ```python
      analyzer.compute('quality_metrics', metric_names=['snr'])
      ```
      
      | Value | Interpretation |
      |-------|---------------|
      | > 10 | Excellent |
      | 5 - 10 | Good |
      | 2 - 5 | Acceptable |
      | < 2 | Poor (may be noise) |
      
      #### Isolation Distance
      Mahalanobis distance to nearest cluster in PCA space.
      
      ```python
      analyzer.compute('quality_metrics',
                       metric_names=['isolation_distance'],
                       n_neighbors=4)
      ```
      
      | Value | Interpretation |
      |-------|---------------|
      | > 50 | Well-isolated |
      | 20 - 50 | Moderately isolated |
      | < 20 | Poorly isolated |
      
      #### L-ratio
      Contamination measure based on Mahalanobis distances.
      
      | Value | Interpretation |
      |-------|---------------|
      | < 0.05 | Well-isolated |
      | 0.05 - 0.1 | Acceptable |
      | > 0.1 | Contaminated |
      
      #### D-prime
      Discriminability between unit and nearest neighbor.
      
      | Value | Interpretation |
      |-------|---------------|
      | > 8 | Excellent separation |
      | 5 - 8 | Good separation |
      | < 5 | Poor separation |
      
      ### Completeness Metrics
      
      #### Amplitude Cutoff
      Estimates fraction of spikes below detection threshold.
      
      ```python
      analyzer.compute('quality_metrics',
                       metric_names=['amplitude_cutoff'])
      ```
      
      `peak_sign` is **not** a parameter here in SpikeInterface 0.105+: the peak sign and
      main channel are decided once, when the `SortingAnalyzer` is created, and stored as
      the `main_channel_id` sorting property. On 0.104 and earlier the same call took
      `peak_sign='neg'`; passing it on 0.105 raises.
      
      | Value | Interpretation |
      |-------|---------------|
      | < 0.01 | Excellent (nearly complete) |
      | 0.01 - 0.1 | Good |
      | 0.1 - 0.2 | Moderate (some missed spikes) |
      | > 0.2 | Poor (many missed spikes) |
      
      **For precise timing analyses:** Use < 0.01
      
      #### Presence Ratio
      Fraction of recording time with detected spikes.
      
      ```python
      analyzer.compute('quality_metrics',
                       metric_names=['presence_ratio'],
                       bin_duration_s=60)  # 1-minute bins
      ```
      
      | Value | Interpretation |
      |-------|---------------|
      | > 0.99 | Excellent |
      | 0.9 - 0.99 | Good |
      | 0.8 - 0.9 | Acceptable |
      | < 0.8 | Unit may have drifted out |
      
      ### Stability Metrics
      
      #### Drift Metrics
      Measure unit movement over time.
      
      ```python
      analyzer.compute('quality_metrics',
                       metric_names=['drift_ptp', 'drift_std', 'drift_mad'])
      ```
      
      | Metric | Description | Good Value |
      |--------|-------------|------------|
      | `drift_ptp` | Peak-to-peak drift (μm) | < 40 |
      | `drift_std` | Standard deviation of drift | < 10 |
      | `drift_mad` | Median absolute deviation | < 10 |
      
      #### Amplitude CV
      Coefficient of variation of spike amplitudes.
      
      | Value | Interpretation |
      |-------|---------------|
      | < 0.25 | Very stable |
      | 0.25 - 0.5 | Acceptable |
      | > 0.5 | Unstable (drift or contamination) |
      
      ### Cluster Quality Metrics
      
      #### Silhouette Score
      Cluster cohesion vs separation (-1 to 1).
      
      | Value | Interpretation |
      |-------|---------------|
      | > 0.5 | Well-defined cluster |
      | 0.25 - 0.5 | Moderate |
      | < 0.25 | Overlapping clusters |
      
      #### Nearest-Neighbor Metrics
      
      ```python
      analyzer.compute('quality_metrics',
                       metric_names=['nn_hit_rate', 'nn_miss_rate'],
                       n_neighbors=4)
      ```
      
      | Metric | Description | Good Value |
      |--------|-------------|------------|
      | `nn_hit_rate` | Fraction of spikes with same-unit neighbors | > 0.9 |
      | `nn_miss_rate` | Fraction of spikes with other-unit neighbors | < 0.1 |
      
      ## Standard Filtering Criteria
      
      ### Allen Institute Defaults
      
      ```python
      # Allen Visual Coding / Behavior defaults
      allen_query = """
          presence_ratio > 0.95 and
          isi_violations_ratio < 0.5 and
          amplitude_cutoff < 0.1
      """
      good_units = qm.query(allen_query).index.tolist()
      ```
      
      ### IBL Standards
      
      ```python
      # IBL reproducible ephys criteria
      ibl_query = """
          presence_ratio > 0.9 and
          isi_violations_ratio < 0.1 and
          amplitude_cutoff < 0.1 and
          firing_rate > 0.1
      """
      good_units = qm.query(ibl_query).index.tolist()
      ```
      
      ### Strict Single-Unit Criteria
      
      ```python
      # For precise timing / spike-timing analyses
      strict_query = """
          snr > 5 and
          presence_ratio > 0.99 and
          isi_violations_ratio < 0.01 and
          amplitude_cutoff < 0.01 and
          isolation_distance > 20 and
          drift_ptp < 40
      """
      single_units = qm.query(strict_query).index.tolist()
      ```
      
      ### Multi-Unit Activity (MUA)
      
      ```python
      # Include multi-unit activity
      mua_query = """
          snr > 2 and
          presence_ratio > 0.5 and
          isi_violations_ratio < 1.0
      """
      all_units = qm.query(mua_query).index.tolist()
      ```
      
      ## Visualization
      
      ### Quality Metric Summary
      
      ```python
      # Plot all metrics
      si.plot_quality_metrics(analyzer)
      ```
      
      ### Individual Metric Distributions
      
      ```python
      import matplotlib.pyplot as plt
      
      fig, axes = plt.subplots(2, 3, figsize=(15, 10))
      
      metrics = ['snr', 'isi_violations_ratio', 'presence_ratio',
                 'amplitude_cutoff', 'firing_rate', 'drift_ptp']
      
      for ax, metric in zip(axes.flat, metrics):
          ax.hist(qm[metric].dropna(), bins=50, edgecolor='black')
          ax.set_xlabel(metric)
          ax.set_ylabel('Count')
          # Add threshold line
          if metric == 'snr':
              ax.axvline(5, color='r', linestyle='--', label='threshold')
          elif metric == 'isi_violations_ratio':
              ax.axvline(0.01, color='r', linestyle='--')
          elif metric == 'presence_ratio':
              ax.axvline(0.9, color='r', linestyle='--')
      
      plt.tight_layout()
      ```
      
      ### Unit Quality Summary
      
      ```python
      # Comprehensive unit summary plot
      si.plot_unit_summary(analyzer, unit_id=0)
      ```
      
      ### Quality vs Firing Rate
      
      ```python
      fig, ax = plt.subplots()
      scatter = ax.scatter(qm['firing_rate'], qm['snr'],
                           c=qm['isi_violations_ratio'],
                           cmap='RdYlGn_r', alpha=0.6)
      ax.set_xlabel('Firing Rate (Hz)')
      ax.set_ylabel('SNR')
      plt.colorbar(scatter, label='ISI Violations')
      ax.set_xscale('log')
      ```
      
      ## Compute All Metrics at Once
      
      ```python
      # Full quality metrics computation
      all_metric_names = [
          # Firing properties
          'firing_rate', 'presence_ratio',
          # Waveform
          'snr', 'amplitude_cutoff', 'amplitude_cv_median', 'amplitude_cv_range',
          # ISI
          'isi_violations_ratio', 'isi_violations_count',
          # Drift
          'drift_ptp', 'drift_std', 'drift_mad',
          # Isolation (require PCA)
          'isolation_distance', 'l_ratio', 'd_prime',
          # Nearest neighbor (require PCA)
          'nn_hit_rate', 'nn_miss_rate',
          # Cluster quality
          'silhouette_score',
          # Synchrony
          'sync_spike_2', 'sync_spike_4', 'sync_spike_8',
      ]
      
      # Compute PCA first (required for some metrics)
      analyzer.compute('principal_components', n_components=5)
      
      # Compute metrics
      analyzer.compute('quality_metrics', metric_names=all_metric_names)
      qm = analyzer.get_extension('quality_metrics').get_data()
      
      # Save to CSV
      qm.to_csv('quality_metrics.csv')
      ```
      
      ## Custom Metrics
      
      ```python
      from spikeinterface.qualitymetrics import compute_firing_rates, compute_snrs
      
      # Compute individual metrics
      firing_rates = compute_firing_rates(sorting)
      snrs = compute_snrs(analyzer)
      
      # Add custom metric to DataFrame
      qm['custom_score'] = qm['snr'] * qm['presence_ratio'] / (qm['isi_violations_ratio'] + 0.001)
      ```
      
      ## References
      
      - [SpikeInterface Quality Metrics](https://spikeinterface.readthedocs.io/en/latest/modules/qualitymetrics.html)
      - [Allen Institute ecephys_quality_metrics](https://allensdk.readthedocs.io/en/latest/_static/examples/nb/ecephys_quality_metrics.html)
      - Hill et al. (2011) "Quality metrics to accompany spike sorting of extracellular signals"
      - Siegle et al. (2021) "Survey of spiking in the mouse visual system reveals functional hierarchy"
      
    • SPIKE_SORTING.md 8.5 KB
      # Spike Sorting Reference
      
      Comprehensive guide to spike sorting Neuropixels data.
      
      ## Available Sorters
      
      | Sorter | GPU Required | Speed | Quality | Best For |
      |--------|--------------|-------|---------|----------|
      | **Kilosort4** | Yes (CUDA) | Fast | Excellent | Production use |
      | **Kilosort3** | Yes (CUDA) | Fast | Very Good | Legacy compatibility |
      | **Kilosort2.5** | Yes (CUDA) | Fast | Good | Older pipelines |
      | **SpykingCircus2** | No | Medium | Good | CPU-only systems |
      | **Mountainsort5** | No | Medium | Good | Small recordings |
      | **Tridesclous2** | No | Medium | Good | Interactive sorting |
      
      ## Kilosort4 (Recommended)
      
      ### Installation
      ```bash
      pip install kilosort
      ```
      
      ### Basic Usage
      ```python
      import spikeinterface.full as si
      
      # Run Kilosort4
      sorting = si.run_sorter(
          'kilosort4',
          recording,
          folder='ks4_output',
          verbose=True
      )
      
      print(f"Found {len(sorting.unit_ids)} units")
      ```
      
      ### Custom Parameters
      ```python
      sorting = si.run_sorter(
          'kilosort4',
          recording,
          folder='ks4_output',
          # Detection
          Th_universal=9,        # Spike detection threshold
          Th_learned=8,          # Learned threshold
          # Templates
          dmin=15,               # Min vertical distance between templates (um)
          dminx=12,              # Min horizontal distance (um)
          nblocks=5,             # Number of non-rigid blocks
          # Clustering
          max_channel_distance=None,  # Max distance for template channel
          # Output
          do_CAR=False,          # Skip CAR (done in preprocessing)
          skip_kilosort_preprocessing=True,
          save_extra_kwargs=True
      )
      ```
      
      ### Kilosort4 Full Parameters
      ```python
      # Get all available parameters
      params = si.get_default_sorter_params('kilosort4')
      print(params)
      
      # Key parameters:
      ks4_params = {
          # Detection
          'Th_universal': 9,      # Universal threshold for spike detection
          'Th_learned': 8,        # Threshold for learned templates
          'spkTh': -6,            # Spike threshold during extraction
      
          # Clustering
          'dmin': 15,             # Min distance between clusters (um)
          'dminx': 12,            # Min horizontal distance (um)
          'nblocks': 5,           # Blocks for non-rigid drift correction
      
          # Templates
          'n_templates': 6,       # Number of universal templates per group
          'nt': 61,               # Number of time samples in template
      
          # Performance
          'batch_size': 60000,    # Batch size in samples
          'nfilt_factor': 8,      # Factor for number of filters
      }
      ```
      
      ## Kilosort3
      
      ### Usage
      ```python
      sorting = si.run_sorter(
          'kilosort3',
          recording,
          folder='ks3_output',
          # Key parameters
          detect_threshold=6,
          projection_threshold=[9, 9],
          preclust_threshold=8,
          car=False,  # CAR done in preprocessing
          freq_min=300,
      )
      ```
      
      ## SpykingCircus2 (CPU-Only)
      
      ### Installation
      ```bash
      pip install spykingcircus
      ```
      
      ### Usage
      ```python
      sorting = si.run_sorter(
          'spykingcircus2',
          recording,
          folder='sc2_output',
          # Parameters
          detect_threshold=5,
          selection_method='all',
      )
      ```
      
      ## Mountainsort5 (CPU-Only)
      
      ### Installation
      ```bash
      pip install mountainsort5
      ```
      
      ### Usage
      ```python
      sorting = si.run_sorter(
          'mountainsort5',
          recording,
          folder='ms5_output',
          # Parameters
          detect_threshold=5.0,
          scheme='2',  # '1', '2', or '3'
      )
      ```
      
      ## Running Multiple Sorters
      
      ### Compare Sorters
      ```python
      # Run multiple sorters
      sorting_ks4 = si.run_sorter('kilosort4', recording, folder='ks4/')
      sorting_sc2 = si.run_sorter('spykingcircus2', recording, folder='sc2/')
      sorting_ms5 = si.run_sorter('mountainsort5', recording, folder='ms5/')
      
      # Compare results
      comparison = si.compare_multiple_sorters(
          [sorting_ks4, sorting_sc2, sorting_ms5],
          name_list=['KS4', 'SC2', 'MS5']
      )
      
      # Get agreement scores
      agreement = comparison.get_agreement_sorting()
      ```
      
      ### Consensus / Agreement Sorting
      ```python
      # Keep only units found by at least `minimum_agreement_count` sorters.
      # (There is no `si.create_ensemble_sorting`; the agreement sorting comes from
      #  the MultiSortingComparison object.)
      comparison = si.compare_multiple_sorters(
          [sorting_ks4, sorting_sc2, sorting_ms5],
          name_list=['KS4', 'SC2', 'MS5'],
      )
      sorting_consensus = comparison.get_agreement_sorting(minimum_agreement_count=2)
      ```
      
      ## Sorting in Docker/Singularity
      
      ### Using Docker
      ```python
      sorting = si.run_sorter(
          'kilosort3',
          recording,
          folder='ks3_docker/',
          docker_image='spikeinterface/kilosort3-compiled-base:latest',
          verbose=True
      )
      ```
      
      ### Using Singularity
      ```python
      sorting = si.run_sorter(
          'kilosort3',
          recording,
          folder='ks3_singularity/',
          singularity_image='/path/to/kilosort3.sif',
          verbose=True
      )
      ```
      
      ## Long Recording Strategy
      
      ### Concatenate Recordings
      ```python
      # Multiple recording files
      recordings = [
          si.read_spikeglx(f'/path/to/recording_{i}', stream_id='imec0.ap')
          for i in range(3)
      ]
      
      # Concatenate
      recording_concat = si.concatenate_recordings(recordings)
      
      # Sort the concatenated recording, then slice the result back per original
      # segment using frame ranges (there is no `si.split_sorting` helper).
      sorting = si.run_sorter('kilosort4', recording_concat, folder='ks4/')
      
      fs = recording_concat.get_sampling_frequency()
      n_samples = [r.get_num_samples() for r in recordings]
      bounds = [0]
      for n in n_samples:
          bounds.append(bounds[-1] + n)
      sortings_split = [
          sorting.frame_slice(start_frame=bounds[i], end_frame=bounds[i + 1])
          for i in range(len(recordings))
      ]
      ```
      
      ### Sort by Segment
      ```python
      # For very long recordings, sort segments separately
      from pathlib import Path
      
      segments_output = Path('sorting_segments')
      sortings = []
      
      # Slice the recording into time windows with time_slice (there is no
      # `split_by_times`), then sort each window.
      edges_s = [0, 3600, 7200, 10800]
      for i in range(len(edges_s) - 1):
          segment = recording.time_slice(start_time=edges_s[i], end_time=edges_s[i + 1])
          sorting_seg = si.run_sorter(
              'kilosort4',
              segment,
              folder=segments_output / f'segment_{i}'
          )
          sortings.append(sorting_seg)
      ```
      
      ## Post-Sorting Curation
      
      ### Manual Curation with Phy
      ```python
      # Export to Phy format
      analyzer = si.create_sorting_analyzer(sorting, recording)
      analyzer.compute(['random_spikes', 'waveforms', 'templates'])
      si.export_to_phy(analyzer, output_folder='phy_export/')
      
      # Open Phy
      # Run in terminal: phy template-gui phy_export/params.py
      ```
      
      ### Load Phy Curation
      ```python
      # After manual curation in Phy, read the curated sorting back. By default
      # read_phy keeps units labelled 'good' (and 'mua' if you ask for it).
      sorting_curated = si.read_phy('phy_export/', exclude_cluster_groups=['noise'])
      
      # The 'quality' labels assigned in Phy are available as a unit property:
      labels = sorting_curated.get_property('quality')
      ```
      
      ### Automatic Curation
      ```python
      # Remove units below quality threshold
      analyzer = si.create_sorting_analyzer(sorting, recording)
      analyzer.compute('quality_metrics')
      
      qm = analyzer.get_extension('quality_metrics').get_data()
      
      # Define quality criteria
      query = "(snr > 5) & (isi_violations_ratio < 0.01) & (presence_ratio > 0.9)"
      good_unit_ids = qm.query(query).index.tolist()
      
      sorting_clean = sorting.select_units(good_unit_ids)
      print(f"Kept {len(good_unit_ids)}/{len(sorting.unit_ids)} units")
      ```
      
      ## Sorting Metrics
      
      ### Check Sorter Output
      ```python
      # Basic stats
      print(f"Units found: {len(sorting.unit_ids)}")
      print(f"Total spikes: {sorting.get_total_num_spikes()}")
      
      # Per-unit spike counts
      for unit_id in sorting.unit_ids[:10]:
          n_spikes = len(sorting.get_unit_spike_train(unit_id))
          print(f"Unit {unit_id}: {n_spikes} spikes")
      ```
      
      ### Firing Rates
      ```python
      # Compute firing rates
      duration = recording.get_total_duration()
      for unit_id in sorting.unit_ids:
          n_spikes = len(sorting.get_unit_spike_train(unit_id))
          fr = n_spikes / duration
          print(f"Unit {unit_id}: {fr:.2f} Hz")
      ```
      
      ## Troubleshooting
      
      ### Common Issues
      
      **Out of GPU Memory**
      ```python
      # Reduce batch size
      sorting = si.run_sorter(
          'kilosort4',
          recording,
          folder='ks4/',
          batch_size=30000  # Smaller batch
      )
      ```
      
      **Too Few Units Found**
      ```python
      # Lower detection threshold
      sorting = si.run_sorter(
          'kilosort4',
          recording,
          folder='ks4/',
          Th_universal=7,  # Lower from default 9
          Th_learned=6
      )
      ```
      
      **Too Many Units (Over-splitting)**
      ```python
      # Increase minimum distance between templates
      sorting = si.run_sorter(
          'kilosort4',
          recording,
          folder='ks4/',
          dmin=20,   # Increase from 15
          dminx=16   # Increase from 12
      )
      ```
      
      **Check GPU Availability**
      ```python
      import torch
      print(f"CUDA available: {torch.cuda.is_available()}")
      print(f"GPU: {torch.cuda.get_device_name(0)}")
      ```
      
    • standard_workflow.md 9.9 KB
      # Standard Neuropixels Analysis Workflow
      
      Complete step-by-step guide for analyzing Neuropixels recordings from raw data to curated units.
      
      > **API note.** The `npa.*` calls below are illustrative shorthand for "a convenience
      > wrapper" — there is **no installable `npa`/`neuropixels_analysis` package**. The only
      > bundled helpers are in `scripts/neuropixels_pipeline.py`: `load_recording`,
      > `preprocess`, `check_drift`, `correct_motion`, `run_spike_sorting`, `postprocess`,
      > `curate_units`, `export_results`, `run_pipeline`. For anything not in that list, use the
      > SpikeInterface call shown alongside it. The verified end-to-end example is in
      > "Full Pipeline Example" at the bottom of this file.
      
      ## Overview
      
      This reference documents the complete analysis pipeline:
      
      ```
      Raw Recording → Preprocessing → Motion Correction → Spike Sorting →
      Postprocessing → Quality Metrics → Curation → Export
      ```
      
      ## 1. Data Loading
      
      ### Supported Formats
      
      ```python
      import spikeinterface.full as si
      # Bundled helpers (local scripts — no installable package):
      from scripts.neuropixels_pipeline import (
          load_recording, preprocess, check_drift, correct_motion,
          run_spike_sorting, postprocess, curate_units, export_results, run_pipeline,
      )
      
      # SpikeGLX (most common)
      recording = si.read_spikeglx('/path/to/run/', stream_id='imec0.ap')
      
      # Open Ephys
      recording = si.read_openephys('/path/to/experiment/')
      
      # NWB format
      recording = si.read_nwb('/path/to/file.nwb')
      
      # Or use the bundled helper (format is auto-detected from directory contents)
      recording = load_recording('/path/to/data/', stream_id='imec0.ap')
      ```
      
      ### Verify Recording Properties
      
      ```python
      # Basic properties
      print(f"Channels: {recording.get_num_channels()}")
      print(f"Duration: {recording.get_total_duration():.1f}s")
      print(f"Sampling rate: {recording.get_sampling_frequency()}Hz")
      
      # Probe geometry
      print(f"Probe: {recording.get_probe().name}")
      
      # Channel locations
      locations = recording.get_channel_locations()
      ```
      
      ## 2. Preprocessing
      
      ### Standard Preprocessing Chain
      
      ```python
      # Option 1: Full pipeline (recommended)
      rec_preprocessed = npa.preprocess(recording)
      
      # Option 2: Step-by-step control
      rec = si.highpass_filter(recording, freq_min=400)  # 400 Hz is the SpikeInterface NP default
      rec = si.phase_shift(rec)  # Correct ADC phase (NP 1.0)
      # detect_bad_channels returns a (bad_channel_ids, channel_labels) TUPLE — unpack it:
      bad_channel_ids, channel_labels = si.detect_bad_channels(rec)
      rec = rec.remove_channels(bad_channel_ids)
      rec = si.common_reference(rec, operator='median', reference='global')
      rec_preprocessed = rec
      ```
      
      ### IBL-Style Destriping
      
      For recordings with strong artifacts:
      
      ```python
      from ibldsp.voltage import decompress_destripe_cbin
      
      # IBL destriping (very effective)
      rec = si.highpass_filter(recording, freq_min=400)
      rec = si.phase_shift(rec)
      rec = si.highpass_spatial_filter(rec)  # Destriping
      rec = si.common_reference(rec, reference='global', operator='median')
      ```
      
      ### Save Preprocessed Data
      
      ```python
      # Save for reuse (speeds up iteration)
      rec_preprocessed.save(folder='preprocessed/', n_jobs=4)
      ```
      
      ## 3. Motion/Drift Correction
      
      ### Check if Correction Needed
      
      ```python
      # Estimate motion
      motion_info = npa.estimate_motion(rec_preprocessed, preset='kilosort_like')
      
      # Visualize drift
      npa.plot_drift(rec_preprocessed, motion_info, output='drift_map.png')
      
      # Check magnitude
      if motion_info['motion'].max() > 10:  # microns
          print("Significant drift detected - correction recommended")
      ```
      
      ### Apply Correction
      
      ```python
      # DREDge-based correction (default)
      rec_corrected = npa.correct_motion(
          rec_preprocessed,
          preset='nonrigid_accurate',  # or 'kilosort_like' for speed
      )
      
      # Or full control
      from spikeinterface.preprocessing import correct_motion
      
      rec_corrected = correct_motion(
          rec_preprocessed,
          preset='nonrigid_accurate',
          folder='motion_output/',
          output_motion=True,
      )
      ```
      
      ## 4. Spike Sorting
      
      ### Recommended: Kilosort4
      
      ```python
      # Run Kilosort4 (requires GPU)
      sorting = npa.run_sorting(
          rec_corrected,
          sorter='kilosort4',
          output_folder='sorting_KS4/',
      )
      
      # With custom parameters
      sorting = npa.run_sorting(
          rec_corrected,
          sorter='kilosort4',
          output_folder='sorting_KS4/',
          sorter_params={
              'batch_size': 30000,
              'nblocks': 5,  # For nonrigid drift
              'Th_learned': 8,  # Detection threshold
          },
      )
      ```
      
      ### Alternative Sorters
      
      ```python
      # SpykingCircus2 (CPU-based)
      sorting = npa.run_sorting(rec_corrected, sorter='spykingcircus2')
      
      # Mountainsort5 (fast, good for short recordings)
      sorting = npa.run_sorting(rec_corrected, sorter='mountainsort5')
      ```
      
      ### Compare Multiple Sorters
      
      ```python
      # Run multiple sorters
      sortings = {}
      for sorter in ['kilosort4', 'spykingcircus2']:
          sortings[sorter] = npa.run_sorting(rec_corrected, sorter=sorter)
      
      # Compare results
      comparison = npa.compare_sorters(list(sortings.values()))
      agreement_matrix = comparison.get_agreement_matrix()
      ```
      
      ## 5. Postprocessing
      
      ### Create Analyzer
      
      ```python
      # Create sorting analyzer (central object for all postprocessing)
      analyzer = npa.create_analyzer(
          sorting,
          rec_corrected,
          output_folder='analyzer/',
      )
      
      # Compute all standard extensions
      analyzer = npa.postprocess(
          sorting,
          rec_corrected,
          output_folder='analyzer/',
          compute_all=True,  # Waveforms, templates, metrics, etc.
      )
      ```
      
      ### Compute Individual Extensions
      
      ```python
      # Waveforms
      analyzer.compute('waveforms', ms_before=1.0, ms_after=2.0, max_spikes_per_unit=500)
      
      # Templates
      analyzer.compute('templates', operators=['average', 'std'])
      
      # Spike amplitudes
      analyzer.compute('spike_amplitudes')
      
      # Correlograms
      analyzer.compute('correlograms', window_ms=50.0, bin_ms=1.0)
      
      # Unit locations
      analyzer.compute('unit_locations', method='monopolar_triangulation')
      
      # Spike locations
      analyzer.compute('spike_locations', method='center_of_mass')
      ```
      
      ## 6. Quality Metrics
      
      ### Compute All Metrics
      
      ```python
      # Compute comprehensive metrics
      metrics = npa.compute_quality_metrics(
          analyzer,
          metric_names=[
              'snr',
              'isi_violations_ratio',
              'presence_ratio',
              'amplitude_cutoff',
              'firing_rate',
              'amplitude_cv',
              'sliding_rp_violation',
              'd_prime',
              'nearest_neighbor',
          ],
      )
      
      # View metrics
      print(metrics.head())
      ```
      
      ### Key Metrics Explained
      
      | Metric | Good Value | Description |
      |--------|------------|-------------|
      | `snr` | > 5 | Signal-to-noise ratio |
      | `isi_violations_ratio` | < 0.01 | Refractory period violations |
      | `presence_ratio` | > 0.9 | Fraction of recording with spikes |
      | `amplitude_cutoff` | < 0.1 | Estimated missed spikes |
      | `firing_rate` | > 0.1 Hz | Average firing rate |
      
      ## 7. Curation
      
      ### Automated Curation
      
      ```python
      # Allen Institute criteria
      labels = npa.curate(metrics, method='allen')
      
      # IBL criteria
      labels = npa.curate(metrics, method='ibl')
      
      # Custom thresholds
      labels = npa.curate(
          metrics,
          snr_threshold=5,
          isi_violations_threshold=0.01,
          presence_threshold=0.9,
      )
      ```
      
      ### AI-Assisted Curation
      
      ```python
      from anthropic import Anthropic
      
      # Setup API
      client = Anthropic()
      
      # Visual analysis for uncertain units
      uncertain = metrics.query('snr > 3 and snr < 8').index.tolist()
      
      for unit_id in uncertain:
          result = npa.analyze_unit_visually(analyzer, unit_id, api_client=client)
          labels[unit_id] = result['classification']
      ```
      
      ### Interactive Curation Session
      
      ```python
      # Create session
      session = npa.CurationSession.create(analyzer, output_dir='curation/')
      
      # Review units
      while session.current_unit():
          unit = session.current_unit()
          report = npa.generate_unit_report(analyzer, unit.unit_id)
      
          # Your decision
          decision = input(f"Unit {unit.unit_id}: ")
          session.set_decision(unit.unit_id, decision)
          session.next_unit()
      
      # Export
      labels = session.get_final_labels()
      ```
      
      ## 8. Export Results
      
      ### Export to Phy
      
      ```python
      from spikeinterface.exporters import export_to_phy
      
      export_to_phy(
          analyzer,
          output_folder='phy_export/',
          copy_binary=True,
      )
      ```
      
      ### Export to NWB
      
      SpikeInterface has **no** native NWB exporter. Use NeuroConv (`pip install neuroconv`):
      
      ```python
      from neuroconv.tools.spikeinterface import write_recording, write_sorting
      
      write_recording(recording=rec_corrected, nwbfile_path='results.nwb', overwrite=True)
      write_sorting(sorting=sorting, nwbfile_path='results.nwb')
      ```
      
      ### Save Quality Summary
      
      ```python
      # Save metrics CSV
      metrics.to_csv('quality_metrics.csv')
      
      # Save labels
      import json
      with open('curation_labels.json', 'w') as f:
          json.dump(labels, f, indent=2)
      
      # Generate summary report
      npa.plot_quality_metrics(analyzer, metrics, output='quality_summary.png')
      ```
      
      ## Full Pipeline Example
      
      ```python
      from scripts.neuropixels_pipeline import (
          load_recording, preprocess, correct_motion,
          run_spike_sorting, postprocess, curate_units,
      )
      
      # Load (format is auto-detected from the directory contents)
      recording = load_recording('/data/experiment/', stream_id='imec0.ap')
      
      # Preprocess (returns the recording and the detected bad-channel ids)
      rec, bad_channels = preprocess(recording)
      
      # Motion correction (writes motion info under output_folder)
      rec = correct_motion(rec, output_folder='output/')
      
      # Sort
      sorting = run_spike_sorting(rec, output_folder='output/', sorter='kilosort4')
      
      # Postprocess (returns analyzer and the quality-metrics DataFrame)
      analyzer, metrics = postprocess(sorting, rec, output_folder='output/')
      
      # Curate (returns {unit_id: 'good'|'mua'|'noise'})
      labels = curate_units(metrics, method='allen')
      
      # Export good units
      good_units = [uid for uid, label in labels.items() if label == 'good']
      print(f"Good units: {len(good_units)}/{len(labels)}")
      ```
      
      ## Tips for Success
      
      1. **Always visualize drift** before deciding on motion correction
      2. **Save preprocessed data** to avoid recomputing
      3. **Compare multiple sorters** for critical experiments
      4. **Review uncertain units manually** - don't trust automated curation blindly
      5. **Document your parameters** for reproducibility
      6. **Use GPU** for Kilosort4 (10-50x faster than CPU alternatives)
      
  • scripts
    • compute_metrics.py 5.1 KB
      #!/usr/bin/env python
      """
      Compute quality metrics and curate units.
      
      Usage:
          python compute_metrics.py sorting/ preprocessed/ --output metrics/
      """
      
      import argparse
      from pathlib import Path
      import json
      
      import spikeinterface.full as si
      
      
      # Curation criteria presets
      CURATION_CRITERIA = {
          'allen': {
              'snr': 3.0,
              'isi_violations_ratio': 0.1,
              'presence_ratio': 0.9,
              'amplitude_cutoff': 0.1,
          },
          'ibl': {
              'snr': 4.0,
              'isi_violations_ratio': 0.5,
              'presence_ratio': 0.5,
              'amplitude_cutoff': None,
          },
          'strict': {
              'snr': 5.0,
              'isi_violations_ratio': 0.01,
              'presence_ratio': 0.95,
              'amplitude_cutoff': 0.05,
          },
      }
      
      
      def compute_metrics(
          sorting_path: str,
          recording_path: str,
          output_dir: str,
          curation_method: str = 'allen',
          n_jobs: int = -1,
      ):
          """Compute quality metrics and apply curation."""
      
          print(f"Loading sorting from: {sorting_path}")
          sorting = si.load_extractor(Path(sorting_path) / 'sorting')
      
          print(f"Loading recording from: {recording_path}")
          recording = si.load_extractor(Path(recording_path) / 'preprocessed')
      
          print(f"Units: {len(sorting.unit_ids)}")
      
          output_path = Path(output_dir)
          output_path.mkdir(parents=True, exist_ok=True)
      
          # Create analyzer
          print("Creating SortingAnalyzer...")
          analyzer = si.create_sorting_analyzer(
              sorting,
              recording,
              format='binary_folder',
              folder=output_path / 'analyzer',
              sparse=True,
          )
      
          # Compute extensions
          print("Computing waveforms...")
          analyzer.compute('random_spikes', max_spikes_per_unit=500)
          analyzer.compute('waveforms', ms_before=1.0, ms_after=2.0)
          analyzer.compute('templates', operators=['average', 'std'])
      
          print("Computing additional extensions...")
          analyzer.compute('noise_levels')
          analyzer.compute('spike_amplitudes')
          analyzer.compute('correlograms', window_ms=50.0, bin_ms=1.0)
          analyzer.compute('unit_locations', method='monopolar_triangulation')
      
          # Compute quality metrics
          print("Computing quality metrics...")
          metrics = si.compute_quality_metrics(
              analyzer,
              metric_names=[
                  'snr',
                  'isi_violations_ratio',
                  'presence_ratio',
                  'amplitude_cutoff',
                  'firing_rate',
                  'amplitude_cv',
                  'sliding_rp_violation',
              ],
              n_jobs=n_jobs,
          )
      
          # Save metrics
          metrics.to_csv(output_path / 'quality_metrics.csv')
          print(f"Saved metrics to: {output_path / 'quality_metrics.csv'}")
      
          # Apply curation
          criteria = CURATION_CRITERIA.get(curation_method, CURATION_CRITERIA['allen'])
          print(f"\nApplying {curation_method} curation criteria: {criteria}")
      
          labels = {}
          for unit_id in metrics.index:
              row = metrics.loc[unit_id]
      
              # Check each criterion
              is_good = True
      
              if criteria.get('snr') and row.get('snr', 0) < criteria['snr']:
                  is_good = False
      
              if criteria.get('isi_violations_ratio') and row.get('isi_violations_ratio', 1) > criteria['isi_violations_ratio']:
                  is_good = False
      
              if criteria.get('presence_ratio') and row.get('presence_ratio', 0) < criteria['presence_ratio']:
                  is_good = False
      
              if criteria.get('amplitude_cutoff') and row.get('amplitude_cutoff', 1) > criteria['amplitude_cutoff']:
                  is_good = False
      
              # Classify
              if is_good:
                  labels[int(unit_id)] = 'good'
              elif row.get('snr', 0) < 2:
                  labels[int(unit_id)] = 'noise'
              else:
                  labels[int(unit_id)] = 'mua'
      
          # Save labels
          with open(output_path / 'curation_labels.json', 'w') as f:
              json.dump(labels, f, indent=2)
      
          # Summary
          label_counts = {}
          for label in labels.values():
              label_counts[label] = label_counts.get(label, 0) + 1
      
          print(f"\nCuration summary:")
          print(f"  Good: {label_counts.get('good', 0)}")
          print(f"  MUA: {label_counts.get('mua', 0)}")
          print(f"  Noise: {label_counts.get('noise', 0)}")
          print(f"  Total: {len(labels)}")
      
          # Metrics summary
          print(f"\nMetrics summary:")
          for col in ['snr', 'isi_violations_ratio', 'presence_ratio', 'firing_rate']:
              if col in metrics.columns:
                  print(f"  {col}: {metrics[col].median():.4f} (median)")
      
          return analyzer, metrics, labels
      
      
      def main():
          parser = argparse.ArgumentParser(description='Compute quality metrics')
          parser.add_argument('sorting', help='Path to sorting directory')
          parser.add_argument('recording', help='Path to preprocessed recording')
          parser.add_argument('--output', '-o', default='metrics/', help='Output directory')
          parser.add_argument('--curation', '-c', default='allen',
                             choices=['allen', 'ibl', 'strict'])
          parser.add_argument('--n-jobs', type=int, default=-1, help='Number of parallel jobs')
      
          args = parser.parse_args()
      
          compute_metrics(
              args.sorting,
              args.recording,
              args.output,
              curation_method=args.curation,
              n_jobs=args.n_jobs,
          )
      
      
      if __name__ == '__main__':
          main()
      
    • explore_recording.py 5.4 KB
      #!/usr/bin/env python3
      """
      Quick exploration of Neuropixels recording.
      
      Usage:
          python explore_recording.py /path/to/spikeglx/data
      """
      
      import argparse
      import spikeinterface.full as si
      import matplotlib.pyplot as plt
      import numpy as np
      
      
      def explore_recording(data_path: str, stream_id: str = 'imec0.ap'):
          """Explore a Neuropixels recording."""
      
          print(f"Loading: {data_path}")
          recording = si.read_spikeglx(data_path, stream_id=stream_id)
      
          # Basic info
          print("\n" + "="*50)
          print("RECORDING INFO")
          print("="*50)
          print(f"Channels: {recording.get_num_channels()}")
          print(f"Duration: {recording.get_total_duration():.2f} s ({recording.get_total_duration()/60:.2f} min)")
          print(f"Sampling rate: {recording.get_sampling_frequency()} Hz")
          print(f"Total samples: {recording.get_num_samples()}")
      
          # Probe info
          probe = recording.get_probe()
          print(f"\nProbe: {probe.manufacturer} {probe.model_name if hasattr(probe, 'model_name') else ''}")
          print(f"Probe shape: {probe.ndim}D")
      
          # Channel groups
          if recording.get_channel_groups() is not None:
              groups = np.unique(recording.get_channel_groups())
              print(f"Channel groups (shanks): {len(groups)}")
      
          # Check for bad channels
          print("\n" + "="*50)
          print("BAD CHANNEL DETECTION")
          print("="*50)
          bad_ids, labels = si.detect_bad_channels(recording)
          if len(bad_ids) > 0:
              print(f"Bad channels found: {len(bad_ids)}")
              for ch, label in zip(bad_ids, labels):
                  print(f"  Channel {ch}: {label}")
          else:
              print("No bad channels detected")
      
          # Sample traces
          print("\n" + "="*50)
          print("SIGNAL STATISTICS")
          print("="*50)
      
          # Get 1 second of data
          n_samples = int(recording.get_sampling_frequency())
          traces = recording.get_traces(start_frame=0, end_frame=n_samples)
      
          print(f"Sample mean: {np.mean(traces):.2f}")
          print(f"Sample std: {np.std(traces):.2f}")
          print(f"Sample min: {np.min(traces):.2f}")
          print(f"Sample max: {np.max(traces):.2f}")
      
          return recording
      
      
      def plot_probe(recording, output_path=None):
          """Plot probe layout."""
          fig, ax = plt.subplots(figsize=(4, 12))
          si.plot_probe_map(recording, ax=ax, with_channel_ids=False)
          ax.set_title('Probe Layout')
      
          if output_path:
              plt.savefig(output_path, dpi=150, bbox_inches='tight')
              print(f"Saved: {output_path}")
          else:
              plt.show()
      
      
      def plot_traces(recording, duration=1.0, output_path=None):
          """Plot raw traces."""
          n_samples = int(duration * recording.get_sampling_frequency())
          traces = recording.get_traces(start_frame=0, end_frame=n_samples)
      
          fig, ax = plt.subplots(figsize=(12, 8))
      
          # Plot subset of channels
          n_channels = min(20, recording.get_num_channels())
          channel_idx = np.linspace(0, recording.get_num_channels()-1, n_channels, dtype=int)
      
          time = np.arange(n_samples) / recording.get_sampling_frequency()
      
          for i, ch in enumerate(channel_idx):
              offset = i * 200  # Offset for visibility
              ax.plot(time, traces[:, ch] + offset, 'k', linewidth=0.5)
      
          ax.set_xlabel('Time (s)')
          ax.set_ylabel('Channel (offset)')
          ax.set_title(f'Raw Traces ({n_channels} channels)')
      
          if output_path:
              plt.savefig(output_path, dpi=150, bbox_inches='tight')
              print(f"Saved: {output_path}")
          else:
              plt.show()
      
      
      def plot_power_spectrum(recording, output_path=None):
          """Plot power spectrum."""
          from scipy import signal
      
          # Get data from middle channel
          mid_ch = recording.get_num_channels() // 2
          n_samples = min(int(10 * recording.get_sampling_frequency()), recording.get_num_samples())
      
          traces = recording.get_traces(
              start_frame=0,
              end_frame=n_samples,
              channel_ids=[recording.channel_ids[mid_ch]]
          ).flatten()
      
          fs = recording.get_sampling_frequency()
      
          # Compute power spectrum
          freqs, psd = signal.welch(traces, fs, nperseg=4096)
      
          fig, ax = plt.subplots(figsize=(10, 5))
          ax.semilogy(freqs, psd)
          ax.set_xlabel('Frequency (Hz)')
          ax.set_ylabel('Power Spectral Density')
          ax.set_title(f'Power Spectrum (Channel {mid_ch})')
          ax.set_xlim(0, 5000)
          ax.axvline(300, color='r', linestyle='--', alpha=0.5, label='300 Hz')
          ax.axvline(6000, color='r', linestyle='--', alpha=0.5, label='6000 Hz')
          ax.legend()
          ax.grid(True, alpha=0.3)
      
          if output_path:
              plt.savefig(output_path, dpi=150, bbox_inches='tight')
              print(f"Saved: {output_path}")
          else:
              plt.show()
      
      
      if __name__ == '__main__':
          parser = argparse.ArgumentParser(description='Explore Neuropixels recording')
          parser.add_argument('data_path', help='Path to SpikeGLX recording')
          parser.add_argument('--stream', default='imec0.ap', help='Stream ID')
          parser.add_argument('--plot', action='store_true', help='Generate plots')
          parser.add_argument('--output', default=None, help='Output directory for plots')
      
          args = parser.parse_args()
      
          recording = explore_recording(args.data_path, args.stream)
      
          if args.plot:
              import os
              if args.output:
                  os.makedirs(args.output, exist_ok=True)
                  plot_probe(recording, f"{args.output}/probe_map.png")
                  plot_traces(recording, output_path=f"{args.output}/raw_traces.png")
                  plot_power_spectrum(recording, f"{args.output}/power_spectrum.png")
              else:
                  plot_probe(recording)
                  plot_traces(recording)
                  plot_power_spectrum(recording)
      
    • export_to_phy.py 2.4 KB
      #!/usr/bin/env python
      """
      Export sorting results to Phy for manual curation.
      
      Usage:
          python export_to_phy.py metrics/analyzer --output phy_export/
      """
      
      import argparse
      from pathlib import Path
      
      import spikeinterface.full as si
      from spikeinterface.exporters import export_to_phy
      
      
      def export_phy(
          analyzer_path: str,
          output_dir: str,
          copy_binary: bool = True,
          compute_amplitudes: bool = True,
          compute_pc_features: bool = True,
          n_jobs: int = -1,
      ):
          """Export to Phy format."""
      
          print(f"Loading analyzer from: {analyzer_path}")
          analyzer = si.load_sorting_analyzer(analyzer_path)
      
          print(f"Units: {len(analyzer.sorting.unit_ids)}")
      
          output_path = Path(output_dir)
      
          # Compute required extensions if missing
          if compute_amplitudes and analyzer.get_extension('spike_amplitudes') is None:
              print("Computing spike amplitudes...")
              analyzer.compute('spike_amplitudes')
      
          if compute_pc_features and analyzer.get_extension('principal_components') is None:
              print("Computing principal components...")
              analyzer.compute('principal_components', n_components=5, mode='by_channel_local')
      
          print(f"Exporting to Phy: {output_path}")
          export_to_phy(
              analyzer,
              output_folder=output_path,
              copy_binary=copy_binary,
              compute_amplitudes=compute_amplitudes,
              compute_pc_features=compute_pc_features,
              n_jobs=n_jobs,
          )
      
          print("\nExport complete!")
          print(f"To open in Phy, run:")
          print(f"  phy template-gui {output_path / 'params.py'}")
      
      
      def main():
          parser = argparse.ArgumentParser(description='Export to Phy')
          parser.add_argument('analyzer', help='Path to sorting analyzer')
          parser.add_argument('--output', '-o', default='phy_export/', help='Output directory')
          parser.add_argument('--no-binary', action='store_true', help='Skip copying binary file')
          parser.add_argument('--no-amplitudes', action='store_true', help='Skip amplitude computation')
          parser.add_argument('--no-pc', action='store_true', help='Skip PC feature computation')
          parser.add_argument('--n-jobs', type=int, default=-1, help='Number of parallel jobs')
      
          args = parser.parse_args()
      
          export_phy(
              args.analyzer,
              args.output,
              copy_binary=not args.no_binary,
              compute_amplitudes=not args.no_amplitudes,
              compute_pc_features=not args.no_pc,
              n_jobs=args.n_jobs,
          )
      
      
      if __name__ == '__main__':
          main()
      
    • neuropixels_pipeline.py 13 KB
      #!/usr/bin/env python3
      """
      Neuropixels Data Analysis Pipeline (Best Practices Version)
      
      Based on SpikeInterface, Allen Institute, and IBL recommendations.
      
      Usage:
          python neuropixels_pipeline.py /path/to/spikeglx/data /path/to/output
      
      References:
          - https://spikeinterface.readthedocs.io/en/stable/how_to/analyze_neuropixels.html
          - https://github.com/AllenInstitute/ecephys_spike_sorting
      """
      
      import argparse
      from pathlib import Path
      import json
      import spikeinterface.full as si
      import numpy as np
      
      
      def load_recording(data_path: str, stream_id: str = 'imec0.ap') -> si.BaseRecording:
          """Load a SpikeGLX or Open Ephys recording."""
      
          data_path = Path(data_path)
      
          # Auto-detect format
          if any(data_path.rglob('*.ap.bin')) or any(data_path.rglob('*.ap.meta')):
              # SpikeGLX format
              streams, _ = si.get_neo_streams('spikeglx', data_path)
              print(f"Available streams: {streams}")
              recording = si.read_spikeglx(data_path, stream_id=stream_id)
          elif any(data_path.rglob('*.oebin')):
              # Open Ephys format
              recording = si.read_openephys(data_path)
          else:
              raise ValueError(f"Unknown format in {data_path}")
      
          print(f"Loaded recording:")
          print(f"  Channels: {recording.get_num_channels()}")
          print(f"  Duration: {recording.get_total_duration():.2f} s")
          print(f"  Sampling rate: {recording.get_sampling_frequency()} Hz")
      
          return recording
      
      
      def preprocess(
          recording: si.BaseRecording,
          apply_phase_shift: bool = True,
          freq_min: float = 400.,
      ) -> tuple:
          """
          Apply standard Neuropixels preprocessing.
      
          Following SpikeInterface recommendations:
          1. High-pass filter at 400 Hz (not 300)
          2. Detect and remove bad channels
          3. Phase shift (NP 1.0 only)
          4. Common median reference
          """
          print("Preprocessing...")
      
          # Step 1: High-pass filter
          rec = si.highpass_filter(recording, freq_min=freq_min)
          print(f"  Applied high-pass filter at {freq_min} Hz")
      
          # Step 2: Detect bad channels
          bad_channel_ids, channel_labels = si.detect_bad_channels(rec)
          if len(bad_channel_ids) > 0:
              print(f"  Detected {len(bad_channel_ids)} bad channels: {bad_channel_ids}")
              rec = rec.remove_channels(bad_channel_ids)
          else:
              print("  No bad channels detected")
      
          # Step 3: Phase shift (for Neuropixels 1.0)
          if apply_phase_shift:
              rec = si.phase_shift(rec)
              print("  Applied phase shift correction")
      
          # Step 4: Common median reference
          rec = si.common_reference(rec, operator='median', reference='global')
          print("  Applied common median reference")
      
          return rec, bad_channel_ids
      
      
      def check_drift(recording: si.BaseRecording, output_folder: str) -> dict:
          """
          Detect peaks and check for drift before spike sorting.
          """
          print("Checking for drift...")
      
          from spikeinterface.sortingcomponents.peak_detection import detect_peaks
          from spikeinterface.sortingcomponents.peak_localization import localize_peaks
      
          job_kwargs = dict(n_jobs=8, chunk_duration='1s', progress_bar=True)
      
          # Get noise levels
          noise_levels = si.get_noise_levels(recording, return_in_uV=False)
      
          # Detect peaks
          peaks = detect_peaks(
              recording,
              method='locally_exclusive',
              noise_levels=noise_levels,
              detect_threshold=5,
              radius_um=50.,
              **job_kwargs
          )
          print(f"  Detected {len(peaks)} peaks")
      
          # Localize peaks
          peak_locations = localize_peaks(
              recording, peaks,
              method='center_of_mass',
              **job_kwargs
          )
      
          # Save drift plot
          import matplotlib.pyplot as plt
          fig, ax = plt.subplots(figsize=(12, 6))
      
          # Subsample for plotting
          n_plot = min(100000, len(peaks))
          idx = np.random.choice(len(peaks), n_plot, replace=False)
      
          ax.scatter(
              peaks['sample_index'][idx] / recording.get_sampling_frequency(),
              peak_locations['y'][idx],
              s=1, alpha=0.1, c='k'
          )
          ax.set_xlabel('Time (s)')
          ax.set_ylabel('Depth (μm)')
          ax.set_title('Peak Activity (Check for Drift)')
      
          plt.savefig(f'{output_folder}/drift_check.png', dpi=150, bbox_inches='tight')
          plt.close()
          print(f"  Saved drift plot to {output_folder}/drift_check.png")
      
          # Estimate drift magnitude
          y_positions = peak_locations['y']
          drift_estimate = np.percentile(y_positions, 95) - np.percentile(y_positions, 5)
          print(f"  Estimated drift range: {drift_estimate:.1f} μm")
      
          return {
              'peaks': peaks,
              'peak_locations': peak_locations,
              'drift_estimate': drift_estimate
          }
      
      
      def correct_motion(
          recording: si.BaseRecording,
          output_folder: str,
          preset: str = 'nonrigid_fast_and_accurate'
      ) -> si.BaseRecording:
          """Apply motion correction if needed."""
          print(f"Applying motion correction (preset: {preset})...")
      
          rec_corrected = si.correct_motion(
              recording,
              preset=preset,
              folder=f'{output_folder}/motion',
              output_motion_info=True,
              n_jobs=8,
              chunk_duration='1s',
              progress_bar=True
          )
      
          print("  Motion correction complete")
          return rec_corrected
      
      
      def run_spike_sorting(
          recording: si.BaseRecording,
          output_folder: str,
          sorter: str = 'kilosort4'
      ) -> si.BaseSorting:
          """Run spike sorting."""
          print(f"Running spike sorting with {sorter}...")
      
          sorter_folder = f'{output_folder}/sorting_{sorter}'
      
          sorting = si.run_sorter(
              sorter,
              recording,
              folder=sorter_folder,
              verbose=True
          )
      
          print(f"  Found {len(sorting.unit_ids)} units")
          print(f"  Total spikes: {sorting.get_total_num_spikes()}")
      
          return sorting
      
      
      def postprocess(
          sorting: si.BaseSorting,
          recording: si.BaseRecording,
          output_folder: str
      ) -> tuple:
          """Run post-processing and compute quality metrics."""
          print("Post-processing...")
      
          job_kwargs = dict(n_jobs=8, chunk_duration='1s', progress_bar=True)
      
          # Create analyzer
          analyzer = si.create_sorting_analyzer(
              sorting, recording,
              sparse=True,
              format='binary_folder',
              folder=f'{output_folder}/analyzer'
          )
      
          # Compute extensions (order matters)
          print("  Computing waveforms...")
          analyzer.compute('random_spikes', method='uniform', max_spikes_per_unit=500)
          analyzer.compute('waveforms', ms_before=1.5, ms_after=2.0, **job_kwargs)
          analyzer.compute('templates', operators=['average', 'std'])
          analyzer.compute('noise_levels')
      
          print("  Computing spike features...")
          analyzer.compute('spike_amplitudes', **job_kwargs)
          analyzer.compute('correlograms', window_ms=100, bin_ms=1)
          analyzer.compute('unit_locations', method='monopolar_triangulation')
          analyzer.compute('template_similarity')
      
          print("  Computing quality metrics...")
          analyzer.compute('quality_metrics')
      
          qm = analyzer.get_extension('quality_metrics').get_data()
      
          return analyzer, qm
      
      
      def curate_units(qm, method: str = 'allen') -> dict:
          """
          Classify units based on quality metrics.
      
          Methods:
              'allen': Allen Institute defaults (more permissive)
              'ibl': IBL standards
              'strict': Strict single-unit criteria
          """
          print(f"Curating units (method: {method})...")
      
          labels = {}
      
          for unit_id in qm.index:
              row = qm.loc[unit_id]
      
              # Noise detection (universal)
              if row['snr'] < 1.5:
                  labels[unit_id] = 'noise'
                  continue
      
              if method == 'allen':
                  # Allen Institute defaults
                  if (row['presence_ratio'] > 0.9 and
                      row['isi_violations_ratio'] < 0.5 and
                      row['amplitude_cutoff'] < 0.1):
                      labels[unit_id] = 'good'
                  elif row['isi_violations_ratio'] > 0.5:
                      labels[unit_id] = 'mua'
                  else:
                      labels[unit_id] = 'unsorted'
      
              elif method == 'ibl':
                  # IBL standards
                  if (row['presence_ratio'] > 0.9 and
                      row['isi_violations_ratio'] < 0.1 and
                      row['amplitude_cutoff'] < 0.1 and
                      row['firing_rate'] > 0.1):
                      labels[unit_id] = 'good'
                  elif row['isi_violations_ratio'] > 0.1:
                      labels[unit_id] = 'mua'
                  else:
                      labels[unit_id] = 'unsorted'
      
              elif method == 'strict':
                  # Strict single-unit
                  if (row['snr'] > 5 and
                      row['presence_ratio'] > 0.95 and
                      row['isi_violations_ratio'] < 0.01 and
                      row['amplitude_cutoff'] < 0.01):
                      labels[unit_id] = 'good'
                  elif row['isi_violations_ratio'] > 0.05:
                      labels[unit_id] = 'mua'
                  else:
                      labels[unit_id] = 'unsorted'
      
          # Summary
          from collections import Counter
          counts = Counter(labels.values())
          print(f"  Classification: {dict(counts)}")
      
          return labels
      
      
      def export_results(
          analyzer,
          sorting,
          recording,
          labels: dict,
          output_folder: str
      ):
          """Export results to various formats."""
          print("Exporting results...")
      
          # Get good units
          good_ids = [u for u, label in labels.items() if label == 'good']
          _sorting_good = sorting.select_units(good_ids)
      
          # Export to Phy
          phy_folder = f'{output_folder}/phy_export'
          si.export_to_phy(analyzer, phy_folder,
                           compute_pc_features=True,
                           compute_amplitudes=True)
          print(f"  Phy export: {phy_folder}")
      
          # Generate report
          report_folder = f'{output_folder}/report'
          si.export_report(analyzer, report_folder, format='png')
          print(f"  Report: {report_folder}")
      
          # Save quality metrics
          qm = analyzer.get_extension('quality_metrics').get_data()
          qm.to_csv(f'{output_folder}/quality_metrics.csv')
      
          # Save labels
          with open(f'{output_folder}/unit_labels.json', 'w') as f:
              json.dump({str(k): v for k, v in labels.items()}, f, indent=2)
      
          # Save summary
          summary = {
              'total_units': len(sorting.unit_ids),
              'good_units': len(good_ids),
              'total_spikes': int(sorting.get_total_num_spikes()),
              'duration_s': float(recording.get_total_duration()),
              'n_channels': int(recording.get_num_channels()),
          }
          with open(f'{output_folder}/summary.json', 'w') as f:
              json.dump(summary, f, indent=2)
      
          print(f"  Summary: {summary}")
      
      
      def run_pipeline(
          data_path: str,
          output_path: str,
          sorter: str = 'kilosort4',
          stream_name: str = 'imec0.ap',
          apply_motion_correction: bool = True,
          curation_method: str = 'allen'
      ):
          """Run complete Neuropixels analysis pipeline."""
      
          output_path = Path(output_path)
          output_path.mkdir(parents=True, exist_ok=True)
      
          # 1. Load data
          recording = load_recording(data_path, stream_name)
      
          # 2. Preprocess
          rec_preprocessed, bad_channels = preprocess(recording)
      
          # Save preprocessed
          preproc_folder = output_path / 'preprocessed'
          job_kwargs = dict(n_jobs=8, chunk_duration='1s', progress_bar=True)
          rec_preprocessed = rec_preprocessed.save(
              folder=str(preproc_folder),
              format='binary',
              **job_kwargs
          )
      
          # 3. Check drift
          drift_info = check_drift(rec_preprocessed, str(output_path))
      
          # 4. Motion correction (if needed)
          if apply_motion_correction and drift_info['drift_estimate'] > 20:
              print(f"Drift > 20 μm detected, applying motion correction...")
              rec_final = correct_motion(rec_preprocessed, str(output_path))
          else:
              print("Skipping motion correction (low drift)")
              rec_final = rec_preprocessed
      
          # 5. Spike sorting
          sorting = run_spike_sorting(rec_final, str(output_path), sorter)
      
          # 6. Post-processing
          analyzer, qm = postprocess(sorting, rec_final, str(output_path))
      
          # 7. Curation
          labels = curate_units(qm, method=curation_method)
      
          # 8. Export
          export_results(analyzer, sorting, rec_final, labels, str(output_path))
      
          print("\n" + "="*50)
          print("Pipeline complete!")
          print(f"Output directory: {output_path}")
          print("="*50)
      
          return analyzer, sorting, qm, labels
      
      
      if __name__ == '__main__':
          parser = argparse.ArgumentParser(
              description='Neuropixels analysis pipeline (best practices)'
          )
          parser.add_argument('data_path', help='Path to SpikeGLX/OpenEphys recording')
          parser.add_argument('output_path', help='Output directory')
          parser.add_argument('--sorter', default='kilosort4',
                              choices=['kilosort4', 'kilosort3', 'spykingcircus2', 'mountainsort5'],
                              help='Spike sorter to use')
          parser.add_argument('--stream', default='imec0.ap', help='Stream name')
          parser.add_argument('--no-motion-correction', action='store_true',
                              help='Skip motion correction')
          parser.add_argument('--curation', default='allen',
                              choices=['allen', 'ibl', 'strict'],
                              help='Curation method')
      
          args = parser.parse_args()
      
          run_pipeline(
              args.data_path,
              args.output_path,
              sorter=args.sorter,
              stream_name=args.stream,
              apply_motion_correction=not args.no_motion_correction,
              curation_method=args.curation
          )
      
    • preprocess_recording.py 4.1 KB
      #!/usr/bin/env python
      """
      Preprocess Neuropixels recording.
      
      Usage:
          python preprocess_recording.py /path/to/data --output preprocessed/ --format spikeglx
      """
      
      import argparse
      from pathlib import Path
      
      import spikeinterface.full as si
      
      
      def preprocess_recording(
          input_path: str,
          output_dir: str,
          format: str = 'auto',
          stream_id: str = None,
          freq_min: float = 300,
          freq_max: float = 6000,
          phase_shift: bool = True,
          common_ref: bool = True,
          detect_bad: bool = True,
          n_jobs: int = -1,
      ):
          """Preprocess a Neuropixels recording."""
      
          print(f"Loading recording from: {input_path}")
      
          # Load recording
          if format == 'spikeglx' or (format == 'auto' and 'imec' in str(input_path).lower()):
              recording = si.read_spikeglx(input_path, stream_id=stream_id or 'imec0.ap')
          elif format == 'openephys':
              recording = si.read_openephys(input_path)
          elif format == 'nwb':
              recording = si.read_nwb(input_path)
          else:
              # Try auto-detection
              try:
                  recording = si.read_spikeglx(input_path, stream_id=stream_id or 'imec0.ap')
              except Exception:
                  recording = si.load_extractor(input_path)
      
          print(f"Recording: {recording.get_num_channels()} channels, {recording.get_total_duration():.1f}s")
      
          # Preprocessing chain
          rec = recording
      
          # Bandpass filter
          print(f"Applying bandpass filter ({freq_min}-{freq_max} Hz)...")
          rec = si.bandpass_filter(rec, freq_min=freq_min, freq_max=freq_max)
      
          # Phase shift correction (for Neuropixels ADC)
          if phase_shift:
              print("Applying phase shift correction...")
              rec = si.phase_shift(rec)
      
          # Bad channel detection
          if detect_bad:
              print("Detecting bad channels...")
              bad_channel_ids, bad_labels = si.detect_bad_channels(rec)
              if len(bad_channel_ids) > 0:
                  print(f"  Removing {len(bad_channel_ids)} bad channels: {bad_channel_ids[:10]}...")
                  rec = rec.remove_channels(bad_channel_ids)
      
          # Common median reference
          if common_ref:
              print("Applying common median reference...")
              rec = si.common_reference(rec, operator='median', reference='global')
      
          # Save preprocessed
          output_path = Path(output_dir)
          output_path.mkdir(parents=True, exist_ok=True)
      
          print(f"Saving preprocessed recording to: {output_path}")
          rec.save(folder=output_path / 'preprocessed', n_jobs=n_jobs)
      
          # Save probe info
          probe = rec.get_probe()
          if probe is not None:
              from probeinterface import write_probeinterface
              write_probeinterface(output_path / 'probe.json', probe)
      
          print("Done!")
          print(f"  Output channels: {rec.get_num_channels()}")
          print(f"  Output duration: {rec.get_total_duration():.1f}s")
      
          return rec
      
      
      def main():
          parser = argparse.ArgumentParser(description='Preprocess Neuropixels recording')
          parser.add_argument('input', help='Path to input recording')
          parser.add_argument('--output', '-o', default='preprocessed/', help='Output directory')
          parser.add_argument('--format', '-f', default='auto', choices=['auto', 'spikeglx', 'openephys', 'nwb'])
          parser.add_argument('--stream-id', default=None, help='Stream ID for multi-probe recordings')
          parser.add_argument('--freq-min', type=float, default=300, help='Highpass cutoff (Hz)')
          parser.add_argument('--freq-max', type=float, default=6000, help='Lowpass cutoff (Hz)')
          parser.add_argument('--no-phase-shift', action='store_true', help='Skip phase shift correction')
          parser.add_argument('--no-cmr', action='store_true', help='Skip common median reference')
          parser.add_argument('--no-bad-channel', action='store_true', help='Skip bad channel detection')
          parser.add_argument('--n-jobs', type=int, default=-1, help='Number of parallel jobs')
      
          args = parser.parse_args()
      
          preprocess_recording(
              args.input,
              args.output,
              format=args.format,
              stream_id=args.stream_id,
              freq_min=args.freq_min,
              freq_max=args.freq_max,
              phase_shift=not args.no_phase_shift,
              common_ref=not args.no_cmr,
              detect_bad=not args.no_bad_channel,
              n_jobs=args.n_jobs,
          )
      
      
      if __name__ == '__main__':
          main()
      
    • run_sorting.py 2.5 KB
      #!/usr/bin/env python
      """
      Run spike sorting on preprocessed recording.
      
      Usage:
          python run_sorting.py preprocessed/ --sorter kilosort4 --output sorting/
      """
      
      import argparse
      from pathlib import Path
      
      import spikeinterface.full as si
      
      
      # Default parameters for each sorter
      SORTER_DEFAULTS = {
          'kilosort4': {
              'batch_size': 30000,
              'nblocks': 1,
              'Th_learned': 8,
              'Th_universal': 9,
          },
          'kilosort3': {
              'do_CAR': False,  # Already done in preprocessing
          },
          'spykingcircus2': {
              'apply_preprocessing': False,
          },
          'mountainsort5': {
              'filter': False,
              'whiten': False,
          },
      }
      
      
      def run_sorting(
          input_path: str,
          output_dir: str,
          sorter: str = 'kilosort4',
          sorter_params: dict = None,
          n_jobs: int = -1,
      ):
          """Run spike sorting."""
      
          print(f"Loading preprocessed recording from: {input_path}")
          recording = si.load_extractor(Path(input_path) / 'preprocessed')
      
          print(f"Recording: {recording.get_num_channels()} channels, {recording.get_total_duration():.1f}s")
      
          # Get sorter parameters
          params = SORTER_DEFAULTS.get(sorter, {}).copy()
          if sorter_params:
              params.update(sorter_params)
      
          print(f"Running {sorter} with params: {params}")
      
          output_path = Path(output_dir)
      
          # Run sorter (note: parameter is 'folder' not 'output_folder' in newer SpikeInterface)
          sorting = si.run_sorter(
              sorter,
              recording,
              folder=output_path / f'{sorter}_output',
              verbose=True,
              **params,
          )
      
          print(f"\nSorting complete!")
          print(f"  Units found: {len(sorting.unit_ids)}")
          print(f"  Total spikes: {sum(len(sorting.get_unit_spike_train(uid)) for uid in sorting.unit_ids)}")
      
          # Save sorting
          sorting.save(folder=output_path / 'sorting')
          print(f"  Saved to: {output_path / 'sorting'}")
      
          return sorting
      
      
      def main():
          parser = argparse.ArgumentParser(description='Run spike sorting')
          parser.add_argument('input', help='Path to preprocessed recording')
          parser.add_argument('--output', '-o', default='sorting/', help='Output directory')
          parser.add_argument('--sorter', '-s', default='kilosort4',
                             choices=['kilosort4', 'kilosort3', 'spykingcircus2', 'mountainsort5'])
          parser.add_argument('--n-jobs', type=int, default=-1, help='Number of parallel jobs')
      
          args = parser.parse_args()
      
          run_sorting(
              args.input,
              args.output,
              sorter=args.sorter,
              n_jobs=args.n_jobs,
          )
      
      
      if __name__ == '__main__':
          main()
      
  • SKILL.md 15.8 KB
    ---
    name: alterlab-neuropixels
    description: Analyze Neuropixels 1.0/2.0 extracellular electrophysiology with SpikeInterface — load SpikeGLX/Open Ephys recordings, preprocess and motion-correct, run Kilosort4 spike sorting, compute quality metrics, apply Allen/IBL curation, and do AI-assisted visual inspection. Use when working with neural recordings, spike sorting, or extracellular electrophysiology, or when the user mentions Neuropixels, SpikeGLX, Open Ephys, Kilosort, quality metrics, or unit curation. Part of the AlterLab Academic Skills suite.
    license: MIT
    allowed-tools: Read Write Edit Bash(python:*) Bash(uv:*)
    compatibility: "Self-contained — runs under `uv run python` with the skill's Python package installed; no API key or account required. Written for SpikeInterface 0.104/0.105 (current 0.105.0, released 2026-09-21), which requires Python >= 3.10. Kilosort4 needs a CUDA GPU; the internal CPU sorters do not."
    metadata:
        skill-author: AlterLab
        version: "1.1.0"
        last_updated: "2026-09-23"
    ---
    
    # Neuropixels Data Analysis
    
    ## Overview
    
    Comprehensive toolkit for analyzing Neuropixels high-density neural recordings using current best practices from SpikeInterface, Allen Institute, and International Brain Laboratory (IBL). Supports the full workflow from raw data to publication-ready curated units.
    
    ## When to Use This Skill
    
    This skill should be used when:
    - Working with Neuropixels recordings (.ap.bin, .lf.bin, .meta files)
    - Loading data from SpikeGLX, Open Ephys, or NWB formats
    - Preprocessing neural recordings (filtering, CAR, bad channel detection)
    - Detecting and correcting motion/drift in recordings
    - Running spike sorting (Kilosort4, SpykingCircus2, Mountainsort5)
    - Computing quality metrics (SNR, ISI violations, presence ratio)
    - Curating units using Allen/IBL criteria
    - Creating visualizations of neural data
    - Exporting results to Phy or NWB
    
    ### Does NOT Trigger
    
    | Scenario | Use Instead |
    |----------|-------------|
    | Single-cell or bulk **transcriptomics** rather than electrophysiology | `alterlab-scanpy` / `alterlab-rnaseq-quant` |
    | Flow/mass **cytometry** FCS files | `alterlab-flowio` |
    | Generic time-series statistics or ML on already-extracted spike features | `alterlab-statistical-analysis` / `alterlab-scikit-learn` |
    | Plotting an arbitrary figure from data you already have | `alterlab-matplotlib` / `alterlab-scientific-viz` |
    | Network/graph analysis of a connectivity matrix | `alterlab-networkx` |
    
    ## Supported Hardware & Formats
    
    | Probe | Electrodes | Channels | Notes |
    |-------|-----------|----------|-------|
    | Neuropixels 1.0 | 960 | 384 | Requires phase_shift correction |
    | Neuropixels 2.0 (single) | 1280 | 384 | Denser geometry |
    | Neuropixels 2.0 (4-shank) | 5120 | 384 | Multi-region recording |
    
    | Format | Extension | Reader |
    |--------|-----------|--------|
    | SpikeGLX | `.ap.bin`, `.lf.bin`, `.meta` | `si.read_spikeglx()` |
    | Open Ephys | `.continuous`, `.oebin` | `si.read_openephys()` |
    | NWB | `.nwb` | `si.read_nwb()` |
    
    ## Quick Start
    
    ### Basic Import and Setup
    
    ```python
    import spikeinterface.full as si
    
    # Bundled helper functions live in scripts/neuropixels_pipeline.py
    from scripts.neuropixels_pipeline import (
        load_recording, preprocess, check_drift, correct_motion,
        run_spike_sorting, postprocess, curate_units, export_results, run_pipeline,
    )
    
    # Configure parallel processing
    job_kwargs = dict(n_jobs=-1, chunk_duration='1s', progress_bar=True)
    ```
    
    ### Loading Data
    
    ```python
    # SpikeGLX (most common)
    recording = si.read_spikeglx('/path/to/data', stream_id='imec0.ap')
    
    # Open Ephys (common for many labs)
    recording = si.read_openephys('/path/to/Record_Node_101/')
    
    # Check available streams
    streams, ids = si.get_neo_streams('spikeglx', '/path/to/data')
    print(streams)  # ['imec0.ap', 'imec0.lf', 'nidq']
    
    # For testing with subset of data
    recording = recording.frame_slice(0, int(60 * recording.get_sampling_frequency()))
    ```
    
    ### Complete Pipeline (One Command)
    
    ```python
    # Run full analysis pipeline (writes all outputs under output_path/)
    from scripts.neuropixels_pipeline import run_pipeline
    
    run_pipeline(
        data_path='/path/to/data',
        output_path='output/',
        sorter='kilosort4',
        stream_name='imec0.ap',
        apply_motion_correction=True,
        curation_method='allen',
    )
    
    # Results are written to disk:
    #   output/sorting_output/    spike sorter output
    #   output/analyzer/          SortingAnalyzer (waveforms, metrics)
    #   output/quality_metrics.csv
    #   output/curation_labels.json
    ```
    
    Or run it from the command line:
    
    ```bash
    python scripts/neuropixels_pipeline.py /path/to/data output/ --sorter kilosort4 --curation allen
    ```
    
    ## Standard Analysis Workflow
    
    ### 1. Preprocessing
    
    ```python
    # Recommended preprocessing chain
    rec = si.highpass_filter(recording, freq_min=400)
    rec = si.phase_shift(rec)  # Required for Neuropixels 1.0
    bad_ids, _ = si.detect_bad_channels(rec)
    rec = rec.remove_channels(bad_ids)
    rec = si.common_reference(rec, operator='median')
    
    # Or use the bundled wrapper (returns the preprocessed recording + bad channel ids)
    from scripts.neuropixels_pipeline import preprocess
    rec, bad_channels = preprocess(recording)
    ```
    
    ### 2. Check and Correct Drift
    
    ```python
    from scripts.neuropixels_pipeline import check_drift, correct_motion
    
    # Check for drift (always do this!) — detects/localizes peaks and saves
    # a drift plot to <output_folder>/drift_check.png, returns a dict with
    # 'drift_estimate' (μm range).
    drift_info = check_drift(rec, output_folder='output/')
    
    # Apply correction if needed
    if drift_info['drift_estimate'] > 20:  # microns
        rec = correct_motion(rec, output_folder='output/', preset='nonrigid_fast_and_accurate')
    ```
    
    ### 3. Spike Sorting
    
    ```python
    # Kilosort4 (recommended, requires GPU)
    sorting = si.run_sorter('kilosort4', rec, folder='ks4_output')
    
    # CPU alternatives
    sorting = si.run_sorter('tridesclous2', rec, folder='tdc2_output')
    sorting = si.run_sorter('spykingcircus2', rec, folder='sc2_output')
    sorting = si.run_sorter('mountainsort5', rec, folder='ms5_output')
    
    # Check available sorters
    print(si.installed_sorters())
    ```
    
    ### 4. Postprocessing
    
    ```python
    # Create analyzer and compute all extensions
    analyzer = si.create_sorting_analyzer(sorting, rec, sparse=True)
    
    analyzer.compute('random_spikes', max_spikes_per_unit=500)
    analyzer.compute('waveforms', ms_before=1.0, ms_after=2.0)
    analyzer.compute('templates', operators=['average', 'std'])
    analyzer.compute('spike_amplitudes')
    analyzer.compute('correlograms', window_ms=50.0, bin_ms=1.0)
    analyzer.compute('unit_locations', method='monopolar_triangulation')
    analyzer.compute('quality_metrics')
    
    metrics = analyzer.get_extension('quality_metrics').get_data()
    ```
    
    ### 5. Curation
    
    ```python
    # Allen Institute criteria (conservative)
    good_units = metrics.query("""
        presence_ratio > 0.9 and
        isi_violations_ratio < 0.5 and
        amplitude_cutoff < 0.1
    """).index.tolist()
    
    # Or use automated curation (returns {unit_id: 'good'|'mua'|'noise'})
    from scripts.neuropixels_pipeline import curate_units
    labels = curate_units(metrics, method='allen')  # 'allen', 'ibl', 'strict'
    ```
    
    ### 6. AI-Assisted Curation (For Uncertain Units)
    
    When using this skill with Claude Code, Claude can directly analyze waveform plots and provide expert curation decisions. The recommended workflow is to render per-unit summary plots with SpikeInterface and let Claude inspect them:
    
    ```python
    import spikeinterface.widgets as sw
    import matplotlib.pyplot as plt
    
    # Find borderline units worth a visual look
    uncertain = metrics.query('snr > 3 and snr < 8').index.tolist()
    
    # Render a summary figure per uncertain unit (waveform + correlogram + amplitudes)
    for unit_id in uncertain:
        sw.plot_unit_summary(analyzer, unit_id=unit_id)
        plt.savefig(f'ai_curation/unit_{unit_id}_summary.png', dpi=150, bbox_inches='tight')
        plt.close()
    ```
    
    **Claude Code Integration**: When running within Claude Code, ask Claude to examine the saved waveform/correlogram plots directly - no API setup required.
    
    ### 7. Generate Analysis Report
    
    ```python
    # The bundled run_pipeline writes a machine-readable summary.json
    # (sampling rate, duration, channel count, unit counts) into output_path/.
    import json
    with open('output/summary.json') as f:
        summary = json.load(f)
    print(summary)
    
    # For a browsable HTML report of waveforms/metrics, use SpikeInterface's exporter:
    si.export_report(analyzer, output_folder='output/report/')
    # Open output/report/index.html for figures and the per-unit table
    ```
    
    ### 8. Export Results
    
    ```python
    # Export to Phy for manual review
    si.export_to_phy(analyzer, output_folder='phy_export/',
                     compute_pc_features=True, compute_amplitudes=True)
    
    # Export to NWB (via NeuroConv — SpikeInterface has no native NWB exporter)
    # pip install neuroconv
    from neuroconv.tools.spikeinterface import write_sorting, write_recording
    write_recording(recording=rec, nwbfile_path='output.nwb', overwrite=True)
    write_sorting(sorting=sorting, nwbfile_path='output.nwb')
    
    # Save quality metrics
    metrics.to_csv('quality_metrics.csv')
    ```
    
    ## Common Pitfalls and Best Practices
    
    1. **Always check drift** before spike sorting - drift > 10μm significantly impacts quality
    2. **Use phase_shift** for Neuropixels 1.0 probes (not needed for 2.0)
    3. **Save preprocessed data** to avoid recomputing - use `rec.save(folder='preprocessed/')`
    4. **Use GPU** for Kilosort4 - it's 10-50x faster than CPU alternatives
    5. **Review uncertain units manually** - automated curation is a starting point
    6. **Combine metrics with AI** - use metrics for clear cases, AI for borderline units
    7. **Document your thresholds** - different analyses may need different criteria
    8. **Export to Phy** for critical experiments - human oversight is valuable
    
    ## Key Parameters to Adjust
    
    ### Preprocessing
    - `freq_min`: Highpass cutoff (300-400 Hz typical)
    - `detect_threshold`: Bad channel detection sensitivity
    
    ### Motion Correction
    - `preset`: 'kilosort_like' (fast) or 'nonrigid_accurate' (better for severe drift)
    
    ### Spike Sorting (Kilosort4)
    - `batch_size`: Samples per batch (30000 default)
    - `nblocks`: Number of drift blocks (increase for long recordings)
    - `Th_learned`: Detection threshold (lower = more spikes)
    
    ### Quality Metrics
    - `snr_threshold`: Signal-to-noise cutoff (3-5 typical)
    - `isi_violations_ratio`: Refractory violations (0.01-0.5)
    - `presence_ratio`: Recording coverage (0.5-0.95)
    
    ## Bundled Resources
    
    ### scripts/preprocess_recording.py
    Automated preprocessing script:
    ```bash
    python scripts/preprocess_recording.py /path/to/data --output preprocessed/
    ```
    
    ### scripts/run_sorting.py
    Run spike sorting:
    ```bash
    python scripts/run_sorting.py preprocessed/ --sorter kilosort4 --output sorting/
    ```
    
    ### scripts/compute_metrics.py
    Compute quality metrics and apply curation:
    ```bash
    python scripts/compute_metrics.py sorting/ preprocessed/ --output metrics/ --curation allen
    ```
    
    ### scripts/export_to_phy.py
    Export to Phy for manual curation:
    ```bash
    python scripts/export_to_phy.py metrics/analyzer --output phy_export/
    ```
    
    ### assets/analysis_template.py
    Complete analysis template. Copy and customize:
    ```bash
    cp assets/analysis_template.py my_analysis.py
    # Edit parameters and run
    python my_analysis.py
    ```
    
    ### references/standard_workflow.md
    Detailed step-by-step workflow with explanations for each stage.
    
    ### references/api_reference.md
    Quick function reference organized by module.
    
    ### references/plotting_guide.md
    Comprehensive visualization guide for publication-quality figures.
    
    ## Detailed Reference Guides
    
    | Topic | Reference |
    |-------|-----------|
    | Full workflow | [references/standard_workflow.md](references/standard_workflow.md) |
    | API reference | [references/api_reference.md](references/api_reference.md) |
    | Plotting guide | [references/plotting_guide.md](references/plotting_guide.md) |
    | Preprocessing | [references/PREPROCESSING.md](references/PREPROCESSING.md) |
    | Spike sorting | [references/SPIKE_SORTING.md](references/SPIKE_SORTING.md) |
    | Motion correction | [references/MOTION_CORRECTION.md](references/MOTION_CORRECTION.md) |
    | Quality metrics | [references/QUALITY_METRICS.md](references/QUALITY_METRICS.md) |
    | Automated curation | [references/AUTOMATED_CURATION.md](references/AUTOMATED_CURATION.md) |
    | AI-assisted curation | [references/AI_CURATION.md](references/AI_CURATION.md) |
    | Waveform analysis | [references/ANALYSIS.md](references/ANALYSIS.md) |
    
    ## Installation
    
    ```bash
    # Core packages
    uv pip install "spikeinterface[full]" probeinterface neo
    
    # External sorters (separate packages, run via si.run_sorter)
    uv pip install kilosort            # Kilosort4 (CUDA GPU required)
    uv pip install mountainsort5       # Mountainsort5 (CPU)
    
    # Internal sorters ship INSIDE spikeinterface — install the extra, not a
    # standalone package. There is no `spykingcircus` package for SpykingCircus2.
    uv pip install "spikeinterface[spykingcircus2]"
    uv pip install "spikeinterface[tridesclous2]"
    
    # Our toolkit ships as local scripts (scripts/) — no pip install needed;
    # run them directly or import from scripts.neuropixels_pipeline
    
    # Optional: AI curation
    uv pip install anthropic
    
    # Optional: IBL tools
    uv pip install ibl-neuropixel ibllib
    ```
    
    ### SpikeInterface 0.105 — breaking changes worth knowing
    
    0.105.0 (2026-09-21) renamed a lot of surface area. Objects saved by older versions
    still load, but scripts written against 0.104 may not run:
    
    - **`peak_sign` is gone from the analysis API.** The main channel and peak sign are
      now fixed when the `SortingAnalyzer` is created and stored as the `main_channel_id`
      sorting property, so `compute_quality_metrics`, `compute_template_metrics`,
      `compute_spike_amplitudes`, `compute_spike_locations` and `compute_unit_locations`
      no longer accept it. `compute_snrs(..., peak_sign=, peak_mode=)` became
      `compute_snrs(..., method=)`.
    - `get_template_extremum_channel()` -> `SortingAnalyzer.get_main_channels()` (old
      names remain as deprecated shims).
    - `estimate_sparsity()` now defaults to `peak_sign="both"` instead of `"neg"` — a
      silent change in results, not an error, so re-check sparsity-dependent output.
    - Probe handling: `set_probe()`/`set_probegroup()` act **in place** and return `None`;
      use `select_channels_with_probe()` to get a new recording. `reset_probe()` ->
      `remove_probe()`.
    - `ChunkRecordingExecutor` -> `TimeSeriesChunkExecutor`;
      `BaseExtractor.load_from_folder()` removed in favour of `si.load()`.
    - New in 0.105: `dartsort` as an external sorter, `SortingAnalyzer` `lazy` and
      `read_only` modes, `detect_and_remove_artifacts` preprocessing, and
      `read_kilosort4_motion()` to build a `Motion` object from a Kilosort4 output folder.
    
    Pin the SpikeInterface version in any analysis you intend to reproduce, and record it
    in the methods section — metric definitions have changed across minor releases.
    
    ## Project Structure
    
    ```
    project/
    ├── raw_data/
    │   └── recording_g0/
    │       └── recording_g0_imec0/
    │           ├── recording_g0_t0.imec0.ap.bin
    │           └── recording_g0_t0.imec0.ap.meta
    ├── preprocessed/           # Saved preprocessed recording
    ├── motion/                 # Motion estimation results
    ├── sorting_output/         # Spike sorter output
    ├── analyzer/               # SortingAnalyzer (waveforms, metrics)
    ├── phy_export/             # For manual curation
    ├── ai_curation/            # AI analysis reports
    └── results/
        ├── quality_metrics.csv
        ├── curation_labels.json
        └── output.nwb
    ```
    
    ## Additional Resources
    
    - **SpikeInterface Docs**: https://spikeinterface.readthedocs.io/
    - **Neuropixels Tutorial**: https://spikeinterface.readthedocs.io/en/stable/how_to/analyze_neuropixels.html
    - **Kilosort4 GitHub**: https://github.com/MouseLand/Kilosort
    - **IBL Neuropixel Tools**: https://github.com/int-brain-lab/ibl-neuropixel
    - **Allen Institute ecephys**: https://github.com/AllenInstitute/ecephys_spike_sorting
    - **Bombcell (Automated QC)**: https://github.com/Julie-Fabre/bombcell
    - **SpikeAgent (AI Curation)**: https://github.com/SpikeAgent/SpikeAgent
    
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related