Skip to content

escpod resquiggle

Experimental — opt-in feature

resquiggle is gated behind the experimental Cargo feature. Output tag schema and DP parameters are still being tuned and may change between releases.

cargo build --release --features experimental
cargo install --git https://github.com/rnabioco/escapepod-rs --features experimental

Refine signal-to-base mapping using banded dynamic programming. Takes an input POD5 file (raw signal) and a BAM file with basecaller move tables, then produces a new BAM file with refined signal boundaries stored in auxiliary tags.

Overview

Nanopore basecallers produce a move table that maps signal blocks to bases, but this mapping is coarse (one decision per stride block). Resquiggle refines these boundaries to sample-level resolution by aligning the raw signal against expected kmer levels using banded dynamic programming.

  Raw signal (from POD5)
  ┌──────────────────────────────────────────────────────┐
  │  ╱╲    ╱╲╱╲      ╱╲                                 │
  │ ╱  ╲  ╱    ╲    ╱  ╲    ╱╲  ╱╲╱╲                   │
  │╱    ╲╱      ╲──╱    ╲──╱  ╲╱    ╲──╲╱╲──            │
  └──────────────────────────────────────────────────────┘
        ▲            ▲           ▲          ▲
  Move table      (coarse boundaries from basecaller)

        ▼  resquiggle refinement  ▼

  ┌──────────────────────────────────────────────────────┐
  │  ╱╲    ╱╲╱╲      ╱╲                                 │
  │ ╱  ╲  ╱    ╲    ╱  ╲    ╱╲  ╱╲╱╲                   │
  │╱    ╲╱      ╲──╱    ╲──╱  ╲╱    ╲──╲╱╲──            │
  └──────────────────────────────────────────────────────┘
       ▲           ▲          ▲          ▲
  Refined       (sample-level boundaries from DP)

Usage

escpod resquiggle <INPUT> -b <BAM> -k <KMER_TABLE> -o <OUTPUT> [OPTIONS]

Arguments

Argument Description
<INPUT> Input POD5 file or directory

Options

Option Description
-b, --bam <FILE> Input BAM file with move table (mv tag) (required)
-k, --kmer-table <FILE> Tab-delimited kmer level table file (mutually exclusive with --kmer-model)
--kmer-model <NAME> Named k-mer model resolved from the local cache, e.g. dna_r10.4.1_e8.2_400bps (see K-mer models; mutually exclusive with --kmer-table)
-o, --output <FILE> Output BAM file (required)
--algo <ALGO> Refinement algorithm: dwell-penalty (default) or viterbi
--iterations <N> Number of refinement iterations (default: 1)
--half-bandwidth <N> Half bandwidth for banded DP (default: 5)
--rescale <ALGO> Rescale algorithm: theil-sen (default) or least-squares
--dwell-target <N> Target dwell time per base for dwell-penalty (default: 0 = auto from move table)
--dwell-weight <W> Dwell penalty weight (default: 0.5)
--normalize <MODE> Normalization mode for kmer levels (e.g., mad)
--rna RNA mode: reverse raw signal to match basecaller's 5'→3' orientation
-j, --threads <N> Number of threads for parallel processing
-h, --help Print help

K-mer models

Resquiggle aligns signal against a table of expected k-mer levels. You can either point at a file directly (--kmer-table <path>) or refer to one of Oxford Nanopore's canonical tables by name (--kmer-model <name>), resolved from a local cache.

Model name Chemistry k
dna_r10.4.1_e8.2_400bps DNA, R10.4.1 (400 bps) 9
dna_r10.4.1_e8.2_260bps DNA, R10.4.1 (260 bps) 9
rna004 RNA004 9
rna_r9.4_180mv_70bps RNA, R9.4 5

These tables come from nanoporetech/kmer_models (MPL-2.0) and are not vendored — they are downloaded on demand into a cache and pinned to a specific upstream commit + sha256, so results are reproducible and integrity-checked.

Prefetch on a networked node (HPC)

Downloading requires the models-download Cargo feature, and runtime never touches the network — a --kmer-model that isn't cached errors with a hint rather than hanging. On clusters where compute nodes can't reach the internet, prefetch from a login node before submitting jobs:

# built with:  cargo build --release --features models-download
escpod resquiggle models list                       # show models + cache status
escpod resquiggle models path                        # print the cache directory
escpod resquiggle models fetch dna_r10.4.1_e8.2_400bps
escpod resquiggle models fetch --all                 # grab every model

# then, on a compute node (offline is fine — resolves from cache):
escpod resquiggle reads.pod5 -b calls.bam --kmer-model dna_r10.4.1_e8.2_400bps -o refined.bam

The cache location is $ESCAPEPOD_KMER_CACHE, else $XDG_CACHE_HOME/escapepod/kmer_models, else $HOME/.cache/escapepod/kmer_models. Cached files are named <model-name>.txt (e.g. dna_r10.4.1_e8.2_400bps.txt).

Put the cache on shared scratch (HPC)

The default falls back to $HOME, which on many clusters is small-quota and sometimes not mounted on compute nodes. Prefetch resolves the cache directory once — so if $HOME and the compute node disagree, a job can't find models the login node downloaded. Point the cache at a shared scratch filesystem that's visible everywhere, and export it in both the prefetch shell and the job script (the four models total ~23 MB):

export ESCAPEPOD_KMER_CACHE=/scratch/$USER/escapepod/kmer_models

# login node (networked): populate the cache
escpod resquiggle models fetch --all

# job script (same export): resolves from scratch, no network needed
#SBATCH ...
export ESCAPEPOD_KMER_CACHE=/scratch/$USER/escapepod/kmer_models
escpod resquiggle reads.pod5 -b calls.bam --kmer-model rna004 -o refined.bam

Input Requirements

POD5 File

The input POD5 file (or directory of POD5 files) must contain the raw signal data for the reads present in the BAM file.

BAM File

The BAM file must contain:

  • Read names that are UUIDs matching POD5 read IDs
  • mv tag (move table) from the basecaller, encoding stride and per-block move decisions
  • Sequence from basecalling

The following optional BAM tags are used when present:

Tag Description
sm Signal mean (scaling)
sd Signal standard deviation (scaling)
sp Parent signal offset
ts Trimmed signal length
ns Subread signal length

Kmer Level Table

A tab-delimited file mapping kmers to expected signal levels. Each row contains a kmer sequence and its expected pA level. The kmer size must be odd (e.g., 9).

  Kmer table file (TSV):

  AAAAAAAAA    0.958
  AAAAAAAAC    1.023
  AAAAAAAAG    0.912
  ...          ...
  UUUUUUUUU    0.445

The table is loaded into a flat lookup array indexed by 2-bit encoding (A=0, C=1, G=2, T/U=3) for O(1) per-kmer lookup.

From sequence to expected levels

A sliding window of size k extracts the kmer at each position. Each kmer maps to an expected signal level from the table. The level is assigned to the dominant base position within the kmer (determined automatically by a Kruskal-Wallis H test over all table entries to find which position most influences the level).

  Basecalled sequence (from BAM):

  Position:   0   1   2   3   4   5   6   7   8   9  10  11
  Base:       A   C   G   U   A   G   C   U   A   G   C   A

  Sliding 9-mer window (k=9):
                                                     dominant
  pos 0:    [ A C G U A G C U A ]  ──▶  level[0]  ─▶  base
  pos 1:      [ C G U A G C U A G ]  ──▶  level[1]     pos
  pos 2:        [ G U A G C U A G C ]  ──▶  level[2]    │
  pos 3:          [ U A G C U A G C A ]  ──▶  level[3]  │
                                                        ▼
  Expected                                      (e.g., center
  levels:   L0  L1  L2  L3  L4  L5  L6  L7 ...   of the kmer)

How expected levels drive the DP

The DP aligns the raw signal against the expected level sequence. At each candidate boundary, the cost is the squared error between the measured signal samples and the expected level for that base. The DP finds the set of boundaries that minimizes total squared error (plus any dwell penalty).

  signal ▲
         │    ╱╲
         │   ╱  ╲       ╱╲          expected level
   L0    │──╱────╲─────╱──╲── ─ ─ ─ ─ ─ ─ ─ ─ ─
         │ ╱      ╲   ╱    ╲
         │╱        ╲─╱      ╲
         │          ╲         ╲           ╱╲
   L1    │─ ─ ─ ─ ─ ╲─ ─ ─ ─ ╲─ ─ ─ ─ ╱──╲─ ─
         │            ╲        ╲       ╱    ╲
         │             ╲        ╲─────╱      ╲
   L2    │─ ─ ─ ─ ─ ─ ─╲─ ─ ─ ─╲─ ─ ─ ─ ─ ─╲─
         │               ╲       ╲╱            ╲
         └────────┬───────┬───────┬──────────┬──▶ time
                base 0  base 1  base 2     base 3

         cost = sum of (signal - expected_level)^2 within each base

When --normalize mad is used, the kmer levels are MAD-normalized before alignment: (level - median) / (MAD * 1.4826), centering them at zero with unit dispersion.

Output

The output BAM file contains all input records with refined signal-to-base mapping stored in auxiliary tags:

Tag Type Description
rs B:I (uint32 array) Refined signal boundaries per base (in full-signal coordinates)
rc f (float) Refined calibration scale
ro f (float) Refined calibration offset
rd f (float) Refined calibration drift (per-sample trend)
ri C (uint8) RNA indicator (1 = RNA mode, signal was reversed); only present with --rna

Records that could not be refined (missing POD5 data, no move table, etc.) are written through unchanged.

RNA mode

For RNA data, the raw nanopore signal is acquired 3'→5', but the basecaller reverses it to 5'→3' before basecalling. The BAM move table (mv) remains in raw signal order (3'→5'), opposite to the sequence direction. When --rna is specified, the CLI reverses the trimmed signal and inverts the query-to-signal map so the DP operates in the same 5'→3' orientation as the basecaller.

The rs boundaries are offset so they index directly into the full reversed raw signal (matching fishnet's convention). To recover per-base signal: 1. Read the full raw signal from POD5 2. Reverse it 3. Use rs boundaries directly: reversed_raw[rs[i]..rs[i+1]]

The ri:C:1 tag signals to downstream tools that RNA reversal was applied.

Processing Phases

The command runs in three phases:

┌─────────────────────────────────────────────────────────────────┐
│                        Phase 1: LOAD                            │
│                                                                 │
│  ┌──────────┐   ┌───────────────┐   ┌──────────────────────┐   │
│  │ kmer     │   │ BAM           │   │ POD5                 │   │
│  │ table    │   │ read IDs +    │   │ signal extraction    │   │
│  │          │   │ move tables   │   │ (bulk, by read ID)   │   │
│  └────┬─────┘   └───────┬───────┘   └──────────┬───────────┘   │
│       │                 │                       │               │
│       ▼                 ▼                       ▼               │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │              Matched reads (POD5 ∩ BAM)                  │   │
│  └──────────────────────────┬───────────────────────────────┘   │
└─────────────────────────────┼───────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Phase 2: REFINE (parallel)                   │
│                                                                 │
│  For each matched read:                                         │
│                                                                 │
│  ┌────────────────────────────────────────────────────────┐     │
│  │  1. Initial scaling from POD5 calibration + BAM tags   │     │
│  │  2. Rough rescale (Theil-Sen on quantiles)             │     │
│  │  3. Iterative DP refinement + rescale loop:            │     │
│  │     ┌────────────────────────────────────────────┐     │     │
│  │     │  normalize signal ──▶ banded DP ──▶ rescale│──┐  │     │
│  │     └────────────────────────────────────────────┘  │  │     │
│  │              ▲                                      │  │     │
│  │              └──────────── repeat N iterations ─────┘  │     │
│  └────────────────────────────────────────────────────────┘     │
└─────────────────────────────┬───────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                       Phase 3: WRITE                            │
│                                                                 │
│  Output BAM with rs/rc/ro tags added to refined records         │
└─────────────────────────────────────────────────────────────────┘

Algorithms

Banded Dynamic Programming

The DP operates within a band around the initial move-table alignment to avoid exploring the entire signal x sequence space. The --half-bandwidth parameter controls how far from the initial alignment the DP can search.

  Signal position ──▶
  0         10        20        30        40        50
  ├─────────┼─────────┼─────────┼─────────┼─────────┤

  B  ░░░░░▓▓▓▓░░░░░
  a       ░░░░░▓▓▓▓░░░░░
  s            ░░░░░▓▓▓▓░░░░░
  e                 ░░░░░▓▓▓▓░░░░░
  ▼                      ░░░░░▓▓▓▓░░░░░

  ░ = band (allowed search region)
  ▓ = initial alignment from move table
  half_bandwidth = 5 in this example

The DP finds the lowest-cost path through the band, where cost is the squared error between the measured signal and the expected kmer level at each position.

Refinement (--algo)

  • dwell-penalty (default) -- Banded DP with an asymmetric dwell penalty. Short dwells get a strong quadratic penalty to prevent degenerate single-sample bases; long dwells get a gentle logarithmic nudge that is easily overcome by good signal fit, preserving genuine long dwells.

  • viterbi -- Standard Viterbi-style banded DP without dwell penalty.

  Dwell penalty (asymmetric)

  penalty
  ▲
  │▓
  │▓
  │ ▓                    quadratic        │    logarithmic
  │ ▓                   (short dwell)     │   (long dwell)
  │  ▓                                   │
  │  ▓                                   │
  │   ▓                                  │
  │    ▓▓                                │
  │      ▓▓▓                             │
  │         ▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▶
  └──────────────┬───────────────────────────────────────▶ dwell
                target

When --dwell-target 0 (the default), the target is automatically set to the median dwell time from the move table, adapting to the read's actual signal-to-base ratio.

Rescaling (--rescale)

After each DP iteration, the signal scale and shift are re-estimated to correct for calibration drift between the basecaller model and the kmer level table.

  • theil-sen (default) -- Robust Theil-Sen regression for estimating signal scale and shift, resistant to outliers.
  • least-squares -- Least-squares regression with dwell filtering and level truncation.
  Rescaling: fit measured signal to expected levels

  measured ▲
  signal   │            ╱
           │          ╱  ○        ○ = per-base mean signal
           │        ╱ ○            vs expected kmer level
           │   ○  ╱
           │    ╱ ○
           │  ╱○               slope  = new scale
           │╱  ○               offset = new shift
           └──────────────────▶
                expected level

Examples

Basic Resquiggle

escpod resquiggle reads.pod5 \
    -b basecalls.bam \
    -k kmer_levels.tsv \
    -o refined.bam

With Multiple Iterations and Normalization

escpod resquiggle pod5_dir/ \
    -b basecalls.bam \
    -k kmer_levels.tsv \
    -o refined.bam \
    --iterations 3 \
    --normalize mad

Tuning Bandwidth and Algorithm

escpod resquiggle reads.pod5 \
    -b basecalls.bam \
    -k kmer_levels.tsv \
    -o refined.bam \
    --algo viterbi \
    --half-bandwidth 10 \
    --rescale least-squares

Custom Dwell Penalty Parameters

escpod resquiggle reads.pod5 \
    -b basecalls.bam \
    -k kmer_levels.tsv \
    -o refined.bam \
    --algo dwell-penalty \
    --dwell-target 36 \
    --dwell-weight 0.3

RNA Data

escpod resquiggle rna_reads.pod5 \
    -b rna_basecalls.bam \
    -k rna004_9mer_levels_v1.txt \
    -o rna_refined.bam \
    --rna

Using Multiple Threads

escpod resquiggle reads.pod5 \
    -b basecalls.bam \
    -k kmer_levels.tsv \
    -o refined.bam \
    -j 8

Algorithm References

The resquiggle algorithms are inspired by:

  • Banded DP refinement and signal rescaling: Inspired by fishnet by Brickner et al. This is an independent implementation of the published algorithm, not a derivative of fishnet's source.

Notes

  • Only reads present in both the BAM and POD5 input are refined; all BAM records are still written to the output.
  • Signal data is bulk-extracted before the parallel refinement phase, so POD5 files are not accessed during refinement.
  • Error diagnostics are printed at the end, showing aggregated counts of skip reasons.
  • The --half-bandwidth parameter controls the width of the DP band around the initial move-table alignment. Larger values allow more deviation from the basecaller's initial mapping but increase computation.
  • A @PG header record is added to the output BAM with the resquiggle command line parameters.