Skip to content

escpod demux

Barcode demultiplexing for Oxford Nanopore sequencing data, in the default escpod build — including CNN/TCN adapter detection (--method cnn, what the published escapepod-models barcodes were trained against) and CTC-CRF basecalling. Reads are classified from the raw signal and either split into per-barcode POD5 files or recorded in the .p5s sidecar so nothing needs splitting at all.

GPU acceleration and model training are opt-in builds — see GPU acceleration below. In a GPU-capable build the device is used automatically where it wins; --device overrides that.

Running escpod demux with no subcommand streams the whole pipeline — adapter detection, classification, and output — in one pass over the input:

# Fetch a model bundle once (networked node; compute nodes can't reach GitHub)
escpod demux models fetch crf_nbc16_rna004
escpod demux models fetch --all      # every bundle in the manifest

# Demux into per-barcode POD5 files
escpod demux reads.pod5 --model ~/.cache/escapepod/demux_models/barcode_crf_nbc16_rna004@v0.3.1 -d out/

# Or demux into the sidecar only — no split files, no CSV
escpod demux reads.pod5 --model <bundle> --annotate

Key options (see escpod demux --help for the full set):

Option Description
--model [NAME=]<PATH> Model: DTW-SVM / GBM JSON, or a CTC-CRF bundle directory. Repeatable — see Several barcode axes
-d, --output-dir <DIR> Write per-barcode POD5 files (optional with --annotate)
--annotate Record assignments in each input's .p5s sidecar
--classifications <FILE> Also write a read_id,barcode,confidence[,crf_*],adapter_end CSV
--prefix <STR> Split-file prefix (default: barcode)
--method <cnn\|llr> Adapter detector (CRF bundles pin their own)
--device <auto\|cpu\|gpu> Where GPU-capable stages run (default auto) — see GPU acceleration
--boundary-margin <N>, --clamp-max-shift <N> Overrule a CRF bundle's window rules — see Window rules before using either
--info Describe the model and exit

Sidecar output

--annotate composes with the other outputs: alone it is sidecar-only demux (the POD5 is never duplicated), with -d it writes split files and the sidecar, and --classifications can keep the CSV too. Materialize subsets later, on demand:

escpod demux split reads.pod5 --sidecar -d out/                    # all barcodes
escpod filter reads.pod5 --annotation barcode=nbc05 -o nbc05.pod5  # one group

Pointed at a directory, --annotate writes one collection sidecar beside it rather than one per file — a fifty-file run leaves pod5.p5s and nothing else, and every command above still resolves a read's barcode through the POD5 it names:

escpod demux run1/pod5/ --model <bundle> --annotate   # writes run1/pod5.p5s
python -c "import pyarrow.ipc as i; print(i.open_file('run1/pod5.p5s').read_all())"

Several barcode axes in one pass

A dual-indexed library carries more than one barcode — say a 3′ adapter index and a 5′ one. --model is repeatable, so both are called in a single pass over the POD5 instead of decompressing every read twice:

escpod demux reads.pod5 \
    --model ldx=<ldx bundle> \
    --model fdx=<fdx bundle> \
    --annotate

With more than one model each is given as NAME=PATH. NAME is that axis's sidecar annotation column, and it is yours, not the bundle's — it names the axis in your experimental design, and the same model can serve different axes in different runs. Those column names are exactly what annotate --design keys a multi-column samplesheet on:

cat samplesheet.csv
# ldx,fdx,library,condition
# ldx01,fdx01,A,plain_adapter
# ldx04,fdx02,B,plain_adapter
escpod annotate --design samplesheet.csv reads.pod5
escpod view reads.pod5 --include read_id,ldx,fdx,library,condition

What is shared is the expensive prefix — one POD5 sweep, one signal decode, one adapter detection — and not the encoders, which are per-model and are most of the cost. Measured on an A30 at 40 k reads, a fused ldx+fdx run takes 14.6 s against 16.8 s for the two runs separately (1.15×); the win should be larger on a cold network filesystem, where reading the POD5 is a bigger share. Both axes' calls are identical to what the separate runs produce.

Four rules, each an error rather than a surprise:

  • Several models need --annotate / --classifications, not -d. Writing one POD5 per barcode is single-axis. Split afterwards on whichever axis you want: escpod demux split reads.pod5 --sidecar --annotation ldx.
  • Only CTC-CRF bundles combine. The fingerprint heads (DTW-SVM, GBM) share one compiled-in segmentation geometry, so a second one would silently be fingerprinted with the first one's parameters.
  • Every axis that needs a boundary detector must pin the same one. Detection runs once and its answer goes to every axis.
  • Names must be unique, and cannot be read_id, batch_idx, row_idx or unclassified.

A single-model run is unchanged: the sidecar column is still barcode and the classifications CSV is still read_id,barcode,confidence (plus a trailing adapter_end whenever a boundary detector ran). Only a multi-axis run prefixes its columns (ldx, ldx_confidence, …).

Window rules: which reads a CRF head decodes

A CTC-CRF head decodes a fixed window of signal.chunk samples measured back from the boundary detector's adapter_end — or from the read's last sample, for a read-end bundle such as a 5′ index, which has no detector and only ever refuses a read shorter than the window. Two rules in the bundle decide whether a read has such a window at all, and both can be overruled per run:

Rule Bundle key Flag Refuses
Boundary margin boundary.margin (unset → 200) --boundary-margin N adapter_end in [chunk, chunk + margin)
Window clamp boundary.clamp_max_shift (unset → 0, off) --clamp-max-shift N adapter_end < chunk, unless chunk - adapter_end ≤ N, in which case the read decodes from [0, chunk] instead

A refused read routes to unclassified with confidence 0, undecoded. On the two RNA004 tRNA flowcells this was measured on, about 15% of reads have an adapter_end below 3200 (the ldx32 window plus its margin), so the rules are worth knowing about — but relaxing them is not free. Measured on barcode_crf_ldx32_rna004@v0.2.1 over a co-barcoded run, scoring each 3′ call against the independent 5′ index on the same molecule (#323):

adapter_end share of run rule that reaches it design-consistent calls calls to codes absent from the pool
≥ 3200 85.1% none 85% 5%
3000–3199 1.7% --boundary-margin 0 55% 27%
2700–2999 2.0% --clamp-max-shift 300 28% 45%
< 2700 8.5% a larger clamp chance 56–67%

A window that holds no barcode still decodes to a perfect reference — whichever one the degenerate signal happens to sit nearest — so a recovered band reporting "median edit distance 0" is not evidence that it is callable. Check recovered calls against something the decode cannot influence (a second index, an adapter the basecaller reads, a pool of known composition), and if a band is worth keeping, keep it under --min-crf-margin: the margin band above is 84% consistent under the ldx32 bundle's declared gate of 2.0, at 56% yield. Leave the clamp at 0 unless a measurement of that kind says otherwise, and prefer declaring measured values in the bundle — escpod demux --model <bundle> --info shows what it declares — over passing the flags on every run.

Two things make the rules observable without a second detect pass:

  • The summary says why. Under the barcode table, unclassified, by reason: counts each window rule, the detector's adapter_end = 0 sentinel, reads shorter than the window, encoder errors, decodes that matched no reference, and the gates — per axis in a multi-axis run.
  • The classifications CSV carries adapter_end as its last column whenever a boundary detector ran, so calls can be split by band against any independent label.

In a multi-axis run both flags apply to every head that has a boundary detector; a read-end head ignores them and is named as doing so. With only read-end models they are refused.

The stepwise subcommands below remain available for running stages individually or inspecting intermediates.

Comparison with WarpDemuX

Escapepod demux is a pure Rust reimplementation of the signal-level barcode demultiplexing algorithms from WarpDemuX and ADAPTed. The key differences are:

Feature Escapepod WarpDemuX/ADAPTed
Language Pure Rust Python + C
Dependencies None (statically linked) PyTorch, dtaidistance, pod5
Adapter detection LLR + CNN LLR + CNN + fallback
Classification DTW (Rust) DTW (dtaidistance)
Model format JSON (native or WarpDemuX) Scikit-learn pickle

Performance Benchmarks

Early numbers (LLR + DTW path, before the CNN/CRF heads landed) on RNA004 data with 5 barcodes (1000 reads total), 4 threads:

Metric Escapepod WarpDemuX
Detection speed 14x faster baseline
Full pipeline ~0.5s ~2.4s
Throughput ~2000 reads/sec ~400 reads/sec

Note: For best classification accuracy, use a published model bundle (escpod demux models list / fetch) or a WarpDemuX-exported model via --model. The escpod training workflow is experimental and may not generalize well to new samples.

Overview

The demux workflow analyzes the raw nanopore signal to detect adapter regions, extract barcode fingerprints, classify reads, and optionally split them into separate files.

flowchart LR
    pod5([POD5 Files]) --> fused
    fused["<b>demux</b> (fused)<br/><i>detect + classify</i>"] --> demuxed([Per-barcode POD5s])
    fused -. "--annotate" .-> sidecar[reads.pod5.p5s]
    sidecar -. "split --sidecar<br/>filter --annotation" .-> demuxed

    pod5 -.-> detect
    detect["<b>detect</b><br/><i>LLR / CNN</i>"] -.-> boundaries[boundaries.csv]
    boundaries -.-> fingerprint["<b>fingerprint</b><br/><i>t-test seg</i>"]
    boundaries -.-> basecall["<b>basecall</b><br/><i>CTC-CRF</i>"]
    fingerprint -.-> classify["<b>classify</b><br/><i>DTW / GBM</i>"]
    classify -.-> classifications[classifications.csv]
    basecall -.-> classifications
    classifications -.-> split["<b>split</b><br/><i>by barcode</i>"]

Subcommands

Subcommand Description
detect Detect adapter boundaries (LLR or CNN)
fingerprint Extract signal fingerprints from adapter regions
classify Classify reads by barcode using DTW distance
basecall CTC-CRF barcode basecalling from a boundaries CSV; --barcodes assigns reads by edit distance
split Split reads into separate POD5 files by barcode (CSV or --sidecar)
models List / fetch published model bundles (list, path, fetch [--all])
train Train reference fingerprints from known samples
train-svm Train SVM model from fingerprints (requires train feature)

detect

Detect adapter boundaries in reads using the Log-Likelihood Ratio (LLR) algorithm. This identifies where the adapter sequence starts and ends in the raw signal.

Signal Structure (RNA Sequencing)

Signal Level
     │
high │  ╭──────╮                              ╭────────────
     │  │      │                              │
     │  │      ╰──────────────────────────────╯
     │  │  Open   Adapter      Barcode      RNA
low  │──╯  Pore   (detected    region       transcript
     └────────────────────────────────────────────────────▶
                                                        Time
              │◀─── Adapter Region ───▶│
          adapter_start            adapter_end

LLR Algorithm

The LLR algorithm finds boundaries by maximizing the variance difference between adjacent segments:

                    LLR Boundary Detection
                    ─────────────────────

Signal:  ▁▁▁▁▁▁▁█████████▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
                ▲       ▲
                │       │
              Split   Split
              Point   Point

For each candidate position i:

  gain(i) = n × log(var[0,n)) - [n_head × log(var[0,i)) + n_tail × log(var[i,n))]
                ▲                      ▲                        ▲
                │                      │                        │
         Full variance          Head variance           Tail variance

The position with maximum gain indicates the best split point.

Usage

escpod demux detect <FILES>... -o <OUTPUT>

Arguments

Argument Description
<FILES> Input POD5 file(s)

Options

Option Description
-o, --output <FILE> Output boundaries CSV file (required)
--min-adapter <N> Minimum adapter observations (default: 200)
--border-trim <N> Border trim size (default: 50)
--downscale <N> Downscale factor for signal processing (default: 10, WarpDemuX-native; set 1 for full resolution)
--method <cnn\|llr> Boundary detector; cnn is what the published models were trained against
--cnn-model <FILE> / --cnn-model-name <NAME> Boundary-CNN ONNX (explicit path, or a fetched bundle by name)
--device <auto\|cpu\|gpu> Where CNN detection runs (default auto, which prefers the GPU here)
--emit-llr-delta QC column comparing the CNN boundary against an LLR-detected one on the same read (cnn-detect builds only). Opt-in for I/O, not compute: LLR normalizes over the whole read, so the CNN path can no longer decode just its leading max_obs_trace samples — every read is decompressed in full while this is on
-t, -j, --threads <N> Number of threads for parallel processing (default: 16, capped at available CPUs)
-h, --help Print help

Output Format

The output CSV contains:

read_id,num_samples,adapter_start,adapter_end
a1b2c3d4-...,50000,1500,4200
b2c3d4e5-...,48000,1200,3800
Column Description
read_id Read UUID
num_samples Total signal samples
adapter_start Adapter start position (samples)
adapter_end Adapter end position (samples)

Example

escpod demux detect *.pod5 -o boundaries.csv --min-adapter 200 -j 8

fingerprint

Extract barcode fingerprints from adapter regions using t-test segmentation. The fingerprint is a fixed-length feature vector representing the barcode signal pattern.

Fingerprint Extraction Pipeline

┌─────────────────────────────────────────────────────────────────────────────┐
│                     FINGERPRINT EXTRACTION                                   │
└─────────────────────────────────────────────────────────────────────────────┘

Raw Signal (adapter region only)
    │
    ▼
┌─────────────┐
│ Normalize   │  MAD normalization: (x - median) / MAD
│ (MAD)       │
└─────────────┘
    │
    ▼
┌─────────────┐
│ T-test      │  Find N-1 changepoints using sliding window t-test
│ Segment     │
└─────────────┘
    │
    ▼
┌─────────────┐
│ Compute     │  Mean signal level per segment
│ Means       │
└─────────────┘
    │
    ▼
┌─────────────┐
│ Normalize   │  Z-score, min-max, median, or none
│ Features    │
└─────────────┘
    │
    ▼
Fingerprint Vector [fp_0, fp_1, ..., fp_n]

T-test Segmentation

The algorithm uses a sliding window t-test to find changepoints:

Window-Based Changepoint Detection
──────────────────────────────────

Signal: ████████▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄████████████████
              ◀──W──▶◀──W──▶
              Window1 Window2

At each position, compare adjacent windows:

  t_score = |mean₁ - mean₂| / √(var₁ + var₂)

        t-score
          ▲
          │        *
          │       * *
          │      *   *
          │  ···*     *···
          │ *           *
          └─────────────────▶ position
                   ▲
                   │
              Changepoint
              (local max)

Select top N changepoints with minimum separation.

Resulting Segments

Segmented Signal with Means
───────────────────────────

Signal: ─────────────────────────────────────────────────
        ▁▁▁▁▁│████│▄▄▄▄▄│███│▁▁▁▁▁│▄▄▄▄│▁▁▁▁▁▁▁│████│▁▁
        seg 0│seg1│seg 2│seg3│seg 4│seg5│ seg 6 │seg7│...
        ──────────────────────────────────────────────────▶
                                                     samples

Fingerprint = [mean₀, mean₁, mean₂, mean₃, mean₄, mean₅, mean₆, mean₇, ...]
            = [-0.82,  1.23, -0.15,  0.95, -0.71,  0.12, -0.45,  1.08, ...]

Usage

escpod demux fingerprint <FILES>... --boundaries <CSV> -o <OUTPUT>

Arguments

Argument Description
<FILES> Input POD5 file(s)

Options

Option Description
--boundaries <FILE> Boundaries CSV from detect command (required)
-o, --output <FILE> Output fingerprints CSV file (required)
--segment-start <N> Start sample offset within adapter region (default: 1000)
--segment-end <N> End sample offset within adapter region (default: 2000)
--num-segments <N> Number of fingerprint segments (default: 10)
--window-width <N> T-test window width (default: 5)
--normalize <METHOD> Normalization method: zscore, minmax, median, mean, none (default: zscore)
--warpdemux-compat WarpDemuX-compatible mode: full adapter region, 110 t-test events (window=12, min_sep=6), keeps the last 25 segment means, mean normalization
--emit-dwell Append per-segment dwell time as a second feature channel (log1p + z-score), doubling the output width from N to 2N columns (fp_0..N-1, dwell_0..N-1). Opt-in: breaks binary compatibility with existing 25-wide WarpDemuX-compat models
-t, -j, --threads <N> Number of threads for parallel processing (default: 16, capped at available CPUs)
-h, --help Print help

Note: The --segment-start and --segment-end options define which region within the adapter to use for fingerprinting. The defaults (1000-2000) match the training parameters, ensuring consistency between training and classification.

Output Format

read_id,fp_0,fp_1,fp_2,fp_3,fp_4,fp_5,fp_6,fp_7,fp_8,fp_9
a1b2c3d4-...,-0.823451,1.234567,-0.156789,0.951234,...
b2c3d4e5-...,-0.712345,0.987654,-0.234567,1.123456,...

Example

escpod demux fingerprint *.pod5 --boundaries boundaries.csv -o fingerprints.csv
escpod demux fingerprint *.pod5 --boundaries boundaries.csv -o fp.csv --num-segments 12 --normalize median

classify

Classify reads by barcode using Dynamic Time Warping (DTW) distance between fingerprints and reference barcodes.

DTW Distance Calculation

┌─────────────────────────────────────────────────────────────────────────────┐
│                  DYNAMIC TIME WARPING (DTW)                                  │
└─────────────────────────────────────────────────────────────────────────────┘

Query fingerprint:     Q = [q₀, q₁, q₂, q₃, q₄, ...]
Reference fingerprint: R = [r₀, r₁, r₂, r₃, r₄, ...]

DTW finds the optimal alignment between sequences:

        r₀  r₁  r₂  r₃  r₄
       ┌───┬───┬───┬───┬───┐
   q₀  │ ● │   │   │   │   │   Legend:
       ├───┼───┼───┼───┼───┤   ● = optimal path
   q₁  │   │ ● │   │   │   │   ─ = allowed moves
       ├───┼───┼───┼───┼───┤
   q₂  │   │ ● │ ● │   │   │   D[i,j] = |qᵢ - rⱼ| + min(D[i-1,j],
       ├───┼───┼───┼───┼───┤                          D[i,j-1],
   q₃  │   │   │   │ ● │   │                          D[i-1,j-1])
       ├───┼───┼───┼───┼───┤
   q₄  │   │   │   │   │ ● │   DTW distance = D[n,m]
       └───┴───┴───┴───┴───┘

Sakoe-Chiba Band Constraint (--window):
────────────────────────────────────────
       ┌───┬───┬───┬───┬───┐
   q₀  │░░░│░░░│   │   │   │   ░ = valid region
       ├───┼───┼───┼───┼───┤       (within window)
   q₁  │░░░│░░░│░░░│   │   │
       ├───┼───┼───┼───┼───┤   Constraint: |i - j| ≤ window
   q₂  │   │░░░│░░░│░░░│   │
       ├───┼───┼───┼───┼───┤   Reduces time from O(nm) to O(n·w)
   q₃  │   │   │░░░│░░░│░░░│
       ├───┼───┼───┼───┼───┤
   q₄  │   │   │   │░░░│░░░│
       └───┴───┴───┴───┴───┘

Classification Process

Classification Decision
───────────────────────

Query fingerprint ─┬─▶ DTW(query, barcode_01) ───▶ d₁ = 0.23
                   ├─▶ DTW(query, barcode_02) ───▶ d₂ = 0.87
                   ├─▶ DTW(query, barcode_03) ───▶ d₃ = 0.45
                   └─▶ DTW(query, barcode_04) ───▶ d₄ = 0.91

Best match:        barcode_01 (d₁ = 0.23)
Second best:       barcode_03 (d₃ = 0.45)

Confidence ratio = d_best / d_second_best = 0.23 / 0.45 = 0.51

If ratio < threshold (e.g., 0.8):
  → Assign to barcode_01 with confidence 0.51
Else:
  → Mark as "unclassified" (ambiguous)

Usage

escpod demux classify <FINGERPRINTS> --reference <CSV> -o <OUTPUT>
escpod demux classify <FINGERPRINTS> --model <JSON> -o <OUTPUT>

Arguments

Argument Description
<FINGERPRINTS> Input fingerprints CSV

Options

Option Description
--reference <FILE> Reference fingerprints CSV (from train command)
--model <FILE> WarpDemuX model JSON file
-o, --output <FILE> Output classifications CSV (required)
--window <N> DTW window size (Sakoe-Chiba band) for --reference mode; a model JSON carries its own, and passing this with --model is an error
--min-ratio <RATIO> Top-2 distance-ratio threshold below which a read is confident (default: 0.8)
--model-name <NAME> Use a fetched model bundle by name instead of --model
--probabilities Emit per-class probabilities (SVM models)
--device <auto\|cpu\|gpu> Where DTW runs. auto keeps it on the CPU — see the warning below
-t, -j, --threads <N> Number of threads for parallel processing (default: 16, capped at available CPUs)
-h, --help Print help

GPU DTW classify is experimental and usually slower

This is why --device auto leaves DTW on the CPU even with an idle GPU in the node. On a full node the CPU DTW beats the GPU: 113 s on 64 CPU cores vs 132 s on an A30 (0.85x), plus ~2.2 GB more RSS, on a 1.22M-read DTW-SVM run. An apparent 1.67x speedup vanishes once the CPU is given the whole node instead of 16 of 64 cores. It may still help where cores are scarce relative to the DTW workload, which is what --device gpu is for; it does nothing for GBM models. The GPU paths that do pay off are CNN adapter detection and the CRF encoder — see GPU acceleration.

Output Format

read_id,barcode,confidence,best_distance,second_best_distance
a1b2c3d4-...,barcode_01,0.512,0.234,0.457
b2c3d4e5-...,barcode_03,0.723,0.156,0.216
c3d4e5f6-...,unclassified,0.912,0.345,0.378
Column Description
read_id Read UUID
barcode Assigned barcode or "unclassified"
confidence Distance ratio (lower = more confident)
best_distance DTW distance to best match
second_best_distance DTW distance to second best

Example

# Using reference fingerprints
escpod demux classify fingerprints.csv --reference reference.csv -o classifications.csv

# Using WarpDemuX model (the DTW window comes from the model file)
escpod demux classify fingerprints.csv --model warpdemux.json -o classifications.csv

# Reference fingerprints with a Sakoe-Chiba band
escpod demux classify fingerprints.csv --reference reference.csv -o classifications.csv --window 10

# With a custom confidence ratio
escpod demux classify fingerprints.csv --reference reference.csv -o out.csv --min-ratio 0.7

basecall

CTC-CRF barcode basecalling from a boundaries CSV — the alternative to fingerprint + classify in the stepwise pipeline (crf-decode feature, in the default build). Decodes each read's barcode region with the CRF encoder + lattice decode; given --barcodes, it also matches the decoded sequence to the closest reference by edit distance, producing exactly the barcode column escpod demux split consumes.

Usage

escpod demux basecall <FILES>... --boundaries <CSV> --model <DIR> -o <OUTPUT>

Arguments

Argument Description
<FILES> Input POD5 file(s) or directory

Options

Option Description
--boundaries <FILE> Detected boundaries CSV, from escpod demux detect (required)
--model <DIR> CRF encoder bundle directory (metadata.json + the ONNX graph it names) (required)
-o, --output <FILE> Output CSV of decoded sequences (required)
--barcodes <FILE> Barcode reference CSV (name,sequence columns). Assigns each read to its closest reference by edit distance, adding barcode/confidence-shaped columns; without it, only decoded sequences are emitted
--min-margin <N> Call a read unclassified when its edit-distance margin to the second-best reference is below this (default: 0, keeps every call including ties). Requires --barcodes
--ref-scores Also score every reference against the lattice itself, adding crf_logp (log P(called barcode \| signal), a real probability — unlike the edit-distance confidence, which measures how far apart the references are rather than how sure the model is), crf_margin (the call's log-odds in nats against its best alternative), crf_best, and mean_logpost. ~+7.6% runtime; on GPU the decode is pulled back to the host to see the raw scores. Requires --barcodes
--min-crf-margin <NATS> Call a read unclassified when the lattice's log-odds for the called barcode against its best alternative are below this (implies --ref-scores). Continuous, unlike --min-margin's handful of edit-distance values; ~0.7 nats is 2:1 odds, 2.3 is 10:1, 4.6 is 100:1. Requires --barcodes
--min-crf-prob <P> Call a read unclassified when P(called barcode \| signal) is below this (implies --ref-scores) — absolute confidence, not relative to the runner-up. Requires --barcodes
--boundary-margin <N> Overrule the bundle's declared boundary.margin; reads below it emit an empty sequence rather than a poor one
--clamp-max-shift <N> Overrule the bundle's declared boundary.clamp_max_shift: decode a read whose adapter ends before the model's chunk window, provided the shift needed is at most N (0 disables). Reaches reads --boundary-margin cannot, at lower accuracy — check calls it adds independently before relying on them
--device <auto\|cpu\|gpu> Where encoder inference runs (default auto, prefers GPU — the encoder is ~91% of this command's CPU cost)
-t, -j, --threads <N> Number of threads for parallel processing (default: 16, capped at available CPUs)
-h, --help Print help

Output Format

Headerless CSV. Every read that reached the decoder gets a row, including ones that could not be decoded (emitted as unclassified rather than dropped, so demux split cannot silently lose reads):

# Without --barcodes:
read_id,adapter_end,decoded_len,sequence

# With --barcodes:
read_id,barcode,margin,best_dist,second_best_dist,adapter_end,decoded_len,sequence

# With --barcodes --ref-scores (or a --min-crf-* gate, which implies it):
read_id,barcode,margin,best_dist,second_best_dist,adapter_end,decoded_len,sequence,crf_logp,crf_margin,crf_best,mean_logpost

Example

escpod demux detect *.pod5 -o boundaries.csv
escpod demux basecall *.pod5 --boundaries boundaries.csv --model bundles/barcode_crf_ldx32_rna004 \
    --barcodes barcodes.csv -o classifications.csv
escpod demux split *.pod5 --classifications classifications.csv -d demuxed/

split

Split reads into separate POD5 files based on barcode classification.

Usage

escpod demux split <FILES>... --classifications <CSV> --output-dir <DIR>

Arguments

Argument Description
<FILES> Input POD5 file(s)

Options

Option Description
--classifications <FILE> Classifications CSV from classify/basecall (or use --sidecar)
--sidecar Read assignments from each input's .p5s sidecar instead of a CSV
--annotation <NAME> Sidecar annotation to split by (default: barcode) — e.g. a design-derived condition
-d, --output-dir <DIR> Output directory for demuxed files (required)
--prefix <STR> Output file prefix (default: barcode)
--classified-only Drop unclassified reads instead of writing them to their own file
--force Overwrite existing per-barcode output files
-t, -j, --threads <N> Number of threads for parallel processing (default: 16, capped at available CPUs)
-h, --help Print help

Output Structure

output_dir/
├── barcode_nbc01.pod5
├── barcode_nbc02.pod5
├── barcode_nbc03.pod5
└── barcode_unclassified.pod5   (omitted with --classified-only)

Example

escpod demux split *.pod5 --classifications classifications.csv -d demuxed/
escpod demux split reads.pod5 --sidecar -d demuxed/                        # from .p5s
escpod demux split reads.pod5 --sidecar --annotation condition -d by_cond/ # by design variable

train

Train reference barcode fingerprints from known samples. Use this to create a custom reference for your barcode set.

Training Workflow

Training Reference Fingerprints
───────────────────────────────

Input Option A: Directory structure
─────────────────────────────────
input_dir/
├── barcode_01/
│   ├── sample1.pod5
│   └── sample2.pod5
├── barcode_02/
│   ├── sample1.pod5
│   └── sample2.pod5
└── barcode_03/
    └── sample1.pod5

Input Option B: Assignments CSV
───────────────────────────────
read_id,barcode,pod5_file
a1b2...,barcode_01,sample1.pod5
b2c3...,barcode_01,sample1.pod5
c3d4...,barcode_02,sample2.pod5

Processing:
───────────
For each barcode:
  1. Extract fingerprints from all assigned reads
  2. Compute consensus (mean) fingerprint
  3. Compute standard deviation per feature

Output: reference.json
─────────────────────
{
  "barcodes": {
    "barcode_01": {
      "fingerprint": [0.12, -0.45, ...],
      "std_dev": [0.05, 0.08, ...],
      "read_count": 150
    },
    "barcode_02": { ... }
  },
  "metadata": {
    "num_segments": 10,
    "normalization": "zscore"
  }
}

Usage

escpod demux train --input-dir <DIR> -o <OUTPUT>
escpod demux train --assignments <CSV> -o <OUTPUT>

Options

Option Description
--input-dir <DIR> Directory with barcode subdirectories containing POD5 files
--assignments <CSV> CSV with read_id, barcode, pod5_file columns
-o, --output <FILE> Output reference JSON file (required)
--segment-start <N> Start sample for fingerprint region (default: 1000)
--segment-end <N> End sample for fingerprint region (default: 2000)
--num-segments <N> Number of fingerprint segments (default: 10)
--window-width <N> T-test window width (default: 5)
--normalize <METHOD> Normalization method: zscore, minmax, median, mean, none (default: zscore)
--min-adapter <N> Minimum adapter observations (default: 200)
--border-trim <N> Border trim size (default: 50)
-t, -j, --threads <N> Number of threads for parallel processing (default: 16, capped at available CPUs)
-h, --help Print help

Output Format

The train command outputs a JSON file with consensus fingerprints for each barcode:

{
  "barcodes": {
    "BC00": {
      "fingerprint": [0.12, -0.45, ...],
      "std_dev": [0.05, 0.08, ...],
      "read_count": 150
    }
  },
  "params": { "segment_start": 1000, "segment_end": 2000, "num_segments": 10 }
}

Note: For best classification accuracy, we recommend using WarpDemuX pre-trained models instead of training your own. The escpod training workflow produces consensus fingerprints that may not generalize as well as WarpDemuX's SVM-based models.

Example

# From directory structure
escpod demux train --input-dir training_samples/ -o reference.json

# From assignments CSV
escpod demux train --assignments known_barcodes.csv -o reference.json

# Use trained reference for classification
escpod demux classify fingerprints.csv --reference reference.json -o classifications.csv

train-svm

Train an SVM model from labeled fingerprints for probabilistic barcode classification. This command requires the train feature to be enabled.

Note: This creates a DTW-SVM model that provides probability outputs for each class, enabling more nuanced confidence thresholds.

Usage

escpod demux train-svm -f <FINGERPRINTS> -o <OUTPUT> [OPTIONS]

Options

Option Description
-f, --fingerprints <FILE> CSV file with fingerprints (read_id, barcode, feat1, feat2, ...) (required)
-o, --output <FILE> Output JSON file for trained SVM model (required)
--gamma <VALUE> RBF kernel gamma parameter (default: 1.0)
--power <VALUE> Power to raise distances before exponential (default: 1.0)
--window <N> DTW window constraint (Sakoe-Chiba band)
--thresholds <VALUES> Per-class confidence thresholds (comma-separated)
--max-per-class <N> Randomly subsample each barcode class to at most N fingerprints before training — required for large datasets since the all-pairs DTW distance matrix is O(N²) memory. Balanced (same cap per class) and deterministic under --seed
--seed <N> RNG seed for --max-per-class subsampling (default: 42)
-h, --help Print help

Input Format

The fingerprints CSV should include barcode labels:

read_id,barcode,fp_0,fp_1,fp_2,...,fp_9
a1b2c3d4-...,BC00,-0.823,1.234,-0.156,...
b2c3d4e5-...,BC00,-0.712,0.987,-0.234,...
c3d4e5f6-...,BC01,-0.456,0.789,-0.321,...

Example

# Train SVM with default parameters
escpod demux train-svm -f fingerprints.csv -o model.json

# Train with custom hyperparameters
escpod demux train-svm -f fingerprints.csv -o model.json --gamma 0.5 --window 10

# Use the trained SVM model for classification (--model auto-detects the JSON shape)
escpod demux classify fingerprints.csv --model model.json -o classifications.csv

Building with train feature

cargo build --release --features train

Complete Workflow Example

# One pass: detect + classify + output. --annotate keeps everything in the
# sidecar; add -d out/ to also write per-barcode files.
escpod demux reads.pod5 --model <bundle-or-model.json> --annotate

escpod inspect summary reads.pod5                 # barcode counts, sidecar state
escpod demux split reads.pod5 --sidecar -d out/   # materialize when needed

Stepwise (inspectable intermediates)

# 1. Detect adapter boundaries in all POD5 files
escpod demux detect *.pod5 -o boundaries.csv -j 8

# 2. Extract fingerprints from adapter regions
escpod demux fingerprint *.pod5 --boundaries boundaries.csv -o fingerprints.csv

# 3. Classify reads using a pre-trained model
escpod demux classify fingerprints.csv --model warpdemux_model.json -o classifications.csv

# 4. Split into separate files (unclassified reads get their own file by default)
escpod demux split *.pod5 --classifications classifications.csv -d demuxed/

# View classification summary
cut -d, -f2 classifications.csv | sort | uniq -c | sort -rn

Training Your Own Reference (Experimental)

If you have known barcode samples, you can train a consensus-based reference:

# Create assignments CSV with known read-to-barcode mappings
cat > assignments.csv << EOF
read_id,barcode,pod5_file
a1b2c3d4-...,BC00,sample1.pod5
b2c3d4e5-...,BC00,sample1.pod5
c3d4e5f6-...,BC01,sample2.pod5
EOF

# Train reference fingerprints
escpod demux train --assignments assignments.csv -o reference.json -j 8

# Use the trained reference for classification
escpod demux detect *.pod5 -o boundaries.csv -j 8
escpod demux fingerprint *.pod5 --boundaries boundaries.csv -o fingerprints.csv
escpod demux classify fingerprints.csv --reference reference.json -o classifications.csv

Note: For production use, we recommend using WarpDemuX pre-trained models which provide significantly better generalization.

Using WarpDemuX Models

You can export WarpDemuX models using the provided script:

# Export a WarpDemuX model to JSON format
python scripts/export_warpdemux_model.py path/to/warpdemux_model.pkl -o model.json

# Use the exported model
escpod demux classify fingerprints.csv --model model.json -o classifications.csv

GPU acceleration

One opt-in Cargo feature, gpu, enables every GPU path. At run time --device auto (the default) decides per stage:

Stage Runs on the GPU under auto? Why
CNN adapter detection (--method cnn) Yes Detection is inference-bound and tract has no efficient batched conv. ~7× faster end-to-end on an A30 — the path that pays off most.
CTC-CRF encoder Yes The encoder is ~91% of that head's CPU cost. ~4× end-to-end.
DTW classification No The CPU is faster: 113 s on 64 cores vs 132 s on an A30, plus ~2.2 GB more RSS. --device gpu opts in anyway; worth it only when cores are scarce.
GBM classification No No GPU implementation — a 32-core CPU pool beats one GPU stream on the tree walk by roughly 20×.
LLR adapter detection No No GPU implementation; it is a CPU changepoint search.

--device takes three values:

  • auto (default) — the table above, and only when the relevant Cargo feature is compiled in and a CUDA device is visible. Never an error.
  • gpu — a requirement, not a request. A missing feature, an absent device, or an onnxruntime that cannot register its CUDA execution provider each fail the run with a message naming the cause. This is deliberate: a best-effort CUDA registration that quietly commits to the CPU provider is how a broken runtime produced correct, slow, accelerated-looking runs for months.

    Registration is not the only way a GPU run can be broken, and the other way is not something --device can pre-check: the provider registers fine and then its kernels cannot dlopen cuDNN. detect --method cnn catches that downstream — see the troubleshooting table below. - cpu — CPU everywhere, device or no device.

--cpu is an alias for --device cpu; it conflicts with --device. (The old --gpu boolean was removed in favor of --device gpu — see the CHANGELOG.) --device is accepted in every build, including the release artifacts that contain no GPU code — --device gpu there tells you the feature is not compiled in rather than failing on an unknown argument.

A CPU run that could have been a GPU run says so

Whenever a GPU-capable stage lands on the CPU, escpod logs a warning at startup naming the cost and the cause:

WARN boundary CNN adapter detection is running on the CPU (~7x slower than
     GPU end-to-end); no CUDA device is visible. Allocate a GPU (SLURM:
     `--gres=gpu:1`) and check `nvidia-smi`, or pass `--device cpu` to
     silence this.

The two causes are distinguished — this build has no gpu feature needs a rebuild, no CUDA device is visible needs an allocation.

gpu is the only GPU flag, in the CLI and in escapepod-demux alike. There is no way to build half a GPU binary, so there is no combination in which --gpu silently does nothing for a stage that has a device path.

(escapepod-signal keeps its own, narrower gpu feature: the cudarc DTW kernel without onnxruntime, for a consumer that wants the kernel and not the pipeline.)

Getting a GPU-capable binary

Each release ships one, escpod-<ver>-x86_64-unknown-linux-gnu-gpu.tar.gz on the releases page — the only artifact built --features gpu, and the only dynamically linked Linux one, because the GPU runtimes are dlopened and static musl cannot do that. It needs glibc ≥ 2.28 (RHEL/Rocky/Alma 8+, Ubuntu 20.04+). Prefer it over a local build where you need a knowable version: escpod --version on a hand-built binary is a local fact, and adapter_end — which escpod computes and which defines the training window — makes the binary part of a model's definition.

To build it yourself instead, nothing CUDA-related is needed at build time: the GPU runtimes load with dlopen at run time, so you can build on any machine, and a gpu-built binary still runs fine on CPU-only nodes as long as the GPU is not demanded — --device auto falls back on its own, and only --device gpu insists:

cargo build --release --features gpu -p escapepod-cli

Runtime libraries: the pixi environment

At run time the GPU paths need a CUDA 12 stack and a CUDA-enabled libonnxruntime. The repository's pixi gpu environment supplies all of it as ordinary conda-forge packages, so pixi.lock describes every byte of it and there is nothing to fetch by hand:

# once, on a machine with network access (on clusters: the login node)
pixi run install-gpu

# then, on a node with a visible NVIDIA GPU — no env vars needed
pixi run -e gpu ./target/release/escpod demux reads.pod5 --model <bundle> --annotate

Use the install-gpu task rather than pixi install -e gpu. The gpu environment targets a platform with the __cuda virtual package asserted — that is what lets conda-forge's CUDA builds resolve — and a login node with no GPU cannot satisfy it, so the plain command fails with a solver error about missing virtual packages. The task supplies the install-time probe. Nothing is faked: the resolve is already committed in pixi.lock, and the packages installed are the ones it names. On a GPU node pixi install -e gpu works directly.

Activating the environment (pixi run -e gpu … or pixi shell -e gpu) sets LD_LIBRARY_PATH and ORT_DYLIB_PATH automatically. The only system requirement on the node itself is an NVIDIA driver new enough for CUDA 12.2 (≥ 535) — the DTW kernels target that driver API. This works the same for the released -gnu-gpu binary as for a local build; point the command at wherever you unpacked it.

Checking who is really supplying CUDA

ldd $CONDA_PREFIX/lib/libonnxruntime_providers_cuda.so on a GPU node should resolve every CUDA library inside the environment. Anything coming from a system path such as /usr/local/cuda-13.0 means the environment is not the thing carrying the work, which is a bug worth reporting — it is how a CUDA major mismatch hid here for months.

Verifying the GPU is actually in use

At the default log level, escpod announces which device each stage runs on:

INFO Detecting adapter boundaries using boundary CNN (GPU)
INFO Encoder runs on: GPU (onnxruntime CUDA)

Those lines say what was placed, and any stage that ends up on the CPU when it could have been on the device says so as a warning (visible by default), so a warning-free run on a GPU node is a healthy one. Under --device gpu an onnxruntime that cannot register its CUDA provider is a hard error rather than a demotion. For positive confirmation that the CUDA execution provider loaded, raise the dependency log level — escpod pins third-party logs at warn unless RUST_LOG overrides it:

RUST_LOG=ort=info escpod demux basecall  2>&1 | grep CUDAExecutionProvider
# INFO [ort::ep] Successfully registered `CUDAExecutionProvider`
Symptom Cause
Warning that the execution provider "may fall back to CPU", run is slow A CUDA runtime library is missing (typically libcublasLt.so.12 or libcudnn.so.9). Run inside the pixi gpu environment so LD_LIBRARY_PATH includes them. Re-run with --device gpu to turn the same condition into a hard error.
detect --method cnn errors with "every one of the N read(s) failed CNN inference on the GPU" The same missing runtime library, caught rather than written out. Registering the CUDA provider succeeds — it only appends a factory to the session options — and the libraries the kernels need are dlopened later, inside the first Conv. Same fix: the pixi gpu environment, or --device cpu.
Clear startup error: could not load onnxruntime ORT_DYLIB_PATH is unset or points at a CPU-only build of onnxruntime. Inside the pixi gpu environment it is set for you.
Process hangs at startup and prints nothing, not even a status line ORT_DYLIB_PATH is set but points at a file that does not exist — normally a stale manual override, since the environment's own value is a package path the lockfile guarantees.

On a cluster, redirect output to a file

When running under a scheduler, don't pipe the job's output through tail or similar — those buffer until EOF, so a healthy job looks identical to a hung one. Redirect to a log file and follow that instead.

Manual setup (without pixi)

Reproduce what the environment provides:

  • Put the CUDA 12 runtime on LD_LIBRARY_PATH: libcublas.so.12, libcublasLt.so.12, libcudart.so.12, libcufft.so.11, libcudnn.so.9, and libnvrtc.so.12 for the DTW kernels.
  • For the CNN/CRF paths, set ORT_DYLIB_PATH to a CUDA-enabled libonnxruntime, and keep the provider libraries (libonnxruntime_providers_cuda.so) beside it — onnxruntime loads them from the same directory. The onnxruntime version must be compatible with the ort crate the binary was built with; current pins are onnxruntime 1.28 with ort 2.0.0-rc.13, and the build must be a CUDA 12 one to match the libraries above.

HPC notes

  • pixi run install-gpu must run on a networked node — compute nodes typically cannot reach the internet. It does not need a GPU (that is the whole reason the task exists), so the login node is the right place.
  • The environment (.pixi/envs/gpu) lives inside the project directory. On a shared filesystem the GPU nodes see it with no further staging.
  • Model files are also fetched explicitly, never at run time — see escpod demux models fetch above.

Algorithm References

The demux algorithms are based on:

  • LLR boundary detection: Adapted from ADAPTed by Wiep K. van der Toorn
  • T-test segmentation: Based on the Tombo algorithm used in WarpDemuX
  • DTW classification: Standard Dynamic Time Warping with optional Sakoe-Chiba band constraint

See Also