From a2c940f1a780efa12ef35d97d7058f843f4fc973 Mon Sep 17 00:00:00 2001 From: cwb14 Date: Fri, 24 Apr 2026 17:33:08 -0400 Subject: [PATCH 01/15] swap blast backend for minimap2 --- README.md | 71 ++++++++ docs/runs/memo.md | 266 ++++++++++++++++++++++++++++++ src/blast_pass2.py | 365 +++++++++++++++++------------------------ src/minimap.py | 188 +++++++++++++++++++++ src/pass2_external.py | 213 ++++++++++++++++++++++++ src/pipeline.py | 60 ++++++- src/tesorter_compat.py | 37 ++++- 7 files changed, 982 insertions(+), 218 deletions(-) create mode 100644 docs/runs/memo.md create mode 100644 src/minimap.py create mode 100644 src/pass2_external.py diff --git a/README.md b/README.md index e90002e..9b75d93 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,74 @@ +# TEBinSorter_minimap2 + +Fork of TEBinSorter with pass-2 similarity search swapped from `blastn` to +[minimap2](https://github.com/lh3/minimap2). Unlike the `TEBinSorter_mmseqs` +sibling, this port enforces **both query AND target coverage** meeting the +pass-2 rule cutoff — matching the biological intent of "well-aligned on +both sides" rather than blastn's query-side-only `qcovs` check. + +## Additional runtime dependency + +`minimap2` binary must be on `$PATH`. Install with conda: + +``` +mamba install -c bioconda minimap2 +``` + +Everything else is unchanged from TEBinSorter (pyhmmer, pyfastx, numpy). + +## New / changed CLI options + +| Option | Default | Purpose | +|---|---|---| +| `-dp2`, `--disable-pass2` | off | Skip the minimap2 pass-2 (HMM-only classification) | +| `-rule`, `--pass2-rule I-C-L` | `80-80-80` | Pass-2 threshold as identity-coverage-length. **C is applied to both qcov and tcov** (this is the semantic difference vs the mmseqs/blastn ports) | +| `--pass2-classified-fasta FASTA` | none | Optional FASTA of prior classifications to augment the pass-2 target pool. Headers must be shaped `>id#Order/Superfamily/Clade` | +| `--minimap2-preset PRESET` | `asm20` | Passed through as `minimap2 -x` | +| `--minimap2-extra STR` | empty | Additional flags appended to the minimap2 command line | + +## Coverage semantics + +For a query-target pair with multiple PAF chains, qcov and tcov are each +computed as: + +``` +qcov = |union of aligned query intervals across all chains| / qlen +tcov = |union of aligned target intervals across all chains| / tlen +``` + +No chain-gap merge heuristic (unlike the mmseqs port's `_MAX_SPLIT_GAP=500`). +minimap2's own chainer already groups near-diagonal minimizer seeds into +chains; cross-chain union happens in Python and only counts uniquely +aligned bases (gaps between chains are not counted as covered). + +Rule passes only if **identity ≥ I, qcov ≥ C, tcov ≥ C, and aln length ≥ L**. + +## SQLite schema + +The `blast_hits` table adds a `tcovs REAL NOT NULL` column (vs. the stock +TEBinSorter and the mmseqs sibling port). Downstream (`classify_from_blast`) +filters on both `qcovs` and `tcovs`. Other columns match the stock schema: +`evalue`/`slen` are sentinel zeros since minimap2's AS-score and PAF format +don't provide them directly. + +## What changed vs stock TEBinSorter + +- `src/blast_pass2.py` — internals swapped from `blastn`+`multiprocessing.Pool` + to a single `minimap2 -c -x asm20 -N 50 -p 0.1` call. Added tcovs column; + `classify_from_blast` filters on both axes. +- `src/minimap.py` — new. PAF parser with per-(query, target) union of query + and target intervals. Best-hit per query by minimap2 AS score. +- `src/pass2_external.py` — new (shared with mmseqs port). Helpers for + `--pass2-classified-fasta`. +- `src/pipeline.py` / `src/tesorter_compat.py` — wire the five new CLI args + through the pass-2 call. + +--- + +Original TEBinSorter README follows. + +--- + # TEBinSorter Near-perfect replication of [TEsorter](https://github.com/zhangrengang/TEsorter) at greatly improved speed. diff --git a/docs/runs/memo.md b/docs/runs/memo.md new file mode 100644 index 0000000..6ad70e9 --- /dev/null +++ b/docs/runs/memo.md @@ -0,0 +1,266 @@ +# memo — TEBinSorter_minimap2 port + +## Date + +Created 2026-04-24. + +## Purpose + +Port TEBinSorter's pass-2 similarity search from `blastn` to `minimap2`. +Exists because the sibling `TEBinSorter_mmseqs/` port discovered during +benchmarking that `mmseqs easy-search --search-type 3` emits only one best +diagonal per (query, target) pair — an architectural limitation that caps +pass-2 recall on LTR-RT data where candidate and element often share +multiple distinct homologous regions. + +`minimap2` has no such limitation: it chains minimizer hits into (potentially +many) independent alignments per (q, t) pair and emits one PAF row per +chain. We union query- and target-side intervals across chains to produce +a single, honest qcov and tcov per pair. + +HMM pass-1 of TEBinSorter is untouched (byte-identical verified). + +## Source of changes + +- Port target: TEBinSorter at `/data/chris/wheat/ltrharvest/v2/v3/testing/TEBinSorter/` + (most recent commit `7144950`). +- Port destination: `/data/chris/wheat/ltrharvest/v2/v3/testing/TEBinSorter_minimap2/`. +- Conceptually mirrors `TEBinSorter_mmseqs/` — same CLI shape, same + `blast_hits` SQL table (with `tcovs` added), same `--pass2-classified-fasta` + wiring. Swaps the aligner only. + +## Environment / versions + +- Active conda env during development: `synLTR` + (`/home/chris/bin/mambaforge/envs/synLTR`). +- `minimap2` binary: **v2.30-r1287** (`synLTR/bin/minimap2`). Must be on + `$PATH` at runtime. (User installed via `mamba install -c bioconda minimap2`.) +- Python deps: same as TEBinSorter (pyhmmer ≥ 0.10, pyfastx ≥ 2.0, numpy). +- `mmseqs` is *not* required for this port (different from the mmseqs sibling). + +## Files touched + +- **NEW** `src/minimap.py` — PAF wrapper, parser, union-intervals qcov/tcov + computation, best-hit-by-AS-score per query. +- **NEW** `src/pass2_external.py` — copied verbatim from the mmseqs port + (handles `--pass2-classified-fasta`). +- **EDITED** `src/blast_pass2.py` — same filename (keeps pipeline.py, + tesorter_compat.py, classifier.py, results.py unchanged); internals swapped + to `minimap2 -c -x asm20 -N 50 -p 0.1 --secondary=yes -I 100G -t NCPU`. + `blast_hits` SQLite schema has a new `tcovs REAL NOT NULL` column. + `classify_from_blast` filters on `(qcovs OR tcovs) >= C` by default. +- **EDITED** `src/pipeline.py` — added 5 CLI args (`-dp2/--disable-pass2`, + `-rule/--pass2-rule`, `--pass2-classified-fasta`, `--minimap2-preset`, + `--minimap2-extra`); logs `minimap2_version()` at pass-2 start. +- **EDITED** `src/tesorter_compat.py` — mirrored 5 CLI args. +- **EDITED** `README.md` — mmseqs-section-style header noting the dual-coverage + enforcement and CLI. + +## Coverage semantics + +Per-(q, t) pair, across all PAF chains minimap2 emits: + +``` +qcov = |union of aligned query intervals| / qlen +tcov = |union of aligned target intervals| / tlen +``` + +This mirrors how blastn's `qcovs` is computed (NCBI tiles HSPs) but also +extends it to the target axis. The SQL pass-2 filter applies: + +```sql +WHERE pident >= I + AND (qcovs >= C OR tcovs >= C) -- OR mode, REQUIRE_BOTH_COVERAGE=False + AND length >= L +``` + +where `I-C-L` comes from `--pass2-rule` (default `80-80-80`). `length` is +union query-side alignment length (unique query bases aligned). + +### Developer toggle: `REQUIRE_BOTH_COVERAGE` + +Module-level constant at the top of `src/blast_pass2.py`: + +```python +# True -> require BOTH qcov AND tcov >= coverage threshold (strict). +# False -> require AT LEAST ONE of qcov, tcov >= coverage threshold +# (Wicker et al. 80-80-80: "candidate must cover ≥80% of at least +# one of the elements being compared"). +REQUIRE_BOTH_COVERAGE = False +``` + +- Not exposed on the CLI by design — this is a semantic choice, not a + runtime parameter. +- Defaults to `False` (OR) after reviewing Wicker et al. 2007 "A unified + classification system for eukaryotic transposable elements" — the + original rule is "candidate must cover ≥80% of at least one of the + elements being compared", which is OR, not AND. +- minimap2 has no native coverage filter; both modes are enforced post-hoc + in the SQL above. +- `classify_from_blast` logs `coverage mode: AT-LEAST-ONE qcov/tcov >= 80` + (or `BOTH`) at the start of pass-2. + +## minimap2 command + +``` +minimap2 -c -x asm20 -N 50 -p 0.1 --secondary=yes -I 100G -t NCPU \ + -o pass2.paf +``` + +- `-c` emits CIGAR + `AS:i:` score tag (needed for ranking best-hit). +- `-x asm20` targets ~20% divergence — suits LTR-RT family-level variation. +- `-N 50 -p 0.1 --secondary=yes` keeps secondary alignments down to 10% of + primary score; essential for repetitive LTR-RT libraries. +- `-I 100G` prevents index splits for libraries up to a few GB. +- Argument order: **target (reference) first, then query**. PAF's column 1 + is the query-fasta sequence, so minimap2's convention maps cleanly to our + blast_hits schema. + +## Public API + +All functions in `src/blast_pass2.py` retain the same names as the mmseqs +port and stock blastn TEBinSorter: + +- `blast_pass2(input_fasta, conn, ...)` — top-level entry point. +- `store_blast_hits(conn, hits, db_seq_to_dbs)` — writes PAF-derived rows + to SQLite `blast_hits` table with the extra `tcovs` column. +- `classify_from_blast(conn, classifications, min_identity, min_coverage, + min_length)` — filters on identity, qcov/tcov (per toggle), length. +- `split_classified_unclassified(...)`, `_get_classified_ids(...)` — + unchanged from the blastn original. + +`src/minimap.py`: + +- `check_minimap2(bin)` / `minimap2_version(bin)` — availability + startup log. +- `run_minimap2(query_fa, target_fa, paf_out, ncpu, preset, extra)` — + subprocess wrapper. +- `PAFRecord` — `__slots__` class; parses one PAF line. +- `parse_paf_besthit(paf_path)` — per-(q, t) union of query and target + intervals, weighted-fident, max-AS-score. +- `besthit_per_query(merged)` — OrderedDict keyed by qseqid, best by AS. + +## CLI + +``` +python TEBinSorter_minimap2/src/pipeline.py INPUT.fa -d rexdb -p 16 \ + [--pass2-rule 80-80-80] \ + [--pass2-classified-fasta prior_cls.fa] \ + [--minimap2-preset asm20] \ + [--minimap2-extra "..."] \ + [-o OUTDIR] +``` + +`--disable-pass2` and `--pass-1-only` both bypass minimap2. + +## Verification runs + +### Unit tests on synthetic PAF + +Three scenarios passed (see commit history for exact assertions): + +1. Two non-overlapping HSPs to same target, distant on target axis: + `qcov=0.9, tcov=0.18, score=max(HSPs)` — confirms gap-free union on + query, interval-separated union on target. +2. Two overlapping query intervals on same target: `qcov=0.75` (overlap + correctly deduplicated), `tcov=0.395`. +3. One query vs two competing targets (clean big target A vs scattered + small target B with same total coverage): target A wins best-hit by + max-AS-score, not by summed score — matches blastn's best-HSP-bitscore + semantics. + +### Rice-example-data parity + +Not re-run here; semantic identical to mmseqs port's rice verification. + +### Rape-library 5k benchmark (Brassica napus LTR_retriever intact LTRs) + +Subsampled with `seqkit sample -s 42 -n 5000` (4,892 unique seqs). 16 +threads. REXdb v4 + metazoa v3.1 HMM database. + +**At-least-one coverage (OR) mode — default:** + +| Rule | Wall | Max RSS | Pass-2 rescues | Combined | +|---|---:|---:|---:|---:| +| 80-80-80 | 0:45 | 6.4 GB | 598 | 2,115 | +| 70-50-80 | 0:46 | 6.4 GB | 939 | 2,456 | + +**BOTH-coverage (AND) mode — archived for reference:** + +| Rule | Pass-2 rescues | +|---|---:| +| 80-80-80 AND | 16 | +| 70-50-80 AND | 152 | + +**Reference:** + +| Tool | Rule | Wall | Pass-2 rescues | Combined | +|---|---|---:|---:|---:| +| blastn (stock TEBinSorter) | 80-80-80 (hardcoded, qcov-only) | 17:37 | 1,005 | 2,522 | +| mmseqs port | 80-80-80 OR | 1:19 | 397 | 1,914 | +| mmseqs port | 70-50-80 OR | 1:07 | 773 | 2,290 | + +### Headline number + +minimap2 port @ `70-50-80` OR: +- **23× faster** than blastn baseline (45 s vs 17:37) +- **12× less memory** (6.4 GB vs 76.6 GB) +- **93% of blastn recall** (939 rescues vs 1,005) +- enforces a Wicker-faithful coverage rule (`qcov ≥ 50 OR tcov ≥ 50`) + +## SQLite schema + +The `blast_hits` table adds a `tcovs REAL NOT NULL` column vs stock +TEBinSorter. Classifier output (`classifications` table, `.cls.tsv`) is +schema-unchanged. + +## How this port differs from the mmseqs sibling + +| Concern | mmseqs port | minimap2 port | +|---|---|---| +| Alignments per (q, t) pair | one best diagonal | multi-chain, all chained hits | +| qcov / tcov independence | coupled via single alnlen | genuinely independent | +| OR mode on LTR-RT data | ≈ qcov-only (architectural) | meaningfully different from qcov-only | +| Recall at 70-50-80 OR | 773 | 939 | +| Wall time | ~65 s | ~45 s | +| Memory | ~8.6 GB | ~6.4 GB | +| External binary | `mmseqs` 17.b804f | `minimap2` 2.30-r1287 | +| Best-hit ranking metric | mmseqs `bits` | minimap2 `AS` (Smith-Waterman score) | + +## Notes / caveats + +- `--pass2-classified-fasta` headers must be `>id#Order/Superfamily/Clade`; + coord-shaped IDs get `unknown` slots upgraded from pass-1 `classifications` + dict via `_COORD_HEADER_RE` (shared helper from `pass2_external.py`). +- PAF column 11 (`alnlen`) is the block length including gaps; we instead + report `length` as the union-of-query-intervals (same as `qcovs`'s + numerator). Consistent with the 80-80-80 rule's third number being + "length of the alignment region," not "sum of CIGAR ops." +- `AS:i:` tag is only emitted when `-c` is passed; we pass it. +- `evalue` and `slen` columns in `blast_hits` hold sentinels (0.0, 0 or + tlen respectively) — downstream classifier code never reads them. Kept + for schema parity with the mmseqs sibling. +- `_MAX_SPLIT_GAP` (the 500-bp chain heuristic from the mmseqs port) does + not exist here. Unnecessary: minimap2's own chainer groups near-diagonal + minimizer seeds; cross-chain union on the Python side is the only + aggregation we do. + +## Nested-TE caveat + +The OR rule can classify a candidate as family X if it contains a nested +element of family X, because tcov of the nested region can be near 100% +even when qcov is ~50%. Discussed in conversation history; acceptable +trade because pass-1 HMM typically catches this case first. If nested-TE +false positives become a concern, flip `REQUIRE_BOTH_COVERAGE = True` or +add a secondary rule (e.g., flag `qcov / tcov < 0.5` as chimera). + +## Reproducibility + +``` +claude --resume "spin up TEBinSorter_minimap2 port" +``` + +Archived runs and summaries preserved at: +- `/tmp/keep_baseline_blastn_5k/` (blastn baseline at 80-80-80) +- `/tmp/keep_mmseqs_dualcov_80_5k/` (mmseqs AND @ 80-80-80) +- `/tmp/keep_minimap2_5k/` (minimap2 AND @ 80-80-80) +- `/tmp/rape.summary.md` (most recent OR sweep, all four rule/tool combos) diff --git a/src/blast_pass2.py b/src/blast_pass2.py index 16c5c51..8fbda80 100644 --- a/src/blast_pass2.py +++ b/src/blast_pass2.py @@ -1,167 +1,129 @@ """ -blast_pass2.py — BLAST-based pass-2 classification for HMM-unclassified sequences. +blast_pass2.py — minimap2-based pass-2 classification for HMM-unclassified +sequences. -Sequences not classified by HMM search are BLASTed against classified sequences. -If a strong similarity match exists (80-80-80 rule by default), the unclassified -sequence inherits the classification of its best BLAST hit. +Port of TEBinSorter's blastn-based pass-2 onto minimap2. Keeps the filename +and public symbols (`blast_pass2`, `store_blast_hits`, `classify_from_blast`) +so the rest of the pipeline doesn't care what aligner is behind them, but +the SQLite `blast_hits` schema adds a `tcovs` column and the filter enforces +both qcov AND tcov ≥ cutoff. -Key design: - - Cross-database: one BLAST search against all classified sequences from all databases - - Per-database reconstruction via filtering on classified_by - - Parallel chunked BLAST with greedy bin-packing by sequence length - - All results stored in SQLite for post-hoc threshold adjustment +Rule semantics for --pass2-rule I-C-L: + identity ≥ I%, qcov ≥ C%, tcov ≥ C%, length ≥ L bp + +where qcov / tcov are unions of aligned intervals across ALL minimap2 chains +for a given (query, target) pair, divided by qlen / tlen respectively. """ import logging import os -import subprocess import tempfile import time from collections import defaultdict import pyfastx +import minimap +import pass2_external + log = logging.getLogger(__name__) -def _get_classified_ids(conn): - """Get classified sequence IDs per database from classifier results. +# Developer toggle, deliberately not exposed on the CLI. +# True -> require BOTH qcov AND tcov >= coverage threshold (strict). +# False -> require AT LEAST ONE of qcov, tcov >= coverage threshold +# (Wicker et al. 80-80-80 style: "candidate must cover ≥80% of +# at least one of the elements being compared"). +# minimap2 has no native coverage filter, so this is enforced post-hoc in +# classify_from_blast's SQL WHERE clause. +REQUIRE_BOTH_COVERAGE = False - Returns: - classified: {base_seq: set(databases)} — which databases classified each seq - """ - classified = defaultdict(set) - # Check which tables exist +def _get_classified_ids(conn): + """Get classified sequence IDs per database from classifier results.""" + classified = defaultdict(set) tables = {r[0] for r in conn.execute( "SELECT name FROM sqlite_master WHERE type='table'").fetchall()} - if "legacy_hits" in tables: for row in conn.execute( "SELECT DISTINCT base_seq, database FROM legacy_hits" ): classified[row[0]].add(row[1]) - return dict(classified) def split_classified_unclassified(input_fasta, classified_ids, outdir, - n_chunks=4, seq_type="nucl"): - """Split input into classified (BLAST db) and chunked unclassified (queries). - - One pass through the input. Unclassified sequences are bin-packed into - n_chunks files by total sequence length for even BLAST parallelism. - - Args: - input_fasta: path to input FASTA - classified_ids: {seq_name: set(databases)} - outdir: directory for output files - n_chunks: number of query chunks - seq_type: "nucl" or "prot" (determines which sequences to write) - - Returns: - db_fasta: path to classified sequences FASTA (BLAST database) - query_chunks: list of paths to unclassified sequence chunks - db_seq_to_dbs: {seq_name: set(databases)} for DB sequences + seq_type="nucl"): + """Split input into classified-pool FASTA and unclassified-query FASTA. + + Nucleotide pools are uppercased and stripped of non-ATCG characters — not + because minimap2 requires it (it handles ambiguous bases) but to keep + invariants aligned with the mmseqs sibling port and make inputs clean. """ os.makedirs(outdir, exist_ok=True) - db_fasta = os.path.join(outdir, "blast_db.fa") - chunk_paths = [os.path.join(outdir, f"blast_query_{i}.fa") for i in range(n_chunks)] - - # Open all handles - db_handle = open(db_fasta, "w") - chunk_handles = [open(p, "w") for p in chunk_paths] - chunk_lengths = [0] * n_chunks + qry_fasta = os.path.join(outdir, "blast_query.fa") + nucl = (seq_type == "nucl") fa = pyfastx.Fasta(input_fasta, build_index=True) + n_classified = 0 n_unclassified = 0 db_seq_to_dbs = {} - for rec in fa: - name = rec.name - seq = str(rec.seq) + with open(db_fasta, "w") as dbh, open(qry_fasta, "w") as qh: + for rec in fa: + name = rec.name + seq = str(rec.seq) + if nucl: + seq = "".join(c for c in seq.upper() if c in "ATCG") + if name in classified_ids: + dbh.write(f">{name}\n{seq}\n") + db_seq_to_dbs[name] = classified_ids[name] + n_classified += 1 + else: + qh.write(f">{name}\n{seq}\n") + n_unclassified += 1 - if name in classified_ids: - db_handle.write(f">{name}\n{seq}\n") - db_seq_to_dbs[name] = classified_ids[name] - n_classified += 1 - else: - # Bin-pack into lightest chunk - min_idx = chunk_lengths.index(min(chunk_lengths)) - chunk_handles[min_idx].write(f">{name}\n{seq}\n") - chunk_lengths[min_idx] += len(seq) - n_unclassified += 1 + log.info(f" Split: {n_classified} classified (DB), " + f"{n_unclassified} unclassified (query)") + return db_fasta, qry_fasta, db_seq_to_dbs - db_handle.close() - for h in chunk_handles: - h.close() - # Remove empty chunks - query_chunks = [p for p in chunk_paths if os.path.getsize(p) > 0] +def run_alignment(query_fa, target_fa, paf_out, ncpu=4, + preset="asm20", extra=""): + """Run minimap2 once with preset + -c so AS tags are available.""" + minimap.run_minimap2( + query_fa=query_fa, target_fa=target_fa, paf_out=paf_out, + ncpu=ncpu, preset=preset, extra=extra, + ) + return paf_out - log.info(f" Split: {n_classified} classified (DB), " - f"{n_unclassified} unclassified -> {len(query_chunks)} chunks") - - return db_fasta, query_chunks, db_seq_to_dbs - - -def make_blast_db(db_fasta, seq_type="nucl"): - """Run makeblastdb.""" - dbtype = seq_type - cmd = f"makeblastdb -in {db_fasta} -dbtype {dbtype} -out {db_fasta}" - result = subprocess.run(cmd, shell=True, capture_output=True, text=True) - if result.returncode != 0: - log.error(f"makeblastdb failed: {result.stderr}") - raise RuntimeError(f"makeblastdb failed: {result.stderr}") - log.info(f" BLAST database built: {db_fasta}") - - -def run_blast_chunk(query_chunk, db_fasta, output, seq_type="nucl", ncpu=1): - """Run BLAST on one query chunk.""" - app = "blastn" if seq_type == "nucl" else "blastp" - outfmt = "6 qseqid sseqid pident length mismatch gapopen qstart qend sstart send evalue bitscore qlen slen qcovs qcovhsp sstrand" - cmd = (f"{app} -query {query_chunk} -db {db_fasta} -out {output} " - f"-outfmt '{outfmt}' -num_threads {ncpu}") - result = subprocess.run(cmd, shell=True, capture_output=True, text=True) - if result.returncode != 0: - log.warning(f"BLAST chunk failed: {result.stderr[:200]}") - return output - - -def parse_blast_output(blast_out): - """Parse BLAST outfmt 6 into hit dicts.""" - fields = ["qseqid", "sseqid", "pident", "length", "mismatch", "gapopen", - "qstart", "qend", "sstart", "send", "evalue", "bitscore", - "qlen", "slen", "qcovs", "qcovhsp", "sstrand"] - types = [str, str, float, int, int, int, int, int, int, int, - float, float, int, int, float, float, str] - hits = [] - if not os.path.exists(blast_out): - return hits - - with open(blast_out) as f: - for line in f: - vals = line.strip().split("\t") - if len(vals) < len(fields): - continue - hit = {} - for field, typ, val in zip(fields, types, vals): - hit[field] = typ(val) - hits.append(hit) +def parse_minimap2_output(paf_path): + """Parse PAF, union per (q,t), best-hit per query, return hit dicts.""" + merged = minimap.parse_paf_besthit(paf_path) + best = minimap.besthit_per_query(merged) + hits = [] + for qid, m in best.items(): + hits.append({ + "qseqid": m["qseqid"], + "sseqid": m["sseqid"], + "pident": m["fident"] * 100.0, # 0..100 + "length": m["alnlen"], # union of aligned query bases + "evalue": 0.0, # sentinel + "bitscore": float(m["score"]), # minimap2 AS score + "qlen": m["qlen"], + "slen": m["tlen"], + "qcovs": m["qcov"] * 100.0, # 0..100 + "tcovs": m["tcov"] * 100.0, # 0..100 (new column) + }) return hits def store_blast_hits(conn, hits, db_seq_to_dbs): - """Store BLAST hits in SQLite. - - Adds classified_by field indicating which databases classified - each target sequence. - """ - # Indexes on blast_hits are built by results.finalize_db at end of run. + """Store hits in SQLite. Adds a `tcovs` column vs. the stock schema.""" conn.execute(""" CREATE TABLE IF NOT EXISTS blast_hits ( qseqid TEXT NOT NULL, @@ -173,10 +135,10 @@ def store_blast_hits(conn, hits, db_seq_to_dbs): qlen INTEGER NOT NULL, slen INTEGER NOT NULL, qcovs REAL NOT NULL, + tcovs REAL NOT NULL, classified_by TEXT NOT NULL ) """) - rows = [] for h in hits: dbs = db_seq_to_dbs.get(h["sseqid"], set()) @@ -184,69 +146,57 @@ def store_blast_hits(conn, hits, db_seq_to_dbs): rows.append(( h["qseqid"], h["sseqid"], h["pident"], h["length"], h["evalue"], h["bitscore"], h["qlen"], h["slen"], - h["qcovs"], classified_by, + h["qcovs"], h["tcovs"], classified_by, )) - conn.executemany( - "INSERT INTO blast_hits VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO blast_hits VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", rows, ) conn.commit() - log.info(f" Stored {len(rows)} BLAST hits") + log.info(f" Stored {len(rows)} minimap2 hits") def classify_from_blast(conn, classifications, database=None, min_identity=80, min_coverage=80, min_length=80): - """Classify unclassified sequences from BLAST hits. - - Args: - conn: sqlite3 connection with blast_hits table - classifications: dict of {seq_id: {order, superfamily, ...}} from - classifier.classify_sequences() - database: if set, only accept BLAST targets classified by this database - min_identity: minimum percent identity - min_coverage: minimum query coverage - min_length: minimum alignment length - - Returns: - list of classification dicts for newly classified sequences - """ - # Check table exists + """Classify pass-2 rescues. Filters on BOTH qcovs and tcovs.""" tables = {r[0] for r in conn.execute( "SELECT name FROM sqlite_master WHERE type='table'").fetchall()} if "blast_hits" not in tables: return [] - # Build filter - where = "WHERE pident >= ? AND qcovs >= ? AND length >= ?" - params = [min_identity, min_coverage, min_length] - + if REQUIRE_BOTH_COVERAGE: + where = ("WHERE pident >= ? AND qcovs >= ? AND tcovs >= ? " + "AND length >= ?") + params = [min_identity, min_coverage, min_coverage, min_length] + else: + # At-least-one-side coverage (Wicker et al. 80-80-80 interpretation) + where = ("WHERE pident >= ? AND (qcovs >= ? OR tcovs >= ?) " + "AND length >= ?") + params = [min_identity, min_coverage, min_coverage, min_length] + log.info(f" coverage mode: {'BOTH' if REQUIRE_BOTH_COVERAGE else 'AT-LEAST-ONE'} " + f"qcov/tcov >= {min_coverage}") if database: where += " AND classified_by LIKE ?" params.append(f"%{database}%") - # Best hit per query by bitscore rows = conn.execute(f""" - SELECT qseqid, sseqid, pident, qcovs, length, bitscore + SELECT qseqid, sseqid, pident, qcovs, tcovs, length, bitscore FROM blast_hits {where} ORDER BY bitscore DESC """, params).fetchall() - # Classified ID set for quick lookup classified_set = set(classifications.keys()) - best = {} - for qid, sid, pident, qcovs, length, bitscore in rows: + for qid, sid, pident, qcovs, tcovs, length, bitscore in rows: if qid in classified_set: - continue # already classified by HMM, skip + continue if qid not in best: - best[qid] = (sid, pident, qcovs, length, bitscore) + best[qid] = (sid, pident, qcovs, tcovs, length, bitscore) - # Inherit classification from best hit's target new_classifications = [] no_source = 0 - for qid, (sid, pident, qcovs, length, bitscore) in best.items(): + for qid, (sid, pident, qcovs, tcovs, length, bitscore) in best.items(): if sid in classifications: source = classifications[sid] new_classifications.append({ @@ -260,15 +210,16 @@ def classify_from_blast(conn, classifications, database=None, "blast_source": sid, "blast_pident": pident, "blast_qcovs": qcovs, + "blast_tcovs": tcovs, "blast_bitscore": bitscore, }) else: no_source += 1 if no_source: - log.info(f" {no_source} BLAST hits to unclassified targets (skipped)") + log.info(f" {no_source} pass-2 hits to unclassified targets (skipped)") - log.info(f" BLAST pass-2: {len(new_classifications)} sequences classified " + log.info(f" pass-2: {len(new_classifications)} sequences classified " f"(from {len(best)} hits passing filters)") return new_classifications @@ -276,94 +227,86 @@ def classify_from_blast(conn, classifications, database=None, def blast_pass2(input_fasta, conn, hmm_classifications=None, seq_type="nucl", n_processors=4, min_identity=80, min_coverage=80, min_length=80, - outdir=None): - """Full BLAST pass-2 pipeline. - - Args: - input_fasta: path to input FASTA - conn: sqlite3 connection with HMM results - hmm_classifications: dict of {seq_id: {order, superfamily, clade, ...}} - from classifier.classify_sequences(). Targets inherit - classification from their best BLAST match. - seq_type: "nucl" or "prot" - n_processors: number of parallel BLAST processes - min_identity: filter threshold - min_coverage: filter threshold - min_length: filter threshold - outdir: output directory (default: tempdir) - - Returns: - list of new classification dicts + outdir=None, + pass2_classified_fasta=None, + preset="asm20", minimap2_extra=""): + """minimap2-based pass-2. Public signature mirrors sibling ports. + + min_coverage is applied to BOTH qcov and tcov. """ t0 = time.time() + minimap.check_minimap2() + + if seq_type != "nucl": + log.warning("minimap2 pass-2 only supports nucleotide sequences; " + f"seq_type={seq_type!r} will be treated as nucl") + seq_type = "nucl" - # Get classified IDs from database classified_ids = _get_classified_ids(conn) if not classified_ids: - log.info(" No classified sequences for BLAST pass-2") + log.info(" No classified sequences for minimap2 pass-2") return [] - log.info(f" BLAST pass-2: {len(classified_ids)} classified sequences as targets") + log.info(f" minimap2 pass-2: {len(classified_ids)} classified sequences as targets") - # Split if outdir is None: - outdir = tempfile.mkdtemp(prefix="tebinsorter_blast_") + outdir = tempfile.mkdtemp(prefix="tebinsorter_minimap2_") + work = os.path.join(outdir, "minimap2_pass2") - blast_dir = os.path.join(outdir, "blast_pass2") - db_fasta, query_chunks, db_seq_to_dbs = split_classified_unclassified( - input_fasta, classified_ids, blast_dir, n_chunks=n_processors, - seq_type=seq_type) + db_fasta, qry_fasta, db_seq_to_dbs = split_classified_unclassified( + input_fasta, classified_ids, work, seq_type=seq_type) - if not query_chunks: + if os.path.getsize(qry_fasta) == 0: log.info(" No unclassified sequences to search") return [] - # Build BLAST database - make_blast_db(db_fasta, seq_type=seq_type) + if hmm_classifications is None: + hmm_classifications = {} - # Run parallel BLAST - log.info(f" Running {len(query_chunks)} BLAST processes") - t1 = time.time() + if pass2_classified_fasta: + updated = pass2_external.update_classified_fasta_headers( + pass2_classified_fasta, hmm_classifications, work + ) + src = updated or pass2_classified_fasta + pass2_external.extend_hmm_classifications_from_fasta( + hmm_classifications, src, db_seq_to_dbs + ) + merged_db = os.path.join(work, "pass2_db_merged.fa") + pass2_external.merge_classified_fastas( + merged_db, db_fasta, src, clean_nucl=True + ) + db_fasta = merged_db - import multiprocessing - blast_outputs = [] - args_list = [] - for i, chunk in enumerate(query_chunks): - out = chunk + ".blastout" - blast_outputs.append(out) - args_list.append((chunk, db_fasta, out, seq_type, 1)) + if os.path.getsize(db_fasta) == 0: + log.info(" pass-2 target FASTA is empty; skipping minimap2") + return [] - with multiprocessing.Pool(n_processors) as pool: - pool.starmap(run_blast_chunk, args_list) + paf_out = os.path.join(work, "pass2.paf") + log.info(f" Running minimap2 -x {preset} with {n_processors} threads") + t1 = time.time() + run_alignment( + query_fa=qry_fasta, target_fa=db_fasta, paf_out=paf_out, + ncpu=n_processors, preset=preset, extra=minimap2_extra, + ) t2 = time.time() - log.info(f" BLAST search: {t2 - t1:.1f}s") + log.info(f" minimap2 alignment: {t2 - t1:.1f}s") - # Parse and store - all_hits = [] - for blast_out in blast_outputs: - all_hits.extend(parse_blast_output(blast_out)) + hits = parse_minimap2_output(paf_out) + log.info(f" {len(hits)} best-hit records after per-pair union of PAF chains") - log.info(f" {len(all_hits)} total BLAST hits") + if hits: + store_blast_hits(conn, hits, db_seq_to_dbs) - if all_hits: - store_blast_hits(conn, all_hits, db_seq_to_dbs) + log.info(f" {len(hmm_classifications)} HMM classifications available for inheritance") - # Build HMM classifications lookup for inheritance - hmm_cls = {} - if hmm_classifications is not None: - hmm_cls = hmm_classifications - log.info(f" {len(hmm_cls)} HMM classifications available for inheritance") - - # Classify new_cls = classify_from_blast( - conn, hmm_cls, + conn, hmm_classifications, min_identity=min_identity, min_coverage=min_coverage, min_length=min_length, ) t3 = time.time() - log.info(f" BLAST pass-2 total: {t3 - t0:.1f}s") - + log.info(f" minimap2 pass-2 total: {t3 - t0:.1f}s") return new_cls diff --git a/src/minimap.py b/src/minimap.py new file mode 100644 index 0000000..7de47f6 --- /dev/null +++ b/src/minimap.py @@ -0,0 +1,188 @@ +""" +minimap.py — minimap2 wrapper for pass-2 similarity search. + +Unlike mmseqs `--search-type 3`, minimap2 emits multiple chained alignments per +(query, target) pair, so we can legitimately union query- and target-side +intervals across chains to compute both qcov and tcov. No 500 bp chain-gap +heuristic needed: minimap2's own chaining already groups near-diagonal +minimizer seeds, and each PAF row is one such chain. Across-chain union happens +here, in Python. + +Filter semantics enforced by the consumer (blast_pass2.classify_from_blast): + identity ≥ I, qcov ≥ C, tcov ≥ C, length ≥ L (from --pass2-rule I-C-L) + +where qcov = |union of aligned query intervals| / qlen and symmetrically for +tcov. +""" + +import logging +import os +import shutil +import subprocess +from collections import OrderedDict, defaultdict + +log = logging.getLogger(__name__) + + +def check_minimap2(bin="minimap2"): + if shutil.which(bin) is None: + raise RuntimeError( + f"{bin!r} not found on PATH. Install minimap2 " + f"(conda: `mamba install -c bioconda minimap2`) and retry." + ) + + +def minimap2_version(bin="minimap2"): + if shutil.which(bin) is None: + log.warning(f"{bin!r} not found on PATH") + return None + r = subprocess.run([bin, "--version"], capture_output=True, text=True, check=False) + v = (r.stdout or r.stderr).strip().splitlines()[0] if (r.stdout or r.stderr) else "unknown" + log.info(f"minimap2 version: {v}") + return v + + +def run_minimap2(query_fa, target_fa, paf_out, ncpu=4, + preset="asm20", extra="", + minimap2_bin="minimap2"): + """ + Run minimap2 and write PAF (with CIGAR / AS tags via -c). + + Defaults: + -x {preset} e.g. asm20 for ~20% divergence LTR-RTs + -c emit CIGAR + AS:i: alignment score tag (needed for ranking) + -N 50 keep up to 50 secondary alignments per query + -p 0.1 accept secondaries down to 10% of primary score + --secondary=yes + -I 100G don't split index (fine for LTR libraries up to a few GB) + -t ncpu + """ + os.makedirs(os.path.dirname(os.path.abspath(paf_out)) or ".", exist_ok=True) + + cmd = ( + f"{minimap2_bin} -c -x {preset} -N 50 -p 0.1 --secondary=yes " + f"-I 100G -t {ncpu} {extra} " + f"-o {paf_out} {target_fa} {query_fa}" + ) + log.info(f"minimap2 cmd: {cmd}") + r = subprocess.run(cmd, shell=True, capture_output=True, text=True, check=False) + if r.returncode != 0: + raise RuntimeError( + f"minimap2 failed (exit {r.returncode}): " + f"{(r.stderr or r.stdout)[:2000]}" + ) + return paf_out + + +class PAFRecord: + """One minimap2 PAF line. 0-based half-open coords on query and target.""" + __slots__ = ( + "qseqid", "qlen", "qstart", "qend", "strand", + "sseqid", "tlen", "tstart", "tend", + "matches", "alnlen", "mapq", "score", + ) + + def __init__(self, line): + vals = line.rstrip("\n").split("\t") + self.qseqid = vals[0] + self.qlen = int(vals[1]) + self.qstart = int(vals[2]) + self.qend = int(vals[3]) + self.strand = vals[4] + self.sseqid = vals[5] + self.tlen = int(vals[6]) + self.tstart = int(vals[7]) + self.tend = int(vals[8]) + self.matches = int(vals[9]) + self.alnlen = int(vals[10]) + self.mapq = int(vals[11]) + # AS tag = Smith-Waterman alignment score (when -c is used) + self.score = None + for tag in vals[12:]: + if tag.startswith("AS:i:"): + self.score = int(tag[5:]) + break + if self.score is None: + # fallback: use matches as score proxy + self.score = self.matches + + +def _union_len(intervals): + """Length of the union of [lo, hi) half-open intervals.""" + if not intervals: + return 0 + intervals = sorted(intervals) + total = 0 + cur_lo, cur_hi = intervals[0] + for lo, hi in intervals[1:]: + if lo <= cur_hi: + cur_hi = max(cur_hi, hi) + else: + total += cur_hi - cur_lo + cur_lo, cur_hi = lo, hi + total += cur_hi - cur_lo + return total + + +def parse_paf_besthit(paf_path): + """Parse PAF, union per (query, target) pair on both axes, return one record + per (qseqid, sseqid) pair with merged qcov/tcov/identity; then best-hit-per- + query is selected by caller. + + Returns: list of merged hit dicts with keys: + qseqid, sseqid, qlen, tlen, + qcov (0..1), tcov (0..1), fident (0..1), + alnlen (union on query axis, i.e. unique query bases aligned), + matches, score (max AS across chains for this pair) + """ + if not os.path.exists(paf_path) or os.path.getsize(paf_path) == 0: + return [] + + groups = defaultdict(list) + with open(paf_path) as f: + for line in f: + if not line.strip(): + continue + r = PAFRecord(line) + groups[(r.qseqid, r.sseqid)].append(r) + + merged = [] + for (q, t), recs in groups.items(): + q_intervals = [(r.qstart, r.qend) for r in recs] + t_intervals = [(r.tstart, r.tend) for r in recs] + q_union = _union_len(q_intervals) + t_union = _union_len(t_intervals) + + qlen = recs[0].qlen + tlen = recs[0].tlen + + total_aln = sum(r.alnlen for r in recs) + total_matches = sum(r.matches for r in recs) + fident = (total_matches / total_aln) if total_aln else 0.0 + + best_score = max(r.score for r in recs) + + merged.append({ + "qseqid": q, + "sseqid": t, + "qlen": qlen, + "tlen": tlen, + "qcov": q_union / qlen if qlen else 0.0, + "tcov": t_union / tlen if tlen else 0.0, + "fident": fident, + "alnlen": q_union, + "matches": total_matches, + "score": best_score, + }) + return merged + + +def besthit_per_query(merged): + """Return OrderedDict[qseqid -> best merged record by score].""" + best = OrderedDict() + # Stable sort by score desc for determinism. + merged_sorted = sorted(merged, key=lambda m: -m["score"]) + for m in merged_sorted: + if m["qseqid"] not in best: + best[m["qseqid"]] = m + return best diff --git a/src/pass2_external.py b/src/pass2_external.py new file mode 100644 index 0000000..8db65dc --- /dev/null +++ b/src/pass2_external.py @@ -0,0 +1,213 @@ +""" +pass2_external.py — helpers for the --pass2-classified-fasta feature. + +Ported from github.com/cwb14/TEsorter branch `my-new-idea2` (TEsorter/app.py, +head commit b398509). The upstream helpers used Biopython SeqIO and TEsorter's +CommonClassification namedtuple. Here we use pyfastx (already a TEBinSorter +dependency) and emit dicts matching TEBinSorter's classifications-dict shape +(`id/order/superfamily/clade/complete/strand/domains/score/secondary`). +""" + +import logging +import os +import re + +import pyfastx + +log = logging.getLogger(__name__) + + +_COORD_HEADER_RE = re.compile( + r'^(?P\S+?:\d+[-\.]+\d+)#(?P[^/]+)/(?P[^/]+)/(?P\S+)$' +) + + +def _format_gff_id(s): + """TEsorter's format_gff_id: strip anything after a '#'. Trivial but kept + as a named helper so the intent reads.""" + return s.split("#", 1)[0] + + +def parse_cls_from_fasta_header(header): + """Parse `>id#Order/Superfamily/Clade` -> (id, order, superfamily, clade). + + Returns None for headers without a '#' or without at least Order/Superfamily. + Missing slots are filled with 'Unknown'/'unknown'. + """ + h = header.strip() + if h.startswith('>'): + h = h[1:] + h = h.split(None, 1)[0] + + if '#' not in h: + return None + raw_id, cls = h.split('#', 1) + raw_id = _format_gff_id(raw_id) + + parts = cls.split('/') + if len(parts) < 2: + return None + + order = parts[0] or 'Unknown' + superfamily = parts[1] or 'unknown' + clade = parts[2] if len(parts) >= 3 and parts[2] else 'unknown' + return raw_id, order, superfamily, clade + + +def extend_hmm_classifications_from_fasta(hmm_cls, fasta_path, db_seq_to_dbs): + """Merge external FASTA classifications into hmm_cls in place. + + Each added entry is shaped like TEBinSorter's reconciled classifications + dict so classifier.py:568 still sees the fields it expects. Also extends + db_seq_to_dbs so classify_from_blast accepts hits pointing at these IDs. + """ + if fasta_path is None: + return + + added = 0 + skipped = 0 + # pyfastx is faster than Biopython and already in TEBinSorter's deps. + fa = pyfastx.Fasta(fasta_path, build_index=True, full_name=True) + for rec in fa: + parsed = parse_cls_from_fasta_header(rec.name) + if not parsed: + skipped += 1 + continue + sid, order, superfamily, clade = parsed + if sid in hmm_cls: + continue + hmm_cls[sid] = { + "id": sid, + "order": order, + "superfamily": superfamily, + "clade": clade, + "complete": "none", + "strand": "?", + "domains": "none", + "score": 0.0, + "secondary": [], + } + db_seq_to_dbs.setdefault(sid, set()).add("external") + added += 1 + + log.info( + f"extended pass-1 classifications with {added} entries from {fasta_path} " + f"({skipped} headers skipped: not parseable)" + ) + + +def merge_classified_fastas(out_fa, fa_primary, fa_extra=None, clean_nucl=True): + """Write out_fa as the combined pass-2 target FASTA. + + IDs are stripped of any trailing '#...' annotation so they round-trip + cleanly through mmseqs. Primary takes precedence on duplicate IDs. + When clean_nucl=True, non-ATCG characters are stripped before writing. + """ + seen = set() + n = 0 + + def emit_records(path, fout): + nonlocal n + fa = pyfastx.Fasta(path, build_index=True) + for rec in fa: + rid = _format_gff_id(rec.name) + if rid in seen: + continue + seen.add(rid) + seq = str(rec.seq) + if clean_nucl: + seq = "".join(c for c in seq.upper() if c in "ATCG") + fout.write(f">{rid}\n{seq}\n") + n += 1 + + with open(out_fa, "w") as fout: + emit_records(fa_primary, fout) + if fa_extra: + emit_records(fa_extra, fout) + + suffix = " [non-ATCG stripped]" if clean_nucl else "" + log.info(f"pass-2 database FASTA written: {out_fa} ({n} unique IDs){suffix}") + + +def update_classified_fasta_headers(fasta_path, hmm_cls, tmpdir): + """Rewrite a pass2-classified FASTA, upgrading `unknown` slots from hmm_cls. + + Only headers shaped `>chr:start-end#Order/Superfamily/Clade` where the ID + matches an entry in hmm_cls AND at least one of Order/Superfamily/Clade + contains 'unknown' are candidates. Returns the path to the written FASTA, + or None if the input was None. + """ + if fasta_path is None: + return None + + os.makedirs(tmpdir, exist_ok=True) + updated_path = os.path.join(tmpdir, "pass2_classified_updated.fa") + n_updated = 0 + n_total = 0 + + fa = pyfastx.Fasta(fasta_path, build_index=True, full_name=True) + with open(updated_path, "w") as fout: + for rec in fa: + n_total += 1 + header = rec.name.split(None, 1)[0] + seq = str(rec.seq) + + if '#' in header: + raw_id_part, _ = header.split('#', 1) + rid = _format_gff_id(raw_id_part) + + m = _COORD_HEADER_RE.match(header) + if m and rid in hmm_cls: + old_order = m.group('order') + old_sfam = m.group('sfam') + old_clade = m.group('clade') + + if 'unknown' in (old_order.lower(), old_sfam.lower(), old_clade.lower()): + cls = hmm_cls[rid] + new_cls = "{}/{}/{}".format( + cls["order"], cls["superfamily"], cls["clade"] + ) + header = f"{raw_id_part}#{new_cls}" + n_updated += 1 + + fout.write(f">{header}\n{seq}\n") + + log.info( + f"updated {n_updated}/{n_total} headers in pass2-classified-fasta " + f"using pass-1 classifications" + ) + return updated_path + + +def clean_fasta_atcg(path): + """In-place ATCG-only cleaner. Kept for completeness / upstream parity. + + Not actively called by TEBinSorter's pass-2 because the mmseqs wrapper + already cleans the query and merge_classified_fastas cleans the DB. + """ + tmp = path + ".atcg_clean.tmp" + with open(path) as fin, open(tmp, "w") as fout: + buf = [] + header = [None] + + def flush(): + if header[0] is None: + return + seq = "".join(buf).upper() + seq = "".join(c for c in seq if c in "ATCG") + fout.write(header[0] + "\n") + fout.write(seq + "\n") + + for line in fin: + line = line.rstrip("\n") + if not line: + continue + if line.startswith(">"): + flush() + header[0] = line + buf.clear() + else: + buf.append(line) + flush() + os.replace(tmp, path) + log.info(f"non-ATCG characters removed from {path}") diff --git a/src/pipeline.py b/src/pipeline.py index ce7bc82..da9642f 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -2,7 +2,7 @@ Main pipeline for TE classification. Orchestrates: FASTA ingestion -> alphabet detection -> optional translation --> HMM search -> classification -> BLAST pass-2 -> SQLite + TSV output. +-> HMM search -> classification -> minimap2 pass-2 -> SQLite + TSV output. """ import argparse @@ -24,6 +24,7 @@ store_classifications, reconcile_classifications, DB_CONFIGS) from blast_pass2 import blast_pass2 +from minimap import minimap2_version logging.basicConfig( level=logging.INFO, @@ -149,6 +150,38 @@ def parse_args(): "per-database classifications and their summed normalized " "scores in descending order of evidence strength.", ) + + # minimap2 pass-2 options + parser.add_argument( + "-dp2", "--disable-pass2", + action="store_true", default=False, + help="Skip pass-2 similarity search (HMM-only classification)", + ) + parser.add_argument( + "-rule", "--pass2-rule", + default="80-80-80", type=str, metavar="I-C-L", + help="Pass-2 threshold as identity-coverage-length. The coverage " + "value C is applied to BOTH qcov and tcov [default: %(default)s]", + ) + parser.add_argument( + "--pass2-classified-fasta", + default=None, type=str, metavar="FASTA", + help="Optional FASTA of previously-classified elements to augment " + "the pass-2 target database. Headers must be like " + ">id#Order/Superfamily/Clade", + ) + parser.add_argument( + "--minimap2-preset", + default="asm20", type=str, metavar="PRESET", + help="minimap2 -x preset [default: %(default)s]", + ) + parser.add_argument( + "--minimap2-extra", + default="", type=str, metavar="STR", + help="Extra flags passed through to minimap2 (advanced) " + "[default: empty]", + ) + return parser.parse_args() @@ -416,16 +449,35 @@ def main(): log.info(f" Reconciled across {len(per_db_results)} databases: " f"{len(reconciled)} sequences") - # --- BLAST pass-2 --- + # --- minimap2 pass-2 --- all_results = list(reconciled) - if not args.pass_1_only and all_classifications: - log.info("--- BLAST pass-2 ---") + if (not args.pass_1_only and not args.disable_pass2 + and all_classifications): + try: + p2_id, p2_cov, p2_len = args.pass2_rule.split("-") + p2_id = float(p2_id) + p2_cov = float(p2_cov) + p2_len = float(p2_len) + except ValueError: + raise SystemExit( + f"--pass2-rule must be I-C-L (three numbers separated by '-'), " + f"got {args.pass2_rule!r}" + ) + + log.info("--- minimap2 pass-2 ---") + minimap2_version() blast_cls = blast_pass2( args.sequence, conn, hmm_classifications=all_classifications, seq_type="nucl", n_processors=args.processors, + min_identity=p2_id, + min_coverage=p2_cov, + min_length=p2_len, outdir=outdir, + pass2_classified_fasta=args.pass2_classified_fasta, + preset=args.minimap2_preset, + minimap2_extra=args.minimap2_extra, ) if blast_cls: diff --git a/src/tesorter_compat.py b/src/tesorter_compat.py index 00d4fb0..2916ba2 100644 --- a/src/tesorter_compat.py +++ b/src/tesorter_compat.py @@ -71,7 +71,23 @@ def parse_args(): help="Minimum normalized score [default: 0.1]") parser.add_argument("-dp2", "--disable-pass2", action="store_true", default=False, - help="Do not run BLAST pass-2 classification") + help="Do not run pass-2 minimap2 classification") + parser.add_argument("-rule", "--pass2-rule", type=str, default="80-80-80", + metavar="I-C-L", + help="Pass-2 threshold identity-coverage-length. " + "C is applied to both qcov and tcov " + "[default: 80-80-80]") + parser.add_argument("--pass2-classified-fasta", type=str, default=None, + metavar="FASTA", + help="Optional FASTA of previously-classified elements " + "to augment pass-2 target DB. Headers must be " + "like >id#Order/Superfamily/Clade") + parser.add_argument("--minimap2-preset", type=str, default="asm20", + metavar="PRESET", + help="minimap2 -x preset [default: asm20]") + parser.add_argument("--minimap2-extra", type=str, default="", + metavar="STR", + help="Extra flags passed through to minimap2") parser.add_argument("-nolib", "--no-library", action="store_true", default=False, help="Do not generate RepeatMasker library file") @@ -231,15 +247,30 @@ def main(): export_classification_tsv(results, cls_out) log.info(f"Classification: {len(results)} sequences -> {cls_out}") - # BLAST pass-2 + # minimap2 pass-2 if not args.disable_pass2 and args.seq_type == "nucl": + try: + p2_id, p2_cov, p2_len = args.pass2_rule.split("-") + p2_id = float(p2_id) + p2_cov = float(p2_cov) + p2_len = float(p2_len) + except ValueError: + log.error(f"--pass2-rule must be I-C-L, got {args.pass2_rule!r}") + sys.exit(1) + hmm_cls = {r["id"]: r for r in results} blast_cls = blast_pass2( args.sequence, conn, hmm_classifications=hmm_cls, seq_type="nucl", n_processors=args.processors, + min_identity=p2_id, + min_coverage=p2_cov, + min_length=p2_len, outdir=args.tmp_dir or os.path.dirname(prefix) or ".", + pass2_classified_fasta=args.pass2_classified_fasta, + preset=args.minimap2_preset, + minimap2_extra=args.minimap2_extra, ) if blast_cls: @@ -247,7 +278,7 @@ def main(): mode=run_mode) all_results = results + blast_cls export_classification_tsv(all_results, cls_out) - log.info(f"BLAST pass-2: {len(blast_cls)} additional -> {cls_out}") + log.info(f"minimap2 pass-2: {len(blast_cls)} additional -> {cls_out}") # Generate TEsorter-format output files if config and results: From 13760e0678d4f1dbd9a7699c86847de0d621058e Mon Sep 17 00:00:00 2001 From: cwb14 Date: Wed, 29 Apr 2026 11:44:10 -0400 Subject: [PATCH 02/15] Notes and scripts for 2-pass improvement --- docs/runs/memo.md | 21 + src/WFA_TEsorter | Bin 0 -> 81720 bytes src/WFA_TEsorter.cpp | 948 +++++++++++++++++++++++++++++++++++ src/classify_final.py | 240 +++++++++ src/classify_ltr_paf_fast.py | 338 +++++++++++++ 5 files changed, 1547 insertions(+) create mode 100755 src/WFA_TEsorter create mode 100644 src/WFA_TEsorter.cpp create mode 100755 src/classify_final.py create mode 100755 src/classify_ltr_paf_fast.py diff --git a/docs/runs/memo.md b/docs/runs/memo.md index 6ad70e9..e22b99a 100644 --- a/docs/runs/memo.md +++ b/docs/runs/memo.md @@ -264,3 +264,24 @@ Archived runs and summaries preserved at: - `/tmp/keep_mmseqs_dualcov_80_5k/` (mmseqs AND @ 80-80-80) - `/tmp/keep_minimap2_5k/` (minimap2 AND @ 80-80-80) - `/tmp/rape.summary.md` (most recent OR sweep, all four rule/tool combos) + + +####################################### +# Looks like we should use minimap2, no cigar and no cs strings (too costly on runtime). +# Something like. +minimap2 -x asm20 -k 15 -w 10 -p 0.2 -U 200,5000 -B 3 -O 4,18 --end-bonus 10 -z 400,200 --no-long-join -g 3000 -m 150 --paf-no-hit -t 200 -K 1G -o minimap2.paf TARGET.fa QUERY.fa +# These are loosly benchmarked, but may benifit from being benchmarked against a wavefront goldstandard alignment dataset: +nohup /usr/bin/time -v ./WFA_TEsorter -q QUERY.fa -t TARGET.fa --cigar --threads 20 > WFA.paf & +# This will generate end-to-end alignment for all-vs-all, whcih will need post-processed to identify the single-best target for each query and filter down to only those subset of query target pairs that pass whatever rule (80-80-80, etc). +# This would make a good goldstandard set. +# I can eyeball human-readable goldstandard alignments to see if im comfortable with them: +./WFA_TEsorter view --paf WFA.paf -q QUERY.fa -t TARGET.fa --qname 'chr1:15216993-15222304' --tname 'chr2:1122-3264#LTR/Copia/Bianca' --width 80 +# Note: ./WFA_TEsorter will take a long time, so QUERY.fa and TARGET.fa should be small (a subset). +# Target can be the TSD-containing ones. Query can be the full candidate FASTA. +# The PAF contains multiple lines for each q & t pair, so we need a script to merge them. +# I have two candidate scripts for this purpose. +# This script uses a more complicated (but probably more accurate) method to calculate pid. It also uses pass * pid * qcov * tcov to determine *best*. qcov and tcov are calculated identically between scripts. +python3 classify_ltr_paf_fast.py minimap2.paf --min-pid 0.80 --min-qcov 0.80 --min-tcov 0.80 --header -o merged.tsv +# This script uses a simpler approach to calculate pid, but it may not tip the scale in many cases. It uses pass * ident * min(qcov, tcov) to determine *best*. qcov and tcov are calculated identically between scripts. +python3 classify_final.py minimap2.paf --queries-fa QUERY.fa --qcov 0.80 --tcov 0.80 --ident 0.80 -o merged.tsv +# Practically, they give the same output for most things. pronbably classify_ltr_paf_fast.py is perfered due to the pid calculation. diff --git a/src/WFA_TEsorter b/src/WFA_TEsorter new file mode 100755 index 0000000000000000000000000000000000000000..20823db1a33b519b3e0491c82a02c211479f8dd4 GIT binary patch literal 81720 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 when serialising. +// +// Build (one line): +// g++ -O3 -std=c++17 -fopenmp -Wall -I./WFA2-lib WFA_TEsorter.cpp -o WFA_TEsorter -L./WFA2-lib/build -Wl,-rpath,'$ORIGIN/WFA2-lib/build' -lwfa2cpp -lwfa2 -lpthread -lm + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#ifdef _OPENMP +#include +#endif + +#include "bindings/cpp/WFAligner.hpp" + +using std::string; +using std::vector; +using std::ostream; +using std::cerr; +using std::cout; +using std::endl; + +// ---------- FASTA ------------------------------------------------------------- + +struct FastaRec { + string name; // first whitespace-delimited token of the header + string desc; // remainder of the header (may be empty) + string seq; // upper-cased sequence +}; + +static void upper_inplace(string& s) { + for (char& c : s) c = (char)std::toupper((unsigned char)c); +} + +// Read a FASTA file into a vector of records. Sequence is upper-cased. +// Multi-line sequences and blank lines are handled. Errors abort the program. +static vector read_fasta(const string& path) { + std::ifstream in(path); + if (!in) { + cerr << "[ERROR] cannot open FASTA: " << path << " (" << std::strerror(errno) << ")\n"; + std::exit(1); + } + vector recs; + string line; + FastaRec cur; + bool have_cur = false; + while (std::getline(in, line)) { + if (line.empty()) continue; + if (line[0] == '>') { + if (have_cur) { upper_inplace(cur.seq); recs.push_back(std::move(cur)); cur = FastaRec{}; } + // parse name and description + size_t p = 1; + while (p < line.size() && !std::isspace((unsigned char)line[p])) ++p; + cur.name = line.substr(1, p - 1); + while (p < line.size() && std::isspace((unsigned char)line[p])) ++p; + cur.desc = (p < line.size()) ? line.substr(p) : string(); + have_cur = true; + } else { + // strip trailing CR (windows line endings) + while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) line.pop_back(); + cur.seq.append(line); + } + } + if (have_cur) { upper_inplace(cur.seq); recs.push_back(std::move(cur)); } + if (recs.empty()) { + cerr << "[ERROR] no sequences found in " << path << "\n"; + std::exit(1); + } + return recs; +} + +// ---------- reverse complement ------------------------------------------------ + +static char comp_base(char c) { + switch (c) { + case 'A': return 'T'; case 'T': return 'A'; + case 'C': return 'G'; case 'G': return 'C'; + case 'U': return 'A'; + case 'R': return 'Y'; case 'Y': return 'R'; + case 'S': return 'S'; case 'W': return 'W'; + case 'K': return 'M'; case 'M': return 'K'; + case 'B': return 'V'; case 'V': return 'B'; + case 'D': return 'H'; case 'H': return 'D'; + case 'N': return 'N'; + case 'a': return 't'; case 't': return 'a'; + case 'c': return 'g'; case 'g': return 'c'; + case 'u': return 'a'; + case 'n': return 'n'; + default: return 'N'; + } +} + +static string revcomp(const string& s) { + string out; + out.resize(s.size()); + for (size_t i = 0; i < s.size(); ++i) out[s.size() - 1 - i] = comp_base(s[i]); + return out; +} + +// ---------- alignment op-string utilities ------------------------------------- +// +// `ops` is the raw WFA operation string of M/X/I/D, one char per column. +// Convention here: WFA pattern == query, WFA text == target. +// WFA 'M': match, advances both +// WFA 'X': mismatch, advances both +// WFA 'I': advances text (target) -> SAM-style 'D' (deletion from query) +// WFA 'D': advances pattern (query) -> SAM-style 'I' (insertion in query) + +struct AlnStats { + int matches = 0; // count of M (or = if show_eq) operations + int mismatches = 0; // X operations + int ins_q = 0; // bases inserted in query (gap in target) = WFA 'D' + int del_q = 0; // bases deleted from query (gap in query, base in target) = WFA 'I' + int aln_len = 0; // total ops length (alignment block length) + int q_consumed = 0; // bases of query consumed (M+X+ins_q) + int t_consumed = 0; // bases of target consumed (M+X+del_q) +}; + +static AlnStats compute_stats(const string& ops) { + AlnStats s; + s.aln_len = (int)ops.size(); + for (char c : ops) { + switch (c) { + case 'M': ++s.matches; ++s.q_consumed; ++s.t_consumed; break; + case 'X': ++s.mismatches; ++s.q_consumed; ++s.t_consumed; break; + case 'I': ++s.del_q; ++s.t_consumed; break; // gap in query + case 'D': ++s.ins_q; ++s.q_consumed; break; // gap in target + default: break; + } + } + return s; +} + +// Run-length-encode WFA ops into a SAM CIGAR string with I<->D inversion so +// that the result describes query-vs-target in the standard way. +// show_eq=false: emit M for both M and X (legacy SAM) +// show_eq=true: emit '=' for M and 'X' for X (extended SAM) +static string ops_to_cigar(const string& ops, bool show_eq) { + string out; + if (ops.empty()) return out; + auto sam_op = [show_eq](char w) -> char { + switch (w) { + case 'M': return show_eq ? '=' : 'M'; + case 'X': return show_eq ? 'X' : 'M'; + case 'I': return 'D'; // WFA I -> SAM D + case 'D': return 'I'; // WFA D -> SAM I + } + return '?'; + }; + char run_op = sam_op(ops[0]); + int run_len = 1; + for (size_t i = 1; i < ops.size(); ++i) { + char op = sam_op(ops[i]); + if (op == run_op) { ++run_len; continue; } + out += std::to_string(run_len); out += run_op; + run_op = op; run_len = 1; + } + out += std::to_string(run_len); out += run_op; + return out; +} + +// Build minimap2-style cs tag. +// short (default): :LEN *xy +bases -bases +// long: =BASES *xy +bases -bases +// `query` and `target` are the (oriented) full sequences corresponding to the +// alignment. q0/t0 are the alignment start offsets in those sequences. +static string ops_to_cs(const string& ops, + const string& query, int q0, + const string& target, int t0, + bool long_form) { + string out; + int qi = q0, ti = t0; + size_t i = 0; + while (i < ops.size()) { + char w = ops[i]; + if (w == 'M') { + size_t j = i; + while (j < ops.size() && ops[j] == 'M') ++j; + int run = (int)(j - i); + if (long_form) { + out += '='; + out.append(query, qi, run); + } else { + out += ':'; + out += std::to_string(run); + } + qi += run; ti += run; i = j; + } else if (w == 'X') { + // emit each mismatch separately as *xy where x=target base, y=query base + // (lowercase per minimap2 short-cs convention) + char tbase = (char)std::tolower((unsigned char)target[ti]); + char qbase = (char)std::tolower((unsigned char)query[qi]); + out += '*'; out += tbase; out += qbase; + ++qi; ++ti; ++i; + } else if (w == 'D') { + // WFA 'D' -> SAM 'I' -> +bases (insertion to target = bases from query) + size_t j = i; + while (j < ops.size() && ops[j] == 'D') ++j; + int run = (int)(j - i); + out += '+'; + for (int k = 0; k < run; ++k) { + out += (char)std::tolower((unsigned char)query[qi + k]); + } + qi += run; i = j; + } else if (w == 'I') { + // WFA 'I' -> SAM 'D' -> -bases (deletion from query = bases from target) + size_t j = i; + while (j < ops.size() && ops[j] == 'I') ++j; + int run = (int)(j - i); + out += '-'; + for (int k = 0; k < run; ++k) { + out += (char)std::tolower((unsigned char)target[ti + k]); + } + ti += run; i = j; + } else { + ++i; // unknown op, skip + } + } + return out; +} + +// ---------- PAF record -------------------------------------------------------- + +struct PafRec { + string qname; int qlen, qstart, qend; + char strand; + string tname; int tlen, tstart, tend; + int matches; + int aln_len; + int mapq; + int score; // WFA penalty (typically <= 0) + int nm; // edit distance: X+I+D + string cigar; // optional; empty if not requested + string cs; // optional; empty if not requested +}; + +static void write_paf(ostream& os, const PafRec& p) { + os << p.qname << '\t' << p.qlen << '\t' << p.qstart << '\t' << p.qend << '\t' + << p.strand << '\t' + << p.tname << '\t' << p.tlen << '\t' << p.tstart << '\t' << p.tend << '\t' + << p.matches << '\t' << p.aln_len << '\t' << p.mapq + << "\ttp:A:P" + << "\tAS:i:" << p.score + << "\tNM:i:" << p.nm; + if (!p.cigar.empty()) os << "\tcg:Z:" << p.cigar; + if (!p.cs.empty()) os << "\tcs:Z:" << p.cs; + os << '\n'; +} + +// ---------- option parsing ---------------------------------------------------- + +enum class CsMode { None, Short, Long }; +enum class AlnMode { End2End, EndsFree, Extension }; +enum class Strand { Both, Forward, Reverse }; +enum class MemMode { High, Med, Low, Ultralow }; + +struct Opts { + string query_path; + string target_path; + string output_path; // empty => stdout + string pairs_path; // optional; empty => default all-vs-all behavior + int threads = 1; + int match_pen = 0; + int mismatch = 4; + int gap_open = 6; + int gap_ext = 2; + bool emit_cigar = false; + CsMode cs = CsMode::None; + bool show_eq = false; + AlnMode mode = AlnMode::End2End; + Strand strand = Strand::Both; + MemMode mem = MemMode::High; + bool has_min_score = false; + int min_score = 0; + bool verbose = false; +}; + +static void usage_align(FILE* f = stderr) { + std::fprintf(f, +"Usage: WFA_TEsorter -q QUERY.fa -t TARGET.fa [options] > out.paf\n" +" WFA_TEsorter view --paf F.paf -q Q.fa -t T.fa --qname X --tname Y\n" +"\n" +"Required:\n" +" -q, --query FILE multi-seq query FASTA\n" +" -t, --target FILE multi-seq target FASTA\n" +"\n" +"Output:\n" +" -o, --output FILE PAF output (default: stdout)\n" +" --cigar emit CIGAR as cg:Z tag\n" +" --cs [=short|long] emit minimap2 cs tag (default: short)\n" +" --show-eq use '='/'X' instead of 'M' in CIGAR\n" +"\n" +"Penalties (gap-affine, all positive integers):\n" +" --match N match score (default: 0)\n" +" --mismatch N mismatch penalty (default: 4)\n" +" --gap-open N gap-open penalty (default: 6)\n" +" --gap-ext N gap-extension penalty (default: 2)\n" +"\n" +"Alignment:\n" +" --mode STR end2end|ends-free|extension (default: end2end)\n" +" ends-free: query is anchored, target ends are free\n" +" --strand STR both|forward|reverse (default: both)\n" +" --memory STR high|med|low|ultralow (default: high)\n" +" --min-score N drop alignments with WFA score < N (default: keep all)\n" +" --pairs FILE restrict to (qnametname) pairs from FILE\n" +" (default: full all-vs-all over the FASTAs)\n" +" --threads N worker threads (default: 1)\n" +"\n" +"Misc:\n" +" -v, --verbose progress messages to stderr\n" +" -h, --help this message\n" +"\n" +"Subcommands:\n" +" view pretty-print an alignment from a PAF (requires cg:Z)\n" +" see: WFA_TEsorter view -h\n" +); +} + +static MemMode parse_mem(const string& s) { + if (s == "high") return MemMode::High; + if (s == "med") return MemMode::Med; + if (s == "low") return MemMode::Low; + if (s == "ultralow") return MemMode::Ultralow; + cerr << "[ERROR] unknown --memory value: " << s << "\n"; std::exit(2); +} +static AlnMode parse_mode(const string& s) { + if (s == "end2end" || s == "end-to-end") return AlnMode::End2End; + if (s == "ends-free") return AlnMode::EndsFree; + if (s == "extension") return AlnMode::Extension; + cerr << "[ERROR] unknown --mode value: " << s << "\n"; std::exit(2); +} +static Strand parse_strand(const string& s) { + if (s == "both") return Strand::Both; + if (s == "forward" || s == "+") return Strand::Forward; + if (s == "reverse" || s == "-") return Strand::Reverse; + cerr << "[ERROR] unknown --strand value: " << s << "\n"; std::exit(2); +} + +static Opts parse_align_opts(int argc, char** argv) { + Opts o; + static struct option long_opts[] = { + {"query", required_argument, nullptr, 'q'}, + {"target", required_argument, nullptr, 't'}, + {"output", required_argument, nullptr, 'o'}, + {"threads", required_argument, nullptr, 1 }, + {"match", required_argument, nullptr, 2 }, + {"mismatch", required_argument, nullptr, 3 }, + {"gap-open", required_argument, nullptr, 4 }, + {"gap-ext", required_argument, nullptr, 5 }, + {"cigar", no_argument, nullptr, 6 }, + {"cs", optional_argument, nullptr, 7 }, + {"show-eq", no_argument, nullptr, 8 }, + {"mode", required_argument, nullptr, 9 }, + {"strand", required_argument, nullptr, 10 }, + {"memory", required_argument, nullptr, 11 }, + {"min-score", required_argument, nullptr, 12 }, + {"pairs", required_argument, nullptr, 13 }, + {"verbose", no_argument, nullptr, 'v'}, + {"help", no_argument, nullptr, 'h'}, + {nullptr, 0, nullptr, 0} + }; + int c; + while ((c = getopt_long(argc, argv, "q:t:o:vh", long_opts, nullptr)) != -1) { + switch (c) { + case 'q': o.query_path = optarg; break; + case 't': o.target_path = optarg; break; + case 'o': o.output_path = optarg; break; + case 1 : o.threads = std::atoi(optarg); break; + case 2 : o.match_pen = std::atoi(optarg); break; + case 3 : o.mismatch = std::atoi(optarg); break; + case 4 : o.gap_open = std::atoi(optarg); break; + case 5 : o.gap_ext = std::atoi(optarg); break; + case 6 : o.emit_cigar = true; break; + case 7 : o.cs = (optarg && string(optarg) == "long") ? CsMode::Long : CsMode::Short; break; + case 8 : o.show_eq = true; break; + case 9 : o.mode = parse_mode(optarg); break; + case 10 : o.strand = parse_strand(optarg); break; + case 11 : o.mem = parse_mem(optarg); break; + case 12 : o.has_min_score = true; o.min_score = std::atoi(optarg); break; + case 13 : o.pairs_path = optarg; break; + case 'v': o.verbose = true; break; + case 'h': usage_align(stdout); std::exit(0); + default : usage_align(stderr); std::exit(2); + } + } + if (o.query_path.empty() || o.target_path.empty()) { + cerr << "[ERROR] --query and --target are required\n"; + usage_align(stderr); std::exit(2); + } + if (o.threads < 1) o.threads = 1; + if (o.mismatch < 0 || o.gap_open < 0 || o.gap_ext < 0) { + cerr << "[ERROR] penalties must be non-negative\n"; std::exit(2); + } + return o; +} + +// ---------- alignment runner -------------------------------------------------- + +static wfa::WFAligner::MemoryModel to_wfa_mem(MemMode m) { + switch (m) { + case MemMode::High: return wfa::WFAligner::MemoryHigh; + case MemMode::Med: return wfa::WFAligner::MemoryMed; + case MemMode::Low: return wfa::WFAligner::MemoryLow; + case MemMode::Ultralow: return wfa::WFAligner::MemoryUltralow; + } + return wfa::WFAligner::MemoryHigh; +} + +struct AlnResult { + bool ok = false; + string ops; // WFA M/X/I/D string for the *aligned* region only + int score = 0; + int qstart = 0, qend = 0; + int tstart = 0, tend = 0; +}; + +// Run one alignment. For ends-free (query-anchored) mode the leading and +// trailing runs of WFA 'I' ops (free target-end skips) are stripped from `ops` +// and accounted for in tstart/tend. For extension mode qend/tend reflect the +// actual bases consumed by the partial alignment. +static AlnResult align_one(wfa::WFAlignerGapAffine& aligner, AlnMode mode, + const string& q, const string& t) { + AlnResult r; + int qlen = (int)q.size(), tlen = (int)t.size(); + wfa::WFAligner::AlignmentStatus st; + if (mode == AlnMode::End2End) { + st = aligner.alignEnd2End(q, t); + } else if (mode == AlnMode::EndsFree) { + // Query-anchored semi-global: query is anchored, target ends are free. + st = aligner.alignEndsFree(q, 0, 0, t, tlen, tlen); + } else { + string qm = q, tm = t; + st = aligner.alignExtension(qm, tm); + } + if ((int)st < 0) return r; + string ops = aligner.getAlignment(); + if (ops.empty()) return r; + + int q0 = 0, q1 = qlen, t0 = 0, t1 = tlen; + if (mode == AlnMode::EndsFree) { + // Strip leading/trailing 'I' ops (WFA: text-advance) which represent + // free skips of the target ends. + size_t lo = 0, hi = ops.size(); + while (lo < hi && ops[lo] == 'I') { ++t0; ++lo; } + while (hi > lo && ops[hi - 1] == 'I') { --t1; --hi; } + ops = ops.substr(lo, hi - lo); + } else if (mode == AlnMode::Extension) { + AlnStats s = compute_stats(ops); + q1 = q0 + s.q_consumed; + t1 = t0 + s.t_consumed; + } + + if (ops.empty()) return r; + r.ok = true; + r.ops = std::move(ops); + r.score = aligner.getAlignmentScore(); + r.qstart = q0; r.qend = q1; + r.tstart = t0; r.tend = t1; + return r; +} + +// Build a PafRec from an AlnResult. The aligned ops describe positions +// [qstart,qend) on `q_oriented` and [tstart,tend) on `t_oriented`. +static PafRec make_paf(const string& qname, const string& q_oriented, int qlen_full, + const string& tname, const string& t_oriented, int tlen_full, + char strand, const AlnResult& r, + const Opts& o) { + AlnStats s = compute_stats(r.ops); + PafRec p; + p.qname = qname; p.qlen = qlen_full; p.qstart = r.qstart; p.qend = r.qend; + p.strand = strand; + p.tname = tname; p.tlen = tlen_full; p.tstart = r.tstart; p.tend = r.tend; + p.matches = s.matches; + p.aln_len = s.aln_len; + p.mapq = 60; + p.score = r.score; + p.nm = s.mismatches + s.ins_q + s.del_q; + if (o.emit_cigar) p.cigar = ops_to_cigar(r.ops, o.show_eq); + if (o.cs != CsMode::None) { + p.cs = ops_to_cs(r.ops, q_oriented, r.qstart, + t_oriented, r.tstart, + o.cs == CsMode::Long); + } + return p; +} + +static int run_align(int argc, char** argv) { + Opts o = parse_align_opts(argc, argv); + + if (o.verbose) cerr << "[INFO] reading FASTAs\n"; + auto queries = read_fasta(o.query_path); + auto targets = read_fasta(o.target_path); + if (o.verbose) { + cerr << "[INFO] " << queries.size() << " query seqs, " + << targets.size() << " target seqs\n"; + } + + // Output sink + std::ofstream fout; + ostream* out = &cout; + if (!o.output_path.empty()) { + fout.open(o.output_path); + if (!fout) { cerr << "[ERROR] cannot open output: " << o.output_path << "\n"; return 1; } + out = &fout; + } + std::mutex out_mu; + + // Per-thread aligners (created lazily inside the parallel region so each + // thread owns its own state). + int nthreads = o.threads; +#ifdef _OPENMP + omp_set_num_threads(nthreads); +#else + nthreads = 1; +#endif + + // Optional: pre-load explicit (qname, tname) pairs from --pairs FILE. + // When pairs_path is empty, fall through to the default all-vs-all loop. + std::vector> pair_jobs; + if (!o.pairs_path.empty()) { + std::unordered_map q_idx, t_idx; + q_idx.reserve(queries.size() * 2); + t_idx.reserve(targets.size() * 2); + for (size_t i = 0; i < queries.size(); ++i) q_idx.emplace(queries[i].name, (int)i); + for (size_t i = 0; i < targets.size(); ++i) t_idx.emplace(targets[i].name, (int)i); + + std::ifstream pin(o.pairs_path); + if (!pin) { + cerr << "[ERROR] cannot open --pairs file: " << o.pairs_path << "\n"; + return 1; + } + size_t miss_q = 0, miss_t = 0, parsed = 0; + string line; + while (std::getline(pin, line)) { + if (line.empty() || line[0] == '#') continue; + // strip trailing CR + while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) line.pop_back(); + size_t tab = line.find('\t'); + if (tab == string::npos) continue; + string qn = line.substr(0, tab); + string rest = line.substr(tab + 1); + size_t tab2 = rest.find('\t'); + string tn = (tab2 == string::npos) ? rest : rest.substr(0, tab2); + ++parsed; + auto qit = q_idx.find(qn); + auto tit = t_idx.find(tn); + if (qit == q_idx.end()) { ++miss_q; continue; } + if (tit == t_idx.end()) { ++miss_t; continue; } + pair_jobs.emplace_back(qit->second, tit->second); + } + if (o.verbose) { + cerr << "[INFO] --pairs parsed=" << parsed + << " kept=" << pair_jobs.size() + << " missing-qname=" << miss_q + << " missing-tname=" << miss_t << "\n"; + } + if (pair_jobs.empty()) { + cerr << "[ERROR] no usable pairs from " << o.pairs_path << "\n"; + return 1; + } + } + + std::atomic done{0}; + const size_t total = pair_jobs.empty() + ? queries.size() * targets.size() + : pair_jobs.size(); + auto t_start = std::chrono::steady_clock::now(); + + // Process one (qi, ti) job using the caller's per-thread aligner. + auto process_job = [&](size_t qi, size_t ti, wfa::WFAlignerGapAffine& aligner) { + const FastaRec& Q = queries[qi]; + const FastaRec& T = targets[ti]; + if (Q.seq.empty() || T.seq.empty()) { ++done; return; } + + AlnResult res_f, res_r; + string qrev; + if (o.strand == Strand::Forward || o.strand == Strand::Both) { + res_f = align_one(aligner, o.mode, Q.seq, T.seq); + } + if (o.strand == Strand::Reverse || o.strand == Strand::Both) { + qrev = revcomp(Q.seq); + res_r = align_one(aligner, o.mode, qrev, T.seq); + } + + bool emit_f = false, emit_r = false; + if (o.strand == Strand::Forward) emit_f = res_f.ok; + else if (o.strand == Strand::Reverse) emit_r = res_r.ok; + else { + if (res_f.ok && res_r.ok) { + (res_f.score >= res_r.score) ? (emit_f = true) : (emit_r = true); + } else if (res_f.ok) emit_f = true; + else if (res_r.ok) emit_r = true; + } + if (emit_f && o.has_min_score && res_f.score < o.min_score) emit_f = false; + if (emit_r && o.has_min_score && res_r.score < o.min_score) emit_r = false; + + if (emit_f) { + PafRec p = make_paf(Q.name, Q.seq, (int)Q.seq.size(), + T.name, T.seq, (int)T.seq.size(), + '+', res_f, o); + std::lock_guard lk(out_mu); + write_paf(*out, p); + } + if (emit_r) { + PafRec p = make_paf(Q.name, qrev, (int)qrev.size(), + T.name, T.seq, (int)T.seq.size(), + '-', res_r, o); + std::lock_guard lk(out_mu); + write_paf(*out, p); + } + + size_t now = ++done; + if (o.verbose && (now % 1000 == 0 || now == total)) { + auto el = std::chrono::duration( + std::chrono::steady_clock::now() - t_start).count(); + std::lock_guard lk(out_mu); + cerr << "[INFO] " << now << "/" << total + << " pairs aligned (" << (now / std::max(el, 1e-9)) << "/s)\n"; + } + }; + + if (pair_jobs.empty()) { + // Default behavior: full all-vs-all over the two FASTAs. +#ifdef _OPENMP + #pragma omp parallel +#endif + { + wfa::WFAlignerGapAffine aligner( + o.match_pen, o.mismatch, o.gap_open, o.gap_ext, + wfa::WFAligner::Alignment, to_wfa_mem(o.mem)); +#ifdef _OPENMP + #pragma omp for schedule(dynamic, 1) collapse(2) +#endif + for (size_t qi = 0; qi < queries.size(); ++qi) { + for (size_t ti = 0; ti < targets.size(); ++ti) { + process_job(qi, ti, aligner); + } + } + } + } else { + // --pairs mode: align only the explicit (qi, ti) pairs. +#ifdef _OPENMP + #pragma omp parallel +#endif + { + wfa::WFAlignerGapAffine aligner( + o.match_pen, o.mismatch, o.gap_open, o.gap_ext, + wfa::WFAligner::Alignment, to_wfa_mem(o.mem)); +#ifdef _OPENMP + #pragma omp for schedule(dynamic, 64) +#endif + for (size_t pi = 0; pi < pair_jobs.size(); ++pi) { + process_job(pair_jobs[pi].first, pair_jobs[pi].second, aligner); + } + } + } + + if (o.verbose) { + auto el = std::chrono::duration( + std::chrono::steady_clock::now() - t_start).count(); + cerr << "[INFO] done in " << el << " s\n"; + } + return 0; +} + +// ---------- view subcommand --------------------------------------------------- + +struct ViewOpts { + string paf_path; + string query_path; + string target_path; + string qname; + string tname; + int width = 80; // wrap width for the pretty alignment + bool verbose = false; +}; + +static void usage_view(FILE* f = stderr) { + std::fprintf(f, +"Usage: WFA_TEsorter view --paf F.paf -q Q.fa -t T.fa --qname X --tname Y\n" +"\n" +"Re-creates a human-readable alignment from a PAF record produced by\n" +"WFA_TEsorter (the record must contain a cg:Z CIGAR tag).\n" +"\n" +"Required:\n" +" --paf FILE PAF file written by WFA_TEsorter --cigar\n" +" -q, --query FILE query FASTA used to produce the PAF\n" +" -t, --target FILE target FASTA used to produce the PAF\n" +" --qname STR query sequence name to display\n" +" --tname STR target sequence name to display\n" +"\n" +"Optional:\n" +" --width N characters per row (default: 80)\n" +" -h, --help this message\n" +); +} + +static ViewOpts parse_view_opts(int argc, char** argv) { + ViewOpts v; + static struct option lo[] = { + {"paf", required_argument, nullptr, 1}, + {"query", required_argument, nullptr, 'q'}, + {"target", required_argument, nullptr, 't'}, + {"qname", required_argument, nullptr, 2}, + {"tname", required_argument, nullptr, 3}, + {"width", required_argument, nullptr, 4}, + {"help", no_argument, nullptr, 'h'}, + {nullptr, 0, nullptr, 0} + }; + int c; + while ((c = getopt_long(argc, argv, "q:t:h", lo, nullptr)) != -1) { + switch (c) { + case 1 : v.paf_path = optarg; break; + case 'q': v.query_path = optarg; break; + case 't': v.target_path = optarg; break; + case 2 : v.qname = optarg; break; + case 3 : v.tname = optarg; break; + case 4 : v.width = std::max(20, std::atoi(optarg)); break; + case 'h': usage_view(stdout); std::exit(0); + default : usage_view(stderr); std::exit(2); + } + } + if (v.paf_path.empty() || v.query_path.empty() || v.target_path.empty() || + v.qname.empty() || v.tname.empty()) { + cerr << "[ERROR] --paf, --query, --target, --qname, --tname all required\n"; + usage_view(stderr); std::exit(2); + } + return v; +} + +// Parse a SAM/PAF CIGAR string into pairs (length, op). +// Recognises M, I, D, =, X. Returns false on parse error. +static bool parse_cigar(const string& cg, vector>& out) { + out.clear(); + int i = 0, n = (int)cg.size(); + while (i < n) { + int v = 0; + if (!std::isdigit((unsigned char)cg[i])) return false; + while (i < n && std::isdigit((unsigned char)cg[i])) { + v = v * 10 + (cg[i] - '0'); + ++i; + } + if (i >= n) return false; + char op = cg[i++]; + if (op != 'M' && op != 'I' && op != 'D' && op != '=' && op != 'X') return false; + out.emplace_back(v, op); + } + return true; +} + +// Find a tag in a tab-separated PAF line; returns its value (after "xx:T:") or +// empty string if absent. +static string get_tag(const string& line, const string& key /* e.g. "cg:Z:" */) { + // search tab-delimited fields + size_t pos = 0; + while (pos < line.size()) { + size_t end = line.find('\t', pos); + size_t f_end = (end == string::npos) ? line.size() : end; + if (f_end - pos >= key.size() && + line.compare(pos, key.size(), key) == 0) { + return line.substr(pos + key.size(), f_end - pos - key.size()); + } + if (end == string::npos) break; + pos = end + 1; + } + return string(); +} + +// Build a vector of FastaRec keyed by name, returning a pointer (or nullptr). +static const FastaRec* find_rec(const vector& v, const string& name) { + for (const auto& r : v) if (r.name == name) return &r; + return nullptr; +} + +// Print the pretty alignment given the (oriented) query/target slices and CIGAR. +static void print_pretty(ostream& os, + const string& qname, const string& q_aln, + int q_off, + const string& tname, const string& t_aln, + int t_off, + char strand, + const vector>& cig, + int width) { + // Build aligned strings (with '-' gaps). + string qline, mline, tline; + int qi = 0, ti = 0; + for (auto [len, op] : cig) { + for (int k = 0; k < len; ++k) { + if (op == 'M' || op == '=' || op == 'X') { + char qc = q_aln[qi++], tc = t_aln[ti++]; + qline += qc; tline += tc; + bool match = (qc == tc) && (op != 'X'); + mline += match ? '|' : ' '; + } else if (op == 'I') { // query has extra base (gap in target) + qline += q_aln[qi++]; tline += '-'; mline += ' '; + } else if (op == 'D') { // target has extra base (gap in query) + qline += '-'; tline += t_aln[ti++]; mline += ' '; + } + } + } + + // Coordinate width is sized to fit the largest position printed. + int q_max = q_off + qi; + int t_max = t_off + ti; + int pos_w = (int)std::max(std::to_string(q_max).size(), + std::to_string(t_max).size()); + + auto pad_left = [](int v, int w) { + std::string s = std::to_string(v); + if ((int)s.size() < w) s.insert(0, w - s.size(), ' '); + return s; + }; + + // Header + os << "QUERY : " << qname << " (" << strand << " strand" + << ", " << qi << " bp aligned starting at " << q_off << ")\n" + << "TARGET : " << tname << " (" + << ti << " bp aligned starting at " << t_off << ")\n\n"; + + // Each printed row has: "QUERY " (7) + + " " (2) + alignment + const int label_w = 7 + pos_w + 2; + const std::string mid_pad(label_w, ' '); + + int n = (int)qline.size(); + int q_pos = q_off; + int t_pos = t_off; + for (int p = 0; p < n; p += width) { + int len = std::min(width, n - p); + int q_step = 0, t_step = 0; + for (int k = 0; k < len; ++k) { + if (qline[p + k] != '-') ++q_step; + if (tline[p + k] != '-') ++t_step; + } + os << "QUERY " << pad_left(q_pos + 1, pos_w) << " " + << qline.substr(p, len) << " " << (q_pos + q_step) << "\n"; + os << mid_pad << mline.substr(p, len) << "\n"; + os << "TARGET " << pad_left(t_pos + 1, pos_w) << " " + << tline.substr(p, len) << " " << (t_pos + t_step) << "\n\n"; + q_pos += q_step; + t_pos += t_step; + } +} + +static int run_view(int argc, char** argv) { + ViewOpts v = parse_view_opts(argc, argv); + + auto qs = read_fasta(v.query_path); + auto ts = read_fasta(v.target_path); + const FastaRec* Q = find_rec(qs, v.qname); + const FastaRec* T = find_rec(ts, v.tname); + if (!Q) { cerr << "[ERROR] query name not found: " << v.qname << "\n"; return 1; } + if (!T) { cerr << "[ERROR] target name not found: " << v.tname << "\n"; return 1; } + + std::ifstream pin(v.paf_path); + if (!pin) { cerr << "[ERROR] cannot open PAF: " << v.paf_path << "\n"; return 1; } + + string line; + int hits = 0; + while (std::getline(pin, line)) { + if (line.empty() || line[0] == '#') continue; + // split first 12 fields + vector f; + f.reserve(16); + size_t pos = 0; + while (pos <= line.size() && f.size() < 12) { + size_t end = line.find('\t', pos); + f.push_back(line.substr(pos, (end == string::npos) ? line.size() - pos : end - pos)); + if (end == string::npos) break; + pos = end + 1; + } + if (f.size() < 12) continue; + if (f[0] != v.qname || f[5] != v.tname) continue; + + char strand = f[4].empty() ? '+' : f[4][0]; + int qstart = std::atoi(f[2].c_str()); + int qend = std::atoi(f[3].c_str()); + int tstart = std::atoi(f[7].c_str()); + int tend = std::atoi(f[8].c_str()); + (void)qend; (void)tend; + + string cg = get_tag(line, "cg:Z:"); + if (cg.empty()) { + cerr << "[WARN] PAF row for " << v.qname << " vs " << v.tname + << " has no cg:Z tag; skipping\n"; + continue; + } + vector> cig; + if (!parse_cigar(cg, cig)) { + cerr << "[WARN] could not parse CIGAR; skipping\n"; + continue; + } + // Build oriented query for this strand + string q_oriented = (strand == '-') ? revcomp(Q->seq) : Q->seq; + const string& t_oriented = T->seq; + + // Slice the aligned region from oriented sequences. + string q_aln = q_oriented.substr(qstart, q_oriented.size() - qstart); + string t_aln = t_oriented.substr(tstart, t_oriented.size() - tstart); + + ++hits; + cout << "# alignment " << hits << " : " << v.qname << " (" + << strand << ") vs " << v.tname << "\n"; + print_pretty(cout, v.qname, q_aln, qstart, v.tname, t_aln, tstart, + strand, cig, v.width); + } + if (hits == 0) { + cerr << "[WARN] no PAF records found for " << v.qname << " vs " << v.tname << "\n"; + return 1; + } + return 0; +} + +// ---------- entry ------------------------------------------------------------- + +int main(int argc, char** argv) { + if (argc >= 2 && string(argv[1]) == "view") { + // Shift past subcommand for getopt + return run_view(argc - 1, argv + 1); + } + if (argc >= 2 && string(argv[1]) == "align") { + return run_align(argc - 1, argv + 1); + } + return run_align(argc, argv); +} diff --git a/src/classify_final.py b/src/classify_final.py new file mode 100755 index 0000000..a12da6a --- /dev/null +++ b/src/classify_final.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Classify minimap2 PAF queries at mutual-coverage + identity thresholds. + +Auto-adapts to PAF flavor: + * with -c : has cg:Z, de:f, NM:i tags + exact col10/col11. Uses de:f. + * without -c: only dv:f (chain-estimated divergence) + approximate + col10 (= k * num_seeds) and col11 (= span). Uses dv:f. + * legacy : neither tag — falls back to matches/block_len (BLAST-style), + which over-penalises structural indels (a single 3 kb + deletion drops identity from ~99% to ~63%). + +Per (query,target) pair: + - drops alignments whose query span is fully encompassed by a strictly + longer one (avoids double-counting overlapped regions) + - identity = qspan-weighted mean of (1 - div) over surviving rows + - qcov / tcov = union-of-intervals length divided by qlen / tlen + +Output (TSV): + query qlen best_target tlen qcov tcov identity ident_src pass +ident_src in {de, dv, mb}. +""" +import argparse +import sys +from collections import defaultdict + + +def merge_intervals(ivs): + if not ivs: + return [] + ivs = sorted(ivs) + out = [list(ivs[0])] + for a, b in ivs[1:]: + if a <= out[-1][1]: + out[-1][1] = max(out[-1][1], b) + else: + out.append([a, b]) + return out + + +def total_len(ivs): + return sum(b - a for a, b in ivs) + + +def parse_row(line): + """Return dict for an aligned row, ('NOHIT', qname, qlen) for paf-no-hit + rows (target == '*'), or None for malformed rows.""" + f = line.rstrip("\n").split("\t") + if len(f) < 12: + return None + qname = f[0] + try: + qlen = int(f[1]) + except ValueError: + return None + if f[5] == "*": + return ("NOHIT", qname, qlen) + try: + rec = { + "q": qname, + "qlen": qlen, + "qs": int(f[2]), + "qe": int(f[3]), + "t": f[5], + "tlen": int(f[6]), + "ts": int(f[7]), + "te": int(f[8]), + "matches": int(f[9]), + "blocks": int(f[10]), + } + except ValueError: + return None + de = dv = None + for tag in f[12:]: + if tag.startswith("de:f:"): + try: + de = float(tag[5:]) + except ValueError: + pass + elif tag.startswith("dv:f:"): + try: + dv = float(tag[5:]) + except ValueError: + pass + rec["de"] = de + rec["dv"] = dv + return rec + + +def aggregate_pair(rows, qlen, tlen): + """Return (qcov, tcov, identity, ident_src) for one (q,t) pair.""" + # Coverage from union of spans + qcov = (total_len(merge_intervals([(r["qs"], r["qe"]) for r in rows])) + / qlen) if qlen else 0.0 + tcov = (total_len(merge_intervals([(r["ts"], r["te"]) for r in rows])) + / tlen) if tlen else 0.0 + + # Encompass-drop on query: discard alignments whose qspan is fully + # contained in a strictly longer row's qspan + survivors = [] + for r in rows: + rspan = r["qe"] - r["qs"] + encompassed = False + for o in rows: + if o is r: + continue + ospan = o["qe"] - o["qs"] + if ospan > rspan and o["qs"] <= r["qs"] and r["qe"] <= o["qe"]: + encompassed = True + break + if not encompassed: + survivors.append(r) + if not survivors: + survivors = rows # degenerate, but keep something + + # Identity source priority: de:f > dv:f > matches/blocks + has_de = any(r["de"] is not None for r in survivors) + has_dv = any(r["dv"] is not None for r in survivors) + + if has_de: + wsum = 0.0; wtot = 0 + for r in survivors: + if r["de"] is None: + continue + w = r["qe"] - r["qs"] + wsum += r["de"] * w + wtot += w + ident = 1.0 - (wsum / wtot if wtot else 1.0) + src = "de" + elif has_dv: + wsum = 0.0; wtot = 0 + for r in survivors: + if r["dv"] is None: + continue + w = r["qe"] - r["qs"] + wsum += r["dv"] * w + wtot += w + ident = 1.0 - (wsum / wtot if wtot else 1.0) + src = "dv" + else: + m = sum(r["matches"] for r in rows) + b = sum(r["blocks"] for r in rows) + ident = (m / b) if b else 0.0 + src = "mb" + + return qcov, tcov, ident, src + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("paf", help="input PAF (use '-' for stdin)") + ap.add_argument("-o", "--out", default="-", + help="output TSV (default stdout)") + ap.add_argument("--qcov", type=float, default=0.70) + ap.add_argument("--tcov", type=float, default=0.70) + ap.add_argument("--ident", type=float, default=0.70) + ap.add_argument("--queries-fa", default=None, + help="optional FASTA so unmapped queries also appear") + args = ap.parse_args() + + pairs = defaultdict(list) + pair_lens = {} + qlen_seen = {} + fmt_de = fmt_dv = fmt_mb = 0 + + fh = sys.stdin if args.paf == "-" else open(args.paf) + for line in fh: + rec = parse_row(line) + if rec is None: + continue + if isinstance(rec, tuple) and rec[0] == "NOHIT": + qlen_seen[rec[1]] = rec[2] + continue + qlen_seen[rec["q"]] = rec["qlen"] + key = (rec["q"], rec["t"]) + pairs[key].append(rec) + pair_lens[key] = (rec["qlen"], rec["tlen"]) + if rec["de"] is not None: + fmt_de += 1 + elif rec["dv"] is not None: + fmt_dv += 1 + else: + fmt_mb += 1 + + sys.stderr.write( + f"[classify_final] parsed pairs: {len(pairs)}; " + f"rows w/ de:f={fmt_de}, dv:f={fmt_dv}, neither={fmt_mb}\n" + ) + + # Best (q,t) per query + best = {} + for (q, t), rows in pairs.items(): + qlen, tlen = pair_lens[(q, t)] + qcov, tcov, ident, src = aggregate_pair(rows, qlen, tlen) + passed = qcov >= args.qcov and tcov >= args.tcov and ident >= args.ident + score = (1 if passed else 0, ident * min(qcov, tcov)) + cur = best.get(q) + if cur is None or score > cur[0]: + best[q] = (score, qlen, t, tlen, qcov, tcov, ident, src, passed) + + # Optional FASTA enumeration to fill in unmapped queries + if args.queries_fa: + with open(args.queries_fa) as fa: + cur_id = None; cur_len = 0 + for ln in fa: + if ln.startswith(">"): + if cur_id is not None and cur_id not in best: + best[cur_id] = ((0, 0), cur_len, "*", 0, + 0.0, 0.0, 0.0, "-", False) + cur_id = ln[1:].split()[0] + cur_len = 0 + else: + cur_len += len(ln.strip()) + if cur_id is not None and cur_id not in best: + best[cur_id] = ((0, 0), cur_len, "*", 0, + 0.0, 0.0, 0.0, "-", False) + else: + # at least include queries seen in PAF (mapped + paf-no-hit) + for q, qlen in qlen_seen.items(): + if q not in best: + best[q] = ((0, 0), qlen, "*", 0, 0.0, 0.0, 0.0, "-", False) + + out = sys.stdout if args.out == "-" else open(args.out, "w") + out.write("query\tqlen\tbest_target\ttlen\tqcov\ttcov\t" + "identity\tident_src\tpass\n") + n_pass = 0 + for q in sorted(best): + (_, qlen, t, tlen, qcov, tcov, ident, src, passed) = best[q] + if passed: + n_pass += 1 + out.write(f"{q}\t{qlen}\t{t}\t{tlen}\t{qcov:.4f}\t{tcov:.4f}" + f"\t{ident:.4f}\t{src}\t{int(passed)}\n") + + sys.stderr.write( + f"[classify_final] queries: {len(best)} " + f"classified: {n_pass}\n" + ) + + +if __name__ == "__main__": + main() diff --git a/src/classify_ltr_paf_fast.py b/src/classify_ltr_paf_fast.py new file mode 100755 index 0000000..976bf4e --- /dev/null +++ b/src/classify_ltr_paf_fast.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +"""Classify putative LTR-RTs from a minimap2 PAF -- CIGAR-free version. + +This is a faster, memory-leaner sibling of classify_ltr_paf.py that uses ONLY +the standard PAF columns plus the dv:f tag (approximate per-base sequence +divergence). It does NOT walk the cg:Z CIGAR. The intent is to let you run +minimap2 WITHOUT -c, saving substantial runtime on large inputs: + + minimap2 -k15 -w5 -A2 -B3 -r1k,10k -s30 -m30 -N50 -p0.1 \\ + target.fa query.fa > out.paf # NOTE: no -c, no cg:Z, dv:f tag + +Approximation tradeoff: q- and t-intervals for overlap dedup are taken as +[qstart, qend] and [tstart, tend], i.e. the alignment SPAN, which lumps +gap-induced bases (insertions and deletions) in with the matches+mismatches. +For LTR-RTs at >=70% identity, gap content is typically 2-5%, so eff_qcov / +eff_tcov are inflated by that small amount. dv:f is used in place of de:f +(de:f is gap-compressed; dv:f is approximate per-base divergence -- close +enough at our 70% threshold). + +If both dv:f and de:f are present (e.g. the user kept -c for some reason), +de:f is used. Otherwise dv:f. If neither is present, the line is skipped. + +OUTPUT (TSV; --header to add a header): + qname pass pid eff_qcov eff_tcov best_tname + +PARSIMONY RULE for pid (same as the CIGAR-aware sibling): + 1. Drop alignments fully encompassed by another on q (encompassing aln has + more statistical power). + 2. Sort survivors by dv:f DESCENDING (densest first). + 3. Greedy allocation, densest-first: each alignment's mutations are first + absorbed into already-claimed (denser) overlap; leftovers spill into + its unique region. + 4. pid = 1 - sum(unique_mutations) / sum(unique_q_spans). + +Per-alignment "mutations" weight: dv * qspan (where qspan = qend - qstart). + +Best-target per query: passes-rule first, then joint_score = pid*qcov*tcov, +then tname alphabetical. +""" + +import argparse +import sys +from collections import defaultdict + + +def merge_intervals(ivs): + """Merge overlapping/adjacent intervals; return sorted, disjoint list.""" + if not ivs: + return [] + ivs = sorted(ivs) + out = [list(ivs[0])] + for s, e in ivs[1:]: + if s <= out[-1][1]: + if e > out[-1][1]: + out[-1][1] = e + else: + out.append([s, e]) + return [(s, e) for s, e in out] + + +def interval_total(ivs): + return sum(e - s for s, e in ivs) + + +def interval_difference(A, B): + """Return A \\ B: parts of A not covered by B.""" + A = merge_intervals(A) + B = merge_intervals(B) + out = [] + bi = 0 + for s, e in A: + cur = s + while bi < len(B) and B[bi][1] <= cur: + bi += 1 + j = bi + while cur < e and j < len(B): + bs, be = B[j] + if bs >= e: + break + if bs > cur: + out.append((cur, min(bs, e))) + cur = max(cur, be) + j += 1 + if cur < e: + out.append((cur, e)) + return out + + +def interval_intersection_length(A, B): + A = merge_intervals(A) + B = merge_intervals(B) + i = j = 0 + total = 0 + while i < len(A) and j < len(B): + s = max(A[i][0], B[j][0]) + e = min(A[i][1], B[j][1]) + if s < e: + total += e - s + if A[i][1] < B[j][1]: + i += 1 + else: + j += 1 + return total + + +def is_fully_encompassed(small_iv, big_iv): + """True iff every interval in small_iv is fully inside some interval in big_iv.""" + big_iv = merge_intervals(big_iv) + for s, e in small_iv: + contained = False + for bs, be in big_iv: + if bs <= s and e <= be: + contained = True + break + if not contained: + return False + return True + + +def parse_paf_line(line, lineno, strict=False): + """Return alignment record dict, or None if malformed and strict=False. + + Required: 12 standard columns + a dv:f or de:f tag. + """ + fields = line.rstrip("\n").split("\t") + if len(fields) < 12: + if strict: + raise ValueError("line %d: only %d fields (need >=12)" % (lineno, len(fields))) + return None + dv = None + de = None + for tag in fields[12:]: + if tag.startswith("dv:f:"): + try: + dv = float(tag[5:]) + except ValueError: + pass + elif tag.startswith("de:f:"): + try: + de = float(tag[5:]) + except ValueError: + pass + # Prefer the gap-compressed de:f if present (more accurate); fall back to dv:f. + div = de if de is not None else dv + if div is None: + if strict: + raise ValueError("line %d: neither dv:f nor de:f tag present" % lineno) + return None + try: + return { + "qname": fields[0], + "qlen": int(fields[1]), + "qstart": int(fields[2]), + "qend": int(fields[3]), + "tname": fields[5], + "tlen": int(fields[6]), + "tstart": int(fields[7]), + "tend": int(fields[8]), + "div": div, + } + except (ValueError, IndexError): + if strict: + raise + return None + + +def compute_pair_metrics(alignments, qlen, tlen): + """Compute (pid, eff_qcov, eff_tcov) for one (q,t) pair, span-based. + + `alignments` is a non-empty list of dicts: {qstart, qend, tstart, tend, div}. + """ + # Build per-alignment span intervals (no CIGAR -- one interval each). + for a in alignments: + a["q_iv"] = [(a["qstart"], a["qend"])] + a["t_iv"] = [(a["tstart"], a["tend"])] + a["qspan"] = a["qend"] - a["qstart"] + + # 1. Drop alignments whose q-span is fully encompassed by a STRICTLY longer one. + survivors = [] + for i, A in enumerate(alignments): + encompassed = False + for j, B in enumerate(alignments): + if i == j: + continue + if A["qspan"] < B["qspan"] and is_fully_encompassed(A["q_iv"], B["q_iv"]): + encompassed = True + break + if not encompassed: + survivors.append(A) + if not survivors: + survivors = [max(alignments, key=lambda a: a["div"])] + + # 2. Sort by div descending. + survivors.sort(key=lambda a: -a["div"]) + + # 3. Greedy allocation. + claims = [] + claimed_q_all = [] + for X in survivors: + X_q = X["q_iv"] + X_total_mut = X["div"] * X["qspan"] + mutations_in_overlap = 0.0 + for unique_iv, density in claims: + mutations_in_overlap += density * interval_intersection_length(X_q, unique_iv) + X_unique_iv = interval_difference(X_q, claimed_q_all) + X_unique_len = interval_total(X_unique_iv) + X_unique_mut = max(0.0, X_total_mut - mutations_in_overlap) + if X_unique_len > 0: + X_unique_mut = min(X_unique_mut, float(X_unique_len)) + density = X_unique_mut / X_unique_len + else: + X_unique_mut = 0.0 + density = 0.0 + claims.append((X_unique_iv, density)) + claimed_q_all = merge_intervals(claimed_q_all + X_unique_iv) + + total_unique_mut = sum(d * interval_total(iv) for iv, d in claims) + total_unique_len = interval_total(claimed_q_all) + pid = (1.0 - total_unique_mut / total_unique_len) if total_unique_len > 0 else 0.0 + + # eff_qcov, eff_tcov: union of all alignment spans (use ALL alignments, + # not just survivors -- encompassed ones add nothing new anyway). + all_q_iv = [iv for a in alignments for iv in a["q_iv"]] + all_t_iv = [iv for a in alignments for iv in a["t_iv"]] + eff_qcov = interval_total(merge_intervals(all_q_iv)) / qlen if qlen > 0 else 0.0 + eff_tcov = interval_total(merge_intervals(all_t_iv)) / tlen if tlen > 0 else 0.0 + return pid, eff_qcov, eff_tcov + + +def process_paf(lines, min_pid=0.70, min_qcov=0.70, min_tcov=0.70, verbose=False): + """Stream PAF lines, group by (qname,tname), pick best target per query, + return [(qname, pass_str, pid, qcov, tcov, best_tname), ...] sorted by qname. + """ + pair_alns = defaultdict(list) + pair_lengths = {} + n_lines = 0 + n_skipped = 0 + for lineno, raw in enumerate(lines, start=1): + if not raw.strip() or raw.startswith("#") or raw.startswith("["): + continue + rec = parse_paf_line(raw, lineno, strict=False) + if rec is None: + n_skipped += 1 + continue + key = (rec["qname"], rec["tname"]) + pair_alns[key].append(rec) + if key not in pair_lengths: + pair_lengths[key] = (rec["qlen"], rec["tlen"]) + n_lines += 1 + if verbose: + print("[classify_ltr_paf_fast] parsed %d alignments across %d (q,t) pairs" + % (n_lines, len(pair_alns)), file=sys.stderr) + if n_skipped: + print("[classify_ltr_paf_fast] skipped %d malformed lines (missing dv:f/de:f or unparseable)" + % n_skipped, file=sys.stderr) + + per_query = defaultdict(list) + for (qname, tname), alns in pair_alns.items(): + qlen, tlen = pair_lengths[(qname, tname)] + pid, qcov, tcov = compute_pair_metrics(alns, qlen, tlen) + passes = (pid >= min_pid) and (qcov >= min_qcov) and (tcov >= min_tcov) + per_query[qname].append({ + "tname": tname, + "pid": pid, + "qcov": qcov, + "tcov": tcov, + "passes": passes, + "joint": pid * qcov * tcov, + }) + + results = [] + n_pass = 0 + for qname in sorted(per_query): + candidates = per_query[qname] + candidates.sort(key=lambda c: (-int(c["passes"]), -c["joint"], c["tname"])) + best = candidates[0] + if best["passes"]: + n_pass += 1 + results.append(( + qname, + "pass" if best["passes"] else "fail", + best["pid"], + best["qcov"], + best["tcov"], + best["tname"], + )) + if verbose: + print("[classify_ltr_paf_fast] %d/%d queries pass at pid>=%.3f qcov>=%.3f tcov>=%.3f" + % (n_pass, len(results), min_pid, min_qcov, min_tcov), file=sys.stderr) + return results + + +def format_row(row): + qname, pass_str, pid, qcov, tcov, tname = row + return "%s\t%s\t%.4f\t%.4f\t%.4f\t%s" % (qname, pass_str, pid, qcov, tcov, tname) + + +def main(): + ap = argparse.ArgumentParser( + description="Classify putative LTR-RTs from a minimap2 PAF (CIGAR-free, " + "uses dv:f or de:f and standard PAF columns only).") + ap.add_argument("paf", help="input PAF (use - for stdin)") + ap.add_argument("-o", "--output", default="-", + help="output TSV path (default: stdout)") + ap.add_argument("--min-pid", type=float, default=0.70) + ap.add_argument("--min-qcov", type=float, default=0.70) + ap.add_argument("--min-tcov", type=float, default=0.70) + ap.add_argument("--header", action="store_true") + ap.add_argument("-v", "--verbose", action="store_true") + args = ap.parse_args() + + in_fh = sys.stdin if args.paf == "-" else open(args.paf, "r") + out_fh = sys.stdout if args.output == "-" else open(args.output, "w") + if args.verbose: + print("[classify_ltr_paf_fast] reading %s" % args.paf, file=sys.stderr) + try: + results = process_paf( + in_fh, + min_pid=args.min_pid, min_qcov=args.min_qcov, min_tcov=args.min_tcov, + verbose=args.verbose, + ) + finally: + if args.paf != "-": + in_fh.close() + try: + if args.header: + out_fh.write("qname\tpass\tpid\teff_qcov\teff_tcov\tbest_tname\n") + for row in results: + out_fh.write(format_row(row) + "\n") + finally: + if args.output != "-": + out_fh.close() + if args.verbose: + print("[classify_ltr_paf_fast] done", file=sys.stderr) + + +if __name__ == "__main__": + main() From e44334157fbc8c5e0861aadf030af87d9a9212e3 Mon Sep 17 00:00:00 2001 From: cwb14 Date: Thu, 30 Apr 2026 16:51:51 -0400 Subject: [PATCH 03/15] Benchmarked minimap2 params --- README.md | 55 ++++---- docs/runs/memo.md | 28 ++++ src/WFA_TEsorter | Bin 81720 -> 240624 bytes src/WFA_TEsorter.cpp | 7 +- src/WFA_TEsorter.dynamic.bak | Bin 0 -> 81720 bytes src/WFA_best_per_query.py | 155 +++++++++++++++++++++++ src/blast_pass2.py | 239 +++++++++++++++-------------------- src/eval.py | 133 +++++++++++++++++++ src/minimap.py | 51 ++++---- src/pipeline.py | 14 +- src/tesorter_compat.py | 12 +- 11 files changed, 491 insertions(+), 203 deletions(-) create mode 100755 src/WFA_TEsorter.dynamic.bak create mode 100755 src/WFA_best_per_query.py create mode 100644 src/eval.py diff --git a/README.md b/README.md index 9b75d93..bf46312 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # TEBinSorter_minimap2 Fork of TEBinSorter with pass-2 similarity search swapped from `blastn` to -[minimap2](https://github.com/lh3/minimap2). Unlike the `TEBinSorter_mmseqs` -sibling, this port enforces **both query AND target coverage** meeting the -pass-2 rule cutoff — matching the biological intent of "well-aligned on -both sides" rather than blastn's query-side-only `qcovs` check. +[minimap2](https://github.com/lh3/minimap2). Pass-2 runs minimap2 with a +sensitivity-tuned flag set, then reduces the PAF to one row per query via +`classify_ltr_paf_fast`, which enforces **identity, qcov, and tcov together** +under the user-supplied I-C-L rule. ## Additional runtime dependency @@ -21,44 +21,49 @@ Everything else is unchanged from TEBinSorter (pyhmmer, pyfastx, numpy). | Option | Default | Purpose | |---|---|---| | `-dp2`, `--disable-pass2` | off | Skip the minimap2 pass-2 (HMM-only classification) | -| `-rule`, `--pass2-rule I-C-L` | `80-80-80` | Pass-2 threshold as identity-coverage-length. **C is applied to both qcov and tcov** (this is the semantic difference vs the mmseqs/blastn ports) | +| `-rule`, `--pass2-rule I-C-L` | `70-70-70` | Pass-2 threshold. I drives `--min-pid`, C drives both `--min-qcov` and `--min-tcov`. L is parsed for grammar compatibility but is not consumed by `classify_ltr_paf_fast` | | `--pass2-classified-fasta FASTA` | none | Optional FASTA of prior classifications to augment the pass-2 target pool. Headers must be shaped `>id#Order/Superfamily/Clade` | | `--minimap2-preset PRESET` | `asm20` | Passed through as `minimap2 -x` | | `--minimap2-extra STR` | empty | Additional flags appended to the minimap2 command line | -## Coverage semantics +## minimap2 invocation -For a query-target pair with multiple PAF chains, qcov and tcov are each -computed as: +Pass-2 runs (with target = previously-classified pool, query = HMM-unclassified): ``` -qcov = |union of aligned query intervals across all chains| / qlen -tcov = |union of aligned target intervals across all chains| / tlen +minimap2 -x asm20 --rmq=no --no-long-join \ + -k 10 -w 10 -r 500,20000 -g 500 -p 0.3 -N 100 -m 30 \ + -t NCPU -K 1G --seed 11 --paf-no-hit \ + -o pass2.paf TARGET.fa QUERY.fa ``` -No chain-gap merge heuristic (unlike the mmseqs port's `_MAX_SPLIT_GAP=500`). -minimap2's own chainer already groups near-diagonal minimizer seeds into -chains; cross-chain union happens in Python and only counts uniquely -aligned bases (gaps between chains are not counted as covered). +The PAF is then collapsed by `classify_ltr_paf_fast` to one row per query: -Rule passes only if **identity ≥ I, qcov ≥ C, tcov ≥ C, and aln length ≥ L**. +``` +qname pass/fail pid eff_qcov eff_tcov best_tname +``` + +A query is rescued (best target's classification inherited) iff the row +reads `pass`, i.e. **pid ≥ I/100, eff_qcov ≥ C/100, eff_tcov ≥ C/100**. + +Benchmark at `70-70-70`: F1 ≈ 0.895, accuracy ≈ 0.943, precision ≈ 0.866, +recall ≈ 0.926, MCC ≈ 0.857. ## SQLite schema -The `blast_hits` table adds a `tcovs REAL NOT NULL` column (vs. the stock -TEBinSorter and the mmseqs sibling port). Downstream (`classify_from_blast`) -filters on both `qcovs` and `tcovs`. Other columns match the stock schema: -`evalue`/`slen` are sentinel zeros since minimap2's AS-score and PAF format -don't provide them directly. +The `blast_hits` table stores one row per query (best target) with columns +`qseqid, sseqid, pident, qcovs, tcovs, passes_rule, classified_by`. Indexes +on `qseqid` and `sseqid` are still built by `results.finalize_db`. ## What changed vs stock TEBinSorter - `src/blast_pass2.py` — internals swapped from `blastn`+`multiprocessing.Pool` - to a single `minimap2 -c -x asm20 -N 50 -p 0.1` call. Added tcovs column; - `classify_from_blast` filters on both axes. -- `src/minimap.py` — new. PAF parser with per-(query, target) union of query - and target intervals. Best-hit per query by minimap2 AS score. -- `src/pass2_external.py` — new (shared with mmseqs port). Helpers for + to one `minimap2` call followed by `classify_ltr_paf_fast.process_paf`. +- `src/minimap.py` — minimap2 wrapper with the sensitivity-tuned pass-2 flag + set (no `-c`; relies on PAF + `dv:f` only). +- `src/classify_ltr_paf_fast.py` — PAF → TSV reducer (one row per query, with + pass/fail under the I-C-L rule). +- `src/pass2_external.py` — shared with mmseqs port. Helpers for `--pass2-classified-fasta`. - `src/pipeline.py` / `src/tesorter_compat.py` — wire the five new CLI args through the pass-2 call. diff --git a/docs/runs/memo.md b/docs/runs/memo.md index e22b99a..744fd25 100644 --- a/docs/runs/memo.md +++ b/docs/runs/memo.md @@ -285,3 +285,31 @@ python3 classify_ltr_paf_fast.py minimap2.paf --min-pid 0.80 --min-qcov 0.80 --m # This script uses a simpler approach to calculate pid, but it may not tip the scale in many cases. It uses pass * ident * min(qcov, tcov) to determine *best*. qcov and tcov are calculated identically between scripts. python3 classify_final.py minimap2.paf --queries-fa QUERY.fa --qcov 0.80 --tcov 0.80 --ident 0.80 -o merged.tsv # Practically, they give the same output for most things. pronbably classify_ltr_paf_fast.py is perfered due to the pid calculation. + + +######################################### +# I implemented above ideas. +# 'TEBinSorter/src/WFA_best_per_query.py' converts 'WFA_TEsorter' outputs to classify TSV format (WFA.tsv). +# Here are the best parameters: +./minimap2/minimap2 -x asm20 --rmq=no --no-long-join -k 10 -w 10 -r 500,20000 -g 500 -p 0.3 -N 100 -m 30 -t 5 -K 1G --seed 11 --paf-no-hit -o minimap2.paf TARGET.fa QUERY.fa +# Evaluate perfomance. +python TEBINsorter/eval.py --gold WFA.tsv --pred minimap2.paf +{ + "label": "", + "total": 5556, + "TP": 1339, + "TN": 3903, + "FP": 207, + "FN": 107, + "accuracy": 0.943485, + "precision": 0.866106, + "recall": 0.926003, + "f1": 0.895053, + "mcc": 0.857339, + "tp_agree": 754, + "tp_disagree": 585, + "tp_agree_frac": 0.563107, + "missing_from_pred": 2729, + "extras": 223 +} +[eval] out.tsv F1=0.8951 acc=0.9435 P=0.8661 R=0.9260 MCC=0.8573 TP=1339 TN=3903 FP=207 FN=107 agree=754/1339 miss=2729 extra=223 diff --git a/src/WFA_TEsorter b/src/WFA_TEsorter index 20823db1a33b519b3e0491c82a02c211479f8dd4..9ef284f95d25786209920bb7b0c280a1af6520d6 100755 GIT binary patch literal 240624 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 when serialising. // -// Build (one line): -// g++ -O3 -std=c++17 -fopenmp -Wall -I./WFA2-lib WFA_TEsorter.cpp -o WFA_TEsorter -L./WFA2-lib/build -Wl,-rpath,'$ORIGIN/WFA2-lib/build' -lwfa2cpp -lwfa2 -lpthread -lm +// Build (one line; static-linked against WFA2-lib so the binary has no +// runtime libwfa2*.so dependency). Run from this src/ directory; assumes +// WFA2-lib sits two levels up as a sibling of TEBinSorter/. Adjust the +// -I and .a paths if your layout differs. +// g++ -O3 -std=c++17 -fopenmp -Wall -I../../WFA2-lib WFA_TEsorter.cpp -o WFA_TEsorter ../../WFA2-lib/build/libwfa2cpp.a ../../WFA2-lib/build/libwfa2.a -lpthread -lm #include #include diff --git a/src/WFA_TEsorter.dynamic.bak b/src/WFA_TEsorter.dynamic.bak new file mode 100755 index 0000000000000000000000000000000000000000..20823db1a33b519b3e0491c82a02c211479f8dd4 GIT binary patch literal 81720 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 --min-pid AND qcov >= --min-qcov AND tcov >= --min-tcov + +Per query, we keep the alignment that maximizes pass*pid*qcov*tcov; +ties (e.g. all failing -> product 0) are broken by raw pid*qcov*tcov. + +Output TSV: qname pass pid qcov tcov best_tname + +Notes: +- Streams the PAF line by line; only one row per query is held in memory. +- Requires `cg:Z:` CIGAR on every row (run minimap2/WFA_TEsorter with --cigar). +- mismatches are derived as NM - I_bases - D_bases (NM = mismatches + indels). +""" + +import argparse +import re +import sys + + +CIGAR_RE = re.compile(r'(\d+)([MIDNSHP=X])') + + +def parse_cigar(cigar): + """Return (m_bases, i_bases, d_bases, gap_opens). M counts =/X too.""" + m = i = d = gap_opens = 0 + for n_str, op in CIGAR_RE.findall(cigar): + n = int(n_str) + if op in 'M=X': + m += n + elif op == 'I': + i += n + gap_opens += 1 + elif op == 'D': + d += n + gap_opens += 1 + return m, i, d, gap_opens + + +def row_metrics(fields): + """Compute (qname, tname, pid, qcov, tcov) for one PAF row, or None to skip.""" + qname = fields[0] + qlen = int(fields[1]) + tname = fields[5] + tlen = int(fields[6]) + paf_matches = int(fields[9]) + + cigar = None + nm = None + for tag in fields[12:]: + if tag.startswith('cg:Z:'): + cigar = tag[5:] + elif tag.startswith('NM:i:'): + nm = int(tag[5:]) + + if cigar is None or qlen <= 0 or tlen <= 0: + return None + + m_bases, i_bases, d_bases, gap_opens = parse_cigar(cigar) + + if nm is not None: + # NM = mismatches + indel_bases + mismatches = nm - i_bases - d_bases + matches = m_bases - mismatches + else: + # fall back to PAF column 10 (residue matches) + matches = paf_matches + mismatches = m_bases - matches + + if matches < 0 or mismatches < 0: + return None + + denom = matches + mismatches + gap_opens + pid = matches / denom if denom > 0 else 0.0 + qcov = m_bases / qlen + tcov = m_bases / tlen + + return qname, tname, pid, qcov, tcov + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + ap.add_argument('paf', nargs='?', default='-', + help='Input PAF (default: stdin). Use "-" for stdin.') + ap.add_argument('-o', '--output', default='-', + help='Output TSV (default: stdout).') + ap.add_argument('--min-pid', type=float, default=0.70, + help='Min gap-compressed identity to pass (default: 0.70).') + ap.add_argument('--min-qcov', type=float, default=0.70, + help='Min query coverage to pass (default: 0.70).') + ap.add_argument('--min-tcov', type=float, default=0.70, + help='Min target coverage to pass (default: 0.70).') + ap.add_argument('-v', '--verbose', action='store_true', + help='Report progress to stderr.') + args = ap.parse_args() + + fin = sys.stdin if args.paf == '-' else open(args.paf) + fout = sys.stdout if args.output == '-' else open(args.output, 'w') + + # qname -> (sort_key, passed, pid, qcov, tcov, tname) + best = {} + n_rows = n_skipped = 0 + + for line in fin: + if not line or line[0] == '#': + continue + fields = line.rstrip('\n').split('\t') + if len(fields) < 12: + n_skipped += 1 + continue + n_rows += 1 + out = row_metrics(fields) + if out is None: + n_skipped += 1 + continue + qname, tname, pid, qcov, tcov = out + passed = (pid >= args.min_pid and + qcov >= args.min_qcov and + tcov >= args.min_tcov) + # primary: pass beats fail; secondary: raw product + key = (1 if passed else 0, pid * qcov * tcov) + prev = best.get(qname) + if prev is None or key > prev[0]: + best[qname] = (key, passed, pid, qcov, tcov, tname) + if args.verbose and n_rows % 1_000_000 == 0: + print(f' ... {n_rows:,} rows, {len(best):,} unique queries', + file=sys.stderr) + + fout.write('qname\tpass\tpid\tqcov\ttcov\tbest_tname\n') + for qname, (_, passed, pid, qcov, tcov, tname) in best.items(): + fout.write(f'{qname}\t{"pass" if passed else "fail"}\t' + f'{pid:.4f}\t{qcov:.4f}\t{tcov:.4f}\t{tname}\n') + + if args.verbose: + n_pass = sum(1 for v in best.values() if v[1]) + print(f'rows: {n_rows:,} skipped: {n_skipped:,} ' + f'queries: {len(best):,} pass: {n_pass:,} ' + f'fail: {len(best) - n_pass:,}', file=sys.stderr) + + if fin is not sys.stdin: + fin.close() + if fout is not sys.stdout: + fout.close() + + +if __name__ == '__main__': + main() diff --git a/src/blast_pass2.py b/src/blast_pass2.py index 8fbda80..21e18af 100644 --- a/src/blast_pass2.py +++ b/src/blast_pass2.py @@ -2,17 +2,19 @@ blast_pass2.py — minimap2-based pass-2 classification for HMM-unclassified sequences. -Port of TEBinSorter's blastn-based pass-2 onto minimap2. Keeps the filename -and public symbols (`blast_pass2`, `store_blast_hits`, `classify_from_blast`) -so the rest of the pipeline doesn't care what aligner is behind them, but -the SQLite `blast_hits` schema adds a `tcovs` column and the filter enforces -both qcov AND tcov ≥ cutoff. - -Rule semantics for --pass2-rule I-C-L: - identity ≥ I%, qcov ≥ C%, tcov ≥ C%, length ≥ L bp - -where qcov / tcov are unions of aligned intervals across ALL minimap2 chains -for a given (query, target) pair, divided by qlen / tlen respectively. +Pipeline: + 1. minimap2 (sensitivity-tuned flags from `minimap.run_minimap2`) writes a + PAF for the unclassified queries against the classified-pool target. + 2. `classify_ltr_paf_fast.process_paf` reduces the PAF to one row per query: + qname pass/fail pid eff_qcov eff_tcov best_tname + under the rule `--min-pid I --min-qcov C --min-tcov C` derived from + `--pass2-rule I-C-L`. (Per benchmarking, 70-70-70 is the recommended + default; the L value is parsed for backwards-compat with the I-C-L + grammar but is not consumed downstream.) + 3. Each `pass` row inherits the target's order/superfamily/clade. + +The SQLite `blast_hits` table is preserved for post-run introspection but the +schema has been simplified to the columns classify_ltr_paf_fast emits. """ import logging @@ -25,20 +27,11 @@ import minimap import pass2_external +from classify_ltr_paf_fast import process_paf, format_row log = logging.getLogger(__name__) -# Developer toggle, deliberately not exposed on the CLI. -# True -> require BOTH qcov AND tcov >= coverage threshold (strict). -# False -> require AT LEAST ONE of qcov, tcov >= coverage threshold -# (Wicker et al. 80-80-80 style: "candidate must cover ≥80% of -# at least one of the elements being compared"). -# minimap2 has no native coverage filter, so this is enforced post-hoc in -# classify_from_blast's SQL WHERE clause. -REQUIRE_BOTH_COVERAGE = False - - def _get_classified_ids(conn): """Get classified sequence IDs per database from classifier results.""" classified = defaultdict(set) @@ -54,11 +47,9 @@ def _get_classified_ids(conn): def split_classified_unclassified(input_fasta, classified_ids, outdir, seq_type="nucl"): - """Split input into classified-pool FASTA and unclassified-query FASTA. - - Nucleotide pools are uppercased and stripped of non-ATCG characters — not - because minimap2 requires it (it handles ambiguous bases) but to keep - invariants aligned with the mmseqs sibling port and make inputs clean. + """Split input into classified-pool FASTA (target) and unclassified-query + FASTA. Nucleotide pools are uppercased and stripped of non-ATCG to keep + inputs clean (minimap2 itself tolerates ambiguous bases). """ os.makedirs(outdir, exist_ok=True) db_fasta = os.path.join(outdir, "blast_db.fa") @@ -92,7 +83,7 @@ def split_classified_unclassified(input_fasta, classified_ids, outdir, def run_alignment(query_fa, target_fa, paf_out, ncpu=4, preset="asm20", extra=""): - """Run minimap2 once with preset + -c so AS tags are available.""" + """Run minimap2 with the sensitivity-tuned pass-2 flag set.""" minimap.run_minimap2( query_fa=query_fa, target_fa=target_fa, paf_out=paf_out, ncpu=ncpu, preset=preset, extra=extra, @@ -100,139 +91,113 @@ def run_alignment(query_fa, target_fa, paf_out, ncpu=4, return paf_out -def parse_minimap2_output(paf_path): - """Parse PAF, union per (q,t), best-hit per query, return hit dicts.""" - merged = minimap.parse_paf_besthit(paf_path) - best = minimap.besthit_per_query(merged) - - hits = [] - for qid, m in best.items(): - hits.append({ - "qseqid": m["qseqid"], - "sseqid": m["sseqid"], - "pident": m["fident"] * 100.0, # 0..100 - "length": m["alnlen"], # union of aligned query bases - "evalue": 0.0, # sentinel - "bitscore": float(m["score"]), # minimap2 AS score - "qlen": m["qlen"], - "slen": m["tlen"], - "qcovs": m["qcov"] * 100.0, # 0..100 - "tcovs": m["tcov"] * 100.0, # 0..100 (new column) - }) - return hits +def classify_paf_to_tsv(paf_path, tsv_out, min_pid, min_qcov, min_tcov): + """Run classify_ltr_paf_fast over the PAF, write the TSV alongside, + and return the parsed rows: [(qname, pass_str, pid, qcov, tcov, best_tname), ...]. + """ + if not os.path.exists(paf_path) or os.path.getsize(paf_path) == 0: + return [] + + with open(paf_path) as fh: + rows = process_paf( + fh, min_pid=min_pid, min_qcov=min_qcov, min_tcov=min_tcov, + verbose=False, + ) + + with open(tsv_out, "w") as fh: + for row in rows: + fh.write(format_row(row) + "\n") + n_pass = sum(1 for r in rows if r[1] == "pass") + log.info(f" classify_ltr_paf_fast: {n_pass}/{len(rows)} queries pass " + f"(min_pid={min_pid:.3f} min_qcov={min_qcov:.3f} " + f"min_tcov={min_tcov:.3f}) -> {tsv_out}") + return rows -def store_blast_hits(conn, hits, db_seq_to_dbs): - """Store hits in SQLite. Adds a `tcovs` column vs. the stock schema.""" + +def store_blast_hits(conn, tsv_rows, db_seq_to_dbs): + """Store classify_ltr_paf_fast rows in SQLite. One row per query.""" conn.execute(""" CREATE TABLE IF NOT EXISTS blast_hits ( - qseqid TEXT NOT NULL, - sseqid TEXT NOT NULL, - pident REAL NOT NULL, - length INTEGER NOT NULL, - evalue REAL NOT NULL, - bitscore REAL NOT NULL, - qlen INTEGER NOT NULL, - slen INTEGER NOT NULL, - qcovs REAL NOT NULL, - tcovs REAL NOT NULL, + qseqid TEXT NOT NULL, + sseqid TEXT NOT NULL, + pident REAL NOT NULL, + qcovs REAL NOT NULL, + tcovs REAL NOT NULL, + passes_rule INTEGER NOT NULL, classified_by TEXT NOT NULL ) """) rows = [] - for h in hits: - dbs = db_seq_to_dbs.get(h["sseqid"], set()) + for qname, pass_str, pid, qcov, tcov, tname in tsv_rows: + dbs = db_seq_to_dbs.get(tname, set()) classified_by = ",".join(sorted(dbs)) if dbs else "unknown" rows.append(( - h["qseqid"], h["sseqid"], h["pident"], h["length"], - h["evalue"], h["bitscore"], h["qlen"], h["slen"], - h["qcovs"], h["tcovs"], classified_by, + qname, tname, + pid * 100.0, qcov * 100.0, tcov * 100.0, + 1 if pass_str == "pass" else 0, + classified_by, )) conn.executemany( - "INSERT INTO blast_hits VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - rows, + "INSERT INTO blast_hits VALUES (?, ?, ?, ?, ?, ?, ?)", rows, ) conn.commit() - log.info(f" Stored {len(rows)} minimap2 hits") + log.info(f" Stored {len(rows)} minimap2 best-hit rows") -def classify_from_blast(conn, classifications, database=None, - min_identity=80, min_coverage=80, min_length=80): - """Classify pass-2 rescues. Filters on BOTH qcovs and tcovs.""" - tables = {r[0] for r in conn.execute( - "SELECT name FROM sqlite_master WHERE type='table'").fetchall()} - if "blast_hits" not in tables: - return [] - - if REQUIRE_BOTH_COVERAGE: - where = ("WHERE pident >= ? AND qcovs >= ? AND tcovs >= ? " - "AND length >= ?") - params = [min_identity, min_coverage, min_coverage, min_length] - else: - # At-least-one-side coverage (Wicker et al. 80-80-80 interpretation) - where = ("WHERE pident >= ? AND (qcovs >= ? OR tcovs >= ?) " - "AND length >= ?") - params = [min_identity, min_coverage, min_coverage, min_length] - log.info(f" coverage mode: {'BOTH' if REQUIRE_BOTH_COVERAGE else 'AT-LEAST-ONE'} " - f"qcov/tcov >= {min_coverage}") - if database: - where += " AND classified_by LIKE ?" - params.append(f"%{database}%") - - rows = conn.execute(f""" - SELECT qseqid, sseqid, pident, qcovs, tcovs, length, bitscore - FROM blast_hits - {where} - ORDER BY bitscore DESC - """, params).fetchall() - +def classify_from_blast(tsv_rows, classifications): + """Inherit order/superfamily/clade for queries whose best target passed + the rule. `classifications` maps target_id -> classification dict. + """ classified_set = set(classifications.keys()) - best = {} - for qid, sid, pident, qcovs, tcovs, length, bitscore in rows: - if qid in classified_set: - continue - if qid not in best: - best[qid] = (sid, pident, qcovs, tcovs, length, bitscore) - new_classifications = [] no_source = 0 - for qid, (sid, pident, qcovs, tcovs, length, bitscore) in best.items(): - if sid in classifications: - source = classifications[sid] - new_classifications.append({ - "id": qid, - "order": source["order"], - "superfamily": source["superfamily"], - "clade": "unknown", - "complete": "none", - "strand": "?", - "domains": "none", - "blast_source": sid, - "blast_pident": pident, - "blast_qcovs": qcovs, - "blast_tcovs": tcovs, - "blast_bitscore": bitscore, - }) - else: + n_pass = 0 + for qname, pass_str, pid, qcov, tcov, tname in tsv_rows: + if pass_str != "pass": + continue + n_pass += 1 + if qname in classified_set: + continue + if tname not in classifications: no_source += 1 + continue + source = classifications[tname] + new_classifications.append({ + "id": qname, + "order": source["order"], + "superfamily": source["superfamily"], + "clade": source.get("clade", "unknown"), + "complete": "none", + "strand": "?", + "domains": "none", + "blast_source": tname, + "blast_pident": pid * 100.0, + "blast_qcovs": qcov * 100.0, + "blast_tcovs": tcov * 100.0, + "blast_bitscore": 0.0, + }) if no_source: log.info(f" {no_source} pass-2 hits to unclassified targets (skipped)") - log.info(f" pass-2: {len(new_classifications)} sequences classified " - f"(from {len(best)} hits passing filters)") + f"(from {n_pass} rule-passing queries)") return new_classifications def blast_pass2(input_fasta, conn, hmm_classifications=None, seq_type="nucl", n_processors=4, - min_identity=80, min_coverage=80, min_length=80, + min_identity=70, min_coverage=70, min_length=70, outdir=None, pass2_classified_fasta=None, preset="asm20", minimap2_extra=""): - """minimap2-based pass-2. Public signature mirrors sibling ports. + """minimap2-based pass-2. - min_coverage is applied to BOTH qcov and tcov. + Args: + min_identity: I from --pass2-rule I-C-L (percent, e.g. 70) + min_coverage: C from --pass2-rule I-C-L (percent, applied to qcov AND tcov) + min_length: L from --pass2-rule I-C-L (parsed for backwards-compat + with the I-C-L grammar, not consumed by classify_ltr_paf_fast) """ t0 = time.time() minimap.check_minimap2() @@ -282,6 +247,7 @@ def blast_pass2(input_fasta, conn, hmm_classifications=None, return [] paf_out = os.path.join(work, "pass2.paf") + tsv_out = os.path.join(work, "pass2.tsv") log.info(f" Running minimap2 -x {preset} with {n_processors} threads") t1 = time.time() @@ -292,20 +258,19 @@ def blast_pass2(input_fasta, conn, hmm_classifications=None, t2 = time.time() log.info(f" minimap2 alignment: {t2 - t1:.1f}s") - hits = parse_minimap2_output(paf_out) - log.info(f" {len(hits)} best-hit records after per-pair union of PAF chains") + min_pid = min_identity / 100.0 + min_cov = min_coverage / 100.0 + tsv_rows = classify_paf_to_tsv( + paf_out, tsv_out, + min_pid=min_pid, min_qcov=min_cov, min_tcov=min_cov, + ) - if hits: - store_blast_hits(conn, hits, db_seq_to_dbs) + if tsv_rows: + store_blast_hits(conn, tsv_rows, db_seq_to_dbs) log.info(f" {len(hmm_classifications)} HMM classifications available for inheritance") - new_cls = classify_from_blast( - conn, hmm_classifications, - min_identity=min_identity, - min_coverage=min_coverage, - min_length=min_length, - ) + new_cls = classify_from_blast(tsv_rows, hmm_classifications) t3 = time.time() log.info(f" minimap2 pass-2 total: {t3 - t0:.1f}s") diff --git a/src/eval.py b/src/eval.py new file mode 100644 index 0000000..3e029bc --- /dev/null +++ b/src/eval.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Compare a minimap2-derived classified TSV to a WFA goldstandard TSV. + +Both files share the schema: + qname pass pid qcov tcov best_tname + +Universe = WFA qnames. Queries missing from the prediction file are treated as +predicted_fail (matches downstream behavior of classify_ltr_paf_fast.py, which +drops queries with no PAF alignment carrying dv:f / de:f). + +Pair agreement (TP only): best_tname stems compared after stripping any +"#..." suffix. Records counts of agree / disagree. + +Output: JSON to --out (default stdout), single-line summary to stderr. +""" + +import argparse +import json +import math +import sys + + +def strip_class(tname): + if tname is None: + return None + i = tname.find("#") + return tname if i < 0 else tname[:i] + + +def load_tsv(path, has_header): + """Return {qname: (pass_bool, best_tname)}.""" + out = {} + with open(path) as fh: + for lineno, line in enumerate(fh, 1): + if has_header and lineno == 1: + continue + line = line.rstrip("\n") + if not line: + continue + f = line.split("\t") + if len(f) < 6: + continue + qname = f[0] + passing = (f[1].lower() == "pass") + tname = f[5] + out[qname] = (passing, tname) + return out + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--gold", required=True, help="WFA goldstandard TSV (with header)") + ap.add_argument("--pred", required=True, help="prediction TSV (no header)") + ap.add_argument("--gold-has-header", action="store_true", default=True) + ap.add_argument("--pred-has-header", action="store_true", default=False) + ap.add_argument("--out", default="-", help="output JSON path (default stdout)") + ap.add_argument("--label", default="", help="optional label echoed into JSON") + args = ap.parse_args() + + gold = load_tsv(args.gold, has_header=args.gold_has_header) + pred = load_tsv(args.pred, has_header=args.pred_has_header) + + universe = set(gold.keys()) + extras = set(pred.keys()) - universe + + TP = TN = FP = FN = 0 + tp_agree = tp_disagree = 0 + missing_from_pred = 0 + + for q in universe: + g_pass, g_t = gold[q] + if q in pred: + p_pass, p_t = pred[q] + else: + p_pass, p_t = False, None + missing_from_pred += 1 + + if g_pass and p_pass: + TP += 1 + if strip_class(g_t) == strip_class(p_t): + tp_agree += 1 + else: + tp_disagree += 1 + elif (not g_pass) and (not p_pass): + TN += 1 + elif (not g_pass) and p_pass: + FP += 1 + else: + FN += 1 + + total = TP + TN + FP + FN + accuracy = (TP + TN) / total if total else 0.0 + precision = TP / (TP + FP) if (TP + FP) else 0.0 + recall = TP / (TP + FN) if (TP + FN) else 0.0 + f1 = (2 * precision * recall / (precision + recall)) if (precision + recall) else 0.0 + + denom = math.sqrt((TP + FP) * (TP + FN) * (TN + FP) * (TN + FN)) + mcc = ((TP * TN) - (FP * FN)) / denom if denom else 0.0 + + out = { + "label": args.label, + "total": total, + "TP": TP, "TN": TN, "FP": FP, "FN": FN, + "accuracy": round(accuracy, 6), + "precision": round(precision, 6), + "recall": round(recall, 6), + "f1": round(f1, 6), + "mcc": round(mcc, 6), + "tp_agree": tp_agree, + "tp_disagree": tp_disagree, + "tp_agree_frac": round(tp_agree / TP, 6) if TP else 0.0, + "missing_from_pred": missing_from_pred, + "extras": len(extras), + } + + if args.out == "-": + json.dump(out, sys.stdout, indent=2) + sys.stdout.write("\n") + else: + with open(args.out, "w") as fh: + json.dump(out, fh, indent=2) + fh.write("\n") + + sys.stderr.write( + "[eval] %s F1=%.4f acc=%.4f P=%.4f R=%.4f MCC=%.4f " + "TP=%d TN=%d FP=%d FN=%d agree=%d/%d miss=%d extra=%d\n" + % (args.label or args.pred, f1, accuracy, precision, recall, mcc, + TP, TN, FP, FN, tp_agree, TP, missing_from_pred, len(extras)) + ) + + +if __name__ == "__main__": + main() diff --git a/src/minimap.py b/src/minimap.py index 7de47f6..77ad566 100644 --- a/src/minimap.py +++ b/src/minimap.py @@ -1,18 +1,15 @@ """ minimap.py — minimap2 wrapper for pass-2 similarity search. -Unlike mmseqs `--search-type 3`, minimap2 emits multiple chained alignments per -(query, target) pair, so we can legitimately union query- and target-side -intervals across chains to compute both qcov and tcov. No 500 bp chain-gap -heuristic needed: minimap2's own chaining already groups near-diagonal -minimizer seeds, and each PAF row is one such chain. Across-chain union happens -here, in Python. - -Filter semantics enforced by the consumer (blast_pass2.classify_from_blast): - identity ≥ I, qcov ≥ C, tcov ≥ C, length ≥ L (from --pass2-rule I-C-L) - -where qcov = |union of aligned query intervals| / qlen and symmetrically for -tcov. +Pass-2 uses minimap2 with sensitivity-tuned flags chosen to maximize LTR-RT +recall at moderate identity (benchmarked F1 ~0.90 at the 70-70-70 rule). The +PAF is consumed by classify_ltr_paf_fast.process_paf, which groups chains per +(query, target) pair, picks the best target per query, and emits one TSV row +per query with pass/fail status under the user-supplied I-C-L rule. + +PAFRecord / parse_paf_besthit / besthit_per_query are kept for callers that +still want chain-union best-hit semantics, but the live pass-2 pipeline no +longer uses them. """ import logging @@ -46,22 +43,30 @@ def run_minimap2(query_fa, target_fa, paf_out, ncpu=4, preset="asm20", extra="", minimap2_bin="minimap2"): """ - Run minimap2 and write PAF (with CIGAR / AS tags via -c). - - Defaults: - -x {preset} e.g. asm20 for ~20% divergence LTR-RTs - -c emit CIGAR + AS:i: alignment score tag (needed for ranking) - -N 50 keep up to 50 secondary alignments per query - -p 0.1 accept secondaries down to 10% of primary score - --secondary=yes - -I 100G don't split index (fine for LTR libraries up to a few GB) + Run minimap2 and write PAF. + + Sensitivity-tuned flags for LTR-RT pass-2: + -x {preset} base preset (default asm20 = ~20% divergence) + --rmq=no disable repeat-mask query mode + --no-long-join do not extend chains across long gaps + -k 10 -w 10 smaller k-mer / window than asm20 default + -r 500,20000 chain bandwidth bounds + -g 500 stop chain extension at 500 bp gap + -p 0.3 keep secondaries scoring >=30% of primary + -N 100 up to 100 secondaries per query + -m 30 min chaining score + -K 1G large minibatch for throughput + --seed 11 deterministic seeding + --paf-no-hit emit placeholder PAF lines for unmapped queries -t ncpu """ os.makedirs(os.path.dirname(os.path.abspath(paf_out)) or ".", exist_ok=True) cmd = ( - f"{minimap2_bin} -c -x {preset} -N 50 -p 0.1 --secondary=yes " - f"-I 100G -t {ncpu} {extra} " + f"{minimap2_bin} -x {preset} --rmq=no --no-long-join " + f"-k 10 -w 10 -r 500,20000 -g 500 -p 0.3 -N 100 -m 30 " + f"-t {ncpu} -K 1G --seed 11 --paf-no-hit " + f"{extra} " f"-o {paf_out} {target_fa} {query_fa}" ) log.info(f"minimap2 cmd: {cmd}") diff --git a/src/pipeline.py b/src/pipeline.py index da9642f..a9e31ee 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -159,9 +159,11 @@ def parse_args(): ) parser.add_argument( "-rule", "--pass2-rule", - default="80-80-80", type=str, metavar="I-C-L", - help="Pass-2 threshold as identity-coverage-length. The coverage " - "value C is applied to BOTH qcov and tcov [default: %(default)s]", + default="70-70-70", type=str, metavar="I-C-L", + help="Pass-2 threshold as identity-coverage-length. I drives " + "classify_ltr_paf_fast --min-pid; C drives BOTH --min-qcov and " + "--min-tcov; L is parsed for grammar compatibility but is not " + "consumed by classify_ltr_paf_fast [default: %(default)s]", ) parser.add_argument( "--pass2-classified-fasta", @@ -170,11 +172,6 @@ def parse_args(): "the pass-2 target database. Headers must be like " ">id#Order/Superfamily/Clade", ) - parser.add_argument( - "--minimap2-preset", - default="asm20", type=str, metavar="PRESET", - help="minimap2 -x preset [default: %(default)s]", - ) parser.add_argument( "--minimap2-extra", default="", type=str, metavar="STR", @@ -476,7 +473,6 @@ def main(): min_length=p2_len, outdir=outdir, pass2_classified_fasta=args.pass2_classified_fasta, - preset=args.minimap2_preset, minimap2_extra=args.minimap2_extra, ) diff --git a/src/tesorter_compat.py b/src/tesorter_compat.py index 2916ba2..958d9a4 100644 --- a/src/tesorter_compat.py +++ b/src/tesorter_compat.py @@ -72,19 +72,18 @@ def parse_args(): parser.add_argument("-dp2", "--disable-pass2", action="store_true", default=False, help="Do not run pass-2 minimap2 classification") - parser.add_argument("-rule", "--pass2-rule", type=str, default="80-80-80", + parser.add_argument("-rule", "--pass2-rule", type=str, default="70-70-70", metavar="I-C-L", help="Pass-2 threshold identity-coverage-length. " - "C is applied to both qcov and tcov " - "[default: 80-80-80]") + "I drives classify_ltr_paf_fast --min-pid; C " + "drives both --min-qcov and --min-tcov; L is " + "parsed for grammar compatibility but unused " + "[default: 70-70-70]") parser.add_argument("--pass2-classified-fasta", type=str, default=None, metavar="FASTA", help="Optional FASTA of previously-classified elements " "to augment pass-2 target DB. Headers must be " "like >id#Order/Superfamily/Clade") - parser.add_argument("--minimap2-preset", type=str, default="asm20", - metavar="PRESET", - help="minimap2 -x preset [default: asm20]") parser.add_argument("--minimap2-extra", type=str, default="", metavar="STR", help="Extra flags passed through to minimap2") @@ -269,7 +268,6 @@ def main(): min_length=p2_len, outdir=args.tmp_dir or os.path.dirname(prefix) or ".", pass2_classified_fasta=args.pass2_classified_fasta, - preset=args.minimap2_preset, minimap2_extra=args.minimap2_extra, ) From aff2a49eadb2ff7ad22f0c177e01aafd4e5cd4a1 Mon Sep 17 00:00:00 2001 From: cwb14 Date: Thu, 30 Apr 2026 16:55:05 -0400 Subject: [PATCH 04/15] Remove junk --- src/WFA_TEsorter.dynamic.bak | Bin 81720 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100755 src/WFA_TEsorter.dynamic.bak diff --git a/src/WFA_TEsorter.dynamic.bak b/src/WFA_TEsorter.dynamic.bak deleted file mode 100755 index 20823db1a33b519b3e0491c82a02c211479f8dd4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 81720 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 Date: Mon, 4 May 2026 15:55:41 -0400 Subject: [PATCH 05/15] 2-pass gap chaining --- src/blast_pass2.py | 1 + src/classify_ltr_paf_fast.py | 91 ++++++++++++++++++++++++++++++++---- 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/src/blast_pass2.py b/src/blast_pass2.py index 21e18af..645c952 100644 --- a/src/blast_pass2.py +++ b/src/blast_pass2.py @@ -101,6 +101,7 @@ def classify_paf_to_tsv(paf_path, tsv_out, min_pid, min_qcov, min_tcov): with open(paf_path) as fh: rows = process_paf( fh, min_pid=min_pid, min_qcov=min_qcov, min_tcov=min_tcov, + fill_colinear=True, verbose=False, ) diff --git a/src/classify_ltr_paf_fast.py b/src/classify_ltr_paf_fast.py index 976bf4e..ae9eeee 100755 --- a/src/classify_ltr_paf_fast.py +++ b/src/classify_ltr_paf_fast.py @@ -152,6 +152,7 @@ def parse_paf_line(line, lineno, strict=False): "qlen": int(fields[1]), "qstart": int(fields[2]), "qend": int(fields[3]), + "strand": fields[4], "tname": fields[5], "tlen": int(fields[6]), "tstart": int(fields[7]), @@ -164,10 +165,62 @@ def parse_paf_line(line, lineno, strict=False): return None -def compute_pair_metrics(alignments, qlen, tlen): +def chained_intervals(alignments, max_gap=5000, gap_tol=0.20): + """Group alignments into colinear chains; return per-chain (q,t) envelopes. + + Two adjacent alignments (sorted by qstart) join the same chain iff: + 1. Same strand. + 2. t-order consistent with strand (ascending t for +, descending t for -). + 3. Inner gap on each axis <= max_gap (bp). + 4. |q_gap - t_gap| / max(q_gap, t_gap) <= gap_tol (synchronized indel). + Otherwise a new chain begins. + + Returns ([(qmin, qmax), ...], [(tmin, tmax), ...]) -- one (q,t) envelope per + chain. Multiple chains may overlap on q or t; the caller is responsible for + merging across chains. + """ + if not alignments: + return [], [] + alns = sorted(alignments, key=lambda a: (a["qstart"], a["qend"])) + chains = [[alns[0]]] + for nxt in alns[1:]: + cur = chains[-1][-1] + join = cur["strand"] == nxt["strand"] + if join: + if cur["strand"] == "+": + t_order_ok = nxt["tstart"] >= cur["tstart"] + t_gap = max(0, nxt["tstart"] - cur["tend"]) + else: + t_order_ok = nxt["tstart"] <= cur["tstart"] + t_gap = max(0, cur["tstart"] - nxt["tend"]) + join = t_order_ok + if join: + q_gap = max(0, nxt["qstart"] - cur["qend"]) + big = max(q_gap, t_gap) + if big > max_gap: + join = False + elif big > 0 and abs(q_gap - t_gap) / big > gap_tol: + join = False + if join: + chains[-1].append(nxt) + else: + chains.append([nxt]) + q_ivs, t_ivs = [], [] + for chain in chains: + q_ivs.append((min(a["qstart"] for a in chain), + max(a["qend"] for a in chain))) + t_ivs.append((min(a["tstart"] for a in chain), + max(a["tend"] for a in chain))) + return q_ivs, t_ivs + + +def compute_pair_metrics(alignments, qlen, tlen, + fill_colinear=False, max_gap=5000, gap_tol=0.20): """Compute (pid, eff_qcov, eff_tcov) for one (q,t) pair, span-based. `alignments` is a non-empty list of dicts: {qstart, qend, tstart, tend, div}. + If fill_colinear is True, colinear-chain inner gaps on q and t are bridged + in the qcov/tcov computation (pid logic is unchanged either way). """ # Build per-alignment span intervals (no CIGAR -- one interval each). for a in alignments: @@ -219,15 +272,24 @@ def compute_pair_metrics(alignments, qlen, tlen): pid = (1.0 - total_unique_mut / total_unique_len) if total_unique_len > 0 else 0.0 # eff_qcov, eff_tcov: union of all alignment spans (use ALL alignments, - # not just survivors -- encompassed ones add nothing new anyway). - all_q_iv = [iv for a in alignments for iv in a["q_iv"]] - all_t_iv = [iv for a in alignments for iv in a["t_iv"]] - eff_qcov = interval_total(merge_intervals(all_q_iv)) / qlen if qlen > 0 else 0.0 - eff_tcov = interval_total(merge_intervals(all_t_iv)) / tlen if tlen > 0 else 0.0 + # not just survivors -- encompassed ones add nothing new anyway). When + # fill_colinear is on, inner gaps within colinear chains are bridged before + # the union so a single element fragmented into HSPs counts as one span. + if fill_colinear: + q_chains, t_chains = chained_intervals( + alignments, max_gap=max_gap, gap_tol=gap_tol) + eff_qcov = interval_total(merge_intervals(q_chains)) / qlen if qlen > 0 else 0.0 + eff_tcov = interval_total(merge_intervals(t_chains)) / tlen if tlen > 0 else 0.0 + else: + all_q_iv = [iv for a in alignments for iv in a["q_iv"]] + all_t_iv = [iv for a in alignments for iv in a["t_iv"]] + eff_qcov = interval_total(merge_intervals(all_q_iv)) / qlen if qlen > 0 else 0.0 + eff_tcov = interval_total(merge_intervals(all_t_iv)) / tlen if tlen > 0 else 0.0 return pid, eff_qcov, eff_tcov -def process_paf(lines, min_pid=0.70, min_qcov=0.70, min_tcov=0.70, verbose=False): +def process_paf(lines, min_pid=0.70, min_qcov=0.70, min_tcov=0.70, + fill_colinear=False, max_gap=5000, gap_tol=0.20, verbose=False): """Stream PAF lines, group by (qname,tname), pick best target per query, return [(qname, pass_str, pid, qcov, tcov, best_tname), ...] sorted by qname. """ @@ -257,7 +319,9 @@ def process_paf(lines, min_pid=0.70, min_qcov=0.70, min_tcov=0.70, verbose=False per_query = defaultdict(list) for (qname, tname), alns in pair_alns.items(): qlen, tlen = pair_lengths[(qname, tname)] - pid, qcov, tcov = compute_pair_metrics(alns, qlen, tlen) + pid, qcov, tcov = compute_pair_metrics( + alns, qlen, tlen, + fill_colinear=fill_colinear, max_gap=max_gap, gap_tol=gap_tol) passes = (pid >= min_pid) and (qcov >= min_qcov) and (tcov >= min_tcov) per_query[qname].append({ "tname": tname, @@ -305,6 +369,15 @@ def main(): ap.add_argument("--min-pid", type=float, default=0.70) ap.add_argument("--min-qcov", type=float, default=0.70) ap.add_argument("--min-tcov", type=float, default=0.70) + ap.add_argument("--fill-colinear-gaps", action="store_true", + help="bridge inner gaps within colinear HSP chains " + "(same strand, q-order matches t-order, q-gap ~ t-gap) " + "before computing eff_qcov/eff_tcov. Off by default.") + ap.add_argument("--bridge-max-gap", type=int, default=5000, + help="max inner gap (bp) to bridge on either axis (default: 5000).") + ap.add_argument("--bridge-gap-tol", type=float, default=0.20, + help="max relative mismatch |q_gap - t_gap| / max(q_gap, t_gap) " + "to treat as a synchronized indel (default: 0.20).") ap.add_argument("--header", action="store_true") ap.add_argument("-v", "--verbose", action="store_true") args = ap.parse_args() @@ -317,6 +390,8 @@ def main(): results = process_paf( in_fh, min_pid=args.min_pid, min_qcov=args.min_qcov, min_tcov=args.min_tcov, + fill_colinear=args.fill_colinear_gaps, + max_gap=args.bridge_max_gap, gap_tol=args.bridge_gap_tol, verbose=args.verbose, ) finally: From 00da8728266b13d57d0aa9081d14d55f1f4722ed Mon Sep 17 00:00:00 2001 From: Chris Benson <35149326+cwb14@users.noreply.github.com> Date: Mon, 4 May 2026 16:34:59 -0400 Subject: [PATCH 06/15] Disable fill_colinear option in process_paf call --- src/blast_pass2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blast_pass2.py b/src/blast_pass2.py index 645c952..4b5e506 100644 --- a/src/blast_pass2.py +++ b/src/blast_pass2.py @@ -101,7 +101,7 @@ def classify_paf_to_tsv(paf_path, tsv_out, min_pid, min_qcov, min_tcov): with open(paf_path) as fh: rows = process_paf( fh, min_pid=min_pid, min_qcov=min_qcov, min_tcov=min_tcov, - fill_colinear=True, + fill_colinear=False, verbose=False, ) From e6143d5d72113b171f43fd3ac46b5e79c2692e3e Mon Sep 17 00:00:00 2001 From: Chris Benson <35149326+cwb14@users.noreply.github.com> Date: Wed, 6 May 2026 16:01:11 -0400 Subject: [PATCH 07/15] Add WFA self-analysis script for PAF summarization This script summarizes self-alignment PAF data, producing best per query, reciprocal best hits, and clusters files. It includes functionality for parsing CIGAR strings and calculating metrics based on alignment data. --- src/WFA_self_analysis.py | 304 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 src/WFA_self_analysis.py diff --git a/src/WFA_self_analysis.py b/src/WFA_self_analysis.py new file mode 100644 index 0000000..9d8ea27 --- /dev/null +++ b/src/WFA_self_analysis.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +"""Summarize a self-alignment PAF (with cg:Z: CIGAR). + +Designed for `WFA_TEsorter -q X.fa -t X.fa --cigar > X.paf` where the same +FASTA is both query and target (so every sequence appears with a self-hit +at qname == tname). Self-hits are excluded from all three outputs. + +Produces three files at .*: + + .best_per_query.tsv + One row per query: the single best non-self target. + Same columns as WFA_best_per_query.py: + qname pass pid qcov tcov best_tname + + .rbh.tsv + Reciprocal best-hit pairs: A's best non-self target is B AND B's + best non-self target is A. Pairs are emitted once with seqA.clusters.tsv + Single-linkage clusters from non-self alignments that pass all + three thresholds (--min-pid AND --min-qcov AND --min-tcov). + Edges are added with union-find; isolates become singleton clusters. + Representative = longest member (ties broken by name asc). Members + are listed by length desc then name asc, so the representative is + the first row of each cluster. Clusters ranked by size (desc), then + representative name. One row per member: + cluster_id representative member cluster_size + +Per-row metrics (from cg:Z: CIGAR, NM:i: when present): + pid = matches / (matches + mismatches + gap_opens) # gap-compressed + qcov = M_bases / qlen + tcov = M_bases / tlen + pass = pid >= --min-pid AND qcov >= --min-qcov AND tcov >= --min-tcov + +For "best" target per query the sort key is (passed, pid*qcov*tcov), so a +passing alignment always beats a failing one and ties break by the raw +product. RBH reports the reciprocity regardless of pass/fail and adds a +both_pass column so downstream filtering is one awk away. + +Streams the PAF; only one record per unique query is held in RAM. +Requires `cg:Z:` on every row (run WFA_TEsorter / minimap2 with --cigar). +""" + +import argparse +import re +import sys + + +CIGAR_RE = re.compile(r'(\d+)([MIDNSHP=X])') + + +def parse_cigar(cigar): + """Return (m_bases, i_bases, d_bases, gap_opens). M counts =/X too.""" + m = i = d = gap_opens = 0 + for n_str, op in CIGAR_RE.findall(cigar): + n = int(n_str) + if op in 'M=X': + m += n + elif op == 'I': + i += n + gap_opens += 1 + elif op == 'D': + d += n + gap_opens += 1 + return m, i, d, gap_opens + + +def row_metrics(fields): + """Compute metrics for one PAF row, or None to skip. + + Returns (qname, qlen, tname, tlen, pid, qcov, tcov). + """ + qname = fields[0] + qlen = int(fields[1]) + tname = fields[5] + tlen = int(fields[6]) + paf_matches = int(fields[9]) + + cigar = None + nm = None + for tag in fields[12:]: + if tag.startswith('cg:Z:'): + cigar = tag[5:] + elif tag.startswith('NM:i:'): + nm = int(tag[5:]) + + if cigar is None or qlen <= 0 or tlen <= 0: + return None + + m_bases, i_bases, d_bases, gap_opens = parse_cigar(cigar) + + if nm is not None: + # NM = mismatches + indel_bases + mismatches = nm - i_bases - d_bases + matches = m_bases - mismatches + else: + # fall back to PAF column 10 (residue matches) + matches = paf_matches + mismatches = m_bases - matches + + if matches < 0 or mismatches < 0: + return None + + denom = matches + mismatches + gap_opens + pid = matches / denom if denom > 0 else 0.0 + qcov = m_bases / qlen + tcov = m_bases / tlen + + return qname, qlen, tname, tlen, pid, qcov, tcov + + +class UnionFind: + """Union-find with path compression and union-by-size.""" + + def __init__(self): + self.parent = {} + self.size = {} + + def add(self, x): + if x not in self.parent: + self.parent[x] = x + self.size[x] = 1 + + def find(self, x): + self.add(x) + root = x + while self.parent[root] != root: + root = self.parent[root] + # iterative path compression + while self.parent[x] != root: + self.parent[x], x = root, self.parent[x] + return root + + def union(self, a, b): + ra, rb = self.find(a), self.find(b) + if ra == rb: + return + if self.size[ra] < self.size[rb]: + ra, rb = rb, ra + self.parent[rb] = ra + self.size[ra] += self.size[rb] + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + ap.add_argument('paf', nargs='?', default='-', + help='Input PAF (default: stdin). Use "-" for stdin.') + ap.add_argument('-o', '--output', required=True, + help='Output prefix; writes .best_per_query.tsv, ' + '.rbh.tsv, .clusters.tsv') + ap.add_argument('--min-pid', type=float, default=0.70, + help='Min gap-compressed identity (default: 0.70).') + ap.add_argument('--min-qcov', type=float, default=0.70, + help='Min query coverage (default: 0.70).') + ap.add_argument('--min-tcov', type=float, default=0.70, + help='Min target coverage (default: 0.70).') + ap.add_argument('-v', '--verbose', action='store_true', + help='Report progress and summary stats to stderr.') + args = ap.parse_args() + + fin = sys.stdin if args.paf == '-' else open(args.paf) + + # qname -> (sort_key, passed, pid, qcov, tcov, tname) (non-self only) + best = {} + seq_len = {} + uf = UnionFind() + n_rows = n_skipped = n_self = n_edges_passing = 0 + + for line in fin: + if not line or line[0] == '#': + continue + fields = line.rstrip('\n').split('\t') + if len(fields) < 12: + n_skipped += 1 + continue + n_rows += 1 + out = row_metrics(fields) + if out is None: + n_skipped += 1 + continue + qname, qlen, tname, tlen, pid, qcov, tcov = out + + # register every sequence we see (incl. via self-hit) so isolates + # still appear as singleton clusters in the output + if qname not in seq_len: + seq_len[qname] = qlen + uf.add(qname) + if tname not in seq_len: + seq_len[tname] = tlen + uf.add(tname) + + if qname == tname: + n_self += 1 + continue + + passed = (pid >= args.min_pid and + qcov >= args.min_qcov and + tcov >= args.min_tcov) + key = (1 if passed else 0, pid * qcov * tcov) + prev = best.get(qname) + if prev is None or key > prev[0]: + best[qname] = (key, passed, pid, qcov, tcov, tname) + + if passed: + uf.union(qname, tname) + n_edges_passing += 1 + + if args.verbose and n_rows % 1_000_000 == 0: + print(f' ... {n_rows:,} rows {len(seq_len):,} seqs ' + f'{n_edges_passing:,} passing pairs', + file=sys.stderr) + + if fin is not sys.stdin: + fin.close() + + # ---------- single-best per query (non-self) ---------- + best_path = f'{args.output}.best_per_query.tsv' + with open(best_path, 'w') as fb: + fb.write('qname\tpass\tpid\tqcov\ttcov\tbest_tname\n') + for qname, (_, passed, pid, qcov, tcov, tname) in best.items(): + fb.write(f'{qname}\t{"pass" if passed else "fail"}\t' + f'{pid:.4f}\t{qcov:.4f}\t{tcov:.4f}\t{tname}\n') + + # ---------- reciprocal best hits ---------- + rbh = [] + seen_pairs = set() + for q, qrec in best.items(): + t = qrec[5] + trec = best.get(t) + if trec is None or trec[5] != q: + continue + a, b = (q, t) if q < t else (t, q) + if (a, b) in seen_pairs: + continue + seen_pairs.add((a, b)) + ab = best[a] # a -> b: (key, passed, pid, qcov, tcov, _) + ba = best[b] # b -> a + both_pass = ab[1] and ba[1] + rbh.append((a, b, + ab[2], ab[3], ab[4], + ba[2], ba[3], ba[4], + both_pass)) + rbh.sort() + + rbh_path = f'{args.output}.rbh.tsv' + with open(rbh_path, 'w') as fr: + fr.write('seqA\tseqB\tpid_AB\tqcov_AB\ttcov_AB\t' + 'pid_BA\tqcov_BA\ttcov_BA\tboth_pass\n') + for a, b, p_ab, q_ab, t_ab, p_ba, q_ba, t_ba, bp in rbh: + fr.write(f'{a}\t{b}\t' + f'{p_ab:.4f}\t{q_ab:.4f}\t{t_ab:.4f}\t' + f'{p_ba:.4f}\t{q_ba:.4f}\t{t_ba:.4f}\t' + f'{"yes" if bp else "no"}\n') + + # ---------- clusters (single-linkage on passing non-self edges) ---------- + members = {} + for s in seq_len: + members.setdefault(uf.find(s), []).append(s) + + clusters = [] # list of (size, representative, sorted_members) + for r, mems in members.items(): + # sort by length desc, then name asc; representative = first + mems_sorted = sorted(mems, key=lambda s: (-seq_len.get(s, 0), s)) + clusters.append((len(mems), mems_sorted[0], mems_sorted)) + # rank: largest first, then by representative name + clusters.sort(key=lambda c: (-c[0], c[1])) + + cl_path = f'{args.output}.clusters.tsv' + with open(cl_path, 'w') as fc: + fc.write('cluster_id\trepresentative\tmember\tcluster_size\n') + for idx, (size, rep, mems) in enumerate(clusters, start=1): + cid = f'C{idx:06d}' + for m in mems: + fc.write(f'{cid}\t{rep}\t{m}\t{size}\n') + + if args.verbose: + n_pass_q = sum(1 for v in best.values() if v[1]) + n_singletons = sum(1 for c in clusters if c[0] == 1) + n_nontriv = len(clusters) - n_singletons + n_rbh_pass = sum(1 for r in rbh if r[8]) + print(f'rows: {n_rows:,} skipped: {n_skipped:,} ' + f'self-hits: {n_self:,}', file=sys.stderr) + print(f'sequences seen: {len(seq_len):,}', file=sys.stderr) + print(f'queries with non-self hit: {len(best):,} ' + f'(pass: {n_pass_q:,}, fail: {len(best) - n_pass_q:,})', + file=sys.stderr) + print(f'rbh pairs: {len(rbh):,} (both-pass: {n_rbh_pass:,})', + file=sys.stderr) + print(f'clusters: {len(clusters):,} ' + f'(non-singleton: {n_nontriv:,}, singletons: {n_singletons:,})', + file=sys.stderr) + print(f'wrote {best_path}', file=sys.stderr) + print(f'wrote {rbh_path}', file=sys.stderr) + print(f'wrote {cl_path}', file=sys.stderr) + + +if __name__ == '__main__': + main() From df7a0fd2c629251faf7ebe65784ab97c81ef6676 Mon Sep 17 00:00:00 2001 From: cwb14 Date: Wed, 6 May 2026 17:42:52 -0400 Subject: [PATCH 08/15] IUPAC support --- src/WFA_TEsorter | Bin 240624 -> 245808 bytes src/WFA_TEsorter.cpp | 141 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 130 insertions(+), 11 deletions(-) diff --git a/src/WFA_TEsorter b/src/WFA_TEsorter index 9ef284f95d25786209920bb7b0c280a1af6520d6..a932fbf6364de42e0581b0cf06dfecfa6b519ace 100755 GIT binary patch delta 69038 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 IFPr-QA3z`4R{#J2 delta 65808 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 MR`aBc3t9UAAN8GalmGw# diff --git a/src/WFA_TEsorter.cpp b/src/WFA_TEsorter.cpp index 24d69d1..15a4bfa 100644 --- a/src/WFA_TEsorter.cpp +++ b/src/WFA_TEsorter.cpp @@ -10,9 +10,9 @@ // // Build (one line; static-linked against WFA2-lib so the binary has no // runtime libwfa2*.so dependency). Run from this src/ directory; assumes -// WFA2-lib sits two levels up as a sibling of TEBinSorter/. Adjust the -// -I and .a paths if your layout differs. -// g++ -O3 -std=c++17 -fopenmp -Wall -I../../WFA2-lib WFA_TEsorter.cpp -o WFA_TEsorter ../../WFA2-lib/build/libwfa2cpp.a ../../WFA2-lib/build/libwfa2.a -lpthread -lm +// WFA2-lib sits at ./WFA2-lib with build/ already populated (libwfa2.a, +// libwfa2cpp.a). Adjust the -I and .a paths if your layout differs. +// g++ -O3 -std=c++17 -fopenmp -Wall -I./WFA2-lib WFA_TEsorter.cpp -o WFA_TEsorter ./WFA2-lib/build/libwfa2cpp.a ./WFA2-lib/build/libwfa2.a -lpthread -lm #include #include @@ -124,6 +124,71 @@ static string revcomp(const string& s) { return out; } +// ---------- IUPAC nucleotide matching ---------------------------------------- +// +// Each base is encoded as a 4-bit mask over {A=1, C=2, G=4, T=8}. Two bases +// "match" iff their masks share at least one bit, i.e. their possible-base +// sets intersect. Examples: A vs W={A,T} -> match; W vs S={C,G} -> mismatch; +// N matches everything; anything not in IUPAC has mask 0 and matches nothing. +// Used by the WFA lambda-match path (alignEnd2End / alignEndsFree / +// alignExtension lambda overloads) so the wavefront treats IUPAC-compatible +// positions as exact matches rather than mismatches. + +static uint8_t g_base_mask[256]; // [byte] -> 4-bit base set +static uint8_t g_iupac_match[65536]; // [(qbyte<<8)|tbyte] -> 0/1 match flag + +static void init_iupac_tables() { + static bool inited = false; + if (inited) return; + inited = true; + for (int i = 0; i < 256; ++i) g_base_mask[i] = 0; + auto set_pair = [](char up, char lo, uint8_t m) { + g_base_mask[(unsigned char)up] = m; + g_base_mask[(unsigned char)lo] = m; + }; + set_pair('A','a', 0x1); + set_pair('C','c', 0x2); + set_pair('G','g', 0x4); + set_pair('T','t', 0x8); + set_pair('U','u', 0x8); // RNA U == T + set_pair('R','r', 0x1|0x4); // A|G + set_pair('Y','y', 0x2|0x8); // C|T + set_pair('S','s', 0x2|0x4); // C|G + set_pair('W','w', 0x1|0x8); // A|T + set_pair('K','k', 0x4|0x8); // G|T + set_pair('M','m', 0x1|0x2); // A|C + set_pair('B','b', 0x2|0x4|0x8); // C|G|T + set_pair('D','d', 0x1|0x4|0x8); // A|G|T + set_pair('H','h', 0x1|0x2|0x8); // A|C|T + set_pair('V','v', 0x1|0x2|0x4); // A|C|G + set_pair('N','n', 0xF); // any + for (int a = 0; a < 256; ++a) { + for (int b = 0; b < 256; ++b) { + g_iupac_match[(a << 8) | b] = (g_base_mask[a] & g_base_mask[b]) ? 1 : 0; + } + } +} + +static inline bool iupac_compatible(char a, char b) { + return g_iupac_match[((unsigned char)a << 8) | (unsigned char)b] != 0; +} + +// Context passed to the WFA lambda match callback. +struct LambdaCtx { + const char* pattern; // query bytes, [0, plen) + const char* text; // target bytes, [0, tlen) +}; + +// WFA calls this for each (v=pattern_pos, h=text_pos) probed during extension. +// Return non-zero iff the two bases are IUPAC-compatible; WFA then treats the +// position as a match (no penalty). The indices are guaranteed in-range by +// the caller (see wavefront_sequences_cmp), so no bounds check needed. +static int iupac_match_funct(int v, int h, void* args) { + auto* ctx = static_cast(args); + return g_iupac_match[((unsigned char)ctx->pattern[v] << 8) + | (unsigned char)ctx->text[h]]; +} + // ---------- alignment op-string utilities ------------------------------------- // // `ops` is the raw WFA operation string of M/X/I/D, one char per column. @@ -299,6 +364,7 @@ struct Opts { MemMode mem = MemMode::High; bool has_min_score = false; int min_score = 0; + bool iupac = false; bool verbose = false; }; @@ -329,8 +395,14 @@ static void usage_align(FILE* f = stderr) { " --strand STR both|forward|reverse (default: both)\n" " --memory STR high|med|low|ultralow (default: high)\n" " --min-score N drop alignments with WFA score < N (default: keep all)\n" +" --iupac treat IUPAC-compatible bases as matches (e.g. A==W,\n" +" W==N, R==G). Off by default; turn on to align\n" +" consensus sequences carrying ambiguity codes. Uses\n" +" WFA's lambda-match path, which is slower than the\n" +" default literal-byte kernel.\n" " --pairs FILE restrict to (qnametname) pairs from FILE\n" " (default: full all-vs-all over the FASTAs)\n" +" Self-pairs (qname == tname) are always skipped.\n" " --threads N worker threads (default: 1)\n" "\n" "Misc:\n" @@ -382,6 +454,7 @@ static Opts parse_align_opts(int argc, char** argv) { {"memory", required_argument, nullptr, 11 }, {"min-score", required_argument, nullptr, 12 }, {"pairs", required_argument, nullptr, 13 }, + {"iupac", no_argument, nullptr, 14 }, {"verbose", no_argument, nullptr, 'v'}, {"help", no_argument, nullptr, 'h'}, {nullptr, 0, nullptr, 0} @@ -405,6 +478,7 @@ static Opts parse_align_opts(int argc, char** argv) { case 11 : o.mem = parse_mem(optarg); break; case 12 : o.has_min_score = true; o.min_score = std::atoi(optarg); break; case 13 : o.pairs_path = optarg; break; + case 14 : o.iupac = true; break; case 'v': o.verbose = true; break; case 'h': usage_align(stdout); std::exit(0); default : usage_align(stderr); std::exit(2); @@ -445,12 +519,28 @@ struct AlnResult { // trailing runs of WFA 'I' ops (free target-end skips) are stripped from `ops` // and accounted for in tstart/tend. For extension mode qend/tend reflect the // actual bases consumed by the partial alignment. +// +// When `iupac` is true, the lambda-match overloads are used so that bases +// connected by IUPAC ambiguity codes (e.g. W vs A) score as matches rather +// than mismatches. The lambda path bypasses the SIMD/64-bit-block extension +// kernel and is therefore slower than the default literal-byte path. static AlnResult align_one(wfa::WFAlignerGapAffine& aligner, AlnMode mode, - const string& q, const string& t) { + const string& q, const string& t, bool iupac) { AlnResult r; int qlen = (int)q.size(), tlen = (int)t.size(); wfa::WFAligner::AlignmentStatus st; - if (mode == AlnMode::End2End) { + if (iupac) { + LambdaCtx ctx{q.data(), t.data()}; + if (mode == AlnMode::End2End) { + st = aligner.alignEnd2End(iupac_match_funct, &ctx, qlen, tlen); + } else if (mode == AlnMode::EndsFree) { + st = aligner.alignEndsFree(iupac_match_funct, &ctx, + qlen, 0, 0, + tlen, tlen, tlen); + } else { + st = aligner.alignExtension(iupac_match_funct, &ctx, qlen, tlen); + } + } else if (mode == AlnMode::End2End) { st = aligner.alignEnd2End(q, t); } else if (mode == AlnMode::EndsFree) { // Query-anchored semi-global: query is anchored, target ends are free. @@ -514,12 +604,15 @@ static PafRec make_paf(const string& qname, const string& q_oriented, int qlen_f static int run_align(int argc, char** argv) { Opts o = parse_align_opts(argc, argv); + if (o.iupac) init_iupac_tables(); + if (o.verbose) cerr << "[INFO] reading FASTAs\n"; auto queries = read_fasta(o.query_path); auto targets = read_fasta(o.target_path); if (o.verbose) { cerr << "[INFO] " << queries.size() << " query seqs, " - << targets.size() << " target seqs\n"; + << targets.size() << " target seqs" + << (o.iupac ? " (IUPAC-aware matching)" : "") << "\n"; } // Output sink @@ -556,7 +649,7 @@ static int run_align(int argc, char** argv) { cerr << "[ERROR] cannot open --pairs file: " << o.pairs_path << "\n"; return 1; } - size_t miss_q = 0, miss_t = 0, parsed = 0; + size_t miss_q = 0, miss_t = 0, parsed = 0, skipped_self = 0; string line; while (std::getline(pin, line)) { if (line.empty() || line[0] == '#') continue; @@ -569,6 +662,7 @@ static int run_align(int argc, char** argv) { size_t tab2 = rest.find('\t'); string tn = (tab2 == string::npos) ? rest : rest.substr(0, tab2); ++parsed; + if (qn == tn) { ++skipped_self; continue; } auto qit = q_idx.find(qn); auto tit = t_idx.find(tn); if (qit == q_idx.end()) { ++miss_q; continue; } @@ -578,6 +672,7 @@ static int run_align(int argc, char** argv) { if (o.verbose) { cerr << "[INFO] --pairs parsed=" << parsed << " kept=" << pair_jobs.size() + << " skipped-self=" << skipped_self << " missing-qname=" << miss_q << " missing-tname=" << miss_t << "\n"; } @@ -587,9 +682,21 @@ static int run_align(int argc, char** argv) { } } + // Pre-count self-pairs in the all-vs-all matrix so the progress total + // reflects only the alignments we'll actually run. + size_t self_pairs_avs = 0; + if (pair_jobs.empty()) { + std::unordered_map tname_counts; + tname_counts.reserve(targets.size() * 2); + for (const auto& T : targets) ++tname_counts[T.name]; + for (const auto& Q : queries) { + auto it = tname_counts.find(Q.name); + if (it != tname_counts.end()) self_pairs_avs += (size_t)it->second; + } + } std::atomic done{0}; const size_t total = pair_jobs.empty() - ? queries.size() * targets.size() + ? (queries.size() * targets.size() - self_pairs_avs) : pair_jobs.size(); auto t_start = std::chrono::steady_clock::now(); @@ -598,15 +705,20 @@ static int run_align(int argc, char** argv) { const FastaRec& Q = queries[qi]; const FastaRec& T = targets[ti]; if (Q.seq.empty() || T.seq.empty()) { ++done; return; } + // Skip self-alignments: a sequence aligned to itself yields no useful + // information and is wasted compute (typical case: all-vs-all over a + // single FASTA loaded as both --query and --target). These are + // pre-excluded from `total`, so do not increment `done` here. + if (Q.name == T.name) return; AlnResult res_f, res_r; string qrev; if (o.strand == Strand::Forward || o.strand == Strand::Both) { - res_f = align_one(aligner, o.mode, Q.seq, T.seq); + res_f = align_one(aligner, o.mode, Q.seq, T.seq, o.iupac); } if (o.strand == Strand::Reverse || o.strand == Strand::Both) { qrev = revcomp(Q.seq); - res_r = align_one(aligner, o.mode, qrev, T.seq); + res_r = align_one(aligner, o.mode, qrev, T.seq, o.iupac); } bool emit_f = false, emit_r = false; @@ -816,7 +928,12 @@ static void print_pretty(ostream& os, if (op == 'M' || op == '=' || op == 'X') { char qc = q_aln[qi++], tc = t_aln[ti++]; qline += qc; tline += tc; - bool match = (qc == tc) && (op != 'X'); + // For legacy 'M' (match-or-mismatch), use IUPAC compatibility + // so consensus bases like W vs A are drawn as matches when the + // alignment was produced under --iupac. Pure ACGT pairs reduce + // to literal equality. + bool match = (op == '=') || + (op == 'M' && iupac_compatible(qc, tc)); mline += match ? '|' : ' '; } else if (op == 'I') { // query has extra base (gap in target) qline += q_aln[qi++]; tline += '-'; mline += ' '; @@ -871,6 +988,8 @@ static void print_pretty(ostream& os, static int run_view(int argc, char** argv) { ViewOpts v = parse_view_opts(argc, argv); + init_iupac_tables(); + auto qs = read_fasta(v.query_path); auto ts = read_fasta(v.target_path); const FastaRec* Q = find_rec(qs, v.qname); From 65ed69b6e351a4199e54eff36aa909517675c8fd Mon Sep 17 00:00:00 2001 From: cwb14 Date: Thu, 28 May 2026 09:57:00 -0400 Subject: [PATCH 09/15] feat: add --pass2-aligner {minimap2,blast} --- src/blast_backend.py | 253 +++++++++++++++++++++++++++++++++++++++++++ src/blast_pass2.py | 21 +++- src/pipeline.py | 15 ++- 3 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 src/blast_backend.py diff --git a/src/blast_backend.py b/src/blast_backend.py new file mode 100644 index 0000000..866cc96 --- /dev/null +++ b/src/blast_backend.py @@ -0,0 +1,253 @@ +""" +blast_backend.py — TEBinSorter master's blastn pass-2 logic, lifted verbatim so +the `--pass2-aligner blast` path runs and post-processes identically to +TEBinSorter master. + +The orchestrator `run_pass2_blast` is called by blast_pass2.blast_pass2() AFTER +the shared classified/unclassified split and the optional pass2_external merge, +so the external-pool augmentation (`--pass2-classified-fasta`) applies to both +aligner backends. Only the alignment + parse + classify is master-specific here. + +master functions (make_blast_db, run_blast_chunk, parse_blast_output, +store_blast_hits, classify_from_blast) are copied unchanged from +origin/master:src/blast_pass2.py. chunk_fasta + run_pass2_blast are new. +""" + +import logging +import multiprocessing +import os +import subprocess +import time + +import pyfastx + +log = logging.getLogger(__name__) + + +# ---- lifted verbatim from origin/master:src/blast_pass2.py ---- + +def make_blast_db(db_fasta, seq_type="nucl"): + """Run makeblastdb.""" + dbtype = seq_type + cmd = f"makeblastdb -in {db_fasta} -dbtype {dbtype} -out {db_fasta}" + result = subprocess.run(cmd, shell=True, capture_output=True, text=True) + if result.returncode != 0: + log.error(f"makeblastdb failed: {result.stderr}") + raise RuntimeError(f"makeblastdb failed: {result.stderr}") + log.info(f" BLAST database built: {db_fasta}") + + +def run_blast_chunk(query_chunk, db_fasta, output, seq_type="nucl", ncpu=1): + """Run BLAST on one query chunk.""" + app = "blastn" if seq_type == "nucl" else "blastp" + outfmt = ("6 qseqid sseqid pident length mismatch gapopen qstart qend " + "sstart send evalue bitscore qlen slen qcovs qcovhsp sstrand") + cmd = (f"{app} -query {query_chunk} -db {db_fasta} -out {output} " + f"-outfmt '{outfmt}' -num_threads {ncpu}") + result = subprocess.run(cmd, shell=True, capture_output=True, text=True) + if result.returncode != 0: + log.warning(f"BLAST chunk failed: {result.stderr[:200]}") + return output + + +def parse_blast_output(blast_out): + """Parse BLAST outfmt 6 into hit dicts.""" + fields = ["qseqid", "sseqid", "pident", "length", "mismatch", "gapopen", + "qstart", "qend", "sstart", "send", "evalue", "bitscore", + "qlen", "slen", "qcovs", "qcovhsp", "sstrand"] + types = [str, str, float, int, int, int, int, int, int, int, + float, float, int, int, float, float, str] + + hits = [] + if not os.path.exists(blast_out): + return hits + + with open(blast_out) as f: + for line in f: + vals = line.strip().split("\t") + if len(vals) < len(fields): + continue + hit = {} + for field, typ, val in zip(fields, types, vals): + hit[field] = typ(val) + hits.append(hit) + + return hits + + +def store_blast_hits(conn, hits, db_seq_to_dbs): + """Store BLAST hits in SQLite.""" + conn.execute(""" + CREATE TABLE IF NOT EXISTS blast_hits ( + qseqid TEXT NOT NULL, + sseqid TEXT NOT NULL, + pident REAL NOT NULL, + length INTEGER NOT NULL, + evalue REAL NOT NULL, + bitscore REAL NOT NULL, + qlen INTEGER NOT NULL, + slen INTEGER NOT NULL, + qcovs REAL NOT NULL, + classified_by TEXT NOT NULL + ) + """) + + rows = [] + for h in hits: + dbs = db_seq_to_dbs.get(h["sseqid"], set()) + classified_by = ",".join(sorted(dbs)) if dbs else "unknown" + rows.append(( + h["qseqid"], h["sseqid"], h["pident"], h["length"], + h["evalue"], h["bitscore"], h["qlen"], h["slen"], + h["qcovs"], classified_by, + )) + + conn.executemany( + "INSERT INTO blast_hits VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + rows, + ) + conn.commit() + log.info(f" Stored {len(rows)} BLAST hits") + + +def classify_from_blast(conn, classifications, database=None, + min_identity=80, min_coverage=80, min_length=80): + """Classify unclassified sequences from BLAST hits (master logic).""" + tables = {r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'").fetchall()} + if "blast_hits" not in tables: + return [] + + where = "WHERE pident >= ? AND qcovs >= ? AND length >= ?" + params = [min_identity, min_coverage, min_length] + + if database: + where += " AND classified_by LIKE ?" + params.append(f"%{database}%") + + rows = conn.execute(f""" + SELECT qseqid, sseqid, pident, qcovs, length, bitscore + FROM blast_hits + {where} + ORDER BY bitscore DESC + """, params).fetchall() + + classified_set = set(classifications.keys()) + + best = {} + for qid, sid, pident, qcovs, length, bitscore in rows: + if qid in classified_set: + continue + if qid not in best: + best[qid] = (sid, pident, qcovs, length, bitscore) + + new_classifications = [] + no_source = 0 + for qid, (sid, pident, qcovs, length, bitscore) in best.items(): + if sid in classifications: + source = classifications[sid] + new_classifications.append({ + "id": qid, + "order": source["order"], + "superfamily": source["superfamily"], + "clade": "unknown", + "complete": "none", + "strand": "?", + "domains": "none", + "blast_source": sid, + "blast_pident": pident, + "blast_qcovs": qcovs, + "blast_bitscore": bitscore, + }) + else: + no_source += 1 + + if no_source: + log.info(f" {no_source} BLAST hits to unclassified targets (skipped)") + + log.info(f" BLAST pass-2: {len(new_classifications)} sequences classified " + f"(from {len(best)} hits passing filters)") + return new_classifications + + +# ---- new: chunking + orchestration ---- + +def chunk_fasta(qry_fasta, n_chunks, outdir): + """Bin-pack sequences from qry_fasta into n_chunks files by total length. + + Mirrors master's split bin-packing, but operates on an already-written + unclassified-query FASTA (the shared split in blast_pass2 produced it). + Returns the list of non-empty chunk paths. Returns [] for a missing or + empty input (pyfastx raises on empty files). + """ + os.makedirs(outdir, exist_ok=True) + if not os.path.exists(qry_fasta) or os.path.getsize(qry_fasta) == 0: + return [] + chunk_paths = [os.path.join(outdir, f"blast_query_{i}.fa") + for i in range(max(1, n_chunks))] + handles = [] + try: + handles = [open(p, "w") for p in chunk_paths] + lengths = [0] * len(chunk_paths) + fa = pyfastx.Fasta(qry_fasta, build_index=True) + for rec in fa: + i = lengths.index(min(lengths)) + handles[i].write(f">{rec.name}\n{rec.seq}\n") + lengths[i] += len(rec.seq) + finally: + for h in handles: + h.close() + return [p for p in chunk_paths if os.path.getsize(p) > 0] + + +def run_pass2_blast(qry_fasta, db_fasta, conn, classifications, db_seq_to_dbs, + n_processors, min_identity, min_coverage, min_length, work): + """blastn pass-2 over an already-prepared (db_fasta, qry_fasta) pair. + + Reproduces TEBinSorter master's: makeblastdb -> chunked parallel blastn -> + outfmt6 parse -> SQLite -> classify_from_blast (qcovs+length filter, best by + bitscore, clade=unknown). The I/C/L thresholds come from the run's -rule. + """ + t0 = time.time() + os.makedirs(work, exist_ok=True) + + if not os.path.exists(db_fasta) or os.path.getsize(db_fasta) == 0: + log.info(" pass-2 target FASTA is empty or missing; skipping blastn") + return [] + + make_blast_db(db_fasta, seq_type="nucl") + + query_chunks = chunk_fasta(qry_fasta, n_processors, work) + if not query_chunks: + log.info(" No unclassified sequences to search") + return [] + + log.info(f" Running {len(query_chunks)} BLAST processes") + t1 = time.time() + blast_outputs = [] + args_list = [] + for chunk in query_chunks: + out = chunk + ".blastout" + blast_outputs.append(out) + args_list.append((chunk, db_fasta, out, "nucl", 1)) + with multiprocessing.Pool(len(query_chunks)) as pool: + pool.starmap(run_blast_chunk, args_list) + t2 = time.time() + log.info(f" BLAST search: {t2 - t1:.1f}s") + + all_hits = [] + for blast_out in blast_outputs: + all_hits.extend(parse_blast_output(blast_out)) + log.info(f" {len(all_hits)} total BLAST hits") + + if all_hits: + store_blast_hits(conn, all_hits, db_seq_to_dbs) + + new_cls = classify_from_blast( + conn, classifications, + min_identity=min_identity, + min_coverage=min_coverage, + min_length=min_length, + ) + log.info(f" BLAST pass-2 total: {time.time() - t0:.1f}s") + return new_cls diff --git a/src/blast_pass2.py b/src/blast_pass2.py index 4b5e506..c1ac58b 100644 --- a/src/blast_pass2.py +++ b/src/blast_pass2.py @@ -191,7 +191,8 @@ def blast_pass2(input_fasta, conn, hmm_classifications=None, min_identity=70, min_coverage=70, min_length=70, outdir=None, pass2_classified_fasta=None, - preset="asm20", minimap2_extra=""): + preset="asm20", minimap2_extra="", + aligner="minimap2"): """minimap2-based pass-2. Args: @@ -201,7 +202,8 @@ def blast_pass2(input_fasta, conn, hmm_classifications=None, with the I-C-L grammar, not consumed by classify_ltr_paf_fast) """ t0 = time.time() - minimap.check_minimap2() + if aligner == "minimap2": + minimap.check_minimap2() if seq_type != "nucl": log.warning("minimap2 pass-2 only supports nucleotide sequences; " @@ -247,6 +249,21 @@ def blast_pass2(input_fasta, conn, hmm_classifications=None, log.info(" pass-2 target FASTA is empty; skipping minimap2") return [] + if aligner == "blast": + import blast_backend + new_cls = blast_backend.run_pass2_blast( + qry_fasta=qry_fasta, db_fasta=db_fasta, conn=conn, + classifications=hmm_classifications, + db_seq_to_dbs=db_seq_to_dbs, + n_processors=n_processors, + min_identity=min_identity, + min_coverage=min_coverage, + min_length=min_length, + work=work, + ) + log.info(f" blast pass-2 total: {time.time() - t0:.1f}s") + return new_cls + paf_out = os.path.join(work, "pass2.paf") tsv_out = os.path.join(work, "pass2.tsv") diff --git a/src/pipeline.py b/src/pipeline.py index a9e31ee..8f1b2d4 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -178,6 +178,15 @@ def parse_args(): help="Extra flags passed through to minimap2 (advanced) " "[default: empty]", ) + parser.add_argument( + "--pass2-aligner", + choices=["minimap2", "blast"], default="minimap2", + help="Aligner for the pass-2 similarity search. 'minimap2' (default) " + "uses the PAF qcov+tcov path; 'blast' reproduces TEBinSorter " + "master's blastn pass-2 (qcovs + alignment-length filter, " + "clade=unknown). Both share the same -rule and the " + "--pass2-classified-fasta external-pool merge.", + ) return parser.parse_args() @@ -461,8 +470,9 @@ def main(): f"got {args.pass2_rule!r}" ) - log.info("--- minimap2 pass-2 ---") - minimap2_version() + log.info(f"--- pass-2 ({args.pass2_aligner}) ---") + if args.pass2_aligner == "minimap2": + minimap2_version() blast_cls = blast_pass2( args.sequence, conn, hmm_classifications=all_classifications, @@ -474,6 +484,7 @@ def main(): outdir=outdir, pass2_classified_fasta=args.pass2_classified_fasta, minimap2_extra=args.minimap2_extra, + aligner=args.pass2_aligner, ) if blast_cls: From 94adb617b5e424ca84e5e7fae8060318f900fc96 Mon Sep 17 00:00:00 2001 From: Chris Benson <35149326+cwb14@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:55:41 -0400 Subject: [PATCH 10/15] Update README with note on default task change Added note about switching default task to dc-megablast. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index e90002e..60e3ed0 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Near-perfect replication of [TEsorter](https://github.com/zhangrengang/TEsorter) Most differences in TESorter replication are the result of small bugfixes in TEBinSorter. +Note: I should switch from default task (megablast) to dc-megablast. + ## How it works TEsorter classifies transposable elements by searching translated sequences against HMM profile databases using HMMER's `hmmscan` with `--nobias`. This disables fast filtering for every sequence-model comparison, making the vast majority of runtime a waste: obvious non-hits are screened at full cost so that a handful of true positives aren't missed. From e7fb5669a84759de937cf0e1868d3bf30fa1d047 Mon Sep 17 00:00:00 2001 From: cwb14 Date: Fri, 10 Jul 2026 15:24:38 -0400 Subject: [PATCH 11/15] dc-megablast --- src/blast_backend.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/blast_backend.py b/src/blast_backend.py index 866cc96..7926ba5 100644 --- a/src/blast_backend.py +++ b/src/blast_backend.py @@ -42,7 +42,11 @@ def run_blast_chunk(query_chunk, db_fasta, output, seq_type="nucl", ncpu=1): app = "blastn" if seq_type == "nucl" else "blastp" outfmt = ("6 qseqid sseqid pident length mismatch gapopen qstart qend " "sstart send evalue bitscore qlen slen qcovs qcovhsp sstrand") - cmd = (f"{app} -query {query_chunk} -db {db_fasta} -out {output} " + # dc-megablast (discontiguous megablast) is more sensitive than the blastn + # default (megablast) for divergent/cross-species TE matches. The -task value + # is blastn-only; blastp would reject it, so gate it on the blastn branch. + task = " -task dc-megablast" if app == "blastn" else "" + cmd = (f"{app}{task} -query {query_chunk} -db {db_fasta} -out {output} " f"-outfmt '{outfmt}' -num_threads {ncpu}") result = subprocess.run(cmd, shell=True, capture_output=True, text=True) if result.returncode != 0: From 9675fca773d975d33765593ce2ce6e11a764f566 Mon Sep 17 00:00:00 2001 From: Chris Benson Date: Tue, 28 Jul 2026 11:52:30 -0400 Subject: [PATCH 12/15] Adopt upstream TEsorter2 branding Upstream renamed the project TEsorter2; the fork now follows. README and in-package docstrings/help text updated. The dated run memo under docs/runs/ keeps its original names and paths so the recorded commands stay reproducible, with a note pointing at the rename. --- README.md | 8 ++++---- docs/runs/memo.md | 4 ++++ tesorter2/blast_backend.py | 6 +++--- tesorter2/pass2_external.py | 10 +++++----- tesorter2/pipeline.py | 2 +- 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6c5c289..3b7c479 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# TEBinSorter_minimap2 +# TEsorter2_minimap2 -Fork of TEBinSorter with pass-2 similarity search swapped from `blastn` to +Fork of TEsorter2 with pass-2 similarity search swapped from `blastn` to [minimap2](https://github.com/lh3/minimap2). Pass-2 runs minimap2 with a sensitivity-tuned flag set, then reduces the PAF to one row per query via `classify_ltr_paf_fast`, which enforces **identity, qcov, and tcov together** @@ -14,7 +14,7 @@ under the user-supplied I-C-L rule. mamba install -c bioconda minimap2 ``` -Everything else is unchanged from TEBinSorter (pyhmmer, pyfastx, numpy). +Everything else is unchanged from TEsorter2 (pyhmmer, pyfastx, numpy). ## New / changed CLI options @@ -55,7 +55,7 @@ The `blast_hits` table stores one row per query (best target) with columns `qseqid, sseqid, pident, qcovs, tcovs, passes_rule, classified_by`. Indexes on `qseqid` and `sseqid` are still built by `results.finalize_db`. -## What changed vs stock TEBinSorter +## What changed vs stock TEsorter2 - `tesorter2/blast_pass2.py` — internals swapped from `blastn`+`multiprocessing.Pool` to one `minimap2` call followed by `classify_ltr_paf_fast.process_paf`. diff --git a/docs/runs/memo.md b/docs/runs/memo.md index 744fd25..6042543 100644 --- a/docs/runs/memo.md +++ b/docs/runs/memo.md @@ -1,5 +1,9 @@ # memo — TEBinSorter_minimap2 port +> **Naming note (added 2026-07-28):** this codebase was renamed **TEsorter2** upstream +> (`KGerhardt/TESorter2`) after this run. Names, paths and commands below are left exactly +> as they were at the time so the run stays reproducible — read "TEBinSorter" as "TEsorter2". + ## Date Created 2026-04-24. diff --git a/tesorter2/blast_backend.py b/tesorter2/blast_backend.py index 7926ba5..a24899f 100644 --- a/tesorter2/blast_backend.py +++ b/tesorter2/blast_backend.py @@ -1,7 +1,7 @@ """ -blast_backend.py — TEBinSorter master's blastn pass-2 logic, lifted verbatim so +blast_backend.py — TEsorter2 master's blastn pass-2 logic, lifted verbatim so the `--pass2-aligner blast` path runs and post-processes identically to -TEBinSorter master. +TEsorter2 master. The orchestrator `run_pass2_blast` is called by blast_pass2.blast_pass2() AFTER the shared classified/unclassified split and the optional pass2_external merge, @@ -208,7 +208,7 @@ def run_pass2_blast(qry_fasta, db_fasta, conn, classifications, db_seq_to_dbs, n_processors, min_identity, min_coverage, min_length, work): """blastn pass-2 over an already-prepared (db_fasta, qry_fasta) pair. - Reproduces TEBinSorter master's: makeblastdb -> chunked parallel blastn -> + Reproduces TEsorter2 master's: makeblastdb -> chunked parallel blastn -> outfmt6 parse -> SQLite -> classify_from_blast (qcovs+length filter, best by bitscore, clade=unknown). The I/C/L thresholds come from the run's -rule. """ diff --git a/tesorter2/pass2_external.py b/tesorter2/pass2_external.py index 8db65dc..e430631 100644 --- a/tesorter2/pass2_external.py +++ b/tesorter2/pass2_external.py @@ -3,8 +3,8 @@ Ported from github.com/cwb14/TEsorter branch `my-new-idea2` (TEsorter/app.py, head commit b398509). The upstream helpers used Biopython SeqIO and TEsorter's -CommonClassification namedtuple. Here we use pyfastx (already a TEBinSorter -dependency) and emit dicts matching TEBinSorter's classifications-dict shape +CommonClassification namedtuple. Here we use pyfastx (already a TEsorter2 +dependency) and emit dicts matching TEsorter2's classifications-dict shape (`id/order/superfamily/clade/complete/strand/domains/score/secondary`). """ @@ -57,7 +57,7 @@ def parse_cls_from_fasta_header(header): def extend_hmm_classifications_from_fasta(hmm_cls, fasta_path, db_seq_to_dbs): """Merge external FASTA classifications into hmm_cls in place. - Each added entry is shaped like TEBinSorter's reconciled classifications + Each added entry is shaped like TEsorter2's reconciled classifications dict so classifier.py:568 still sees the fields it expects. Also extends db_seq_to_dbs so classify_from_blast accepts hits pointing at these IDs. """ @@ -66,7 +66,7 @@ def extend_hmm_classifications_from_fasta(hmm_cls, fasta_path, db_seq_to_dbs): added = 0 skipped = 0 - # pyfastx is faster than Biopython and already in TEBinSorter's deps. + # pyfastx is faster than Biopython and already in TEsorter2's deps. fa = pyfastx.Fasta(fasta_path, build_index=True, full_name=True) for rec in fa: parsed = parse_cls_from_fasta_header(rec.name) @@ -182,7 +182,7 @@ def update_classified_fasta_headers(fasta_path, hmm_cls, tmpdir): def clean_fasta_atcg(path): """In-place ATCG-only cleaner. Kept for completeness / upstream parity. - Not actively called by TEBinSorter's pass-2 because the mmseqs wrapper + Not actively called by TEsorter2's pass-2 because the mmseqs wrapper already cleans the query and merge_classified_fastas cleans the DB. """ tmp = path + ".atcg_clean.tmp" diff --git a/tesorter2/pipeline.py b/tesorter2/pipeline.py index ca807bb..c16f602 100644 --- a/tesorter2/pipeline.py +++ b/tesorter2/pipeline.py @@ -243,7 +243,7 @@ def parse_args(): "--pass2-aligner", choices=["minimap2", "blast"], default="minimap2", help="Aligner for the pass-2 similarity search. 'minimap2' (default) " - "uses the PAF qcov+tcov path; 'blast' reproduces TEBinSorter " + "uses the PAF qcov+tcov path; 'blast' reproduces TEsorter2 " "master's blastn pass-2 (qcovs + alignment-length filter, " "clade=unknown). Both share the same -rule and the " "--pass2-classified-fasta external-pool merge.", From 875c7bc9eea75e1dce2bff8f4c184a6d05c6407e Mon Sep 17 00:00:00 2001 From: Chris Benson Date: Fri, 7 Aug 2026 11:35:48 -0400 Subject: [PATCH 13/15] Default pass-2 blastn task to megablast; opt into dc-megablast via --blast-task Co-Authored-By: Claude Fable 5 --- tesorter2/blast_backend.py | 20 +++++++++++--------- tesorter2/blast_pass2.py | 3 ++- tesorter2/pipeline.py | 10 ++++++++++ 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/tesorter2/blast_backend.py b/tesorter2/blast_backend.py index a24899f..0b465d5 100644 --- a/tesorter2/blast_backend.py +++ b/tesorter2/blast_backend.py @@ -9,8 +9,9 @@ aligner backends. Only the alignment + parse + classify is master-specific here. master functions (make_blast_db, run_blast_chunk, parse_blast_output, -store_blast_hits, classify_from_blast) are copied unchanged from -origin/master:src/blast_pass2.py. chunk_fasta + run_pass2_blast are new. +store_blast_hits, classify_from_blast) are copied from +origin/master:src/blast_pass2.py; run_blast_chunk additionally takes a +blast_task parameter (--blast-task). chunk_fasta + run_pass2_blast are new. """ import logging @@ -37,15 +38,15 @@ def make_blast_db(db_fasta, seq_type="nucl"): log.info(f" BLAST database built: {db_fasta}") -def run_blast_chunk(query_chunk, db_fasta, output, seq_type="nucl", ncpu=1): +def run_blast_chunk(query_chunk, db_fasta, output, seq_type="nucl", ncpu=1, + blast_task="megablast"): """Run BLAST on one query chunk.""" app = "blastn" if seq_type == "nucl" else "blastp" outfmt = ("6 qseqid sseqid pident length mismatch gapopen qstart qend " "sstart send evalue bitscore qlen slen qcovs qcovhsp sstrand") - # dc-megablast (discontiguous megablast) is more sensitive than the blastn - # default (megablast) for divergent/cross-species TE matches. The -task value - # is blastn-only; blastp would reject it, so gate it on the blastn branch. - task = " -task dc-megablast" if app == "blastn" else "" + # The -task value is blastn-only; blastp would reject it, so gate it on + # the blastn branch. + task = f" -task {blast_task}" if app == "blastn" else "" cmd = (f"{app}{task} -query {query_chunk} -db {db_fasta} -out {output} " f"-outfmt '{outfmt}' -num_threads {ncpu}") result = subprocess.run(cmd, shell=True, capture_output=True, text=True) @@ -205,7 +206,8 @@ def chunk_fasta(qry_fasta, n_chunks, outdir): def run_pass2_blast(qry_fasta, db_fasta, conn, classifications, db_seq_to_dbs, - n_processors, min_identity, min_coverage, min_length, work): + n_processors, min_identity, min_coverage, min_length, work, + blast_task="megablast"): """blastn pass-2 over an already-prepared (db_fasta, qry_fasta) pair. Reproduces TEsorter2 master's: makeblastdb -> chunked parallel blastn -> @@ -233,7 +235,7 @@ def run_pass2_blast(qry_fasta, db_fasta, conn, classifications, db_seq_to_dbs, for chunk in query_chunks: out = chunk + ".blastout" blast_outputs.append(out) - args_list.append((chunk, db_fasta, out, "nucl", 1)) + args_list.append((chunk, db_fasta, out, "nucl", 1, blast_task)) with multiprocessing.Pool(len(query_chunks)) as pool: pool.starmap(run_blast_chunk, args_list) t2 = time.time() diff --git a/tesorter2/blast_pass2.py b/tesorter2/blast_pass2.py index f612071..a6b4bbd 100644 --- a/tesorter2/blast_pass2.py +++ b/tesorter2/blast_pass2.py @@ -192,7 +192,7 @@ def blast_pass2(input_fasta, conn, hmm_classifications=None, outdir=None, pass2_classified_fasta=None, preset="asm20", minimap2_extra="", - aligner="minimap2"): + aligner="minimap2", blast_task="megablast"): """minimap2-based pass-2. Args: @@ -260,6 +260,7 @@ def blast_pass2(input_fasta, conn, hmm_classifications=None, min_coverage=min_coverage, min_length=min_length, work=work, + blast_task=blast_task, ) log.info(f" blast pass-2 total: {time.time() - t0:.1f}s") return new_cls diff --git a/tesorter2/pipeline.py b/tesorter2/pipeline.py index 9adf4de..5f221fc 100644 --- a/tesorter2/pipeline.py +++ b/tesorter2/pipeline.py @@ -248,6 +248,15 @@ def parse_args(): "clade=unknown). Both share the same -rule and the " "--pass2-classified-fasta external-pool merge.", ) + parser.add_argument( + "--blast-task", + choices=["megablast", "dc-megablast"], default="megablast", + help="blastn -task for the 'blast' pass-2 aligner. megablast " + "(default) is fastest and tuned for near-identical matches; " + "dc-megablast uses discontiguous seeds — slower but more " + "sensitive to diverged/cross-species matches. Ignored when " + "--pass2-aligner=minimap2 [default: %(default)s]", + ) parser.add_argument( "--no-tesorter-outputs", @@ -643,6 +652,7 @@ def main(): pass2_classified_fasta=args.pass2_classified_fasta, minimap2_extra=args.minimap2_extra, aligner=args.pass2_aligner, + blast_task=args.blast_task, ) if blast_cls: From 8369d7c9d0c5a96c67589d1c0b12915c6a0bd9bc Mon Sep 17 00:00:00 2001 From: Chris Benson Date: Fri, 7 Aug 2026 11:46:19 -0400 Subject: [PATCH 14/15] Mirror upstream pass-2 defaults: blast aligner, 80-80-80 rule minimap2 and the 70-70-70 rule remain available as opt-ins (--pass2-aligner minimap2, -rule 70-70-70). tesorter_compat gains --pass2-aligner so the minimap2 path stays reachable there. Co-Authored-By: Claude Fable 5 --- README.md | 20 +++++++++++++------- tesorter2/blast_pass2.py | 29 ++++++++++++++++------------- tesorter2/pipeline.py | 24 ++++++++++++++---------- tesorter2/tesorter_compat.py | 22 +++++++++++++++------- 4 files changed, 58 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index d118b78..ab5ef26 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,17 @@ # TEsorter2_minimap2 -Fork of TEsorter2 with pass-2 similarity search swapped from `blastn` to -[minimap2](https://github.com/lh3/minimap2). Pass-2 runs minimap2 with a -sensitivity-tuned flag set, then reduces the PAF to one row per query via +Fork of TEsorter2 that adds a [minimap2](https://github.com/lh3/minimap2) +option for the pass-2 similarity search. By default, pass-2 behaves exactly +like upstream TEsorter2 (`blastn`, 80-80-80 thresholds); opt into minimap2 +with `--pass2-aligner minimap2`. The minimap2 path runs a sensitivity-tuned +flag set, then reduces the PAF to one row per query via `classify_ltr_paf_fast`, which enforces **identity, qcov, and tcov together** under the user-supplied I-C-L rule. ## Additional runtime dependency -`minimap2` binary must be on `$PATH`. Install with conda: +`minimap2` binary must be on `$PATH` (only needed with +`--pass2-aligner minimap2`). Install with conda: ``` mamba install -c bioconda minimap2 @@ -20,15 +23,18 @@ Everything else is unchanged from TEsorter2 (pyhmmer, pyfastx, numpy). | Option | Default | Purpose | |---|---|---| -| `-dp2`, `--disable-pass2` | off | Skip the minimap2 pass-2 (HMM-only classification) | -| `-rule`, `--pass2-rule I-C-L` | `70-70-70` | Pass-2 threshold. I drives `--min-pid`, C drives both `--min-qcov` and `--min-tcov`. L is parsed for grammar compatibility but is not consumed by `classify_ltr_paf_fast` | +| `--pass2-aligner {blast,minimap2}` | `blast` | Pass-2 engine. `blast` reproduces upstream TEsorter2's blastn pass-2; `minimap2` uses the PAF qcov+tcov path | +| `--blast-task {megablast,dc-megablast}` | `megablast` | blastn `-task` for the blast engine. `dc-megablast` is slower but more sensitive to diverged matches | +| `-dp2`, `--disable-pass2` | off | Skip pass-2 (HMM-only classification) | +| `-rule`, `--pass2-rule I-C-L` | `80-80-80` | Pass-2 threshold. blast: pident, qcovs, and alignment-length filters. minimap2: I drives `--min-pid`, C drives both `--min-qcov` and `--min-tcov`, L is parsed for grammar compatibility but is not consumed by `classify_ltr_paf_fast` | | `--pass2-classified-fasta FASTA` | none | Optional FASTA of prior classifications to augment the pass-2 target pool. Headers must be shaped `>id#Order/Superfamily/Clade` | | `--minimap2-preset PRESET` | `asm20` | Passed through as `minimap2 -x` | | `--minimap2-extra STR` | empty | Additional flags appended to the minimap2 command line | ## minimap2 invocation -Pass-2 runs (with target = previously-classified pool, query = HMM-unclassified): +With `--pass2-aligner minimap2`, pass-2 runs (with target = +previously-classified pool, query = HMM-unclassified): ``` minimap2 -x asm20 --rmq=no --no-long-join \ diff --git a/tesorter2/blast_pass2.py b/tesorter2/blast_pass2.py index a6b4bbd..77108f9 100644 --- a/tesorter2/blast_pass2.py +++ b/tesorter2/blast_pass2.py @@ -8,9 +8,10 @@ 2. `classify_ltr_paf_fast.process_paf` reduces the PAF to one row per query: qname pass/fail pid eff_qcov eff_tcov best_tname under the rule `--min-pid I --min-qcov C --min-tcov C` derived from - `--pass2-rule I-C-L`. (Per benchmarking, 70-70-70 is the recommended - default; the L value is parsed for backwards-compat with the I-C-L - grammar but is not consumed downstream.) + `--pass2-rule I-C-L`. (Per benchmarking, 70-70-70 is recommended for + this minimap2 path; the CLI default is 80-80-80 to mirror upstream's + blastn pass-2. The L value is parsed for backwards-compat with the + I-C-L grammar but is not consumed downstream.) 3. Each `pass` row inherits the target's order/superfamily/clade. The SQLite `blast_hits` table is preserved for post-run introspection but the @@ -188,18 +189,20 @@ def classify_from_blast(tsv_rows, classifications): def blast_pass2(input_fasta, conn, hmm_classifications=None, seq_type="nucl", n_processors=4, - min_identity=70, min_coverage=70, min_length=70, + min_identity=80, min_coverage=80, min_length=80, outdir=None, pass2_classified_fasta=None, preset="asm20", minimap2_extra="", - aligner="minimap2", blast_task="megablast"): - """minimap2-based pass-2. + aligner="blast", blast_task="megablast"): + """Pass-2 similarity search (blastn by default, minimap2 opt-in). Args: - min_identity: I from --pass2-rule I-C-L (percent, e.g. 70) - min_coverage: C from --pass2-rule I-C-L (percent, applied to qcov AND tcov) - min_length: L from --pass2-rule I-C-L (parsed for backwards-compat - with the I-C-L grammar, not consumed by classify_ltr_paf_fast) + min_identity: I from --pass2-rule I-C-L (percent, e.g. 80) + min_coverage: C from --pass2-rule I-C-L (percent; blast applies it to + qcovs, minimap2 to qcov AND tcov) + min_length: L from --pass2-rule I-C-L (blast: minimum alignment + length; minimap2: parsed for backwards-compat with the + I-C-L grammar, not consumed by classify_ltr_paf_fast) """ t0 = time.time() if aligner == "minimap2": @@ -212,10 +215,10 @@ def blast_pass2(input_fasta, conn, hmm_classifications=None, classified_ids = _get_classified_ids(conn) if not classified_ids: - log.info(" No classified sequences for minimap2 pass-2") + log.info(" No classified sequences for pass-2") return [] - log.info(f" minimap2 pass-2: {len(classified_ids)} classified sequences as targets") + log.info(f" pass-2 ({aligner}): {len(classified_ids)} classified sequences as targets") if outdir is None: outdir = tempfile.mkdtemp(prefix="tesorter2_minimap2_") @@ -246,7 +249,7 @@ def blast_pass2(input_fasta, conn, hmm_classifications=None, db_fasta = merged_db if os.path.getsize(db_fasta) == 0: - log.info(" pass-2 target FASTA is empty; skipping minimap2") + log.info(" pass-2 target FASTA is empty; skipping pass-2") return [] if aligner == "blast": diff --git a/tesorter2/pipeline.py b/tesorter2/pipeline.py index 5f221fc..4d105d1 100644 --- a/tesorter2/pipeline.py +++ b/tesorter2/pipeline.py @@ -2,7 +2,8 @@ Main pipeline for TE classification. Orchestrates: FASTA ingestion -> alphabet detection -> optional translation --> HMM search -> classification -> minimap2 pass-2 -> SQLite + TSV output. +-> HMM search -> classification -> pass-2 similarity search -> SQLite + TSV +output. """ import argparse @@ -212,7 +213,7 @@ def parse_args(): "scores in descending order of evidence strength.", ) - # minimap2 pass-2 options + # pass-2 options parser.add_argument( "-dp2", "--disable-pass2", action="store_true", default=False, @@ -220,8 +221,10 @@ def parse_args(): ) parser.add_argument( "-rule", "--pass2-rule", - default="70-70-70", type=str, metavar="I-C-L", - help="Pass-2 threshold as identity-coverage-length. I drives " + default="80-80-80", type=str, metavar="I-C-L", + help="Pass-2 threshold as identity-coverage-length. For the blast " + "aligner: pident, qcovs, and alignment-length filters (80-80-80 " + "matches TEsorter2 master). For minimap2: I drives " "classify_ltr_paf_fast --min-pid; C drives BOTH --min-qcov and " "--min-tcov; L is parsed for grammar compatibility but is not " "consumed by classify_ltr_paf_fast [default: %(default)s]", @@ -241,11 +244,12 @@ def parse_args(): ) parser.add_argument( "--pass2-aligner", - choices=["minimap2", "blast"], default="minimap2", - help="Aligner for the pass-2 similarity search. 'minimap2' (default) " - "uses the PAF qcov+tcov path; 'blast' reproduces TEsorter2 " - "master's blastn pass-2 (qcovs + alignment-length filter, " - "clade=unknown). Both share the same -rule and the " + choices=["blast", "minimap2"], default="blast", + help="Aligner for the pass-2 similarity search. 'blast' (default) " + "reproduces TEsorter2 master's blastn pass-2 (qcovs + " + "alignment-length filter, clade=unknown); 'minimap2' uses the " + "PAF qcov+tcov path and inherits the best target's full " + "classification. Both share the same -rule and the " "--pass2-classified-fasta external-pool merge.", ) parser.add_argument( @@ -622,7 +626,7 @@ def main(): log.info(f" Reconciled across {len(per_db_results)} databases: " f"{len(reconciled)} sequences") - # --- minimap2 pass-2 --- + # --- pass-2 similarity search --- all_results = list(reconciled) if (not args.pass_1_only and not args.disable_pass2 and all_classifications): diff --git a/tesorter2/tesorter_compat.py b/tesorter2/tesorter_compat.py index b39ae6e..88da424 100644 --- a/tesorter2/tesorter_compat.py +++ b/tesorter2/tesorter_compat.py @@ -71,14 +71,21 @@ def parse_args(): help="Minimum normalized score [default: 0.1]") parser.add_argument("-dp2", "--disable-pass2", action="store_true", default=False, - help="Do not run pass-2 minimap2 classification") - parser.add_argument("-rule", "--pass2-rule", type=str, default="70-70-70", + help="Do not run pass-2 classification") + parser.add_argument("-rule", "--pass2-rule", type=str, default="80-80-80", metavar="I-C-L", help="Pass-2 threshold identity-coverage-length. " - "I drives classify_ltr_paf_fast --min-pid; C " - "drives both --min-qcov and --min-tcov; L is " - "parsed for grammar compatibility but unused " - "[default: 70-70-70]") + "blast: pident, qcovs, and alignment-length " + "filters. minimap2: I drives " + "classify_ltr_paf_fast --min-pid; C drives both " + "--min-qcov and --min-tcov; L unused " + "[default: 80-80-80]") + parser.add_argument("--pass2-aligner", choices=["blast", "minimap2"], + default="blast", + help="Aligner for the pass-2 similarity search. " + "blast (default) reproduces TEsorter2 master's " + "blastn pass-2; minimap2 uses the PAF qcov+tcov " + "path [default: blast]") parser.add_argument("--pass2-classified-fasta", type=str, default=None, metavar="FASTA", help="Optional FASTA of previously-classified elements " @@ -249,7 +256,7 @@ def main(): export_classification_tsv(results, cls_out) log.info(f"Classification: {len(results)} sequences -> {cls_out}") - # minimap2 pass-2 + # pass-2 similarity search if not args.disable_pass2 and args.seq_type == "nucl": try: p2_id, p2_cov, p2_len = args.pass2_rule.split("-") @@ -272,6 +279,7 @@ def main(): outdir=args.tmp_dir or os.path.dirname(prefix) or ".", pass2_classified_fasta=args.pass2_classified_fasta, minimap2_extra=args.minimap2_extra, + aligner=args.pass2_aligner, ) if blast_cls: From 4997e92f175f819b748866f4e66cc3c9f6116f50 Mon Sep 17 00:00:00 2001 From: Chris Benson Date: Fri, 7 Aug 2026 11:58:24 -0400 Subject: [PATCH 15/15] Remove benchmarking utilities unused by the pipeline WFA_TEsorter (+ source and scripts), eval.py, classify_final.py, and docs/runs/memo.md were the toolchain for tuning the minimap2 pass-2; nothing imports or invokes them. Git history preserves them. Co-Authored-By: Claude Fable 5 --- docs/runs/memo.md | 319 --------- tesorter2/WFA_TEsorter | Bin 245808 -> 0 bytes tesorter2/WFA_TEsorter.cpp | 1070 ------------------------------- tesorter2/WFA_best_per_query.py | 155 ----- tesorter2/WFA_self_analysis.py | 304 --------- tesorter2/classify_final.py | 240 ------- tesorter2/eval.py | 133 ---- 7 files changed, 2221 deletions(-) delete mode 100644 docs/runs/memo.md delete mode 100755 tesorter2/WFA_TEsorter delete mode 100644 tesorter2/WFA_TEsorter.cpp delete mode 100755 tesorter2/WFA_best_per_query.py delete mode 100644 tesorter2/WFA_self_analysis.py delete mode 100755 tesorter2/classify_final.py delete mode 100644 tesorter2/eval.py diff --git a/docs/runs/memo.md b/docs/runs/memo.md deleted file mode 100644 index 6042543..0000000 --- a/docs/runs/memo.md +++ /dev/null @@ -1,319 +0,0 @@ -# memo — TEBinSorter_minimap2 port - -> **Naming note (added 2026-07-28):** this codebase was renamed **TEsorter2** upstream -> (`KGerhardt/TESorter2`) after this run. Names, paths and commands below are left exactly -> as they were at the time so the run stays reproducible — read "TEBinSorter" as "TEsorter2". - -## Date - -Created 2026-04-24. - -## Purpose - -Port TEBinSorter's pass-2 similarity search from `blastn` to `minimap2`. -Exists because the sibling `TEBinSorter_mmseqs/` port discovered during -benchmarking that `mmseqs easy-search --search-type 3` emits only one best -diagonal per (query, target) pair — an architectural limitation that caps -pass-2 recall on LTR-RT data where candidate and element often share -multiple distinct homologous regions. - -`minimap2` has no such limitation: it chains minimizer hits into (potentially -many) independent alignments per (q, t) pair and emits one PAF row per -chain. We union query- and target-side intervals across chains to produce -a single, honest qcov and tcov per pair. - -HMM pass-1 of TEBinSorter is untouched (byte-identical verified). - -## Source of changes - -- Port target: TEBinSorter at `/data/chris/wheat/ltrharvest/v2/v3/testing/TEBinSorter/` - (most recent commit `7144950`). -- Port destination: `/data/chris/wheat/ltrharvest/v2/v3/testing/TEBinSorter_minimap2/`. -- Conceptually mirrors `TEBinSorter_mmseqs/` — same CLI shape, same - `blast_hits` SQL table (with `tcovs` added), same `--pass2-classified-fasta` - wiring. Swaps the aligner only. - -## Environment / versions - -- Active conda env during development: `synLTR` - (`/home/chris/bin/mambaforge/envs/synLTR`). -- `minimap2` binary: **v2.30-r1287** (`synLTR/bin/minimap2`). Must be on - `$PATH` at runtime. (User installed via `mamba install -c bioconda minimap2`.) -- Python deps: same as TEBinSorter (pyhmmer ≥ 0.10, pyfastx ≥ 2.0, numpy). -- `mmseqs` is *not* required for this port (different from the mmseqs sibling). - -## Files touched - -- **NEW** `src/minimap.py` — PAF wrapper, parser, union-intervals qcov/tcov - computation, best-hit-by-AS-score per query. -- **NEW** `src/pass2_external.py` — copied verbatim from the mmseqs port - (handles `--pass2-classified-fasta`). -- **EDITED** `src/blast_pass2.py` — same filename (keeps pipeline.py, - tesorter_compat.py, classifier.py, results.py unchanged); internals swapped - to `minimap2 -c -x asm20 -N 50 -p 0.1 --secondary=yes -I 100G -t NCPU`. - `blast_hits` SQLite schema has a new `tcovs REAL NOT NULL` column. - `classify_from_blast` filters on `(qcovs OR tcovs) >= C` by default. -- **EDITED** `src/pipeline.py` — added 5 CLI args (`-dp2/--disable-pass2`, - `-rule/--pass2-rule`, `--pass2-classified-fasta`, `--minimap2-preset`, - `--minimap2-extra`); logs `minimap2_version()` at pass-2 start. -- **EDITED** `src/tesorter_compat.py` — mirrored 5 CLI args. -- **EDITED** `README.md` — mmseqs-section-style header noting the dual-coverage - enforcement and CLI. - -## Coverage semantics - -Per-(q, t) pair, across all PAF chains minimap2 emits: - -``` -qcov = |union of aligned query intervals| / qlen -tcov = |union of aligned target intervals| / tlen -``` - -This mirrors how blastn's `qcovs` is computed (NCBI tiles HSPs) but also -extends it to the target axis. The SQL pass-2 filter applies: - -```sql -WHERE pident >= I - AND (qcovs >= C OR tcovs >= C) -- OR mode, REQUIRE_BOTH_COVERAGE=False - AND length >= L -``` - -where `I-C-L` comes from `--pass2-rule` (default `80-80-80`). `length` is -union query-side alignment length (unique query bases aligned). - -### Developer toggle: `REQUIRE_BOTH_COVERAGE` - -Module-level constant at the top of `src/blast_pass2.py`: - -```python -# True -> require BOTH qcov AND tcov >= coverage threshold (strict). -# False -> require AT LEAST ONE of qcov, tcov >= coverage threshold -# (Wicker et al. 80-80-80: "candidate must cover ≥80% of at least -# one of the elements being compared"). -REQUIRE_BOTH_COVERAGE = False -``` - -- Not exposed on the CLI by design — this is a semantic choice, not a - runtime parameter. -- Defaults to `False` (OR) after reviewing Wicker et al. 2007 "A unified - classification system for eukaryotic transposable elements" — the - original rule is "candidate must cover ≥80% of at least one of the - elements being compared", which is OR, not AND. -- minimap2 has no native coverage filter; both modes are enforced post-hoc - in the SQL above. -- `classify_from_blast` logs `coverage mode: AT-LEAST-ONE qcov/tcov >= 80` - (or `BOTH`) at the start of pass-2. - -## minimap2 command - -``` -minimap2 -c -x asm20 -N 50 -p 0.1 --secondary=yes -I 100G -t NCPU \ - -o pass2.paf -``` - -- `-c` emits CIGAR + `AS:i:` score tag (needed for ranking best-hit). -- `-x asm20` targets ~20% divergence — suits LTR-RT family-level variation. -- `-N 50 -p 0.1 --secondary=yes` keeps secondary alignments down to 10% of - primary score; essential for repetitive LTR-RT libraries. -- `-I 100G` prevents index splits for libraries up to a few GB. -- Argument order: **target (reference) first, then query**. PAF's column 1 - is the query-fasta sequence, so minimap2's convention maps cleanly to our - blast_hits schema. - -## Public API - -All functions in `src/blast_pass2.py` retain the same names as the mmseqs -port and stock blastn TEBinSorter: - -- `blast_pass2(input_fasta, conn, ...)` — top-level entry point. -- `store_blast_hits(conn, hits, db_seq_to_dbs)` — writes PAF-derived rows - to SQLite `blast_hits` table with the extra `tcovs` column. -- `classify_from_blast(conn, classifications, min_identity, min_coverage, - min_length)` — filters on identity, qcov/tcov (per toggle), length. -- `split_classified_unclassified(...)`, `_get_classified_ids(...)` — - unchanged from the blastn original. - -`src/minimap.py`: - -- `check_minimap2(bin)` / `minimap2_version(bin)` — availability + startup log. -- `run_minimap2(query_fa, target_fa, paf_out, ncpu, preset, extra)` — - subprocess wrapper. -- `PAFRecord` — `__slots__` class; parses one PAF line. -- `parse_paf_besthit(paf_path)` — per-(q, t) union of query and target - intervals, weighted-fident, max-AS-score. -- `besthit_per_query(merged)` — OrderedDict keyed by qseqid, best by AS. - -## CLI - -``` -python TEBinSorter_minimap2/src/pipeline.py INPUT.fa -d rexdb -p 16 \ - [--pass2-rule 80-80-80] \ - [--pass2-classified-fasta prior_cls.fa] \ - [--minimap2-preset asm20] \ - [--minimap2-extra "..."] \ - [-o OUTDIR] -``` - -`--disable-pass2` and `--pass-1-only` both bypass minimap2. - -## Verification runs - -### Unit tests on synthetic PAF - -Three scenarios passed (see commit history for exact assertions): - -1. Two non-overlapping HSPs to same target, distant on target axis: - `qcov=0.9, tcov=0.18, score=max(HSPs)` — confirms gap-free union on - query, interval-separated union on target. -2. Two overlapping query intervals on same target: `qcov=0.75` (overlap - correctly deduplicated), `tcov=0.395`. -3. One query vs two competing targets (clean big target A vs scattered - small target B with same total coverage): target A wins best-hit by - max-AS-score, not by summed score — matches blastn's best-HSP-bitscore - semantics. - -### Rice-example-data parity - -Not re-run here; semantic identical to mmseqs port's rice verification. - -### Rape-library 5k benchmark (Brassica napus LTR_retriever intact LTRs) - -Subsampled with `seqkit sample -s 42 -n 5000` (4,892 unique seqs). 16 -threads. REXdb v4 + metazoa v3.1 HMM database. - -**At-least-one coverage (OR) mode — default:** - -| Rule | Wall | Max RSS | Pass-2 rescues | Combined | -|---|---:|---:|---:|---:| -| 80-80-80 | 0:45 | 6.4 GB | 598 | 2,115 | -| 70-50-80 | 0:46 | 6.4 GB | 939 | 2,456 | - -**BOTH-coverage (AND) mode — archived for reference:** - -| Rule | Pass-2 rescues | -|---|---:| -| 80-80-80 AND | 16 | -| 70-50-80 AND | 152 | - -**Reference:** - -| Tool | Rule | Wall | Pass-2 rescues | Combined | -|---|---|---:|---:|---:| -| blastn (stock TEBinSorter) | 80-80-80 (hardcoded, qcov-only) | 17:37 | 1,005 | 2,522 | -| mmseqs port | 80-80-80 OR | 1:19 | 397 | 1,914 | -| mmseqs port | 70-50-80 OR | 1:07 | 773 | 2,290 | - -### Headline number - -minimap2 port @ `70-50-80` OR: -- **23× faster** than blastn baseline (45 s vs 17:37) -- **12× less memory** (6.4 GB vs 76.6 GB) -- **93% of blastn recall** (939 rescues vs 1,005) -- enforces a Wicker-faithful coverage rule (`qcov ≥ 50 OR tcov ≥ 50`) - -## SQLite schema - -The `blast_hits` table adds a `tcovs REAL NOT NULL` column vs stock -TEBinSorter. Classifier output (`classifications` table, `.cls.tsv`) is -schema-unchanged. - -## How this port differs from the mmseqs sibling - -| Concern | mmseqs port | minimap2 port | -|---|---|---| -| Alignments per (q, t) pair | one best diagonal | multi-chain, all chained hits | -| qcov / tcov independence | coupled via single alnlen | genuinely independent | -| OR mode on LTR-RT data | ≈ qcov-only (architectural) | meaningfully different from qcov-only | -| Recall at 70-50-80 OR | 773 | 939 | -| Wall time | ~65 s | ~45 s | -| Memory | ~8.6 GB | ~6.4 GB | -| External binary | `mmseqs` 17.b804f | `minimap2` 2.30-r1287 | -| Best-hit ranking metric | mmseqs `bits` | minimap2 `AS` (Smith-Waterman score) | - -## Notes / caveats - -- `--pass2-classified-fasta` headers must be `>id#Order/Superfamily/Clade`; - coord-shaped IDs get `unknown` slots upgraded from pass-1 `classifications` - dict via `_COORD_HEADER_RE` (shared helper from `pass2_external.py`). -- PAF column 11 (`alnlen`) is the block length including gaps; we instead - report `length` as the union-of-query-intervals (same as `qcovs`'s - numerator). Consistent with the 80-80-80 rule's third number being - "length of the alignment region," not "sum of CIGAR ops." -- `AS:i:` tag is only emitted when `-c` is passed; we pass it. -- `evalue` and `slen` columns in `blast_hits` hold sentinels (0.0, 0 or - tlen respectively) — downstream classifier code never reads them. Kept - for schema parity with the mmseqs sibling. -- `_MAX_SPLIT_GAP` (the 500-bp chain heuristic from the mmseqs port) does - not exist here. Unnecessary: minimap2's own chainer groups near-diagonal - minimizer seeds; cross-chain union on the Python side is the only - aggregation we do. - -## Nested-TE caveat - -The OR rule can classify a candidate as family X if it contains a nested -element of family X, because tcov of the nested region can be near 100% -even when qcov is ~50%. Discussed in conversation history; acceptable -trade because pass-1 HMM typically catches this case first. If nested-TE -false positives become a concern, flip `REQUIRE_BOTH_COVERAGE = True` or -add a secondary rule (e.g., flag `qcov / tcov < 0.5` as chimera). - -## Reproducibility - -``` -claude --resume "spin up TEBinSorter_minimap2 port" -``` - -Archived runs and summaries preserved at: -- `/tmp/keep_baseline_blastn_5k/` (blastn baseline at 80-80-80) -- `/tmp/keep_mmseqs_dualcov_80_5k/` (mmseqs AND @ 80-80-80) -- `/tmp/keep_minimap2_5k/` (minimap2 AND @ 80-80-80) -- `/tmp/rape.summary.md` (most recent OR sweep, all four rule/tool combos) - - -####################################### -# Looks like we should use minimap2, no cigar and no cs strings (too costly on runtime). -# Something like. -minimap2 -x asm20 -k 15 -w 10 -p 0.2 -U 200,5000 -B 3 -O 4,18 --end-bonus 10 -z 400,200 --no-long-join -g 3000 -m 150 --paf-no-hit -t 200 -K 1G -o minimap2.paf TARGET.fa QUERY.fa -# These are loosly benchmarked, but may benifit from being benchmarked against a wavefront goldstandard alignment dataset: -nohup /usr/bin/time -v ./WFA_TEsorter -q QUERY.fa -t TARGET.fa --cigar --threads 20 > WFA.paf & -# This will generate end-to-end alignment for all-vs-all, whcih will need post-processed to identify the single-best target for each query and filter down to only those subset of query target pairs that pass whatever rule (80-80-80, etc). -# This would make a good goldstandard set. -# I can eyeball human-readable goldstandard alignments to see if im comfortable with them: -./WFA_TEsorter view --paf WFA.paf -q QUERY.fa -t TARGET.fa --qname 'chr1:15216993-15222304' --tname 'chr2:1122-3264#LTR/Copia/Bianca' --width 80 -# Note: ./WFA_TEsorter will take a long time, so QUERY.fa and TARGET.fa should be small (a subset). -# Target can be the TSD-containing ones. Query can be the full candidate FASTA. -# The PAF contains multiple lines for each q & t pair, so we need a script to merge them. -# I have two candidate scripts for this purpose. -# This script uses a more complicated (but probably more accurate) method to calculate pid. It also uses pass * pid * qcov * tcov to determine *best*. qcov and tcov are calculated identically between scripts. -python3 classify_ltr_paf_fast.py minimap2.paf --min-pid 0.80 --min-qcov 0.80 --min-tcov 0.80 --header -o merged.tsv -# This script uses a simpler approach to calculate pid, but it may not tip the scale in many cases. It uses pass * ident * min(qcov, tcov) to determine *best*. qcov and tcov are calculated identically between scripts. -python3 classify_final.py minimap2.paf --queries-fa QUERY.fa --qcov 0.80 --tcov 0.80 --ident 0.80 -o merged.tsv -# Practically, they give the same output for most things. pronbably classify_ltr_paf_fast.py is perfered due to the pid calculation. - - -######################################### -# I implemented above ideas. -# 'TEBinSorter/src/WFA_best_per_query.py' converts 'WFA_TEsorter' outputs to classify TSV format (WFA.tsv). -# Here are the best parameters: -./minimap2/minimap2 -x asm20 --rmq=no --no-long-join -k 10 -w 10 -r 500,20000 -g 500 -p 0.3 -N 100 -m 30 -t 5 -K 1G --seed 11 --paf-no-hit -o minimap2.paf TARGET.fa QUERY.fa -# Evaluate perfomance. -python TEBINsorter/eval.py --gold WFA.tsv --pred minimap2.paf -{ - "label": "", - "total": 5556, - "TP": 1339, - "TN": 3903, - "FP": 207, - "FN": 107, - "accuracy": 0.943485, - "precision": 0.866106, - "recall": 0.926003, - "f1": 0.895053, - "mcc": 0.857339, - "tp_agree": 754, - "tp_disagree": 585, - "tp_agree_frac": 0.563107, - "missing_from_pred": 2729, - "extras": 223 -} -[eval] out.tsv F1=0.8951 acc=0.9435 P=0.8661 R=0.9260 MCC=0.8573 TP=1339 TN=3903 FP=207 FN=107 agree=754/1339 miss=2729 extra=223 diff --git a/tesorter2/WFA_TEsorter b/tesorter2/WFA_TEsorter deleted file mode 100755 index a932fbf6364de42e0581b0cf06dfecfa6b519ace..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 245808 zcmeFaeSB2K^*_D~2?P~yu%f9CHR`HFL=z=S3TPG*xhor*3Mlm{U`T`-NF*DGE!604 zAh*kEw5ZftKWU3C-;Y>nD~Je~5D54TSS&urR^?tJ528ZwfqdWZGxy%yETo@)`u+X$ zd%e1?+&kx-IdkUBnKLtIW^V2(3}4;H@9*oge|>#d`uupW8mj>LHavTcOXAD-jqnY` zznQ+XeEon=!G8{2@^e=oAn|jaX)5U>H|EQ**(N{Fu<4v9W#W%d=Iia3f4;&vPhYx9 zGhZrSvsF6zdH1DCQ0M8*C*@Vy6-jzdeMs?hp5A=a&Hm$51^1uU^I3JA$~WaiyJohW zEQR^fj#u<)r}unj+hRCRN1v3D|BX=OM?I6Pot$rmoiF)W0G|9gPnN;o#E&A=|DE!R z?R=9SRTW8o&es%w=joL9L*%1g{?(tYPT#e5dA+qmx)_De=R6%dyQE^~t(OnKq+12kdcs(skNJBF1)X#Z%r&snW5Lht1-_>L zvFWoR?6LT)K$*va_k%Ky1)qqD9}6DrMV?i?)a%t=;Qf2yb7C*>eZ9y(uopSE4>-2` z=k>zBx)*qRFY-dz8{i0z1;4)+Ics`>=k&sVLofWl-%GvL_rm{{UhLr~ zz3?gN#opfQ1^(Aw^w!l2yniop-r9>i*}c#|(+m8aUg|r!7d_AGMbBxyzz_6N?&e}zz_8T&+o;L-P#NNo4wEZG`|=4)xG$oYkHAqa4&ZIyI$(Mr5F9b-wXZHUiwj_7x~}mg}$Q~ z`8jT8;=klyK`-)r(~F+(=%rm!da=*0Uii=L#colpW6F6^FYwl0>U(2v?bVAuJ0RyE z-|4=(^H34|z*c?}0YArn_ddP>8R>l&DEf2?j^AY{m+1A`uZkt&hDVsB?XlzC0 z?6OJIZmlR&xMO2(m@qMtGyK+R)iX;=W|mh+=9EpF6)FWRr*y`&IVF)f(`H7hL#2g< zV{;0x9S`CwN=iy=YjbjPE>)S3W#;VLkR_*N;xrN#BJ*V>p~~u#A5@jio>e&=<=-%2 z{6EQgRAGwI6{VH4s;12;D=Z#g3KkP1mu8ogL}tvXoL5p&Hv6{7jFPfBb1LU3>_3yN z7yfxAMJ3a!t7qOe8#S9a94e$Tdad6-<5Gp1PN$r+6q7t#p_NeGF?5+*T2VG_4i$B& zC<@AoOp8!Y(<_SSDjt;;)rB*KTO|dZIlH=SP9!v|u<+W6m6Jr)e?z60SJ&K%1}mIp zm*L?&y(j0~(#V}vWoXY4(<@8n&758~yO50l1v%DI`9E08Ttz-&CM*H!C>tJ{Ju^~} zQ)t8gw|dUWh8E_ORaHzYEra~FuF+n%l|``ffT%|+TG`RHKwM@wh;87LN+xBO6i&RN zQ*_Idj%?; zRvodM$kAH)%!;yGYs!zM;lkmSFnwA+m3&mMpBTBk6diVUM(8koh7BH z^6i(+uAE0(D7me+wxp_TPIcw%X%#agcb3e}R%(J&=oa)xj`1I(h0z<_#@N zQapa*WhE8hHho$#s{{`+!dum8bLLFDvt)Le<4>g8Xk^#+QKKSd{19Pg
  • k;oYWN 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

    3;(qk?@n0X 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
    Hh7Pkw$&$E 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%0A7`*To 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

    e}6XftI#$Ri-zSlmyvPTHWTaLV`lWarunc)nb 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

    et=iAcyy+A}|9~*Y@WIa+iYHbH&cE3LpD!pr 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 aQ9oXT((g4!g@5}$1f6dZssTcu&i@ADLGu^@ diff --git a/tesorter2/WFA_TEsorter.cpp b/tesorter2/WFA_TEsorter.cpp deleted file mode 100644 index 15a4bfa..0000000 --- a/tesorter2/WFA_TEsorter.cpp +++ /dev/null @@ -1,1070 +0,0 @@ -// WFA_TEsorter -// -// Aligns multi-sequence query FASTA against multi-sequence target FASTA -// using the WaveFront Alignment library (WFA2-lib) and emits PAF format. -// -// Convention used here: WFA `pattern` is the QUERY, WFA `text` is the TARGET. -// WFA emits ops where 'I' consumes text (target) and 'D' consumes pattern -// (query). To produce SAM/PAF-compliant CIGAR and cs strings (where 'I' -// consumes query and 'D' consumes target), we invert I<->D when serialising. -// -// Build (one line; static-linked against WFA2-lib so the binary has no -// runtime libwfa2*.so dependency). Run from this src/ directory; assumes -// WFA2-lib sits at ./WFA2-lib with build/ already populated (libwfa2.a, -// libwfa2cpp.a). Adjust the -I and .a paths if your layout differs. -// g++ -O3 -std=c++17 -fopenmp -Wall -I./WFA2-lib WFA_TEsorter.cpp -o WFA_TEsorter ./WFA2-lib/build/libwfa2cpp.a ./WFA2-lib/build/libwfa2.a -lpthread -lm - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#ifdef _OPENMP -#include -#endif - -#include "bindings/cpp/WFAligner.hpp" - -using std::string; -using std::vector; -using std::ostream; -using std::cerr; -using std::cout; -using std::endl; - -// ---------- FASTA ------------------------------------------------------------- - -struct FastaRec { - string name; // first whitespace-delimited token of the header - string desc; // remainder of the header (may be empty) - string seq; // upper-cased sequence -}; - -static void upper_inplace(string& s) { - for (char& c : s) c = (char)std::toupper((unsigned char)c); -} - -// Read a FASTA file into a vector of records. Sequence is upper-cased. -// Multi-line sequences and blank lines are handled. Errors abort the program. -static vector read_fasta(const string& path) { - std::ifstream in(path); - if (!in) { - cerr << "[ERROR] cannot open FASTA: " << path << " (" << std::strerror(errno) << ")\n"; - std::exit(1); - } - vector recs; - string line; - FastaRec cur; - bool have_cur = false; - while (std::getline(in, line)) { - if (line.empty()) continue; - if (line[0] == '>') { - if (have_cur) { upper_inplace(cur.seq); recs.push_back(std::move(cur)); cur = FastaRec{}; } - // parse name and description - size_t p = 1; - while (p < line.size() && !std::isspace((unsigned char)line[p])) ++p; - cur.name = line.substr(1, p - 1); - while (p < line.size() && std::isspace((unsigned char)line[p])) ++p; - cur.desc = (p < line.size()) ? line.substr(p) : string(); - have_cur = true; - } else { - // strip trailing CR (windows line endings) - while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) line.pop_back(); - cur.seq.append(line); - } - } - if (have_cur) { upper_inplace(cur.seq); recs.push_back(std::move(cur)); } - if (recs.empty()) { - cerr << "[ERROR] no sequences found in " << path << "\n"; - std::exit(1); - } - return recs; -} - -// ---------- reverse complement ------------------------------------------------ - -static char comp_base(char c) { - switch (c) { - case 'A': return 'T'; case 'T': return 'A'; - case 'C': return 'G'; case 'G': return 'C'; - case 'U': return 'A'; - case 'R': return 'Y'; case 'Y': return 'R'; - case 'S': return 'S'; case 'W': return 'W'; - case 'K': return 'M'; case 'M': return 'K'; - case 'B': return 'V'; case 'V': return 'B'; - case 'D': return 'H'; case 'H': return 'D'; - case 'N': return 'N'; - case 'a': return 't'; case 't': return 'a'; - case 'c': return 'g'; case 'g': return 'c'; - case 'u': return 'a'; - case 'n': return 'n'; - default: return 'N'; - } -} - -static string revcomp(const string& s) { - string out; - out.resize(s.size()); - for (size_t i = 0; i < s.size(); ++i) out[s.size() - 1 - i] = comp_base(s[i]); - return out; -} - -// ---------- IUPAC nucleotide matching ---------------------------------------- -// -// Each base is encoded as a 4-bit mask over {A=1, C=2, G=4, T=8}. Two bases -// "match" iff their masks share at least one bit, i.e. their possible-base -// sets intersect. Examples: A vs W={A,T} -> match; W vs S={C,G} -> mismatch; -// N matches everything; anything not in IUPAC has mask 0 and matches nothing. -// Used by the WFA lambda-match path (alignEnd2End / alignEndsFree / -// alignExtension lambda overloads) so the wavefront treats IUPAC-compatible -// positions as exact matches rather than mismatches. - -static uint8_t g_base_mask[256]; // [byte] -> 4-bit base set -static uint8_t g_iupac_match[65536]; // [(qbyte<<8)|tbyte] -> 0/1 match flag - -static void init_iupac_tables() { - static bool inited = false; - if (inited) return; - inited = true; - for (int i = 0; i < 256; ++i) g_base_mask[i] = 0; - auto set_pair = [](char up, char lo, uint8_t m) { - g_base_mask[(unsigned char)up] = m; - g_base_mask[(unsigned char)lo] = m; - }; - set_pair('A','a', 0x1); - set_pair('C','c', 0x2); - set_pair('G','g', 0x4); - set_pair('T','t', 0x8); - set_pair('U','u', 0x8); // RNA U == T - set_pair('R','r', 0x1|0x4); // A|G - set_pair('Y','y', 0x2|0x8); // C|T - set_pair('S','s', 0x2|0x4); // C|G - set_pair('W','w', 0x1|0x8); // A|T - set_pair('K','k', 0x4|0x8); // G|T - set_pair('M','m', 0x1|0x2); // A|C - set_pair('B','b', 0x2|0x4|0x8); // C|G|T - set_pair('D','d', 0x1|0x4|0x8); // A|G|T - set_pair('H','h', 0x1|0x2|0x8); // A|C|T - set_pair('V','v', 0x1|0x2|0x4); // A|C|G - set_pair('N','n', 0xF); // any - for (int a = 0; a < 256; ++a) { - for (int b = 0; b < 256; ++b) { - g_iupac_match[(a << 8) | b] = (g_base_mask[a] & g_base_mask[b]) ? 1 : 0; - } - } -} - -static inline bool iupac_compatible(char a, char b) { - return g_iupac_match[((unsigned char)a << 8) | (unsigned char)b] != 0; -} - -// Context passed to the WFA lambda match callback. -struct LambdaCtx { - const char* pattern; // query bytes, [0, plen) - const char* text; // target bytes, [0, tlen) -}; - -// WFA calls this for each (v=pattern_pos, h=text_pos) probed during extension. -// Return non-zero iff the two bases are IUPAC-compatible; WFA then treats the -// position as a match (no penalty). The indices are guaranteed in-range by -// the caller (see wavefront_sequences_cmp), so no bounds check needed. -static int iupac_match_funct(int v, int h, void* args) { - auto* ctx = static_cast(args); - return g_iupac_match[((unsigned char)ctx->pattern[v] << 8) - | (unsigned char)ctx->text[h]]; -} - -// ---------- alignment op-string utilities ------------------------------------- -// -// `ops` is the raw WFA operation string of M/X/I/D, one char per column. -// Convention here: WFA pattern == query, WFA text == target. -// WFA 'M': match, advances both -// WFA 'X': mismatch, advances both -// WFA 'I': advances text (target) -> SAM-style 'D' (deletion from query) -// WFA 'D': advances pattern (query) -> SAM-style 'I' (insertion in query) - -struct AlnStats { - int matches = 0; // count of M (or = if show_eq) operations - int mismatches = 0; // X operations - int ins_q = 0; // bases inserted in query (gap in target) = WFA 'D' - int del_q = 0; // bases deleted from query (gap in query, base in target) = WFA 'I' - int aln_len = 0; // total ops length (alignment block length) - int q_consumed = 0; // bases of query consumed (M+X+ins_q) - int t_consumed = 0; // bases of target consumed (M+X+del_q) -}; - -static AlnStats compute_stats(const string& ops) { - AlnStats s; - s.aln_len = (int)ops.size(); - for (char c : ops) { - switch (c) { - case 'M': ++s.matches; ++s.q_consumed; ++s.t_consumed; break; - case 'X': ++s.mismatches; ++s.q_consumed; ++s.t_consumed; break; - case 'I': ++s.del_q; ++s.t_consumed; break; // gap in query - case 'D': ++s.ins_q; ++s.q_consumed; break; // gap in target - default: break; - } - } - return s; -} - -// Run-length-encode WFA ops into a SAM CIGAR string with I<->D inversion so -// that the result describes query-vs-target in the standard way. -// show_eq=false: emit M for both M and X (legacy SAM) -// show_eq=true: emit '=' for M and 'X' for X (extended SAM) -static string ops_to_cigar(const string& ops, bool show_eq) { - string out; - if (ops.empty()) return out; - auto sam_op = [show_eq](char w) -> char { - switch (w) { - case 'M': return show_eq ? '=' : 'M'; - case 'X': return show_eq ? 'X' : 'M'; - case 'I': return 'D'; // WFA I -> SAM D - case 'D': return 'I'; // WFA D -> SAM I - } - return '?'; - }; - char run_op = sam_op(ops[0]); - int run_len = 1; - for (size_t i = 1; i < ops.size(); ++i) { - char op = sam_op(ops[i]); - if (op == run_op) { ++run_len; continue; } - out += std::to_string(run_len); out += run_op; - run_op = op; run_len = 1; - } - out += std::to_string(run_len); out += run_op; - return out; -} - -// Build minimap2-style cs tag. -// short (default): :LEN *xy +bases -bases -// long: =BASES *xy +bases -bases -// `query` and `target` are the (oriented) full sequences corresponding to the -// alignment. q0/t0 are the alignment start offsets in those sequences. -static string ops_to_cs(const string& ops, - const string& query, int q0, - const string& target, int t0, - bool long_form) { - string out; - int qi = q0, ti = t0; - size_t i = 0; - while (i < ops.size()) { - char w = ops[i]; - if (w == 'M') { - size_t j = i; - while (j < ops.size() && ops[j] == 'M') ++j; - int run = (int)(j - i); - if (long_form) { - out += '='; - out.append(query, qi, run); - } else { - out += ':'; - out += std::to_string(run); - } - qi += run; ti += run; i = j; - } else if (w == 'X') { - // emit each mismatch separately as *xy where x=target base, y=query base - // (lowercase per minimap2 short-cs convention) - char tbase = (char)std::tolower((unsigned char)target[ti]); - char qbase = (char)std::tolower((unsigned char)query[qi]); - out += '*'; out += tbase; out += qbase; - ++qi; ++ti; ++i; - } else if (w == 'D') { - // WFA 'D' -> SAM 'I' -> +bases (insertion to target = bases from query) - size_t j = i; - while (j < ops.size() && ops[j] == 'D') ++j; - int run = (int)(j - i); - out += '+'; - for (int k = 0; k < run; ++k) { - out += (char)std::tolower((unsigned char)query[qi + k]); - } - qi += run; i = j; - } else if (w == 'I') { - // WFA 'I' -> SAM 'D' -> -bases (deletion from query = bases from target) - size_t j = i; - while (j < ops.size() && ops[j] == 'I') ++j; - int run = (int)(j - i); - out += '-'; - for (int k = 0; k < run; ++k) { - out += (char)std::tolower((unsigned char)target[ti + k]); - } - ti += run; i = j; - } else { - ++i; // unknown op, skip - } - } - return out; -} - -// ---------- PAF record -------------------------------------------------------- - -struct PafRec { - string qname; int qlen, qstart, qend; - char strand; - string tname; int tlen, tstart, tend; - int matches; - int aln_len; - int mapq; - int score; // WFA penalty (typically <= 0) - int nm; // edit distance: X+I+D - string cigar; // optional; empty if not requested - string cs; // optional; empty if not requested -}; - -static void write_paf(ostream& os, const PafRec& p) { - os << p.qname << '\t' << p.qlen << '\t' << p.qstart << '\t' << p.qend << '\t' - << p.strand << '\t' - << p.tname << '\t' << p.tlen << '\t' << p.tstart << '\t' << p.tend << '\t' - << p.matches << '\t' << p.aln_len << '\t' << p.mapq - << "\ttp:A:P" - << "\tAS:i:" << p.score - << "\tNM:i:" << p.nm; - if (!p.cigar.empty()) os << "\tcg:Z:" << p.cigar; - if (!p.cs.empty()) os << "\tcs:Z:" << p.cs; - os << '\n'; -} - -// ---------- option parsing ---------------------------------------------------- - -enum class CsMode { None, Short, Long }; -enum class AlnMode { End2End, EndsFree, Extension }; -enum class Strand { Both, Forward, Reverse }; -enum class MemMode { High, Med, Low, Ultralow }; - -struct Opts { - string query_path; - string target_path; - string output_path; // empty => stdout - string pairs_path; // optional; empty => default all-vs-all behavior - int threads = 1; - int match_pen = 0; - int mismatch = 4; - int gap_open = 6; - int gap_ext = 2; - bool emit_cigar = false; - CsMode cs = CsMode::None; - bool show_eq = false; - AlnMode mode = AlnMode::End2End; - Strand strand = Strand::Both; - MemMode mem = MemMode::High; - bool has_min_score = false; - int min_score = 0; - bool iupac = false; - bool verbose = false; -}; - -static void usage_align(FILE* f = stderr) { - std::fprintf(f, -"Usage: WFA_TEsorter -q QUERY.fa -t TARGET.fa [options] > out.paf\n" -" WFA_TEsorter view --paf F.paf -q Q.fa -t T.fa --qname X --tname Y\n" -"\n" -"Required:\n" -" -q, --query FILE multi-seq query FASTA\n" -" -t, --target FILE multi-seq target FASTA\n" -"\n" -"Output:\n" -" -o, --output FILE PAF output (default: stdout)\n" -" --cigar emit CIGAR as cg:Z tag\n" -" --cs [=short|long] emit minimap2 cs tag (default: short)\n" -" --show-eq use '='/'X' instead of 'M' in CIGAR\n" -"\n" -"Penalties (gap-affine, all positive integers):\n" -" --match N match score (default: 0)\n" -" --mismatch N mismatch penalty (default: 4)\n" -" --gap-open N gap-open penalty (default: 6)\n" -" --gap-ext N gap-extension penalty (default: 2)\n" -"\n" -"Alignment:\n" -" --mode STR end2end|ends-free|extension (default: end2end)\n" -" ends-free: query is anchored, target ends are free\n" -" --strand STR both|forward|reverse (default: both)\n" -" --memory STR high|med|low|ultralow (default: high)\n" -" --min-score N drop alignments with WFA score < N (default: keep all)\n" -" --iupac treat IUPAC-compatible bases as matches (e.g. A==W,\n" -" W==N, R==G). Off by default; turn on to align\n" -" consensus sequences carrying ambiguity codes. Uses\n" -" WFA's lambda-match path, which is slower than the\n" -" default literal-byte kernel.\n" -" --pairs FILE restrict to (qnametname) pairs from FILE\n" -" (default: full all-vs-all over the FASTAs)\n" -" Self-pairs (qname == tname) are always skipped.\n" -" --threads N worker threads (default: 1)\n" -"\n" -"Misc:\n" -" -v, --verbose progress messages to stderr\n" -" -h, --help this message\n" -"\n" -"Subcommands:\n" -" view pretty-print an alignment from a PAF (requires cg:Z)\n" -" see: WFA_TEsorter view -h\n" -); -} - -static MemMode parse_mem(const string& s) { - if (s == "high") return MemMode::High; - if (s == "med") return MemMode::Med; - if (s == "low") return MemMode::Low; - if (s == "ultralow") return MemMode::Ultralow; - cerr << "[ERROR] unknown --memory value: " << s << "\n"; std::exit(2); -} -static AlnMode parse_mode(const string& s) { - if (s == "end2end" || s == "end-to-end") return AlnMode::End2End; - if (s == "ends-free") return AlnMode::EndsFree; - if (s == "extension") return AlnMode::Extension; - cerr << "[ERROR] unknown --mode value: " << s << "\n"; std::exit(2); -} -static Strand parse_strand(const string& s) { - if (s == "both") return Strand::Both; - if (s == "forward" || s == "+") return Strand::Forward; - if (s == "reverse" || s == "-") return Strand::Reverse; - cerr << "[ERROR] unknown --strand value: " << s << "\n"; std::exit(2); -} - -static Opts parse_align_opts(int argc, char** argv) { - Opts o; - static struct option long_opts[] = { - {"query", required_argument, nullptr, 'q'}, - {"target", required_argument, nullptr, 't'}, - {"output", required_argument, nullptr, 'o'}, - {"threads", required_argument, nullptr, 1 }, - {"match", required_argument, nullptr, 2 }, - {"mismatch", required_argument, nullptr, 3 }, - {"gap-open", required_argument, nullptr, 4 }, - {"gap-ext", required_argument, nullptr, 5 }, - {"cigar", no_argument, nullptr, 6 }, - {"cs", optional_argument, nullptr, 7 }, - {"show-eq", no_argument, nullptr, 8 }, - {"mode", required_argument, nullptr, 9 }, - {"strand", required_argument, nullptr, 10 }, - {"memory", required_argument, nullptr, 11 }, - {"min-score", required_argument, nullptr, 12 }, - {"pairs", required_argument, nullptr, 13 }, - {"iupac", no_argument, nullptr, 14 }, - {"verbose", no_argument, nullptr, 'v'}, - {"help", no_argument, nullptr, 'h'}, - {nullptr, 0, nullptr, 0} - }; - int c; - while ((c = getopt_long(argc, argv, "q:t:o:vh", long_opts, nullptr)) != -1) { - switch (c) { - case 'q': o.query_path = optarg; break; - case 't': o.target_path = optarg; break; - case 'o': o.output_path = optarg; break; - case 1 : o.threads = std::atoi(optarg); break; - case 2 : o.match_pen = std::atoi(optarg); break; - case 3 : o.mismatch = std::atoi(optarg); break; - case 4 : o.gap_open = std::atoi(optarg); break; - case 5 : o.gap_ext = std::atoi(optarg); break; - case 6 : o.emit_cigar = true; break; - case 7 : o.cs = (optarg && string(optarg) == "long") ? CsMode::Long : CsMode::Short; break; - case 8 : o.show_eq = true; break; - case 9 : o.mode = parse_mode(optarg); break; - case 10 : o.strand = parse_strand(optarg); break; - case 11 : o.mem = parse_mem(optarg); break; - case 12 : o.has_min_score = true; o.min_score = std::atoi(optarg); break; - case 13 : o.pairs_path = optarg; break; - case 14 : o.iupac = true; break; - case 'v': o.verbose = true; break; - case 'h': usage_align(stdout); std::exit(0); - default : usage_align(stderr); std::exit(2); - } - } - if (o.query_path.empty() || o.target_path.empty()) { - cerr << "[ERROR] --query and --target are required\n"; - usage_align(stderr); std::exit(2); - } - if (o.threads < 1) o.threads = 1; - if (o.mismatch < 0 || o.gap_open < 0 || o.gap_ext < 0) { - cerr << "[ERROR] penalties must be non-negative\n"; std::exit(2); - } - return o; -} - -// ---------- alignment runner -------------------------------------------------- - -static wfa::WFAligner::MemoryModel to_wfa_mem(MemMode m) { - switch (m) { - case MemMode::High: return wfa::WFAligner::MemoryHigh; - case MemMode::Med: return wfa::WFAligner::MemoryMed; - case MemMode::Low: return wfa::WFAligner::MemoryLow; - case MemMode::Ultralow: return wfa::WFAligner::MemoryUltralow; - } - return wfa::WFAligner::MemoryHigh; -} - -struct AlnResult { - bool ok = false; - string ops; // WFA M/X/I/D string for the *aligned* region only - int score = 0; - int qstart = 0, qend = 0; - int tstart = 0, tend = 0; -}; - -// Run one alignment. For ends-free (query-anchored) mode the leading and -// trailing runs of WFA 'I' ops (free target-end skips) are stripped from `ops` -// and accounted for in tstart/tend. For extension mode qend/tend reflect the -// actual bases consumed by the partial alignment. -// -// When `iupac` is true, the lambda-match overloads are used so that bases -// connected by IUPAC ambiguity codes (e.g. W vs A) score as matches rather -// than mismatches. The lambda path bypasses the SIMD/64-bit-block extension -// kernel and is therefore slower than the default literal-byte path. -static AlnResult align_one(wfa::WFAlignerGapAffine& aligner, AlnMode mode, - const string& q, const string& t, bool iupac) { - AlnResult r; - int qlen = (int)q.size(), tlen = (int)t.size(); - wfa::WFAligner::AlignmentStatus st; - if (iupac) { - LambdaCtx ctx{q.data(), t.data()}; - if (mode == AlnMode::End2End) { - st = aligner.alignEnd2End(iupac_match_funct, &ctx, qlen, tlen); - } else if (mode == AlnMode::EndsFree) { - st = aligner.alignEndsFree(iupac_match_funct, &ctx, - qlen, 0, 0, - tlen, tlen, tlen); - } else { - st = aligner.alignExtension(iupac_match_funct, &ctx, qlen, tlen); - } - } else if (mode == AlnMode::End2End) { - st = aligner.alignEnd2End(q, t); - } else if (mode == AlnMode::EndsFree) { - // Query-anchored semi-global: query is anchored, target ends are free. - st = aligner.alignEndsFree(q, 0, 0, t, tlen, tlen); - } else { - string qm = q, tm = t; - st = aligner.alignExtension(qm, tm); - } - if ((int)st < 0) return r; - string ops = aligner.getAlignment(); - if (ops.empty()) return r; - - int q0 = 0, q1 = qlen, t0 = 0, t1 = tlen; - if (mode == AlnMode::EndsFree) { - // Strip leading/trailing 'I' ops (WFA: text-advance) which represent - // free skips of the target ends. - size_t lo = 0, hi = ops.size(); - while (lo < hi && ops[lo] == 'I') { ++t0; ++lo; } - while (hi > lo && ops[hi - 1] == 'I') { --t1; --hi; } - ops = ops.substr(lo, hi - lo); - } else if (mode == AlnMode::Extension) { - AlnStats s = compute_stats(ops); - q1 = q0 + s.q_consumed; - t1 = t0 + s.t_consumed; - } - - if (ops.empty()) return r; - r.ok = true; - r.ops = std::move(ops); - r.score = aligner.getAlignmentScore(); - r.qstart = q0; r.qend = q1; - r.tstart = t0; r.tend = t1; - return r; -} - -// Build a PafRec from an AlnResult. The aligned ops describe positions -// [qstart,qend) on `q_oriented` and [tstart,tend) on `t_oriented`. -static PafRec make_paf(const string& qname, const string& q_oriented, int qlen_full, - const string& tname, const string& t_oriented, int tlen_full, - char strand, const AlnResult& r, - const Opts& o) { - AlnStats s = compute_stats(r.ops); - PafRec p; - p.qname = qname; p.qlen = qlen_full; p.qstart = r.qstart; p.qend = r.qend; - p.strand = strand; - p.tname = tname; p.tlen = tlen_full; p.tstart = r.tstart; p.tend = r.tend; - p.matches = s.matches; - p.aln_len = s.aln_len; - p.mapq = 60; - p.score = r.score; - p.nm = s.mismatches + s.ins_q + s.del_q; - if (o.emit_cigar) p.cigar = ops_to_cigar(r.ops, o.show_eq); - if (o.cs != CsMode::None) { - p.cs = ops_to_cs(r.ops, q_oriented, r.qstart, - t_oriented, r.tstart, - o.cs == CsMode::Long); - } - return p; -} - -static int run_align(int argc, char** argv) { - Opts o = parse_align_opts(argc, argv); - - if (o.iupac) init_iupac_tables(); - - if (o.verbose) cerr << "[INFO] reading FASTAs\n"; - auto queries = read_fasta(o.query_path); - auto targets = read_fasta(o.target_path); - if (o.verbose) { - cerr << "[INFO] " << queries.size() << " query seqs, " - << targets.size() << " target seqs" - << (o.iupac ? " (IUPAC-aware matching)" : "") << "\n"; - } - - // Output sink - std::ofstream fout; - ostream* out = &cout; - if (!o.output_path.empty()) { - fout.open(o.output_path); - if (!fout) { cerr << "[ERROR] cannot open output: " << o.output_path << "\n"; return 1; } - out = &fout; - } - std::mutex out_mu; - - // Per-thread aligners (created lazily inside the parallel region so each - // thread owns its own state). - int nthreads = o.threads; -#ifdef _OPENMP - omp_set_num_threads(nthreads); -#else - nthreads = 1; -#endif - - // Optional: pre-load explicit (qname, tname) pairs from --pairs FILE. - // When pairs_path is empty, fall through to the default all-vs-all loop. - std::vector> pair_jobs; - if (!o.pairs_path.empty()) { - std::unordered_map q_idx, t_idx; - q_idx.reserve(queries.size() * 2); - t_idx.reserve(targets.size() * 2); - for (size_t i = 0; i < queries.size(); ++i) q_idx.emplace(queries[i].name, (int)i); - for (size_t i = 0; i < targets.size(); ++i) t_idx.emplace(targets[i].name, (int)i); - - std::ifstream pin(o.pairs_path); - if (!pin) { - cerr << "[ERROR] cannot open --pairs file: " << o.pairs_path << "\n"; - return 1; - } - size_t miss_q = 0, miss_t = 0, parsed = 0, skipped_self = 0; - string line; - while (std::getline(pin, line)) { - if (line.empty() || line[0] == '#') continue; - // strip trailing CR - while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) line.pop_back(); - size_t tab = line.find('\t'); - if (tab == string::npos) continue; - string qn = line.substr(0, tab); - string rest = line.substr(tab + 1); - size_t tab2 = rest.find('\t'); - string tn = (tab2 == string::npos) ? rest : rest.substr(0, tab2); - ++parsed; - if (qn == tn) { ++skipped_self; continue; } - auto qit = q_idx.find(qn); - auto tit = t_idx.find(tn); - if (qit == q_idx.end()) { ++miss_q; continue; } - if (tit == t_idx.end()) { ++miss_t; continue; } - pair_jobs.emplace_back(qit->second, tit->second); - } - if (o.verbose) { - cerr << "[INFO] --pairs parsed=" << parsed - << " kept=" << pair_jobs.size() - << " skipped-self=" << skipped_self - << " missing-qname=" << miss_q - << " missing-tname=" << miss_t << "\n"; - } - if (pair_jobs.empty()) { - cerr << "[ERROR] no usable pairs from " << o.pairs_path << "\n"; - return 1; - } - } - - // Pre-count self-pairs in the all-vs-all matrix so the progress total - // reflects only the alignments we'll actually run. - size_t self_pairs_avs = 0; - if (pair_jobs.empty()) { - std::unordered_map tname_counts; - tname_counts.reserve(targets.size() * 2); - for (const auto& T : targets) ++tname_counts[T.name]; - for (const auto& Q : queries) { - auto it = tname_counts.find(Q.name); - if (it != tname_counts.end()) self_pairs_avs += (size_t)it->second; - } - } - std::atomic done{0}; - const size_t total = pair_jobs.empty() - ? (queries.size() * targets.size() - self_pairs_avs) - : pair_jobs.size(); - auto t_start = std::chrono::steady_clock::now(); - - // Process one (qi, ti) job using the caller's per-thread aligner. - auto process_job = [&](size_t qi, size_t ti, wfa::WFAlignerGapAffine& aligner) { - const FastaRec& Q = queries[qi]; - const FastaRec& T = targets[ti]; - if (Q.seq.empty() || T.seq.empty()) { ++done; return; } - // Skip self-alignments: a sequence aligned to itself yields no useful - // information and is wasted compute (typical case: all-vs-all over a - // single FASTA loaded as both --query and --target). These are - // pre-excluded from `total`, so do not increment `done` here. - if (Q.name == T.name) return; - - AlnResult res_f, res_r; - string qrev; - if (o.strand == Strand::Forward || o.strand == Strand::Both) { - res_f = align_one(aligner, o.mode, Q.seq, T.seq, o.iupac); - } - if (o.strand == Strand::Reverse || o.strand == Strand::Both) { - qrev = revcomp(Q.seq); - res_r = align_one(aligner, o.mode, qrev, T.seq, o.iupac); - } - - bool emit_f = false, emit_r = false; - if (o.strand == Strand::Forward) emit_f = res_f.ok; - else if (o.strand == Strand::Reverse) emit_r = res_r.ok; - else { - if (res_f.ok && res_r.ok) { - (res_f.score >= res_r.score) ? (emit_f = true) : (emit_r = true); - } else if (res_f.ok) emit_f = true; - else if (res_r.ok) emit_r = true; - } - if (emit_f && o.has_min_score && res_f.score < o.min_score) emit_f = false; - if (emit_r && o.has_min_score && res_r.score < o.min_score) emit_r = false; - - if (emit_f) { - PafRec p = make_paf(Q.name, Q.seq, (int)Q.seq.size(), - T.name, T.seq, (int)T.seq.size(), - '+', res_f, o); - std::lock_guard lk(out_mu); - write_paf(*out, p); - } - if (emit_r) { - PafRec p = make_paf(Q.name, qrev, (int)qrev.size(), - T.name, T.seq, (int)T.seq.size(), - '-', res_r, o); - std::lock_guard lk(out_mu); - write_paf(*out, p); - } - - size_t now = ++done; - if (o.verbose && (now % 1000 == 0 || now == total)) { - auto el = std::chrono::duration( - std::chrono::steady_clock::now() - t_start).count(); - std::lock_guard lk(out_mu); - cerr << "[INFO] " << now << "/" << total - << " pairs aligned (" << (now / std::max(el, 1e-9)) << "/s)\n"; - } - }; - - if (pair_jobs.empty()) { - // Default behavior: full all-vs-all over the two FASTAs. -#ifdef _OPENMP - #pragma omp parallel -#endif - { - wfa::WFAlignerGapAffine aligner( - o.match_pen, o.mismatch, o.gap_open, o.gap_ext, - wfa::WFAligner::Alignment, to_wfa_mem(o.mem)); -#ifdef _OPENMP - #pragma omp for schedule(dynamic, 1) collapse(2) -#endif - for (size_t qi = 0; qi < queries.size(); ++qi) { - for (size_t ti = 0; ti < targets.size(); ++ti) { - process_job(qi, ti, aligner); - } - } - } - } else { - // --pairs mode: align only the explicit (qi, ti) pairs. -#ifdef _OPENMP - #pragma omp parallel -#endif - { - wfa::WFAlignerGapAffine aligner( - o.match_pen, o.mismatch, o.gap_open, o.gap_ext, - wfa::WFAligner::Alignment, to_wfa_mem(o.mem)); -#ifdef _OPENMP - #pragma omp for schedule(dynamic, 64) -#endif - for (size_t pi = 0; pi < pair_jobs.size(); ++pi) { - process_job(pair_jobs[pi].first, pair_jobs[pi].second, aligner); - } - } - } - - if (o.verbose) { - auto el = std::chrono::duration( - std::chrono::steady_clock::now() - t_start).count(); - cerr << "[INFO] done in " << el << " s\n"; - } - return 0; -} - -// ---------- view subcommand --------------------------------------------------- - -struct ViewOpts { - string paf_path; - string query_path; - string target_path; - string qname; - string tname; - int width = 80; // wrap width for the pretty alignment - bool verbose = false; -}; - -static void usage_view(FILE* f = stderr) { - std::fprintf(f, -"Usage: WFA_TEsorter view --paf F.paf -q Q.fa -t T.fa --qname X --tname Y\n" -"\n" -"Re-creates a human-readable alignment from a PAF record produced by\n" -"WFA_TEsorter (the record must contain a cg:Z CIGAR tag).\n" -"\n" -"Required:\n" -" --paf FILE PAF file written by WFA_TEsorter --cigar\n" -" -q, --query FILE query FASTA used to produce the PAF\n" -" -t, --target FILE target FASTA used to produce the PAF\n" -" --qname STR query sequence name to display\n" -" --tname STR target sequence name to display\n" -"\n" -"Optional:\n" -" --width N characters per row (default: 80)\n" -" -h, --help this message\n" -); -} - -static ViewOpts parse_view_opts(int argc, char** argv) { - ViewOpts v; - static struct option lo[] = { - {"paf", required_argument, nullptr, 1}, - {"query", required_argument, nullptr, 'q'}, - {"target", required_argument, nullptr, 't'}, - {"qname", required_argument, nullptr, 2}, - {"tname", required_argument, nullptr, 3}, - {"width", required_argument, nullptr, 4}, - {"help", no_argument, nullptr, 'h'}, - {nullptr, 0, nullptr, 0} - }; - int c; - while ((c = getopt_long(argc, argv, "q:t:h", lo, nullptr)) != -1) { - switch (c) { - case 1 : v.paf_path = optarg; break; - case 'q': v.query_path = optarg; break; - case 't': v.target_path = optarg; break; - case 2 : v.qname = optarg; break; - case 3 : v.tname = optarg; break; - case 4 : v.width = std::max(20, std::atoi(optarg)); break; - case 'h': usage_view(stdout); std::exit(0); - default : usage_view(stderr); std::exit(2); - } - } - if (v.paf_path.empty() || v.query_path.empty() || v.target_path.empty() || - v.qname.empty() || v.tname.empty()) { - cerr << "[ERROR] --paf, --query, --target, --qname, --tname all required\n"; - usage_view(stderr); std::exit(2); - } - return v; -} - -// Parse a SAM/PAF CIGAR string into pairs (length, op). -// Recognises M, I, D, =, X. Returns false on parse error. -static bool parse_cigar(const string& cg, vector>& out) { - out.clear(); - int i = 0, n = (int)cg.size(); - while (i < n) { - int v = 0; - if (!std::isdigit((unsigned char)cg[i])) return false; - while (i < n && std::isdigit((unsigned char)cg[i])) { - v = v * 10 + (cg[i] - '0'); - ++i; - } - if (i >= n) return false; - char op = cg[i++]; - if (op != 'M' && op != 'I' && op != 'D' && op != '=' && op != 'X') return false; - out.emplace_back(v, op); - } - return true; -} - -// Find a tag in a tab-separated PAF line; returns its value (after "xx:T:") or -// empty string if absent. -static string get_tag(const string& line, const string& key /* e.g. "cg:Z:" */) { - // search tab-delimited fields - size_t pos = 0; - while (pos < line.size()) { - size_t end = line.find('\t', pos); - size_t f_end = (end == string::npos) ? line.size() : end; - if (f_end - pos >= key.size() && - line.compare(pos, key.size(), key) == 0) { - return line.substr(pos + key.size(), f_end - pos - key.size()); - } - if (end == string::npos) break; - pos = end + 1; - } - return string(); -} - -// Build a vector of FastaRec keyed by name, returning a pointer (or nullptr). -static const FastaRec* find_rec(const vector& v, const string& name) { - for (const auto& r : v) if (r.name == name) return &r; - return nullptr; -} - -// Print the pretty alignment given the (oriented) query/target slices and CIGAR. -static void print_pretty(ostream& os, - const string& qname, const string& q_aln, - int q_off, - const string& tname, const string& t_aln, - int t_off, - char strand, - const vector>& cig, - int width) { - // Build aligned strings (with '-' gaps). - string qline, mline, tline; - int qi = 0, ti = 0; - for (auto [len, op] : cig) { - for (int k = 0; k < len; ++k) { - if (op == 'M' || op == '=' || op == 'X') { - char qc = q_aln[qi++], tc = t_aln[ti++]; - qline += qc; tline += tc; - // For legacy 'M' (match-or-mismatch), use IUPAC compatibility - // so consensus bases like W vs A are drawn as matches when the - // alignment was produced under --iupac. Pure ACGT pairs reduce - // to literal equality. - bool match = (op == '=') || - (op == 'M' && iupac_compatible(qc, tc)); - mline += match ? '|' : ' '; - } else if (op == 'I') { // query has extra base (gap in target) - qline += q_aln[qi++]; tline += '-'; mline += ' '; - } else if (op == 'D') { // target has extra base (gap in query) - qline += '-'; tline += t_aln[ti++]; mline += ' '; - } - } - } - - // Coordinate width is sized to fit the largest position printed. - int q_max = q_off + qi; - int t_max = t_off + ti; - int pos_w = (int)std::max(std::to_string(q_max).size(), - std::to_string(t_max).size()); - - auto pad_left = [](int v, int w) { - std::string s = std::to_string(v); - if ((int)s.size() < w) s.insert(0, w - s.size(), ' '); - return s; - }; - - // Header - os << "QUERY : " << qname << " (" << strand << " strand" - << ", " << qi << " bp aligned starting at " << q_off << ")\n" - << "TARGET : " << tname << " (" - << ti << " bp aligned starting at " << t_off << ")\n\n"; - - // Each printed row has: "QUERY " (7) + + " " (2) + alignment - const int label_w = 7 + pos_w + 2; - const std::string mid_pad(label_w, ' '); - - int n = (int)qline.size(); - int q_pos = q_off; - int t_pos = t_off; - for (int p = 0; p < n; p += width) { - int len = std::min(width, n - p); - int q_step = 0, t_step = 0; - for (int k = 0; k < len; ++k) { - if (qline[p + k] != '-') ++q_step; - if (tline[p + k] != '-') ++t_step; - } - os << "QUERY " << pad_left(q_pos + 1, pos_w) << " " - << qline.substr(p, len) << " " << (q_pos + q_step) << "\n"; - os << mid_pad << mline.substr(p, len) << "\n"; - os << "TARGET " << pad_left(t_pos + 1, pos_w) << " " - << tline.substr(p, len) << " " << (t_pos + t_step) << "\n\n"; - q_pos += q_step; - t_pos += t_step; - } -} - -static int run_view(int argc, char** argv) { - ViewOpts v = parse_view_opts(argc, argv); - - init_iupac_tables(); - - auto qs = read_fasta(v.query_path); - auto ts = read_fasta(v.target_path); - const FastaRec* Q = find_rec(qs, v.qname); - const FastaRec* T = find_rec(ts, v.tname); - if (!Q) { cerr << "[ERROR] query name not found: " << v.qname << "\n"; return 1; } - if (!T) { cerr << "[ERROR] target name not found: " << v.tname << "\n"; return 1; } - - std::ifstream pin(v.paf_path); - if (!pin) { cerr << "[ERROR] cannot open PAF: " << v.paf_path << "\n"; return 1; } - - string line; - int hits = 0; - while (std::getline(pin, line)) { - if (line.empty() || line[0] == '#') continue; - // split first 12 fields - vector f; - f.reserve(16); - size_t pos = 0; - while (pos <= line.size() && f.size() < 12) { - size_t end = line.find('\t', pos); - f.push_back(line.substr(pos, (end == string::npos) ? line.size() - pos : end - pos)); - if (end == string::npos) break; - pos = end + 1; - } - if (f.size() < 12) continue; - if (f[0] != v.qname || f[5] != v.tname) continue; - - char strand = f[4].empty() ? '+' : f[4][0]; - int qstart = std::atoi(f[2].c_str()); - int qend = std::atoi(f[3].c_str()); - int tstart = std::atoi(f[7].c_str()); - int tend = std::atoi(f[8].c_str()); - (void)qend; (void)tend; - - string cg = get_tag(line, "cg:Z:"); - if (cg.empty()) { - cerr << "[WARN] PAF row for " << v.qname << " vs " << v.tname - << " has no cg:Z tag; skipping\n"; - continue; - } - vector> cig; - if (!parse_cigar(cg, cig)) { - cerr << "[WARN] could not parse CIGAR; skipping\n"; - continue; - } - // Build oriented query for this strand - string q_oriented = (strand == '-') ? revcomp(Q->seq) : Q->seq; - const string& t_oriented = T->seq; - - // Slice the aligned region from oriented sequences. - string q_aln = q_oriented.substr(qstart, q_oriented.size() - qstart); - string t_aln = t_oriented.substr(tstart, t_oriented.size() - tstart); - - ++hits; - cout << "# alignment " << hits << " : " << v.qname << " (" - << strand << ") vs " << v.tname << "\n"; - print_pretty(cout, v.qname, q_aln, qstart, v.tname, t_aln, tstart, - strand, cig, v.width); - } - if (hits == 0) { - cerr << "[WARN] no PAF records found for " << v.qname << " vs " << v.tname << "\n"; - return 1; - } - return 0; -} - -// ---------- entry ------------------------------------------------------------- - -int main(int argc, char** argv) { - if (argc >= 2 && string(argv[1]) == "view") { - // Shift past subcommand for getopt - return run_view(argc - 1, argv + 1); - } - if (argc >= 2 && string(argv[1]) == "align") { - return run_align(argc - 1, argv + 1); - } - return run_align(argc, argv); -} diff --git a/tesorter2/WFA_best_per_query.py b/tesorter2/WFA_best_per_query.py deleted file mode 100755 index f0a51b6..0000000 --- a/tesorter2/WFA_best_per_query.py +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env python3 -"""Pick the single best target per query from a PAF (with cg:Z: CIGAR). - -For each PAF row we compute, from the CIGAR: - pid = matches / (matches + mismatches + gap_opens) # gap-compressed - qcov = M_bases / qlen (M_bases = bases not in I or D ops) - tcov = M_bases / tlen - pass = pid >= --min-pid AND qcov >= --min-qcov AND tcov >= --min-tcov - -Per query, we keep the alignment that maximizes pass*pid*qcov*tcov; -ties (e.g. all failing -> product 0) are broken by raw pid*qcov*tcov. - -Output TSV: qname pass pid qcov tcov best_tname - -Notes: -- Streams the PAF line by line; only one row per query is held in memory. -- Requires `cg:Z:` CIGAR on every row (run minimap2/WFA_TEsorter with --cigar). -- mismatches are derived as NM - I_bases - D_bases (NM = mismatches + indels). -""" - -import argparse -import re -import sys - - -CIGAR_RE = re.compile(r'(\d+)([MIDNSHP=X])') - - -def parse_cigar(cigar): - """Return (m_bases, i_bases, d_bases, gap_opens). M counts =/X too.""" - m = i = d = gap_opens = 0 - for n_str, op in CIGAR_RE.findall(cigar): - n = int(n_str) - if op in 'M=X': - m += n - elif op == 'I': - i += n - gap_opens += 1 - elif op == 'D': - d += n - gap_opens += 1 - return m, i, d, gap_opens - - -def row_metrics(fields): - """Compute (qname, tname, pid, qcov, tcov) for one PAF row, or None to skip.""" - qname = fields[0] - qlen = int(fields[1]) - tname = fields[5] - tlen = int(fields[6]) - paf_matches = int(fields[9]) - - cigar = None - nm = None - for tag in fields[12:]: - if tag.startswith('cg:Z:'): - cigar = tag[5:] - elif tag.startswith('NM:i:'): - nm = int(tag[5:]) - - if cigar is None or qlen <= 0 or tlen <= 0: - return None - - m_bases, i_bases, d_bases, gap_opens = parse_cigar(cigar) - - if nm is not None: - # NM = mismatches + indel_bases - mismatches = nm - i_bases - d_bases - matches = m_bases - mismatches - else: - # fall back to PAF column 10 (residue matches) - matches = paf_matches - mismatches = m_bases - matches - - if matches < 0 or mismatches < 0: - return None - - denom = matches + mismatches + gap_opens - pid = matches / denom if denom > 0 else 0.0 - qcov = m_bases / qlen - tcov = m_bases / tlen - - return qname, tname, pid, qcov, tcov - - -def main(): - ap = argparse.ArgumentParser( - description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - ap.add_argument('paf', nargs='?', default='-', - help='Input PAF (default: stdin). Use "-" for stdin.') - ap.add_argument('-o', '--output', default='-', - help='Output TSV (default: stdout).') - ap.add_argument('--min-pid', type=float, default=0.70, - help='Min gap-compressed identity to pass (default: 0.70).') - ap.add_argument('--min-qcov', type=float, default=0.70, - help='Min query coverage to pass (default: 0.70).') - ap.add_argument('--min-tcov', type=float, default=0.70, - help='Min target coverage to pass (default: 0.70).') - ap.add_argument('-v', '--verbose', action='store_true', - help='Report progress to stderr.') - args = ap.parse_args() - - fin = sys.stdin if args.paf == '-' else open(args.paf) - fout = sys.stdout if args.output == '-' else open(args.output, 'w') - - # qname -> (sort_key, passed, pid, qcov, tcov, tname) - best = {} - n_rows = n_skipped = 0 - - for line in fin: - if not line or line[0] == '#': - continue - fields = line.rstrip('\n').split('\t') - if len(fields) < 12: - n_skipped += 1 - continue - n_rows += 1 - out = row_metrics(fields) - if out is None: - n_skipped += 1 - continue - qname, tname, pid, qcov, tcov = out - passed = (pid >= args.min_pid and - qcov >= args.min_qcov and - tcov >= args.min_tcov) - # primary: pass beats fail; secondary: raw product - key = (1 if passed else 0, pid * qcov * tcov) - prev = best.get(qname) - if prev is None or key > prev[0]: - best[qname] = (key, passed, pid, qcov, tcov, tname) - if args.verbose and n_rows % 1_000_000 == 0: - print(f' ... {n_rows:,} rows, {len(best):,} unique queries', - file=sys.stderr) - - fout.write('qname\tpass\tpid\tqcov\ttcov\tbest_tname\n') - for qname, (_, passed, pid, qcov, tcov, tname) in best.items(): - fout.write(f'{qname}\t{"pass" if passed else "fail"}\t' - f'{pid:.4f}\t{qcov:.4f}\t{tcov:.4f}\t{tname}\n') - - if args.verbose: - n_pass = sum(1 for v in best.values() if v[1]) - print(f'rows: {n_rows:,} skipped: {n_skipped:,} ' - f'queries: {len(best):,} pass: {n_pass:,} ' - f'fail: {len(best) - n_pass:,}', file=sys.stderr) - - if fin is not sys.stdin: - fin.close() - if fout is not sys.stdout: - fout.close() - - -if __name__ == '__main__': - main() diff --git a/tesorter2/WFA_self_analysis.py b/tesorter2/WFA_self_analysis.py deleted file mode 100644 index 9d8ea27..0000000 --- a/tesorter2/WFA_self_analysis.py +++ /dev/null @@ -1,304 +0,0 @@ -#!/usr/bin/env python3 -"""Summarize a self-alignment PAF (with cg:Z: CIGAR). - -Designed for `WFA_TEsorter -q X.fa -t X.fa --cigar > X.paf` where the same -FASTA is both query and target (so every sequence appears with a self-hit -at qname == tname). Self-hits are excluded from all three outputs. - -Produces three files at .*: - - .best_per_query.tsv - One row per query: the single best non-self target. - Same columns as WFA_best_per_query.py: - qname pass pid qcov tcov best_tname - - .rbh.tsv - Reciprocal best-hit pairs: A's best non-self target is B AND B's - best non-self target is A. Pairs are emitted once with seqA.clusters.tsv - Single-linkage clusters from non-self alignments that pass all - three thresholds (--min-pid AND --min-qcov AND --min-tcov). - Edges are added with union-find; isolates become singleton clusters. - Representative = longest member (ties broken by name asc). Members - are listed by length desc then name asc, so the representative is - the first row of each cluster. Clusters ranked by size (desc), then - representative name. One row per member: - cluster_id representative member cluster_size - -Per-row metrics (from cg:Z: CIGAR, NM:i: when present): - pid = matches / (matches + mismatches + gap_opens) # gap-compressed - qcov = M_bases / qlen - tcov = M_bases / tlen - pass = pid >= --min-pid AND qcov >= --min-qcov AND tcov >= --min-tcov - -For "best" target per query the sort key is (passed, pid*qcov*tcov), so a -passing alignment always beats a failing one and ties break by the raw -product. RBH reports the reciprocity regardless of pass/fail and adds a -both_pass column so downstream filtering is one awk away. - -Streams the PAF; only one record per unique query is held in RAM. -Requires `cg:Z:` on every row (run WFA_TEsorter / minimap2 with --cigar). -""" - -import argparse -import re -import sys - - -CIGAR_RE = re.compile(r'(\d+)([MIDNSHP=X])') - - -def parse_cigar(cigar): - """Return (m_bases, i_bases, d_bases, gap_opens). M counts =/X too.""" - m = i = d = gap_opens = 0 - for n_str, op in CIGAR_RE.findall(cigar): - n = int(n_str) - if op in 'M=X': - m += n - elif op == 'I': - i += n - gap_opens += 1 - elif op == 'D': - d += n - gap_opens += 1 - return m, i, d, gap_opens - - -def row_metrics(fields): - """Compute metrics for one PAF row, or None to skip. - - Returns (qname, qlen, tname, tlen, pid, qcov, tcov). - """ - qname = fields[0] - qlen = int(fields[1]) - tname = fields[5] - tlen = int(fields[6]) - paf_matches = int(fields[9]) - - cigar = None - nm = None - for tag in fields[12:]: - if tag.startswith('cg:Z:'): - cigar = tag[5:] - elif tag.startswith('NM:i:'): - nm = int(tag[5:]) - - if cigar is None or qlen <= 0 or tlen <= 0: - return None - - m_bases, i_bases, d_bases, gap_opens = parse_cigar(cigar) - - if nm is not None: - # NM = mismatches + indel_bases - mismatches = nm - i_bases - d_bases - matches = m_bases - mismatches - else: - # fall back to PAF column 10 (residue matches) - matches = paf_matches - mismatches = m_bases - matches - - if matches < 0 or mismatches < 0: - return None - - denom = matches + mismatches + gap_opens - pid = matches / denom if denom > 0 else 0.0 - qcov = m_bases / qlen - tcov = m_bases / tlen - - return qname, qlen, tname, tlen, pid, qcov, tcov - - -class UnionFind: - """Union-find with path compression and union-by-size.""" - - def __init__(self): - self.parent = {} - self.size = {} - - def add(self, x): - if x not in self.parent: - self.parent[x] = x - self.size[x] = 1 - - def find(self, x): - self.add(x) - root = x - while self.parent[root] != root: - root = self.parent[root] - # iterative path compression - while self.parent[x] != root: - self.parent[x], x = root, self.parent[x] - return root - - def union(self, a, b): - ra, rb = self.find(a), self.find(b) - if ra == rb: - return - if self.size[ra] < self.size[rb]: - ra, rb = rb, ra - self.parent[rb] = ra - self.size[ra] += self.size[rb] - - -def main(): - ap = argparse.ArgumentParser( - description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - ap.add_argument('paf', nargs='?', default='-', - help='Input PAF (default: stdin). Use "-" for stdin.') - ap.add_argument('-o', '--output', required=True, - help='Output prefix; writes .best_per_query.tsv, ' - '.rbh.tsv, .clusters.tsv') - ap.add_argument('--min-pid', type=float, default=0.70, - help='Min gap-compressed identity (default: 0.70).') - ap.add_argument('--min-qcov', type=float, default=0.70, - help='Min query coverage (default: 0.70).') - ap.add_argument('--min-tcov', type=float, default=0.70, - help='Min target coverage (default: 0.70).') - ap.add_argument('-v', '--verbose', action='store_true', - help='Report progress and summary stats to stderr.') - args = ap.parse_args() - - fin = sys.stdin if args.paf == '-' else open(args.paf) - - # qname -> (sort_key, passed, pid, qcov, tcov, tname) (non-self only) - best = {} - seq_len = {} - uf = UnionFind() - n_rows = n_skipped = n_self = n_edges_passing = 0 - - for line in fin: - if not line or line[0] == '#': - continue - fields = line.rstrip('\n').split('\t') - if len(fields) < 12: - n_skipped += 1 - continue - n_rows += 1 - out = row_metrics(fields) - if out is None: - n_skipped += 1 - continue - qname, qlen, tname, tlen, pid, qcov, tcov = out - - # register every sequence we see (incl. via self-hit) so isolates - # still appear as singleton clusters in the output - if qname not in seq_len: - seq_len[qname] = qlen - uf.add(qname) - if tname not in seq_len: - seq_len[tname] = tlen - uf.add(tname) - - if qname == tname: - n_self += 1 - continue - - passed = (pid >= args.min_pid and - qcov >= args.min_qcov and - tcov >= args.min_tcov) - key = (1 if passed else 0, pid * qcov * tcov) - prev = best.get(qname) - if prev is None or key > prev[0]: - best[qname] = (key, passed, pid, qcov, tcov, tname) - - if passed: - uf.union(qname, tname) - n_edges_passing += 1 - - if args.verbose and n_rows % 1_000_000 == 0: - print(f' ... {n_rows:,} rows {len(seq_len):,} seqs ' - f'{n_edges_passing:,} passing pairs', - file=sys.stderr) - - if fin is not sys.stdin: - fin.close() - - # ---------- single-best per query (non-self) ---------- - best_path = f'{args.output}.best_per_query.tsv' - with open(best_path, 'w') as fb: - fb.write('qname\tpass\tpid\tqcov\ttcov\tbest_tname\n') - for qname, (_, passed, pid, qcov, tcov, tname) in best.items(): - fb.write(f'{qname}\t{"pass" if passed else "fail"}\t' - f'{pid:.4f}\t{qcov:.4f}\t{tcov:.4f}\t{tname}\n') - - # ---------- reciprocal best hits ---------- - rbh = [] - seen_pairs = set() - for q, qrec in best.items(): - t = qrec[5] - trec = best.get(t) - if trec is None or trec[5] != q: - continue - a, b = (q, t) if q < t else (t, q) - if (a, b) in seen_pairs: - continue - seen_pairs.add((a, b)) - ab = best[a] # a -> b: (key, passed, pid, qcov, tcov, _) - ba = best[b] # b -> a - both_pass = ab[1] and ba[1] - rbh.append((a, b, - ab[2], ab[3], ab[4], - ba[2], ba[3], ba[4], - both_pass)) - rbh.sort() - - rbh_path = f'{args.output}.rbh.tsv' - with open(rbh_path, 'w') as fr: - fr.write('seqA\tseqB\tpid_AB\tqcov_AB\ttcov_AB\t' - 'pid_BA\tqcov_BA\ttcov_BA\tboth_pass\n') - for a, b, p_ab, q_ab, t_ab, p_ba, q_ba, t_ba, bp in rbh: - fr.write(f'{a}\t{b}\t' - f'{p_ab:.4f}\t{q_ab:.4f}\t{t_ab:.4f}\t' - f'{p_ba:.4f}\t{q_ba:.4f}\t{t_ba:.4f}\t' - f'{"yes" if bp else "no"}\n') - - # ---------- clusters (single-linkage on passing non-self edges) ---------- - members = {} - for s in seq_len: - members.setdefault(uf.find(s), []).append(s) - - clusters = [] # list of (size, representative, sorted_members) - for r, mems in members.items(): - # sort by length desc, then name asc; representative = first - mems_sorted = sorted(mems, key=lambda s: (-seq_len.get(s, 0), s)) - clusters.append((len(mems), mems_sorted[0], mems_sorted)) - # rank: largest first, then by representative name - clusters.sort(key=lambda c: (-c[0], c[1])) - - cl_path = f'{args.output}.clusters.tsv' - with open(cl_path, 'w') as fc: - fc.write('cluster_id\trepresentative\tmember\tcluster_size\n') - for idx, (size, rep, mems) in enumerate(clusters, start=1): - cid = f'C{idx:06d}' - for m in mems: - fc.write(f'{cid}\t{rep}\t{m}\t{size}\n') - - if args.verbose: - n_pass_q = sum(1 for v in best.values() if v[1]) - n_singletons = sum(1 for c in clusters if c[0] == 1) - n_nontriv = len(clusters) - n_singletons - n_rbh_pass = sum(1 for r in rbh if r[8]) - print(f'rows: {n_rows:,} skipped: {n_skipped:,} ' - f'self-hits: {n_self:,}', file=sys.stderr) - print(f'sequences seen: {len(seq_len):,}', file=sys.stderr) - print(f'queries with non-self hit: {len(best):,} ' - f'(pass: {n_pass_q:,}, fail: {len(best) - n_pass_q:,})', - file=sys.stderr) - print(f'rbh pairs: {len(rbh):,} (both-pass: {n_rbh_pass:,})', - file=sys.stderr) - print(f'clusters: {len(clusters):,} ' - f'(non-singleton: {n_nontriv:,}, singletons: {n_singletons:,})', - file=sys.stderr) - print(f'wrote {best_path}', file=sys.stderr) - print(f'wrote {rbh_path}', file=sys.stderr) - print(f'wrote {cl_path}', file=sys.stderr) - - -if __name__ == '__main__': - main() diff --git a/tesorter2/classify_final.py b/tesorter2/classify_final.py deleted file mode 100755 index a12da6a..0000000 --- a/tesorter2/classify_final.py +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env python3 -"""Classify minimap2 PAF queries at mutual-coverage + identity thresholds. - -Auto-adapts to PAF flavor: - * with -c : has cg:Z, de:f, NM:i tags + exact col10/col11. Uses de:f. - * without -c: only dv:f (chain-estimated divergence) + approximate - col10 (= k * num_seeds) and col11 (= span). Uses dv:f. - * legacy : neither tag — falls back to matches/block_len (BLAST-style), - which over-penalises structural indels (a single 3 kb - deletion drops identity from ~99% to ~63%). - -Per (query,target) pair: - - drops alignments whose query span is fully encompassed by a strictly - longer one (avoids double-counting overlapped regions) - - identity = qspan-weighted mean of (1 - div) over surviving rows - - qcov / tcov = union-of-intervals length divided by qlen / tlen - -Output (TSV): - query qlen best_target tlen qcov tcov identity ident_src pass -ident_src in {de, dv, mb}. -""" -import argparse -import sys -from collections import defaultdict - - -def merge_intervals(ivs): - if not ivs: - return [] - ivs = sorted(ivs) - out = [list(ivs[0])] - for a, b in ivs[1:]: - if a <= out[-1][1]: - out[-1][1] = max(out[-1][1], b) - else: - out.append([a, b]) - return out - - -def total_len(ivs): - return sum(b - a for a, b in ivs) - - -def parse_row(line): - """Return dict for an aligned row, ('NOHIT', qname, qlen) for paf-no-hit - rows (target == '*'), or None for malformed rows.""" - f = line.rstrip("\n").split("\t") - if len(f) < 12: - return None - qname = f[0] - try: - qlen = int(f[1]) - except ValueError: - return None - if f[5] == "*": - return ("NOHIT", qname, qlen) - try: - rec = { - "q": qname, - "qlen": qlen, - "qs": int(f[2]), - "qe": int(f[3]), - "t": f[5], - "tlen": int(f[6]), - "ts": int(f[7]), - "te": int(f[8]), - "matches": int(f[9]), - "blocks": int(f[10]), - } - except ValueError: - return None - de = dv = None - for tag in f[12:]: - if tag.startswith("de:f:"): - try: - de = float(tag[5:]) - except ValueError: - pass - elif tag.startswith("dv:f:"): - try: - dv = float(tag[5:]) - except ValueError: - pass - rec["de"] = de - rec["dv"] = dv - return rec - - -def aggregate_pair(rows, qlen, tlen): - """Return (qcov, tcov, identity, ident_src) for one (q,t) pair.""" - # Coverage from union of spans - qcov = (total_len(merge_intervals([(r["qs"], r["qe"]) for r in rows])) - / qlen) if qlen else 0.0 - tcov = (total_len(merge_intervals([(r["ts"], r["te"]) for r in rows])) - / tlen) if tlen else 0.0 - - # Encompass-drop on query: discard alignments whose qspan is fully - # contained in a strictly longer row's qspan - survivors = [] - for r in rows: - rspan = r["qe"] - r["qs"] - encompassed = False - for o in rows: - if o is r: - continue - ospan = o["qe"] - o["qs"] - if ospan > rspan and o["qs"] <= r["qs"] and r["qe"] <= o["qe"]: - encompassed = True - break - if not encompassed: - survivors.append(r) - if not survivors: - survivors = rows # degenerate, but keep something - - # Identity source priority: de:f > dv:f > matches/blocks - has_de = any(r["de"] is not None for r in survivors) - has_dv = any(r["dv"] is not None for r in survivors) - - if has_de: - wsum = 0.0; wtot = 0 - for r in survivors: - if r["de"] is None: - continue - w = r["qe"] - r["qs"] - wsum += r["de"] * w - wtot += w - ident = 1.0 - (wsum / wtot if wtot else 1.0) - src = "de" - elif has_dv: - wsum = 0.0; wtot = 0 - for r in survivors: - if r["dv"] is None: - continue - w = r["qe"] - r["qs"] - wsum += r["dv"] * w - wtot += w - ident = 1.0 - (wsum / wtot if wtot else 1.0) - src = "dv" - else: - m = sum(r["matches"] for r in rows) - b = sum(r["blocks"] for r in rows) - ident = (m / b) if b else 0.0 - src = "mb" - - return qcov, tcov, ident, src - - -def main(): - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("paf", help="input PAF (use '-' for stdin)") - ap.add_argument("-o", "--out", default="-", - help="output TSV (default stdout)") - ap.add_argument("--qcov", type=float, default=0.70) - ap.add_argument("--tcov", type=float, default=0.70) - ap.add_argument("--ident", type=float, default=0.70) - ap.add_argument("--queries-fa", default=None, - help="optional FASTA so unmapped queries also appear") - args = ap.parse_args() - - pairs = defaultdict(list) - pair_lens = {} - qlen_seen = {} - fmt_de = fmt_dv = fmt_mb = 0 - - fh = sys.stdin if args.paf == "-" else open(args.paf) - for line in fh: - rec = parse_row(line) - if rec is None: - continue - if isinstance(rec, tuple) and rec[0] == "NOHIT": - qlen_seen[rec[1]] = rec[2] - continue - qlen_seen[rec["q"]] = rec["qlen"] - key = (rec["q"], rec["t"]) - pairs[key].append(rec) - pair_lens[key] = (rec["qlen"], rec["tlen"]) - if rec["de"] is not None: - fmt_de += 1 - elif rec["dv"] is not None: - fmt_dv += 1 - else: - fmt_mb += 1 - - sys.stderr.write( - f"[classify_final] parsed pairs: {len(pairs)}; " - f"rows w/ de:f={fmt_de}, dv:f={fmt_dv}, neither={fmt_mb}\n" - ) - - # Best (q,t) per query - best = {} - for (q, t), rows in pairs.items(): - qlen, tlen = pair_lens[(q, t)] - qcov, tcov, ident, src = aggregate_pair(rows, qlen, tlen) - passed = qcov >= args.qcov and tcov >= args.tcov and ident >= args.ident - score = (1 if passed else 0, ident * min(qcov, tcov)) - cur = best.get(q) - if cur is None or score > cur[0]: - best[q] = (score, qlen, t, tlen, qcov, tcov, ident, src, passed) - - # Optional FASTA enumeration to fill in unmapped queries - if args.queries_fa: - with open(args.queries_fa) as fa: - cur_id = None; cur_len = 0 - for ln in fa: - if ln.startswith(">"): - if cur_id is not None and cur_id not in best: - best[cur_id] = ((0, 0), cur_len, "*", 0, - 0.0, 0.0, 0.0, "-", False) - cur_id = ln[1:].split()[0] - cur_len = 0 - else: - cur_len += len(ln.strip()) - if cur_id is not None and cur_id not in best: - best[cur_id] = ((0, 0), cur_len, "*", 0, - 0.0, 0.0, 0.0, "-", False) - else: - # at least include queries seen in PAF (mapped + paf-no-hit) - for q, qlen in qlen_seen.items(): - if q not in best: - best[q] = ((0, 0), qlen, "*", 0, 0.0, 0.0, 0.0, "-", False) - - out = sys.stdout if args.out == "-" else open(args.out, "w") - out.write("query\tqlen\tbest_target\ttlen\tqcov\ttcov\t" - "identity\tident_src\tpass\n") - n_pass = 0 - for q in sorted(best): - (_, qlen, t, tlen, qcov, tcov, ident, src, passed) = best[q] - if passed: - n_pass += 1 - out.write(f"{q}\t{qlen}\t{t}\t{tlen}\t{qcov:.4f}\t{tcov:.4f}" - f"\t{ident:.4f}\t{src}\t{int(passed)}\n") - - sys.stderr.write( - f"[classify_final] queries: {len(best)} " - f"classified: {n_pass}\n" - ) - - -if __name__ == "__main__": - main() diff --git a/tesorter2/eval.py b/tesorter2/eval.py deleted file mode 100644 index 3e029bc..0000000 --- a/tesorter2/eval.py +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env python3 -"""Compare a minimap2-derived classified TSV to a WFA goldstandard TSV. - -Both files share the schema: - qname pass pid qcov tcov best_tname - -Universe = WFA qnames. Queries missing from the prediction file are treated as -predicted_fail (matches downstream behavior of classify_ltr_paf_fast.py, which -drops queries with no PAF alignment carrying dv:f / de:f). - -Pair agreement (TP only): best_tname stems compared after stripping any -"#..." suffix. Records counts of agree / disagree. - -Output: JSON to --out (default stdout), single-line summary to stderr. -""" - -import argparse -import json -import math -import sys - - -def strip_class(tname): - if tname is None: - return None - i = tname.find("#") - return tname if i < 0 else tname[:i] - - -def load_tsv(path, has_header): - """Return {qname: (pass_bool, best_tname)}.""" - out = {} - with open(path) as fh: - for lineno, line in enumerate(fh, 1): - if has_header and lineno == 1: - continue - line = line.rstrip("\n") - if not line: - continue - f = line.split("\t") - if len(f) < 6: - continue - qname = f[0] - passing = (f[1].lower() == "pass") - tname = f[5] - out[qname] = (passing, tname) - return out - - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--gold", required=True, help="WFA goldstandard TSV (with header)") - ap.add_argument("--pred", required=True, help="prediction TSV (no header)") - ap.add_argument("--gold-has-header", action="store_true", default=True) - ap.add_argument("--pred-has-header", action="store_true", default=False) - ap.add_argument("--out", default="-", help="output JSON path (default stdout)") - ap.add_argument("--label", default="", help="optional label echoed into JSON") - args = ap.parse_args() - - gold = load_tsv(args.gold, has_header=args.gold_has_header) - pred = load_tsv(args.pred, has_header=args.pred_has_header) - - universe = set(gold.keys()) - extras = set(pred.keys()) - universe - - TP = TN = FP = FN = 0 - tp_agree = tp_disagree = 0 - missing_from_pred = 0 - - for q in universe: - g_pass, g_t = gold[q] - if q in pred: - p_pass, p_t = pred[q] - else: - p_pass, p_t = False, None - missing_from_pred += 1 - - if g_pass and p_pass: - TP += 1 - if strip_class(g_t) == strip_class(p_t): - tp_agree += 1 - else: - tp_disagree += 1 - elif (not g_pass) and (not p_pass): - TN += 1 - elif (not g_pass) and p_pass: - FP += 1 - else: - FN += 1 - - total = TP + TN + FP + FN - accuracy = (TP + TN) / total if total else 0.0 - precision = TP / (TP + FP) if (TP + FP) else 0.0 - recall = TP / (TP + FN) if (TP + FN) else 0.0 - f1 = (2 * precision * recall / (precision + recall)) if (precision + recall) else 0.0 - - denom = math.sqrt((TP + FP) * (TP + FN) * (TN + FP) * (TN + FN)) - mcc = ((TP * TN) - (FP * FN)) / denom if denom else 0.0 - - out = { - "label": args.label, - "total": total, - "TP": TP, "TN": TN, "FP": FP, "FN": FN, - "accuracy": round(accuracy, 6), - "precision": round(precision, 6), - "recall": round(recall, 6), - "f1": round(f1, 6), - "mcc": round(mcc, 6), - "tp_agree": tp_agree, - "tp_disagree": tp_disagree, - "tp_agree_frac": round(tp_agree / TP, 6) if TP else 0.0, - "missing_from_pred": missing_from_pred, - "extras": len(extras), - } - - if args.out == "-": - json.dump(out, sys.stdout, indent=2) - sys.stdout.write("\n") - else: - with open(args.out, "w") as fh: - json.dump(out, fh, indent=2) - fh.write("\n") - - sys.stderr.write( - "[eval] %s F1=%.4f acc=%.4f P=%.4f R=%.4f MCC=%.4f " - "TP=%d TN=%d FP=%d FN=%d agree=%d/%d miss=%d extra=%d\n" - % (args.label or args.pred, f1, accuracy, precision, recall, mcc, - TP, TN, FP, FN, tp_agree, TP, missing_from_pred, len(extras)) - ) - - -if __name__ == "__main__": - main()