API reference#
Library entry point#
Library-facing API.
A stable, import-friendly surface for embedding arda in other Python tools.
The heavy lifting lands in Phase 2 (arda.annotate); this module keeps the
public signature stable.
- arda.adapter.annotate_sequences(sequences, seqtype='nt', organism='human', map_d=True)[source]#
Annotate FR/CDR regions for a batch of sequences.
- Parameters:
sequences (Iterable[str] | Iterable[tuple[str, str]]) – Either raw sequence strings or
(id, sequence)pairs.seqtype (Literal['nt', 'aa']) –
"nt"for nucleotide input,"aa"for amino acid.organism (str) – One of the supported organisms (human, mouse, rat, rabbit, rhesus_monkey).
map_d (bool) – Map D segments (
d_call/d2_call/np*) for VDJ-locus hits;Falseskips D mapping. Applies to nucleotide input only.
- Returns:
A list of AIRR-style annotation record dicts (one per input sequence).
Runtime annotation#
Runtime annotation: map input sequences to the reference and transfer markup.
Pipeline: read input (FASTA/FASTQ) -> MMseqs2 search against the curated scaffold DB -> best hit per query -> project reference region markup onto the query (C++ hot path) -> AIRR TSV.
- arda.annotate.mapper.annotate_file(input, output, organism='human', seqtype='nt', *, threads=0, sensitivity=None, strand='both', chunk_size=50000, map_d=True, d_max_evalue=None, shm='framework', complete_junction_nt=0)[source]#
Annotate a FASTA/FASTQ file and stream an AIRR TSV.
The input is processed in bounded chunks with a background reader thread that prefetches the next chunk while the current one is annotated (mmseqs releases the GIL during its subprocess), so memory stays flat for arbitrarily large FASTQ and read parsing overlaps compute. The reference + target DB are loaded once and reused across all chunks.
- Parameters:
input (str | Path)
output (str | Path)
organism (str)
seqtype (str)
threads (int)
sensitivity (float | None)
strand (str)
chunk_size (int)
map_d (bool)
d_max_evalue (float | None)
shm (str)
complete_junction_nt (int)
- Return type:
Path
- arda.annotate.mapper.annotate_records(records, organism='human', seqtype='nt', *, threads=0, sensitivity=None, strand='both', map_d=True, d_max_evalue=None, shm='framework', complete_junction_nt=0)[source]#
Annotate in-memory
(id, sequence)records; return AIRR record dicts.- Parameters:
strand (str) –
"both"(default, nt only) searches both strands and re-orients reverse-complement hits;"forward"searches the plus strand only. Ignored for protein input.map_d (bool) –
True(default) maps D segments into the junction of VDJ-locus hits (d_call/d2_call/np*);Falseskips D mapping (nt input only — D mapping never runs for protein input).d_max_evalue (float | None) – E-value gate on the D call(s);
Nonekeeps the shipped 0.2. Lower is stricter (seearda.annotate.transfer._map_d()).shm (str) – SHM scoping —
"framework"(default),"both"or"off"; seearda.shm.records (list[tuple[str, str]])
organism (str)
seqtype (str)
threads (int)
sensitivity (float | None)
complete_junction_nt (int)
- Return type:
list[dict]
- arda.annotate.mapper.build_index(organism='all', *, force=False)[source]#
(Re)build the precompiled mmseqs DBs shipped under
database/.Writes
database/vdj/<org>/mmseqs/<seqtype>/db*+ aVERSIONmarker so the runtime can use them out of the box (and detect a mmseqs-version mismatch). Skips up-to-date DBs unlessforce.- Parameters:
organism (str)
force (bool)
- Return type:
None
Project reference region markup onto a query via the C++ hot path.
Takes a parsed mmseqs hit plus the reference entry for the matched scaffold and returns an AIRR-style record dict for the query.
Junction handling follows AIRR strictly: junction spans the conserved Cys104
through the [FW]118 that opens FR4; junction_aa starts with C and ends with
F/W for a canonical rearrangement. A junction is reported even when not
canonical (out-of-frame, missing the conserved residues). For an out-of-frame
junction (V and J in different frames) the amino-acid translation inserts 1-2 N
bases after the V germline end to restore the J frame; the codon that then
contains an inserted N is rendered as _. The V/J split inside the junction is
located from the transferred v_sequence_end / j_sequence_start.
- arda.annotate.transfer.transfer_hit(query_id, query_seq, hit, ref, seqtype='nt', rev_comp=False, d_germlines=None, submitted_seq=None, anchors=None, d_max_evalue=None, shm='framework', complete_junction_nt=0)[source]#
Build an AIRR record by projecting
refregion coords onto the query.query_seqis the coding-strand sequence all markup/coords/CIGARs are computed on.submitted_seqis the read AS SUBMITTED, stored verbatim in the AIRRsequencefield; for a reverse-strand hit it is the reverse complement ofquery_seqandrev_compis set, per AIRR (“if rev_comp is True, all output data are based on the reverse complement ofsequence”). Defaults toquery_seq(forward reads, where the two are identical).d_max_evalueoverrides the D-call E-value gate; see_map_d().shmscopes the SHM fields —framework(default),bothoroff; seearda.shm.complete_junction_nt> 0 lets a read that reached Cys104 but stopped before [FW]118 have its junction finished from the called J’s germline, up to that many nt; the count lands injunction_completed_nt. 0 (the default) emits observed junctions only. See_germline_completed_junction().- Parameters:
query_id (str)
query_seq (str)
hit (dict)
ref (RefEntry)
seqtype (str)
rev_comp (bool)
d_germlines (list[tuple[str, str]] | None)
submitted_seq (str | None)
anchors (dict | None)
d_max_evalue (float | None)
shm (str)
complete_junction_nt (int)
- Return type:
dict
Load the curated reference markup for runtime projection.
For nucleotide annotation we use markup.tsv + alleles.fasta; for amino
acid annotation markup.aa.tsv + alleles.aa.fasta. Both expose region
*_start/*_end columns in the same coordinate space (nt or aa), so the
projection code is identical.
- class arda.annotate.reference.RefEntry(locus, v_call, j_call, starts, ends, v_sequence_end=0, j_sequence_start=0, c_call='', vj_end=0)[source]#
Bases:
objectPer-scaffold reference markup: region coords (in target space) + calls.
- Parameters:
locus (str)
v_call (str)
j_call (str)
starts (list[int])
ends (list[int])
v_sequence_end (int)
j_sequence_start (int)
c_call (str)
vj_end (int)
- locus: str#
- v_call: str#
- j_call: str#
- starts: list[int]#
- ends: list[int]#
- v_sequence_end: int#
- j_sequence_start: int#
- c_call: str#
- vj_end: int#
- property is_jc: bool#
a J followed by the CH1 exon, with no V.
- Type:
A constant-region scaffold
- class arda.annotate.reference.Reference(organism, seqtype, target_fasta, entries, d_germlines, anchors=<factory>, _jc_combos=None)[source]#
Bases:
objectIn-memory reference for one (organism, seqtype).
- Parameters:
organism (str)
seqtype (str)
target_fasta (Path)
entries (dict[str, RefEntry])
d_germlines (dict[str, list[tuple[str, str]]])
anchors (dict)
_jc_combos (dict[tuple[str, str], str] | None)
- organism: str#
- seqtype: str#
- target_fasta: Path#
- d_germlines: dict[str, list[tuple[str, str]]]#
- anchors: dict#
- jc_combinations()[source]#
(j_allele, c_allele) -> J+C scaffold id, the C-side twin ofcombinations.tsv.The segment reference carries the J and the constant region as SEPARATE targets, because a constant sequence shared across a locus’ J+C scaffolds is a cross-product and copying it through cost 76.4 % of the segment search’s alignments. So a J→C read now names its home the same way a V→J read does — by the pair it hit, resolved through this table.
Derived from the loaded markup rather than from a file: the J+C scaffolds are already there, and a second generated artifact is one more thing that can go stale against it.
⛔ Keyed per ALLELE, never by the comma-joined group string. A j_call is a group of alleles arda could not tell apart, and the two sides of this lookup group them by different rules: a J+C scaffold’s j_call collapses alleles with an identical J sequence (refbuild.constant), while a J| segment target inherits the V×J scaffold’s j_call, which collapses alleles with an identical assembled V + pad + J and folds in reading frame (refbuild.combinations). They genuinely disagree: human IGL yields
J|IGLJ2*01,IGLJ3*01andJ|IGLJ2A*01on one side againstIGLJ2*01,IGLJ2A*01,IGLJ3*01on the other, so a group-string key leaves 24 J+C scaffolds unreachable from any J hit — and an unreachable scaffold means the J→C contest never fires for those reads, which is exactly the invented-junction/destroyed-c_call bug the contest exists to prevent. Splitting both sides removes the need for the two collapses to agree at all.⛔ Segment targets are excluded by their `|` prefix. load_reference loads segments.markup.tsv into this same key space after markup.tsv, so a pre-2.8.0 file – whose 345 rows are J+C scaffolds copied verbatim as
JC|<sid>with the same j_call and c_call – would collide with each base scaffold and, being later, win. Every value would become an id the full target DB does not contain, _align_implied would drop them all as unknown targets, and the contest would be silently off for the whole run: no error, plausible output. Reachable on the first run after any upgrade, because the self-heal regenerates the files without reloading entries.- Return type:
dict[tuple[str, str], str]
- segment_j_call(name)[source]#
J allele for a
JC|segment target, which is named by SCAFFOLD id, not by allele.Feeding the raw target name into a (V, J) combination lookup silently fails for every J->C read; measured, that collapsed the two-pass fast path from 85.3 % to 0.1 %.
Retained for references built before the constant region became its own
C|target — a build and a mapper of different vintages must not silently mis-resolve a J call.- Parameters:
name (str)
- Return type:
str
- arda.annotate.reference.load_reference(organism, seqtype='nt')[source]#
Load reference markup + target FASTA path for an organism.
- Parameters:
organism (str)
seqtype (str)
- Return type:
Sequence I/O: streaming FASTA/FASTQ readers and chunking.
Native parsing (no BioPython). Transparently handles gzip by .gz extension.
- arda.annotate.io.open_text(path)[source]#
Open a (possibly gzipped) text file for reading.
- Parameters:
path (str | Path)
- arda.annotate.io.read_sequences(path, *, with_qual=False)[source]#
Yield
(id, sequence)from a FASTA or FASTQ file (auto-detected).with_qual=Trueyields(id, sequence, qual)instead – the FASTQ Phred string, orNonefor FASTA (which has no quality). The default is unchanged and pays nothing: the quality line is consumed either way, only kept when asked (needed solely by the paired overlap-merge,arda.rnaseq.map.merge_pair()).- Parameters:
path (str | Path)
with_qual (bool)
- Return type:
Iterator[tuple]
- arda.annotate.io.detect_format(path)[source]#
Return
"fasta"or"fastq"by peeking at the first non-empty char.- Parameters:
path (str | Path)
- Return type:
str
- arda.annotate.io.write_fasta(records, path)[source]#
Write
(id, sequence)records to a FASTA file.- Parameters:
records (Iterator[tuple[str, str]])
path (str | Path)
- Return type:
Path
- arda.annotate.io.chunked(it, size)[source]#
Yield lists of up to
sizeitems from an iterator.- Parameters:
it (Iterator)
size (int)
- Return type:
Iterator[list]
Per-segment AIRR CIGAR strings from the mmseqs scaffold alignment.
arda aligns a query to a V + N*pad + J [+ C] scaffold, not to each germline segment separately.
AIRR wants a CIGAR per segment (v_cigar/j_cigar/c_cigar) whose reference is that
segment’s germline; since the scaffold’s V part IS the V germline (target position == germline
position), its J part IS the J allele, and its C part the CH1 exon, each segment’s CIGAR is the
sub-walk of the one query->scaffold alignment whose target falls in that segment’s range.
- CIGAR operators follow the AIRR spec (SAM subset):
S– query positions before the alignment starts (query 5’ offset). Required, precedes N.N– reference positions before the alignment starts (germline 5’ offset). Required.M/I(gap in reference) /D(gap in query) – the aligned body.trailing
S(query 3’ remainder) is emitted; trailingN(germline 3’ remainder) is optional per the spec and omitted (arda does not always know the full germline length, e.g. the C-region CH1 exon is longer than the shipped stub).
segment_cigars builds all three in a SINGLE pass over the aligned strings.
SOMATIC HYPERMUTATION (v_mutations / j_mutations). The same walk answers “which germline
positions does this read differ at”, which is the input an SHM or lineage-tree tool needs, so it is
emitted from here rather than re-derived downstream: G45A,C112T — germline base, 1-based
position in that segment’s OWN germline, read base.
The information was already recoverable — sequence_alignment and germline_alignment carry
every column, and on a real bulk IG library the germline they report matches the shipped allele on
28,365 of 28,365 mapped reads (66,526 V mismatches, zero disagreements). What it was not, was
usable: recovering it needs the scaffold geometry (mmseqs2_tstart, mmseqs2_t_vend,
mmseqs2_t_jstart, mmseqs2_t_vjend) and the knowledge that arda aligns to a V + pad + J
scaffold rather than to a germline. A consumer that does the obvious thing instead — diff the two
AIRR alignment strings — gets 100,091 mismatches on that library of which 20,140 (20.1 %) are
N-pad or constant-region columns, i.e. it attributes junction positions to a germline.
⛔ Which is the reason the scoping here is STRUCTURAL and not a filter. A mutation inside the
V..J interior is not attributable to any germline: V(D)J recombination chews the segment ends back
and adds non-templated N/P bases, so the V-end / NDN / J-start partition of a junction is often not
identifiable from the sequence at all. The mutation lists are built only for seg_key V and J —
the pad is not a segment, so a pad column has no germline coordinate to be recorded under and
cannot enter the list by any code path. Nothing downstream needs to remember to exclude it.
Substitutions only. Indels are in the CIGAR (I/D) and stay there: an SHM indel is one event
of unbounded length, not a per-position call, and its representation would have to be re-interpreted
by every consumer. The germline coordinates on the far side of an indel are still correct, because
the walk tracks the target position across the gap columns.
Correcting cigars for CONTIGS (Stage 3). A contig is just a long query, so BOTH ways to get its
cigars end in segment_cigars and produce the same record (see arda.annotate.contig):
RE-ANNOTATE the assembled contig through
mapper.annotate_records– one mmseqs alignment, thensegment_cigars. No cigar arithmetic;check_cigarvalidates it.MERGE the reads’ existing alignments column-by-column into the contig’s (C++
_markup.merge_alignment), skipping the alignment pass.
Both are built and proven byte-for-byte equal (tests/unit/test_contig_merge.py on 29 real
GenBank contigs). MEASURED (arda-benchmark scripts/bench_contig_cigars.py): at scRNA-seq scale
(~10^5 contigs/sample) merge is ~9x faster – the whole gap is mmseqs; the C++ stitch is ~3 % of
merge’s wall and barely grows with read depth. Prefer merge when the assembly layout is available
(the reads carry their scaffold + offset); re-annotate is the fallback when it is not.
- arda.annotate.cigar.parse_cigar(cigar)[source]#
"57S291M1054S"->[(57,"S"), (291,"M"), (1054,"S")]. Inverse ofbuild_cigar().- Parameters:
cigar (str)
- Return type:
list[tuple[int, str]]
- arda.annotate.cigar.cigar_query_length(cigar)[source]#
Query (read/contig) bases the CIGAR spans – M/I/S/=/X; D and N are reference-side.
- Parameters:
cigar (str)
- Return type:
int
- arda.annotate.cigar.cigar_reference_length(cigar)[source]#
Reference (germline) bases the CIGAR spans – M/D/N/=/X; I and S are query-side.
- Parameters:
cigar (str)
- Return type:
int
- arda.annotate.cigar.check_cigar(cigar, query_len)[source]#
A CIGAR is consistent with a query of
query_leniff its query-side ops sum to it.This is the invariant a corrected/re-annotated sequence (a read OR an assembled contig – a contig is just a long query) must satisfy:
v_cigar/j_cigar/c_cigareach lay over the WHOLE sequence, soft-clipping the parts outside their own segment. Use it to validate a cigar after correcting or re-deriving it.- Parameters:
cigar (str)
query_len (int)
- Return type:
bool
- arda.annotate.cigar.build_cigar(q_lead, g_lead, ops, q_trail)[source]#
Assemble one AIRR CIGAR:
{q_lead}S {g_lead}N <body> {q_trail}S(parts of length 0 are dropped).opsis the per-column M/I/D sequence of the aligned body; consecutive equal operators are run-length encoded. Trailing germlineNis intentionally omitted (optional).- Parameters:
q_lead (int)
g_lead (int)
ops (list[str])
q_trail (int)
- Return type:
str
- arda.annotate.cigar.segment_cigars(qaln, taln, qstart, tstart, qlen, t_vend, t_jstart, t_vjend)[source]#
Per-segment AIRR fields from ONE walk of the alignment:
v_cigar/j_cigar/c_cigarandv_mutations/j_mutations(only the keys that have content).qaln/talnare the mmseqs aligned strings (-for gaps),qstart/tstarttheir 1-based query/target start,qlenthe full query length. Boundaries are 1-based scaffold positions; pass 0 for an absent segment.The mutation lists are
G45A,C112T— germline base, 1-based position in that segment’s own germline, read base — and they are what makes the SHM in an arda record usable without re-deriving it. Seearda.annotate.cigar’s module docstring for why the segment scoping is structural rather than a filter.- Parameters:
qaln (str)
taln (str)
qstart (int)
tstart (int)
qlen (int)
t_vend (int)
t_jstart (int)
t_vjend (int)
- Return type:
dict[str, str]
D-segment mapping on a bare nucleotide junction — no read, no mmseqs search.
transfer._map_d already maps D into the V..J interior of a query, but it is fed
v_sequence_end / j_sequence_start projected from an mmseqs scaffold hit. A
VDJdb-style record has no read to align: it has a junction and a V/J call. The
per-allele germlines shipped in database/vdj/<org>/cdr3_anchors.tsv close that
gap, so the interior can be located directly and the existing mapper reused.
Junction space, as everywhere in arda.cdr3fix: the input runs Cys104 ->
Phe/Trp118 inclusive.
Finding the interior. The V germline is exact at the junction’s 5’ end (V/D/J are not somatically mutated in TCR, and IGH mutation is rare this close to the anchor), so the V contribution is the longest common prefix of the junction and the V’s CDR3-region germline; the J contribution is the longest common suffix. Validated against OLGA ground truth on 1300 junctions across human IGH/TRB/TRD and mouse TRB: the prefix length is exact for 80-85 % of records and never underestimates (it can overshoot by 1-2 nt when the first N-region base happens to match), and the derived interior contains the whole true D segment in 94-99 % of records.
Only IGH, TRB and TRD have D germlines; VJ loci return an empty call.
- class arda.annotate.dmap.DCall(locus='', d_call='', d_sequence_start=-1, d_sequence_end=-1, d_support='', d2_call='', d2_sequence_start=-1, d2_sequence_end=-1, d2_support='', np1='', np2='', np3='', v_sequence_end=-1, j_sequence_start=-1, extra=<factory>)[source]#
Bases:
objectD mapping of one junction. Coordinates are 1-based closed, junction space.
- Parameters:
locus (str)
d_call (str)
d_sequence_start (int)
d_sequence_end (int)
d_support (str)
d2_call (str)
d2_sequence_start (int)
d2_sequence_end (int)
d2_support (str)
np1 (str)
np2 (str)
np3 (str)
v_sequence_end (int)
j_sequence_start (int)
extra (dict)
- locus: str = ''#
- d_call: str = ''#
- d_sequence_start: int = -1#
- d_sequence_end: int = -1#
- d_support: str = ''#
- d2_call: str = ''#
- d2_sequence_start: int = -1#
- d2_sequence_end: int = -1#
- d2_support: str = ''#
- np1: str = ''#
- np2: str = ''#
- np3: str = ''#
- v_sequence_end: int = -1#
- j_sequence_start: int = -1#
- extra: dict#
- property called: bool#
- property is_dd: bool#
- markup(junction_nt)[source]#
The junction cut into labelled parts, 5’->3’.
[("V", ...), ("np1", ...), (d_call, ...), ("np2", ...), (d2_call, ...), ("np3", ...), ("J", ...)]for a tandem D-D, without the last two entries for a single D, and[("V", ...), ("N", ...), ("J", ...)]when no D was called.The parts concatenate back to
junction_ntEXACTLY – that is the contract this method exists to make checkable, and it is what a D-D markup consumer needs: the AIRR columns alone give it as a set of coordinates and three np strings that it must re-derive the D-observed sequence from.⛔ The V-end / np / D-start boundaries INSIDE the junction are not identifiable from sequence – exonuclease chew-back and N/P addition make the partition probabilistic. This is one consistent reading of the junction, not ground truth. Empty when the V/J split could not be located at all.
- Parameters:
junction_nt (str)
- Return type:
list[tuple[str, str]]
- arda.annotate.dmap.map_d_junction(junction_nt, v_call, j_call, species='human', d_max_evalue=None)[source]#
Map D (and a tandem second D) into a bare nucleotide junction.
d_max_evalueoverrides the shipped E-value gate on the D call(s); seearda.annotate.transfer._map_d().Nonekeeps the shipped 0.2.- Parameters:
junction_nt (str)
v_call (str)
j_call (str)
species (str)
d_max_evalue (float | None)
- Return type:
Two ways to give an assembled contig its AIRR cigars — and they agree.
A Stage-3 contig is a consensus of reads that Stage 1 already aligned to a scaffold.
Its v_cigar/j_cigar/c_cigar + alignment strings can be produced two ways:
reannotate_contigs()— treat the contig as one long query and run it back throughannotate_records()(one mmseqs alignment, thensegment_cigars). Simple, exact, no new code; the cost is a second alignment pass.merge_contigs()— stitch the reads’ existing alignments into the contig’s (the C++_markup.merge_alignmentper-column consensus over N reads), skipping the alignment pass. Wins when a sample has ~10^5 contigs (scRNA-seq).
Both converge on the same synthetic hit and reuse transfer_hit(),
so their output is field-for-field comparable. Which is optimal is a measured question —
see tests/unit/test_contig_merge.py and the arda-benchmark Phase-D benchmark.
- class arda.annotate.contig.ReadPlacement(qaln, taln, qstart, tstart, offset)[source]#
Bases:
objectOne read’s placement in a contig: its scaffold alignment + contig offset.
qaln/talnare the read’s coding-strand aligned strings vs the scaffold (-for gaps, as Stage 1 emits).qstart/tstartare 1-based starts in the read / scaffold.offsetis the 0-based position of the read’s first base within the contig (the assembly layout), in contig orientation.- Parameters:
qaln (str)
taln (str)
qstart (int)
tstart (int)
offset (int)
- qaln: str#
- taln: str#
- qstart: int#
- tstart: int#
- offset: int#
- class arda.annotate.contig.Contig(sequence_id, sequence, target, reads=<factory>)[source]#
Bases:
objectAn assembled contig + the reads it was built from, all hitting one scaffold.
- Parameters:
sequence_id (str)
sequence (str)
target (str)
reads (list[ReadPlacement])
- sequence_id: str#
- sequence: str#
- target: str#
- reads: list[ReadPlacement]#
- arda.annotate.contig.reannotate_contigs(records, organism='human', seqtype='nt', *, threads=0, sensitivity=None, strand='both', map_d=True, d_max_evalue=None)[source]#
Annotate assembled contigs by re-aligning them (baseline path).
recordsare(contig_id, contig_seq). A thin wrapper overannotate_records(): a contig is just a long query.- Parameters:
records (list[tuple[str, str]])
organism (str)
seqtype (str)
threads (int)
sensitivity (float | None)
strand (str)
map_d (bool)
d_max_evalue (float | None)
- Return type:
list[dict]
- arda.annotate.contig.merge_contig(contig, reference, *, map_d=True)[source]#
Annotate one contig by stitching its reads’ alignments (merge path).
referenceis a preloadedReference(load it once for a whole sample; seemerge_contigs()). RaisesKeyErrorif the contig’stargetscaffold is absent from the reference.
- arda.annotate.contig.merge_contigs(contigs, organism='human', seqtype='nt', *, reference=None, map_d=True)[source]#
Annotate contigs by the merge path; loads the reference once for all of them.
Segment shortlist → implied V×J scaffold, with a rescue set so no read is ever lost.
The fast path: align against the 1,244-target segment reference (V, J and J+C separately, see
arda.refbuild.segments), take each read’s best V and best J, and look the pair up in
combinations.tsv. That names exactly one V×J scaffold in the full reference — so the second
alignment is one target per read instead of ~277, measured at 5.36 s → 0.044 s (122×) on a
20 k-read TRA amplicon, and it lands back in the scaffold coordinate system arda’s markup
transfer already speaks.
The invariant this module exists to enforce. A fast path that silently drops reads is not an
optimisation, it is a different tool: arda’s whole claim is near-zero Stage-1 false negatives.
So every read is accounted for. shortlist() partitions the input into
implied— a V and a J both hit, and the pair names a real scaffold. The fast path.rescue— anything else: only a V hit, only a J hit, an unknown V×J pair, or a read whose second-pass alignment failed. These go back to the full reference, exactly as today.
implied ∪ rescue == every read that hit anything, by construction and by assertion. The
rescue set is small and cheap to realign — measured 1.9 % of amplicon reads and ~10 % of bulk,
i.e. 11 % and 20 % of the new total cost — so exactness costs almost nothing:
amplicon fast 0.84 s + rescue 0.10 s = 0.94 s vs 5.36 s → 5.7× bulk fast 0.52 s + rescue 0.13 s = 0.65 s vs 1.26 s → 2.0×
Why reads land in rescue, measured on a TRA amplicon:
V only (12.6 %) — the read never reaches a J, so no pair exists. The baseline picks an arbitrary J for these; the rescue pass reproduces that behaviour exactly rather than inventing a different arbitrary answer.
J only (2.1 %) — J→C reads with no V.
failed second alignment (1.9 %) — the synthesized diagonal is derived from the V hit, so it is wrong for a read whose scaffold alignment does not begin in V.
α/δ is not ambiguity, it is the answer. TRD is TRAV/DV + TRDJ: the J (and C) decides the locus. An earlier draft rescued TRAV/DV reads whose best J crossed the locus, which discarded real rearrangements — arda’s reference already carries 45 TRAV/DV + TRDJ scaffolds under locus TRD alongside 1,005 TRAV/DV + TRAJ ones under TRA. So the “114 of 212 residual V disagreements are TRA→TRD” finding is most likely the two-pass being more correct than a baseline that picks by whole-scaffold bit score with an arbitrary J half.
- class arda.annotate.shortlist.Shortlist(implied=<factory>, rescue=<factory>, reasons=<factory>, reason_of=<factory>)[source]#
Bases:
objectWhich reads take the fast path, which must be realigned, and why.
- Parameters:
implied (dict[str, str])
rescue (list[str])
reasons (dict[str, int])
reason_of (dict[str, str])
- implied: dict[str, str]#
read id -> scaffold id implied by (best V, best J)
- rescue: list[str]#
read ids that must go back to the full reference
- reasons: dict[str, int]#
reason -> count, for the run report
- reason_of: dict[str, str]#
a caller can only re-route one rescue class (e.g.
v_onlyonto its own V segment) if it knows which reads are in it.- Type:
read id -> why it was rescued. The same information as
reasons, per read
- property n_total: int#
- property fast_fraction: float#
- arda.annotate.shortlist.load_combinations(path)[source]#
combinations.tsv→{(v_allele, j_allele): scaffold_id}.v_calls/j_callsmay be comma-separated ambiguity lists; every listed allele maps to the scaffold, so a lookup succeeds whichever member the segment pass reported.- Parameters:
path (str | Path)
- Return type:
dict[tuple[str, str], str]
- arda.annotate.shortlist.shortlist(best_v, best_j, combos, *, failed=None)[source]#
Partition reads into the fast path and the rescue set.
- Parameters:
best_v (dict[str, str]) – read id -> best
V|allele (absent if the read hit no V target).best_j (dict[str, str]) – read id -> best
J|/JC|allele.combos (dict[tuple[str, str], str]) – from
load_combinations().failed (set[str] | None) – read ids whose second-pass alignment produced nothing; always rescued.
- Returns:
Shortlist. Every read appearing inbest_vorbest_jlands in exactly one ofimplied/rescue— asserted, not assumed.- Return type:
⚠
best_jmust hold a J allele.JC|targets are named by scaffold id, not by allele (seearda.refbuild.segments), so a caller feeding the raw target name straight through getsno_such_combinationfor every J→C read. Measured cost of getting this wrong: the fast path collapsed from 85.3 % to 0.1 %, with 9,388 reads needlessly rescued — correct output, silently no faster. Resolve viasegments.markup.tsv’sj_callcolumn.
Exact k-mer prefilter — drop reads that cannot align before MMseqs2 sees them.
On bulk RNA-seq, mmseqs search spends nearly all of its time proving that reads are not
receptor reads: 4 M reads of SRR10611239 take 48.9 s to find 947 hits (0.024 %). The fitted cost
model says why — wall ~ reads/46,353 + hits/350, so the dominant term is set by the read count
and not by the answer. A read can only align to a V(D)J scaffold if it shares an exact k-mer with
one; testing that is a lookup, proving it is Smith-Waterman.
The index is built from target_fasta — the same FASTA
MMseqs2 searches. That is deliberate: the design’s largest single finding is that a prefilter
built over V+pad+J alone loses 16.29 % of real reads, 69.27 % of them J->C reads, and that
indexing the constant region takes the loss to 0.53 % (OPTIMIZATION.md §3.3). Deriving the index
from a hand-listed set of segments is exactly how that hole would come back; deriving it from the
search target makes it structurally impossible for the two to disagree about what is indexable.
The filter is off by default. It trades a measured ~0.5 % of real reads for ~6x on bulk, and arda’s near-zero Stage-1 false-negative rate is the thing it is not allowed to trade silently.
- arda.prefilter.K = 16#
Seed length. k=12 passes 62-65 % of reads (no filtering); k>=18 adds nothing over 16.
- arda.prefilter.MIN_HITS = 1#
Windows a read must share with the reference to survive. A real read sharing one exact 16-mer usually shares many, so >=2 barely moves the pass rate (4.64 % -> 3.91 %) while FN climbs.
- arda.prefilter.MAX_USEFUL_PASS_RATE = 0.3#
Above this pass rate the filter costs more than it saves — amplicon libraries run 46-49 % receptor, where MMseqs2 has to look at nearly every read anyway.
- arda.prefilter.build(target_fasta, k=16)[source]#
Index every k-mer of
target_fastaand its reverse complement.Cached per FASTA: the index is ~1-3 MB and takes well under a second to build, but a per-chunk rebuild would charge every chunk of a 4 M-read run for it.
- Parameters:
target_fasta (Path)
k (int)
- arda.prefilter.available()[source]#
Is the native extension importable? A source tree without a built ext is a normal state.
- Return type:
bool
- arda.prefilter.keep_records(records, target_fasta, *, threads=1, min_hits=1, k=16)[source]#
The
(id, sequence)records worth handing to MMseqs2.Filtering happens entirely inside the extension. Returning a per-read mask instead would put two full Python passes around the scan – building a list of sequences to pass in, unpacking a list of ints coming back – and those cost more than the scan does: the same C++ change measured 2.66x in isolation and 1.16x through a mask-shaped API. Here the only Python objects created are the survivors, which on bulk is 0.5-2 % of the input.
- Parameters:
records (list[tuple[str, str]])
target_fasta (Path)
threads (int)
min_hits (int)
k (int)
- Return type:
list[tuple[str, str]]
Junction markup and repair#
Working from a bare (junction_aa, v_call, j_call, species) record — a VDJdb row, with no
read to align — rather than from a sequenced fragment.
Markup and repair of bare (junction_aa, V, J) records — the VDJdb case.
Coordinate convention. Everything here is junction space: Cys104 through the
Phe/Trp118 that opens FR4, both anchors included. That is what VDJdb’s cdr3
column actually holds (CASSARSGELFF with vEnd=4, jStart=7), and it is
NOT arda’s cdr3 (which excludes both anchors). Conflating the two silently
corrupts every coordinate emitted here.
The V and J germlines each template a known run of residues into the junction, and
database/vdj/<organism>/cdr3_anchors.tsv ships them per allele. So marking up a
record needs no germline search: align the junction’s 5’ end against the V’s
templated residues (anchored at Cys104) and its 3’ end against the J’s (anchored at
[FW]118), and read off the edit operations.
Both alignments are one semi-global Needleman-Wunsch anchored at the conserved residue with free end gaps on the junction-interior side. The free end gap is what makes the result honest: the germline templated run is an upper bound (V and J are exonuclease-trimmed), so the alignment stops wherever germline agreement stops paying for itself, and the untemplated N/D region is never scored. Concretely:
germline CASS vs CCSS... -> sub at index 1, d=1 -> repaired to CASS...
germline CASS vs CGGS... -> v_end = 1, no error (that is the V/N boundary)
germline TNEKLFF vs ...NEKLF -> deletion, d=0 -> repaired to ...NEKLFF
germline TNEKLFF vs ...NNKLFF -> sub at index 8, d=4 -> REPORTED, not repaired
Detection and repair are deliberately separate: every germline disagreement is
reported (with its position, extent and distance from the anchor), but only edits
adjacent to the conserved anchor are applied. See _MAX_REPLACE for why.
Repair always targets a canonical junction. cdr3_repaired is only accepted when
it opens with Cys104 and closes with Phe/Trp118 (_canonicalise); a repair that would
hand back a junction missing either anchor is refused and the submission returned
untouched. A repair exists to restore the anchors, and downstream every consumer trusts
cdr3_repaired. So good implies canonical, by construction rather than by luck.
Fix-type names mirror VDJdb’s Cdr3Fixer so its cdr3fix JSON is directly
comparable — on the committed 250-row fixture arda now reproduces VDJdb’s repair on all
100 records it flags, and agrees with its good/vCanonical/jCanonical verdicts
on every row. The per-position errors list is arda’s addition.
- class arda.cdr3fix.Cdr3Error(side, kind, pos, length, frm, to, dist=0, applied=False)[source]#
Bases:
objectOne edit between the observed junction and the germline-templated run.
posindexes the observed junction andlengthis how far the error extends.frmis what the record has,towhat the germline says.distis the distance from the conserved anchor.appliedis true only when this edit was actually written intoCdr3Markup.cdr3_repaired. Being within_MAX_REPLACEof the anchor makes an edit eligible; the whole side’s repair is still discarded if its fix type comes backFailed*— no alignment, more than_MAX_FIXinvented residues, more than_MAX_TRIMtrimmed ones, or a result that would not be canonical. An error can therefore be reported withapplied=Falseand the junction left alone — which is the point: detection and repair are separate decisions.- Parameters:
side (str)
kind (str)
pos (int)
length (int)
frm (str)
to (str)
dist (int)
applied (bool)
- side: str#
- kind: str#
- pos: int#
- length: int#
- frm: str#
- to: str#
- dist: int = 0#
- applied: bool = False#
- class arda.cdr3fix.Cdr3Markup(cdr3, cdr3_repaired, v_call='', j_call='', locus='', species='', v_end=-1, j_start=-1, v_fix='FailedBadSegment', j_fix='FailedBadSegment', errors=<factory>, sequence_id='')[source]#
Bases:
objectResult of marking up one
(junction_aa, V, J)record.- Parameters:
cdr3 (str)
cdr3_repaired (str)
v_call (str)
j_call (str)
locus (str)
species (str)
v_end (int)
j_start (int)
v_fix (str)
j_fix (str)
errors (list[Cdr3Error])
sequence_id (str)
- cdr3: str#
- cdr3_repaired: str#
- v_call: str = ''#
- j_call: str = ''#
- locus: str = ''#
- species: str = ''#
- v_end: int = -1#
- j_start: int = -1#
- v_fix: str = 'FailedBadSegment'#
- j_fix: str = 'FailedBadSegment'#
- sequence_id: str = ''#
- property v_canonical: bool#
Does the junction as repaired open with the conserved Cys104?
Read off
cdr3_repaired, not the submission – restoring the anchor is the whole point of the repair, and VDJdb’svCanonical/jCanonicalmean the same thing. Reading the submission instead disagreed with VDJdb on 76 of 250 fixture rows.
- property j_canonical: bool#
Does the junction as repaired close with the conserved Phe/Trp118?
- property good: bool#
Both sides repaired, and the result carries both conserved anchors.
A repair exists to produce a canonical junction. One that ends up without its Cys104 or its Phe/Trp118 has not repaired the record, it has invented a junction nobody submitted – so it can never be
good(see_canonicalise).
- property fix_needed: bool#
- class arda.cdr3fix.Anchor(locus, segment, templated_aa, functionality, status, source, anchor_nt=-1, partial_nt=0, germline_nt='')[source]#
Bases:
objectA germline segment’s contribution to the junction.
- Parameters:
locus (str)
segment (str)
templated_aa (str)
functionality (str)
status (str)
source (str)
anchor_nt (int)
partial_nt (int)
germline_nt (str)
- locus: str#
- segment: str#
- templated_aa: str#
- functionality: str#
- status: str#
- source: str#
- anchor_nt: int = -1#
- partial_nt: int = 0#
- germline_nt: str = ''#
- arda.cdr3fix.load_anchors(organism)[source]#
{(segment, allele): Anchor}for one organism;{}if not built.- Parameters:
organism (str)
- Return type:
dict[tuple[str, str], Anchor]
- arda.cdr3fix.markup_cdr3(cdr3, v_call, j_call, species='human', *, anchors=None, sequence_id='', max_replace=1)[source]#
Mark up and repair one junction.
cdr3is junction space (C..[FW]).max_replaceis how far from the conserved anchor an edit may sit and still be repaired; edits beyond it are reported withapplied=False. Raising it repairs more, at the cost of rewriting N-region residues that merely look like germline (see_MAX_REPLACE).- Parameters:
cdr3 (str)
v_call (str)
j_call (str)
species (str)
anchors (dict | None)
sequence_id (str)
max_replace (int)
- Return type:
- arda.cdr3fix.markup_records(df, *, cdr3='cdr3', v='v', j='j', species='species', sequence_id=None, organism=None, max_replace=1)[source]#
Mark up a whole table. Anchors are loaded (and cached) once per organism.
- Parameters:
df (DataFrame)
cdr3 (str)
v (str)
j (str)
species (str)
sequence_id (str | None)
organism (str | None)
max_replace (int)
- Return type:
list[Cdr3Markup]
- arda.cdr3fix.markup_batch(df, **kw)[source]#
markup_records+to_frame.- Parameters:
df (DataFrame)
- Return type:
DataFrame
- arda.cdr3fix.to_frame(records)[source]#
Records -> a TSV-ready frame with the vdjdb-compatible
cdr3fixcolumn.- Parameters:
records (Iterable[Cdr3Markup])
- Return type:
DataFrame
- arda.cdr3fix.format_report(records, *, show_ok=False)[source]#
Human-readable log: a summary table, then a line per fixed/failed record.
show_ok=Truealso lists the records that needed no repair.- Parameters:
records (Iterable[Cdr3Markup])
show_ok (bool)
- Return type:
str
Which D, and where — from an amino-acid junction alone.
A VDJdb-style record has no nucleotides, and D segments are short and trimmed at both ends, so the D is often invisible in the translated junction. Two independent sources of information remain, and they are complementary:
Where. The junction’s nucleotide length is known (3x its amino-acid length), so
insVD + |D surviving| + insDJ is pinned. Marginalising the generative model’s
insertion-length and D-trimming distributions therefore places the D even when the
sequence says nothing at all about it. Measured against OLGA ground truth, the MAP
d_start is a median 1 nt off for mouse TRB, 2 nt for human TRB, and 3 nt for TRD and
IGH.
Which. The length constraint is nearly useless for identity — the D length
distributions overlap, so the posterior barely moves off the prior. What identity the
prior does carry is P(D | J), and for TRB that is mostly genomic order: TRBD2 lies 3’
of the whole TRBJ1 cluster, so a TRBJ1 junction can only have used TRBD1 (see
_mask_forbidden). What otherwise identifies a D is the amino-acid match, and only where
enough D survives: median surviving D is 17 nt for IGH (~5.7 aa) but 5 nt for human TRB
(~1.7 aa).
So neither source alone is enough, and which one dominates flips by locus:
locus prior only aa only combined n (held-out seed, generated) human IGH 15 % 81 % 82 % 345 human TRB 76 % 70 % 82 % 595 human TRD 86 % 88 % 87 % 699 mouse TRB 76 % 83 % 85 % 699
“prior only” is beta = 0; “aa only” is argmax of the match score under a uniform prior,
ties broken by marginal usage. Combining wins at IGH and both TRB; TRD is a wash, because
one D gene (TRDD3) accounts for 85 % of rearrangements and the aa match already finds it.
The combination is log P(D | M, J) + beta * s_D: the length-and-J prior, tempered by
the best gapless local alignment score s_D of the D’s three-frame translations against
the non-templated middle of the junction. beta is fitted per locus and shipped in
database/vdj/<org>/d_prior.tsv with the distributions themselves, so nothing here needs
OLGA at runtime. It is flat above ~1.25 for TRB, so the shipped values are not delicate.
Honesty about the numbers. The table is measured on junctions drawn from the same generative model that supplies the prior, so the prior’s contribution is flattered. The amino-acid contribution is not — it is germline matching. Rearrangements that genomic order forbids are excluded from the truth: OLGA’s human TRB model emits TRBD2 x TRBJ1 in 8.7 % of draws, and scoring against those measures agreement with a model artifact.
Out of model, against nucleotide D calls (E <= 0.05) on the real GenBank fixtures: human TRB 94 %, IGH 85 %, TRD 91 %, mouse TRB 85 %. On TRB, note that both this posterior and the nucleotide caller enforce the same D2-x-J1 constraint, so their agreement on TRBJ1 records is guaranteed rather than earned; the TRBJ2 rows, where both D genes stay possible, score 91 % (human) and 81 % (mouse).
Priors exist only for the (organism, locus) pairs with a published model: human IGH, TRB
and TRD, and mouse TRB. Everything else returns None rather than guessing.
- class arda.dpost.DPosterior(locus, d_call, posterior, entropy, by_gene=<factory>, support_aa=0, d_start=-1, d_start_ci90=(-1, -1), n_middle_nt=0)[source]#
Bases:
objectPosterior over the D gene, and over where it sits in the junction.
- Parameters:
locus (str)
d_call (str)
posterior (float)
entropy (float)
by_gene (dict[str, float])
support_aa (int)
d_start (int)
d_start_ci90 (tuple[int, int])
n_middle_nt (int)
- locus: str#
- d_call: str#
- posterior: float#
- entropy: float#
- by_gene: dict[str, float]#
- support_aa: int = 0#
- d_start: int = -1#
- d_start_ci90: tuple[int, int] = (-1, -1)#
- n_middle_nt: int = 0#
- property confident: bool#
A hard call. 0.9 keeps ~the top decile of TRB records and most of IGH.
- arda.dpost.posterior_d(junction_aa, v_call, j_call, species='human')[source]#
Posterior over the D gene (and its position) for an amino-acid junction.
junction_aais junction space (Cys104 .. Phe/Trp118, both included), as inarda.cdr3fix. ReturnsNonewhen the locus has no D, no shipped model, or the junction cannot be marked up.- Parameters:
junction_aa (str)
v_call (str)
j_call (str)
species (str)
- Return type:
DPosterior | None
Bulk RNA-seq#
Stage 1 — RNA-seq filter + map.
Reuses the streaming, memory-bounded annotator (annotate.mapper._prep +
_annotate_chunk + the background-reader/bounded-queue loop of annotate_file):
MMseqs2 is the parallel layer and its k-mer prefilter rejects non-receptor reads
before alignment, so mostly-non-receptor RNA-seq is cheap. The difference from
arda annotate is that we write only the reads that map (keyed by read id, so
the AIRR TSV is the read-id → junction map), plus an optional candidate FASTA and a
run report.
Paired FASTQ mates are streamed independently, tagged <id>/1 / <id>/2 so query
ids stay unique; a pair is kept if either mate maps (recall-first — the base id
recovers the pair).
- arda.rnaseq.map.map_rnaseq(r1, output, *, r2=None, organism='human', seqtype='nt', threads=0, sensitivity=None, strand='both', chunk_size=400000, map_d=True, d_max_evalue=None, reconstruct=False, min_score=75.0, max_seqs=300, kmer=-1, drop_constant_only=True, limit=None, emit_reads=None, report_path=None, two_pass=False, adaptive=False, fast_segments=False, indel_rescue=False, segment_only_v=False, prefilter=False, with_junction_quality=False, with_mutation_quality=False, shm='framework', complete_junction_nt=0)[source]#
Filter + map an RNA-seq FASTQ (single or paired); write mapped reads as AIRR.
- Parameters:
r1 (str | Path) – FASTA/FASTQ (gzip by
.gz). Single-end, or R1 of a pair.output (str | Path) – AIRR TSV of the mapped reads only (keyed by
sequence_id).r2 (str | Path | None) – R2 FASTQ for paired input;
Nonefor single-end.min_score (float) – drop mapped reads below this MMseqs2 bit score.
0disables the filter (recall-max). See_MIN_SCOREfor the calibration.kmer (int | None) – MMseqs2
-k. The memory knob: the nucleotide prefilter allocates 4**k index entries, so the tool default k=15 costs ~8.4 GB peak RSS whatever else you set. arda defaults to 13 (~0.7 GB, and never slower).None= MMseqs2’s default.max_seqs (int) – MMseqs2 target hits per read. Does not change which reads are kept, only which V/J scaffold wins. See
arda.annotate.mapper._MAX_SEQS.limit (int | None) – analyse only the first
limitreads (single-end) / read pairs (paired), then stop — a native head, so a subsample no longer needs an externalzcat | head | gzipround-trip.Nonemaps the whole file.with_junction_quality (bool) – also emit a
junction_qualitycolumn — the read’s Phred+33 string over exactly the bases ofjunction, same orientation (seejunction_quality()). OFF by default: it appends a non-schema column, so the default output stays byte-identical. This is the only place the FASTQ quality is still in hand — Stage 1 otherwise discards it — and it is whatcorrect --min-junction-qgates on. Refused withreconstruct(a merged fragment has no single input quality string).emit_reads (str | Path | None) – optional path — write the mapped reads’ sequences as FASTA (coding-strand oriented) for downstream handoff.
report_path (str | Path | None) – optional path — write the
RnaseqReportas JSON.fast_segments (bool) – with
two_pass, answer the segment pass structurally instead of with mmseqs search – 37x on that step, agreeing with it on .9997 of V alleles and .9998 of J. It only NOMINATES candidates; the winner is still aligned against the full scaffold by MMseqs2, so the contract is that the AIRR output does not move. Ignored withouttwo_pass, since there is no segment pass to replace.indel_rescue (bool) – with
fast_segments, send reads carrying the two-diagonal signature of an indel to the GAPPED rescue path instead of resolving them on the fast path. One ungapped extension scores such a read only up to the indel, so its segment score is systematically low. Measured on 341,294 real IGH mates: 3.18 % of reads carry a V indel, rising to 8.00 % below 90 % V identity. Reroutes, never drops.adaptive (bool) – cap alignments per read and re-search only the reads whose capped score is low (
arda.annotate.mapper._extend_uncertain()). Measured 2.17x on 1 M bulk reads with zero reads lost — but read preservation is not the whole guarantee. OFF by default: on the real-read fixture it also changes junction_aa on 3 of 453 reads, and two of them scored 128 and 131, far above the 90-bit trigger. So a high score does NOT certify that the best alignment was found, and the trigger cannot be calibrated on score alone. Opt in only where a junction-level difference is acceptable.two_pass (bool) –
use the segment reference to shortlist a single V×J scaffold per read before aligning (
arda.annotate.mapper._segment_best_hits()). Reads it cannot resolve are realigned against the full reference, so nothing is dropped — seearda.annotate.shortlist. Requiressegments.fasta(written byarda build-index); silently falls back to the one-pass search when it is absent.⛔ The win is set by whether reads SPAN V INTO J, not by the library type, and the predictor is
fast_fractionin the report. Measured: 3.51× on a TCR amplicon (fast path 85 %), 2.96× on a 100 %-receptor human TRB set (95.6 %), 2.64× on mouse TRA (89 %) — but 1.03× slower on the human IGH leg of that same 100 %-receptor dataset (16.3 %: those reads cover V and stop short of the short IGHJ target), and 0.762× on 2.74 %-receptor bulk (5 %). Off by default because no library type predicts it; run a sample and readfast_fraction.organism (str)
seqtype (str)
threads (int)
sensitivity (float | None)
strand (str)
chunk_size (int)
map_d (bool)
d_max_evalue (float | None)
reconstruct (bool)
drop_constant_only (bool)
segment_only_v (bool)
prefilter (bool)
with_mutation_quality (bool)
shm (str)
complete_junction_nt (int)
- Returns:
The run
RnaseqReport(also printed by the CLI).- Return type:
- arda.rnaseq.map.read_pairs(r1, r2=None, *, reconstruct=False, limit=None, with_qual=False)[source]#
Stream
(id, sequence)reads for single-end (r1only) or paired input.with_qualyields(id, sequence, quality)instead — the Phred+33 string,""for FASTA input. It is incompatible withreconstruct: a merged fragment’s bases come from two different reads, so no single input quality string describes it (map_rnaseq()refuses the combination rather than emitting a quality that does not belong to the sequence).For paired input the two mates carry the same id, so they are tagged
<id>/1and<id>/2to keep query ids unique (strip the suffix to recover the pair). Withreconstruct, overlapping mates are merged into one fragment (merge_pair()) keyed by the bare id — giving a short read the mate’s V/J context; non-overlapping mates fall back to the tagged-independent form.- Parameters:
limit (int | None) – analyse only the first
limitinput records — reads (single-end) or read pairs (paired) — then stop, without decompressing the rest of the file.Nonereads everything. The mate-order / truncation checks below still run on every record actually read; a truncation beyondlimitis simply never reached — that is the intent of a head-style limit, not a hole in the check.r1 (str | Path)
r2 (str | Path | None)
reconstruct (bool)
with_qual (bool)
- Raises:
ValueError – if the two files disagree on read names or record count. This is not paranoia: a truncated R2 makes
zipstop early and silently analyse a prefix, and a shuffled R2 pairs mate 1 of one fragment with mate 2 of another. Both were observed in this project’s own data and produced a published false discovery (a spurious R2-only blind spot) that had to be retracted. A pair of FASTQs is an assertion; check it.- Return type:
Iterator[tuple]
- arda.rnaseq.map.merge_pair(s1, s2, *, q1=None, q2=None, min_overlap=12, max_mismatch_rate=0.1)[source]#
Overlap-merge a read pair into one fragment, or
Noneif they don’t overlap.Aligns
s1(R1) withreverse_complement(s2)(R2 flipped to the same strand) by finding an exact_MERGE_ANCHOR-mer from the flipped R2’s 5’ end inside R1 (C-levelstr.find→ O(len), so non-overlapping pairs — the common RNA-seq case — cost almost nothing), then verifying the implied overlap; the mate provides the V/J context a short read lacks.In the overlap the two mates may disagree. Given Phred qualities
q1/q2the base with the higher quality wins per position (rc(R2)’s quality isq2reversed, not complemented); without them R2 wins the whole overlap (the historical behaviour). Outside the overlap, R1 supplies its 5’ part and R2 its 3’ tail.- Parameters:
s1 (str)
s2 (str)
q1 (str | None)
q2 (str | None)
min_overlap (int)
max_mismatch_rate (float)
- Return type:
str | None
- arda.rnaseq.map.junction_quality(rec, qual)[source]#
The read’s Phred+33 substring covering
rec["junction"], or"".⛔ The quality string belongs to the read AS SUBMITTED, while the junction and every coordinate in the record are on the CODING strand. For
rev_comp == "T"the two run in opposite directions, so the quality is reversed (not complemented – a Phred char has no complement) before slicing. Getting that backwards yields a string of the right LENGTH holding the wrong bases’ qualities, which no length or format check downstream can catch. So the slice is verified against the junction it claims to describe before it is returned.The junction is
coding[cdr3_start - 3 : cdr3_end + 3]– the CDR3 flanked by the Cys104 and [FW]118 anchor codons, seearda.annotate.transfer._junction_nt()– so the coordinates place it in O(1). They can be absent (a producer that did not fill the region columns), hence thefindfallback; if neither reproduces the junction, return""rather than a misaligned string.- Parameters:
rec (dict)
qual (str)
- Return type:
str
- arda.rnaseq.map.mutation_quality(rec, qual)[source]#
Per-mutation Phred scores for
rec["v_mutations"]/rec["j_mutations"].⛔ Driven by the mutation list that was EMITTED, not by re-deriving one. Walking the alignment and scoring every mismatch reproduces what
_markup.segment_cigarsfound – which since 2.16.0 is a SUPERSET of what the columns carry, becausearda.shmthen drops the junction-internal entries (measured on this repo’s own fixture: 25 of 242 V rows had more mismatches than mutations). The result lines up in length only by accident and pairs entry i with a different base’s score. So the walk builds germline position -> query position and each emitted entry looks its own position up; an entry whose position the alignment does not cover yields""for the whole segment rather than a short, misaligned list.⛔ Quality is oriented like the READ AS SUBMITTED; the alignment and every coordinate here are on the coding strand. Same reversal rule as
junction_quality().ponytail:a Python pass over the alignment, not a second output from the C++ walk that already visits these columns. It rides its own flag, so it never lands on a mode run; move it intosegment_cigarsif it ever shows up in a profile.- Parameters:
rec (dict)
qual (str)
- Return type:
dict[str, str]
- class arda.rnaseq.map.RnaseqReport(input, organism, paired=False, input_bytes=0, read_length_min=0, read_length_max=0, read_length_mean=0.0, total_reads=0, mapped_reads=0, per_locus=<factory>, constant_only_fragments=0, isotype_from_mate=0, min_score=0.0, threads=0, wall_seconds=0.0, reads_per_second=0.0, peak_rss_mb=0.0, rss_gain_mb=0.0, segment_search=<factory>, prefilter_stats=<factory>)[source]#
Bases:
objectCounts + timing for one
maprun (written as JSON with--report).- Parameters:
input (str)
organism (str)
paired (bool)
input_bytes (int)
read_length_min (int)
read_length_max (int)
read_length_mean (float)
total_reads (int)
mapped_reads (int)
per_locus (dict[str, int])
constant_only_fragments (int)
isotype_from_mate (int)
min_score (float)
threads (int)
wall_seconds (float)
reads_per_second (float)
peak_rss_mb (float)
rss_gain_mb (float)
segment_search (dict)
prefilter_stats (dict)
- input: str#
- organism: str#
- paired: bool = False#
the AIRR carries only the reads that mapped, so its row count, its sequence lengths and its mate suffixes all describe the receptor subset rather than the library. arda stats reports them.
- Type:
Library shape, recorded here because nothing downstream can recover it
- input_bytes: int = 0#
- read_length_min: int = 0#
- read_length_max: int = 0#
- read_length_mean: float = 0.0#
- total_reads: int = 0#
- mapped_reads: int = 0#
- per_locus: dict[str, int]#
- constant_only_fragments: int = 0#
- isotype_from_mate: int = 0#
- min_score: float = 0.0#
- threads: int = 0#
- wall_seconds: float = 0.0#
- reads_per_second: float = 0.0#
- peak_rss_mb: float = 0.0#
- rss_gain_mb: float = 0.0#
- segment_search: dict#
- prefilter_stats: dict#
- property mapped_fraction: float#
Stage 3 — contig assembly (anchored greedy overlap-extension).
Reconstruct the clonotypes that Stage 1 maps but cannot call: a long CDR3 (V(DD)J
ultralong, ~20-40 aa) does not fit in one 100-150 bp read, so no read spans the junction
and correct’s complete-junction filter drops every read of it. Assembly-based
extractors recover these by assembly; this module does the same on the reads Stage 1 already mapped.
Why overlap-extension and not a de Bruijn graph: every clonotype sharing a germline V/J
contributes identical k-mers, so a dBG collapses distinct clones exactly across the CDR3
(the region of interest). This is the reason the Pevzner-lab Ig assemblers use a read graph,
not a k-mer graph (Safonova 2015, 10.1093/bioinformatics/btv238). We exploit arda’s own
anchors instead: Stage 1 gives every V-side read a cdr3_start offset, so reads of one
clone are already coordinate-aligned at the CDR3 – we seed from those and extend 3’ through
the CDR3 into J, where the sequence is clone-specific, and stop before running deep into the
(shared) constant region. Seeding never extends 5’ into the germline V, which is what keeps
distinct clones apart and bounds the germline-k-mer blow-up.
The assembled contig physically unites a clone’s V-side reads with its J/C-side reads under
one junction, so correct also gets the clone’s isotype (from the J/C
mates’ c_class) for free – the long clones were previously invisible to both.
Output is a per-member-read AIRR fragment (sequence_id -> the contig’s complete junction),
meant to be concatenated with the Stage-1 mapped AIRR and fed to correct in one pass: a
read that was incomplete in Stage 1 is dropped there and kept here, so fragments count once.
- arda.rnaseq.assemble.assemble_contigs(airr_tsv, output, *, organism='human', k=21, min_overlap=21, min_id=0.9, max_ext_past_cdr3=130, scan_cap=400, threads=0, map_d=True, d_max_evalue=None, report_path=None)[source]#
Assemble long-CDR3 contigs from Stage-1 mapped reads and attribute their junctions.
Reads the Stage-1 mapped-reads AIRR (needs
sequence,rev_comp,locus,cdr3_startand thev/j/c_callcolumns), assembles per-locus contigs, re-annotates them (reannotate_contigs()), and writes an AIRR TSV with one row per incomplete member read carrying the contig’s complete junction (and the read’s ownc_classso isotype survives). Concatenate this with the mapped AIRR and runcorrectonce: the read’s incomplete Stage-1 row is dropped and this complete row kept, so each fragment is counted exactly once.The contig’s D call travels with the junction (
d_call,d2_call,d_support,d2_support,np1-np3). An ultralong CDR3 is the one place a tandem D-D is both most likely and least visible to a single read, so the contig is where it must be called.- Parameters:
airr_tsv (str | Path) – Stage-1 mapped-reads AIRR TSV.
output (str | Path) – assembled-reads AIRR TSV (header only if nothing assembles).
map_d (bool) – map D segments on the assembled contig (default
True).d_max_evalue (float | None) – E-value gate on the D call(s);
Nonekeeps the shipped 0.2.max_ext_past_cdr3 (int) – stop extending a contig once it reaches this many nt past the CDR3 start – enough to cross the junction into J without running into the shared C region.
scan_cap (int) – per-step cap on candidate reads examined for a (germline-frequent) k-mer.
organism (str)
k (int)
min_overlap (int)
min_id (float)
threads (int)
report_path (str | Path | None)
- Returns:
An
AssembleReport.- Return type:
- class arda.rnaseq.assemble.AssembleReport(reads_in: 'int' = 0, seeds: 'int' = 0, contigs: 'int' = 0, contigs_complete: 'int' = 0, reads_rescued: 'int' = 0, members_without_junction: 'int' = 0, wall_seconds: 'float' = 0.0, peak_rss_mb: 'float' = 0.0, rss_gain_mb: 'float' = 0.0)[source]#
Bases:
object- Parameters:
reads_in (int)
seeds (int)
contigs (int)
contigs_complete (int)
reads_rescued (int)
members_without_junction (int)
wall_seconds (float)
peak_rss_mb (float)
rss_gain_mb (float)
- reads_in: int = 0#
- seeds: int = 0#
- contigs: int = 0#
- contigs_complete: int = 0#
- reads_rescued: int = 0#
- members_without_junction: int = 0#
Members skipped because their span did not cover the contig’s junction by
_MIN_JUNCTION_COVER– recruited on germline-only overlap, so there is no evidence they belong to this clonotype. They keep their reads; only the attribution is withheld.
- wall_seconds: float = 0.0#
- peak_rss_mb: float = 0.0#
- rss_gain_mb: float = 0.0#
Stage 2 — CDR3 error correction (sequencing-error model).
Collapses sequencing-error CDR3 variants onto their parent clonotype, using seqtree
neighbour search (a fast edit-bounded index) to find substitution/indel neighbours. A clonotype
C is an error child of a more-abundant neighbour P (differing by n_subs
substitutions and n_indel inserted/deleted bases) iff the expected number of such misread
parent reads – count[P] * p_sub**n_subs * p_ind**n_indel – is at least count[C]. The rates
are PER BASE and the per-mismatch probability is length-scaled (p_sub = error_rate * L): a single
mismatch over a longer junction sheds proportionally more error mass, so the default
error_rate = 0.001 reproduces vdjtools’ ~1/20 at a 45 nt (15 aa) junction and scales elsewhere. A
multi-base (in-frame SHM) indel costs p_ind**len and is kept as a real clonotype. The count is
the SPANNING read depth – reads that fully observe the
junction – so the test is over the reads that actually saw the discriminating base ("2/2, not
2/200"); error_method in {binom, betabinom} instead piles up partial reads per position for
extra depth at very low coverage. Children route to the parent; chains collapse to the ultimate
ancestor; count[parent] * p_err >= count[child] with p_err < 1 gives strictly increasing
counts along parent pointers, so there are no cycles.
- arda.rnaseq.correct.correct_airr(airr_tsv, output, *, organism='human', map_d=True, d_max_evalue=None, max_subs=3, max_indel=0, error_rate=0.001, indel_rate=0.001, require_vj=True, error_method=None, ec_mode='fast', clonotype_key='full', call_level='allele', flag_chimeras=False, isotype=True, min_junction_q=None, complete_only=True, coverage=True, read_map=None, extra_airr=None, report_path=None)[source]#
Aggregate mapped reads into clonotypes and collapse CDR3 sequencing errors.
- Parameters:
airr_tsv (str | Path) – Stage-1 mapped-reads AIRR TSV (needs
junction,sequence_id).organism (str) – reference organism, used only to map D into each clonotype’s junction.
map_d (bool) – append the D columns (
_D_STR_COLS+_D_INT_COLS), called once per clonotype on its corrected junction (see_clonotype_d()). DefaultTrue.d_max_evalue (float | None) – E-value gate on the D call(s);
Nonekeeps the shipped 0.2. Lower is stricter – 0.01 is the band where D agrees .9985 with IgBLAST on a TRB amplicon.output (str | Path) – corrected clonotype table TSV (
junction,junction_aa,v_call,j_call,c_call,locus,duplicate_count,consensus_count, and withmap_dthe D columns), sorted by abundance. A clonotype is keyed by(locus, v_call, j_call, junction). Per the AIRR schema,duplicate_countis the number of READS supporting the clonotype (both paired mates of a molecule count) andconsensus_countis the number of distinct fragment consensuses (the two mates of one molecule are one consensus).c_callis the clonotype’s dominant isotype CLASS (fromc_class: IGHG, IGHA, …), preferring a resolved class over the ambiguousIGHC; empty when no read carried a constant call.max_subs (int) – max substitutions between an error child and its parent (seqtree neighbour search). This is a SEARCH RADIUS, not a threshold – the accept/reject decision is the length-scaled probability model above, so widening it only lets the model SEE parents it would already have accepted. The default was 2 through 2.9.0, which truncated the search below what the model would take on a deep clone: on the two monoclonal cell lines in the arda-benchmark set 2 -> 3 collapses Jurkat 74 -> 57 clonotypes and Raji 91 -> 58, while a polyclonal mouse spleen (7,942) and an oligoclonal B-LCL (13) are UNCHANGED at 2, 3 and 4 – the model refuses those collapses on abundance regardless of radius. It saturates at 3 (4 gives the same four numbers), so 3 is the default.
max_indel (int) – max inserted/deleted bases searched for indel error children (default 0). A 1-2 bp instrument indel is a frameshift and is already dropped by
complete_only, so on complete junctions the indel search only costs time (~160x slower) and collapses nothing; a multi-base in-frame SHM indel costs(indel_rate*L)**lenand is kept as a real clonotype either way. Set it > 0 only with--all-junctions(frameshift indels kept).error_rate (float) – per-BASE substitution error rate (~Phred 30 = 0.001). The per-substitution collapse probability is length-scaled,
error_rate * junction_len, so the default reproduces vdjtools’ ~1/20 at a 45 nt (15 aa) junction and scales for other lengths.indel_rate (float) – per-BASE indel error rate (instrument-dependent; default 0.001, length-scaled).
ec_mode (str) – knob preset,
"fast"(default, = today’s shipped behaviour) or"accurate". SeeEC_MODES; an explicitly passederror_method/min_junction_qoverrides it.min_junction_q (int | None) – reassign onto its parent a read whose junction differs from that parent below this Phred score (
_quality_gate()).0disables it. Needs thejunction_qualitycolumn frommap --junction-qualityand RAISES without it.error_method (str | None) –
"simple"(default) tests on spanning read counts;"binom"/"betabinom"pile up partial reads per discriminating position for extra depth at very low coverage (_error_pileup()).require_vj (bool) – only collapse neighbours sharing
v_callandj_call(defaultTrue– a true sequencing error does not change the germline-anchored V/J call).complete_only (bool) – keep only reads whose junction spans both conserved anchors, is in frame, and has no stop codon (see
_COMPLETE). A read that stops short of the [FW]118 anchor yields a prefix of a junction, not a clonotype. Setting thisFalsereproduces the raw per-read behaviour and is almost never what you want. (This governs which reads DEFINE clonotypes, not how they are counted – seecoverage.)coverage (bool) – count a clonotype’s abundance as EVERY read that encompasses its junction (aligns to it), not only the reads that span it end-to-end (default
True). A long CDR3 is covered by many partial V-side / J-side reads that never reach both anchors; counting only spanning reads under-reports it non-uniformly (the deficit scales with CDR3 length). Coverage counting (_assign_coverage()) is the true expression estimate.Falsereverts to spanning-read counts.read_map (str | Path | None) – optional TSV
sequence_id -> junction(the corrected clonotype a read ends up in) — the read-id → junction map after correction.extra_airr (str | Path | None) – optional Stage-3 assembled-reads AIRR (from
assemble_contigs()), concatenated withairr_tsvbefore aggregation. Its rows carry a contig’s complete junction for reads whose own Stage-1 junction was incomplete, so a long-CDR3 clone no single read spans is counted once (the read’s incomplete Stage-1 row is dropped bycomplete_only).clonotype_key (str)
call_level (str)
flag_chimeras (bool)
isotype (bool)
report_path (str | Path | None)
- Returns:
- Return type:
- class arda.rnaseq.correct.CorrectReport(clonotypes_in: 'int' = 0, clonotypes_out: 'int' = 0, reads: 'int' = 0, reads_assigned: 'int' = 0, collapsed: 'int' = 0, reads_with_junction: 'int' = 0, empty_clonotypes: 'int' = 0, reads_from_assembly: 'int' = 0, reads_incomplete: 'int' = 0, reads_low_quality: 'int' = 0, clonotypes_low_quality: 'int' = 0, rescued_clonotypes: 'int' = 0, rescued_reads: 'int' = 0, orphan_clonotypes: 'int' = 0, orphan_reads: 'int' = 0, wall_seconds: 'float' = 0.0, peak_rss_mb: 'float' = 0.0, rss_gain_mb: 'float' = 0.0)[source]#
Bases:
object- Parameters:
clonotypes_in (int)
clonotypes_out (int)
reads (int)
reads_assigned (int)
collapsed (int)
reads_with_junction (int)
empty_clonotypes (int)
reads_from_assembly (int)
reads_incomplete (int)
reads_low_quality (int)
clonotypes_low_quality (int)
rescued_clonotypes (int)
rescued_reads (int)
orphan_clonotypes (int)
orphan_reads (int)
wall_seconds (float)
peak_rss_mb (float)
rss_gain_mb (float)
- clonotypes_in: int = 0#
- clonotypes_out: int = 0#
- reads: int = 0#
⛔ SPANNING reads entering Stage 2, counted BEFORE any correction runs. It is therefore invariant to everything
correctdoes, and it is NOT the read-conservation quantity. Comparing it across--ec-modeshows 0 on every sample and reads exactly like the invariant holding – which is how a 1.39 % leak on a full-depth TRA amplicon was missed. The invariant isreads_assigned.
- reads_assigned: int = 0#
sum(duplicate_count)over the emitted clonotype table, i.e. every read the correction actually placed. This is what must not fall when a denoising mode is switched on – error correction MOVES reads onto a parent, it never discards them. Reported so a run is self-checking instead of relying on a docstring.- Type:
The read-conservation invariant
- collapsed: int = 0#
- reads_with_junction: int = 0#
- empty_clonotypes: int = 0#
Clonotypes dropped because no read was assigned to them – see the note at the emit step. Only reachable with –all-junctions, where a rescued read’s truncated Stage-1 junction is itself a clonotype key and loses its reads to the assembled row.
- reads_from_assembly: int = 0#
Junction-bearing rows contributed by –assemble (extra_airr). A rescued read appears in BOTH frames – its incomplete Stage-1 row and its assembled row – so these are reported separately rather than folded into reads_with_junction, which is a Stage-1 statistic.
- reads_incomplete: int = 0#
- reads_low_quality: int = 0#
- clonotypes_low_quality: int = 0#
- rescued_clonotypes: int = 0#
- rescued_reads: int = 0#
- orphan_clonotypes: int = 0#
- orphan_reads: int = 0#
- wall_seconds: float = 0.0#
- peak_rss_mb: float = 0.0#
- rss_gain_mb: float = 0.0#
- arda.rnaseq.correct.CLONOTYPE_KEYS = ('full', 'junction')#
How a clonotype is identified.
full–(locus, v_call, j_call, junction), the historical key.junction–(locus, junction): the V/J calls are canonicalised to the junction’s majority before grouping, so call splits collapse. That class is invisible to every error model – a junction byte-identical to an abundant clone’s under a different V or J call has no discriminating base to score – and on Jurkat it is the largest error class BY READS (130 of 14,531), including an allele-level TRG split. Measured cost on a POLYCLONAL TRA amplicon: 132 of 19,956 clonotypes merge (0.66 %), and in every ambiguous case inspected the minority call carried ONE read against 4-10 for the majority on a short 30-39 nt junction – a call error on a low-abundance read, not a second clone. Benefit on Jurkat: TRB 35 -> 33 clonotypes at purity .99096 -> .99696, reads unchanged.
- arda.rnaseq.correct.CALL_LEVELS = ('allele', 'gene')#
At what resolution a V/J call names a germline.
allele–TRGJ1*01, the historical behaviour and the default.gene–TRGJ1: the allele suffix is dropped before the clonotype key is formed, so allele-level call splits collapse. Jurkat’s largest single split is exactly that shape (TRGJ1*0164 reads againstTRGJ1*02140 on the same junction), and no error model can see it because two identical junctions have no discriminating base.⚠ It also collapses a TIE LIST whose members differ only by allele (
TRAV1*01,TRAV1*02->TRAV1), which is the artifact behind the 14-point spread between v_allele_exact (median .8328) and v_allele_resolved (.9763) across 25 cluster datasets. ⚠ IGH carries 4.33 alleles/gene, ~2x every other locus, so expect the effect to be far larger there than on TR — measure per library before quoting a cost.
Stages 2-3 as one function, shared by the single-node and sharded delivery paths.
The RNA-seq pipeline is map -> assemble -> correct. Stage 1 (map) is per-read and shards perfectly; Stages 2 and 3 are global and do not shard at all:
correct collapses sequencing-error variants onto a parent clonotype and counts distinct fragments. Run per shard, one clone split across N shards is counted N times, and error children collapse against a fraction of their parent’s depth.
assemble grows contigs across reads. Reads that tile one long CDR3 in different shards never meet, so the contig is never built – which is precisely the class Stage 3 exists for.
So the sharded path runs map per shard, concatenates the Stage-1 AIRR in shard order,
and then calls exactly the same finish() the single-node path calls. Not “the same
steps” – the same function, so the two cannot drift apart in a parameter.
That plus contiguous shards (arda.cluster.split_pairs()) is what makes a sharded run
byte-identical to a single-node one, rather than merely similar.
- arda.rnaseq.pipeline.finish(airr, out_dir, out_prefix, *, organism='human', threads=0, assemble=True, complete_only=True, map_d=True, d_max_evalue=None, ec_mode='fast', min_junction_q=None, clonotype_key='full', call_level='allele', isotype=True, map_report=None, write_qc=True, echo=None)[source]#
Run Stages 2-3 over a Stage-1 AIRR and write the clonotype table + merged report.
Called by both
run()(single node) andreduce()(after a sharded Stage 1).- Parameters:
airr (str | Path) – Stage-1 mapped-reads AIRR TSV.
out_dir (str | Path) – where the outputs land (see
OUTPUTS).out_prefix (str) – where the outputs land (see
OUTPUTS).map_report (dict | None) – the Stage-1 report to embed; for a sharded run, the merged one.
echo – optional
print-like callback for progress lines.organism (str)
threads (int)
assemble (bool)
complete_only (bool)
map_d (bool)
d_max_evalue (float | None)
ec_mode (str)
min_junction_q (int | None)
clonotype_key (str)
call_level (str)
isotype (bool)
write_qc (bool)
- Returns:
The merged report dict, as written to
<prefix>.arda.json.- Return type:
dict
- arda.rnaseq.pipeline.run(r1, out_dir, out_prefix, *, r2=None, organism='human', threads=0, reconstruct=False, min_score=75.0, kmer=12, assemble=True, complete_only=True, map_d=True, d_max_evalue=None, limit=None, two_pass=False, adaptive=False, fast_segments=False, prefilter=False, segment_only_v=False, indel_rescue=False, ec_mode='fast', min_junction_q=None, clonotype_key='full', call_level='allele', isotype=True, shm='framework', complete_junction_nt=0, echo=None)[source]#
Single-node map -> assemble -> correct.
- Parameters:
r1 (str | Path)
out_dir (str | Path)
out_prefix (str)
r2 (str | Path | None)
organism (str)
threads (int)
reconstruct (bool)
min_score (float)
kmer (int | None)
assemble (bool)
complete_only (bool)
map_d (bool)
d_max_evalue (float | None)
limit (int | None)
two_pass (bool)
adaptive (bool)
fast_segments (bool)
prefilter (bool)
segment_only_v (bool)
indel_rescue (bool)
ec_mode (str)
min_junction_q (int | None)
clonotype_key (str)
call_level (str)
isotype (bool)
shm (str)
complete_junction_nt (int)
- Return type:
dict
- arda.rnaseq.pipeline.reduce(shard_dir, out_dir, out_prefix, *, organism='human', threads=0, assemble=True, complete_only=True, map_d=True, d_max_evalue=None, echo=None)[source]#
Merge sharded Stage-1 output, then run Stages 2-3 once over the whole thing.
The shard AIRRs are merged from an explicit sorted list, not a bare
*.tsvglob: shard names are zero-padded sosorted()is numeric (shard_10must not precedeshard_2– concatenation order is read order here), and naming the glob means reduce can never swallow its ownclones.tsvif someone points--out-dirat--shard-dir.- Parameters:
shard_dir (str | Path)
out_dir (str | Path)
out_prefix (str)
organism (str)
threads (int)
assemble (bool)
complete_only (bool)
map_d (bool)
d_max_evalue (float | None)
- Return type:
dict
- arda.rnaseq.pipeline.write_stats_for(out_dir, out_prefix, *, organism='human', say=None)[source]#
Write
<prefix>.stats.tsvfrom the run’s own artifacts. Returns the row count.⛔ Written unconditionally, not behind a flag. It reads only files that already exist and costs one pass over each; the alternative is that the numbers an operator needs to decide whether a sample is usable exist only if they knew to ask for them BEFORE the run.
⛔ Called AFTER the report JSON is final.
run()rewrites it with the whole-run wall time once Stages 2-3 return, so collecting insidefinish()would put the Stage-2/3 time in therunscope under the namewall_seconds– a wrong number that looks like a right one.- Parameters:
out_dir (str | Path)
out_prefix (str)
organism (str)
- Return type:
int
- arda.rnaseq.pipeline.OUTPUTS = {'airr': '{prefix}.airr.tsv', 'assembled_airr': '{prefix}.assembled.airr.tsv', 'clones': '{prefix}.clones.tsv', 'report': '{prefix}.arda.json', 'stats': '{prefix}.stats.tsv'}#
Output basenames, relative to
out_dirand givenprefix.
Wall time and peak RSS for one pipeline stage.
Only map used to report resources, which left the expensive stage unmeasured: mapping is flat (~300-650 MB at any depth) while Stage 3 holds the clone set, and on a B-cell-rich tumour (28,444 clonotypes from 105M reads) correct peaked at 2,071.7 MB. Anyone sizing a SLURM –mem or Nextflow memory directive from the mapping number alone would be OOM-killed.
What the numbers mean, exactly – because resource.getrusage offers no way to be more precise, and a vaguer definition here would be a lie rather than a simplification:
peak_rss_mbThe whole process (plus reaped children) high-water mark as of the end of this stage. Monotone across stages: it can only rise. getrusage reports high-water marks only – RUSAGE_SELF since process start, RUSAGE_CHILDREN cumulative over children – and there is no per-stage reset, so a stage cannot be charged its own peak in isolation when all three run in one process (arda rnaseq / arda amplicon).
rss_gain_mbHow much this stage raised that mark; 0 if it stayed under an earlier stage’s peak.
The monotone number is the one an operator actually needs: it is what the process required at
that point, which is what a memory directive has to cover. For per-stage attribution, run the
stage in its own process (arda map|assemble|correct separately) – then
peak_rss_mb is that stage alone.
- arda.rnaseq._res.peak_rss_mb()[source]#
Peak RSS of this process AND its children, in MB; 0.0 where
resourceis absent.RUSAGE_SELFalone is wrong and was: 92 % of a map run’s wall time is spent inside the mmseqs subprocess, whose nucleotide prefilter allocates a4**kindex table that dominates the footprint. Reporting only the Python process understated peak RSS by roughly an order of magnitude.ru_maxrssis bytes on macOS, KB on Linux.⛔ Lives HERE, in the module with no arda imports, and is re-exported by
arda.rnaseq._res. The other direction is an import cycle:arda.rnaseq.__init__importsmap, which needsThrottle.- Return type:
float
- class arda.rnaseq._res.Stage[source]#
Bases:
objectTimes a stage and records its resource footprint onto a report dataclass.
Not a context manager: correct_airr and assemble_contigs write their JSON report inside the function, so measurement has to close before that write, not after the block.
stage = Stage() … stage.finish(report) # sets wall_seconds / peak_rss_mb / rss_gain_mb if report_path: …
- property wall_seconds: float#
Run QC and logging#
Run QC: one long-format TSV describing a library, its reads and its clonotypes.
arda stats reads the artifacts a run already wrote – the Stage-1 AIRR, the clonotype table,
the .arda.json report – and emits every number an operator needs to decide whether a sample
is usable, without re-reading the FASTQ. It adds no alignment and no reference lookup beyond
the germline gene list.
Long format, four columns, scope / key / metric / value. The scopes, and what keys them:
runkeyed by stage (
map/correct/assemble) – the run report, flattened verbatimsampleunkeyed – library-wide totals, junction lengths and quality, SHM rate, gene coverage
chainkeyed by locus (
TRB,IGH, …) – reads AND clonotypesv_gene/j_genekeyed by gene (
TRBV19) – reads and clonotypes per germline geneallele_candidatekeyed by
allele:mutation– a recurrent, high-quality V mutation
⛔ Long, not wide, and deliberately: the metric set differs per scope (a gene has no junction
length, a chain has no allele frequency), so a wide table would be mostly empty cells, and the
one thing a QC table must support is grep / join / a per-metric plot across samples. One
value per cell, one row per fact – no 134/62 hybrids, and integers stay integers.
⛔ The chimera, non-functional and stop-codon counts are FLAGS, not filters. Nothing here
removes a row from any output; stats only reads. See correct --flag-chimeras for why the
chimera signature cannot separate a true PCR artefact from two real clones sharing a prefix and
a suffix.
⚠ Alleles vs SHM is a heuristic, and it is reported as one. A mutation seen in most of an
allele’s reads at high Phred is far more likely a germline the reference does not carry than
somatic hypermutation or a miscall – but arda does not genotype, and allele_candidate is a
shortlist to look at, never a call. The thresholds are exposed (--allele-min-frac,
--allele-min-reads) precisely so the number can be re-derived rather than trusted.
- arda.stats.collect(*, airr=None, clones=None, report=None, r1=None, r2=None, organism='human', allele_min_frac=0.5, allele_min_reads=10)[source]#
Every statistic arda can derive from a finished run, as
(scope, key, metric, value).Every input is optional and each contributes its own scopes, so this works on a bare
arda annotateoutput as well as on a fullarda rnaseqrun directory.- Parameters:
airr (str | Path | None) – Stage-1 (or
annotate) AIRR TSV -> thechainread rows,*_genereads, andallele_candidate.clones (str | Path | None) – clonotype table -> the
chainclonotype rows and*_geneclonotypes.report (str | Path | None) –
<prefix>.arda.json(or a bare--reportJSON) -> therunscope, which is where total/mapped reads, threads, wall time and peak RSS come from.r1 (str | Path | None) – the input FASTQs. Used ONLY for their size on disk and for whether the library is paired – neither is recoverable from the AIRR, which holds the mapped subset.
r2 (str | Path | None) – the input FASTQs. Used ONLY for their size on disk and for whether the library is paired – neither is recoverable from the AIRR, which holds the mapped subset.
organism (str)
allele_min_frac (float)
allele_min_reads (int)
- Return type:
list[tuple]
- arda.stats.write_stats(rows, output)[source]#
Write
rowsas the QC TSV. Returns the row count.- Parameters:
rows (list[tuple])
output (str | Path)
- Return type:
int
- arda.stats.ALLELE_MIN_FRAC = 0.5#
A V mutation is a candidate allele when it is carried by at least this fraction of the reads calling that allele AND by at least
ALLELE_MIN_READSof them. The fraction is what separates germline from SHM (hypermutation is per-clone, so it does not reach half an allele’s reads); the count is what keeps a 2-read allele from producing a candidate off one read.
Logging, progress and resource reporting.
One arda logger, configured once from the CLI callback, that every module already feeds:
cdr3fix, prefilter, segmap, refbuild.* all call logging.getLogger(__name__),
so they are children of arda and inherit whatever setup installs. Nothing else has to
know about verbosity.
⛔ Progress goes to stderr, results go to stdout. The stage lines used to be typer.echo
on stdout, which means arda export-ref piped into a file interleaved a progress line with the
data. Everything informational is a log record now; only paths and the export payload stay on
stdout.
--log-file is always DEBUG whatever the console level is, and its format carries a timestamp
and the process peak RSS. That is the artifact a cluster job leaves behind, and re-running a
10-hour bulk sample because the console was at the default level is not a thing anyone should
have to do.
- arda._log.setup(verbosity=0, quiet=False, log_file=None)[source]#
Configure the
ardalogger. Idempotent – handlers are replaced, never stacked.- Parameters:
verbosity (int) – 0 = INFO (the stage/progress lines), 1+ = DEBUG with level and module names.
quiet (bool) – WARNING and above only. Loses to
--log-file, which stays at DEBUG.log_file (str | Path | None) – also write DEBUG records here, with timestamps and peak RSS per line.
- Return type:
None
- class arda._log.Throttle(seconds=30.0)[source]#
Bases:
objectOne-every-
secondsgate for a progress line inside a hot loop.A bulk map run flushes hundreds of chunks; logging each at INFO turns a 100 M-read sample into 500 lines of noise, and logging none leaves a multi-hour job with no sign of life. Time is the right axis, not chunk count – chunk wall time varies ~50x with the receptor fraction.
- Parameters:
seconds (float)
- arda._log.peak_rss_mb()[source]#
Peak RSS of this process AND its children, in MB; 0.0 where
resourceis absent.RUSAGE_SELFalone is wrong and was: 92 % of a map run’s wall time is spent inside the mmseqs subprocess, whose nucleotide prefilter allocates a4**kindex table that dominates the footprint. Reporting only the Python process understated peak RSS by roughly an order of magnitude.ru_maxrssis bytes on macOS, KB on Linux.⛔ Lives HERE, in the module with no arda imports, and is re-exported by
arda.rnaseq._res. The other direction is an import cycle:arda.rnaseq.__init__importsmap, which needsThrottle.- Return type:
float
Cluster sharding#
Multi-node (SLURM) sharding for very large inputs.
Single-node runs already stream in bounded memory (see annotate.mapper). For
cluster scale we split the input once into N shards, annotate each shard as an
independent SLURM array task, then concatenate the per-shard AIRR TSVs:
arda cluster split-fasta big.fastq work/shards –shards 50 # SLURM array task i: arda annotate -i work/shards/shard_<i>.fasta -o work/out/out_<i>.tsv … arda cluster merge work/out big.airr.tsv
arda slurm writes (and optionally submits) a single submit.sh that chains
all three with SLURM job dependencies — see render_submit_script.
- arda.cluster.split(input, out_dir, shards, *, prefix='shard')[source]#
Round-robin split a FASTA/FASTQ into
shardsFASTA files (one pass).Round-robin (record
k→ shardk % shards) balances load even when record sizes vary, and every record lands in exactly one shard.- Parameters:
input (str | Path)
out_dir (str | Path)
shards (int)
prefix (str)
- Return type:
list[Path]
- arda.cluster.split_pairs(r1, out_dir, *, shards, r2=None, prefix='shard')[source]#
Split FASTQ into
shardsCONTIGUOUS blocks of read pairs, byte for byte.Not
split(). That one writes FASTA — dropping the quality stringmerge_pair’s per-base tie-break needs under--reconstruct— and round-robins records, which puts a fragment’s two mates in different shards. Mate separation is not a hypothetical defect here: it produced a published false discovery in this project’s own data (a spurious “R2-only blind spot”) that had to be retracted.Contiguous, not round-robin, and that is load-bearing. Concatenating the per-shard Stage-1 AIRR in shard order then reproduces the single-node row order exactly, so Stage 2 and Stage 3 see byte-identical input and the sharded result is byte-identical to a single-node run. Round-robin would only give a permutation, and the clonotype fold is not permutation-invariant (correct collapses error children onto the parent it meets first, and coverage assignment is first-with-longest-overlap-wins).
R1 and R2 are cut at the same record boundaries, so mate k always lands in the same shard as mate k; no read ids are parsed to achieve it.
mapre-checks the pairing on every shard anyway.- Parameters:
r1 (str | Path) – FASTQ (optionally gzipped). Single-end if
r2is None.out_dir (str | Path) – written as
<prefix>_00000_R1.fastq(+_R2when paired).shards (int) – number of blocks; a shard receiving no records is not written.
r2 (str | Path | None) – the mate file.
prefix (str) – shard file stem.
- Returns:
(r1_path, r2_path | None)per non-empty shard, in shard order.- Raises:
ValueError – on FASTA input, a bad shard count, an empty input, or mates of different lengths (a truncated R2, caught here rather than after N wasted tasks).
- Return type:
list[tuple[Path, Path | None]]
- arda.cluster.merge(shard_outputs, output)[source]#
Concatenate per-shard AIRR TSVs into one, keeping a single header.
shard_outputsmay be a directory (its*.tsvare merged in sorted order) or an explicit list of files.- Parameters:
shard_outputs (str | Path | list[Path])
output (str | Path)
- Return type:
Path
- arda.cluster.render_submit_script(input, output, work_dir, *, shards, organism='human', seqtype='nt', threads=8, strand='both', map_d=True, partition=None, time='04:00:00', mem='8G', arda_mmseqs=None)[source]#
Render a
submit.shthat chains split → array-annotate → merge on SLURM.Uses
sbatch --arraywith--wrapand anafterokdependency so the merge runs only once every shard succeeds.arda_mmseqs(if given) is exported so array tasks find the binary.- Parameters:
input (str | Path)
output (str | Path)
work_dir (str | Path)
shards (int)
organism (str)
seqtype (str)
threads (int)
strand (str)
map_d (bool)
partition (str | None)
time (str)
mem (str)
arda_mmseqs (str | None)
- Return type:
str
- arda.cluster.render_rnaseq_submit_script(r1, out_prefix, work_dir, *, shards, r2=None, out_dir='.', organism='human', threads=8, kmer=12, min_score=75.0, reconstruct=False, assemble=True, complete_only=True, map_d=True, partition=None, time='04:00:00', mem='8G', reduce_time='08:00:00', reduce_mem='16G', arda_mmseqs=None)[source]#
Render a
submit.shchaining split → array-map→ reduce for paired RNA-seq.A sibling of
render_submit_script()rather than a generalisation of it. That one chains split → array-annotate→ merge over a single FASTA, and its last step is a pure concatenation. This chain differs in every step: the shard unit is a read pair, the files are FASTQ with quality, and the last step is a reduce — merge, then assemble and correct once over the whole merged AIRR. Folding both into one renderer would mean a parameter deciding which of two unrelated pipelines you get.Only Stage 1 is distributed. correct collapses error variants and counts distinct fragments globally, and assemble grows contigs across reads, so sharding either would double-count clones and silently drop exactly the long-CDR3 contigs Stage 3 exists to build.
Two details in the array body that are not cosmetic:
printf "%05d"— shard names are zero-padded sosorted()is numeric. The merge concatenates in name order and that order is read order.[ -s "$f" ] || exit 0— a shard with no reads must not fail its task, or theafterokdependency drops the whole reduce step. (split_pairsdoes not write empty shards; this is the belt to its braces, for a resubmitted or hand-edited array range.)
- Parameters:
r1 (str | Path)
out_prefix (str)
work_dir (str | Path)
shards (int)
r2 (str | Path | None)
out_dir (str | Path)
organism (str)
threads (int)
kmer (int)
min_score (float)
reconstruct (bool)
assemble (bool)
complete_only (bool)
map_d (bool)
partition (str | None)
time (str)
mem (str)
reduce_time (str)
reduce_mem (str)
arda_mmseqs (str | None)
- Return type:
str
Reference build#
Orchestrate the per-species reference database build.
For each locus: enumerate deduplicated V-J scaffolds, annotate them with IgBLAST,
keep those with complete FR1-FR4 + CDR1-3 markup, translate to protein, and
derive protein markup. Writes the committed artifacts under
database/vdj/<organism>/ plus a comprehensive build.log.
- arda.refbuild.build.build(organism='all', *, one_allele_per_gene=False, allow_chimeras=False)[source]#
Build one organism or
"all"supported organisms.- Parameters:
organism (str)
one_allele_per_gene (bool)
allow_chimeras (bool)
- Return type:
None
- arda.refbuild.build.build_species(organism, *, one_allele_per_gene=False, allow_chimeras=False)[source]#
Build the reference DB for one organism. Returns the output directory.
one_allele_per_genebuilds scaffolds from a single representative allele per gene (*01where it exists, else the lowest-numbered) – roughly a 4x smaller reference with no allele-level ambiguity. Off by default.allow_chimerasadditionally builds theTRDV × TRAJscaffolds the default refuses – seeloci_for()for the measurement that makes this a live question rather than a settled one. Off by default.- Parameters:
organism (str)
one_allele_per_gene (bool)
allow_chimeras (bool)
- Return type:
Path
Enumerate in-frame V-J reference scaffolds.
For markup transfer the FR/CDR region coordinates are fully determined by the V gene (FR1-3, CDR1-2, CDR3 start at the conserved Cys104) and the J gene (CDR3 end, FR4). The D segment lies inside the hypervariable CDR3 — query-specific at runtime — so we enumerate V×J scaffolds for every locus and, for VDJ loci, insert a short frame-neutral N spacer where D would sit so IgBLAST still annotates a plausible CDR3 + FR4.
Each scaffold is V + N*pad + J where pad keeps the J coding frame aligned
to V’s reading frame (jframe from the IgBLAST aux file). Byte-identical
scaffolds are deduplicated: one DB entry, with all contributing (V,J) allele
pairs recorded.
- class arda.refbuild.combinations.Scaffold(scaffold_id, locus, sequence, v_calls=<factory>, j_calls=<factory>, n_pad=0)[source]#
Bases:
objectA deduplicated V-J reference scaffold.
Fields:
scaffold_id(stable"{locus}_{index}"),locus,sequence(assembledV + N*pad + J),v_calls/j_calls(all alleles producing this scaffold), andn_pad(N nucleotides between V and J).- Parameters:
scaffold_id (str)
locus (str)
sequence (str)
v_calls (list[str])
j_calls (list[str])
n_pad (int)
- scaffold_id: str#
- locus: str#
- sequence: str#
- v_calls: list[str]#
- j_calls: list[str]#
- n_pad: int = 0#
- arda.refbuild.combinations.load_j_frames(organism)[source]#
Parse
bin/optional_file/<organism>_gl.aux-> {J allele: frame}.Frame is the 0-based “first coding frame start position” (column 2).
- Parameters:
organism (str)
- Return type:
dict[str, int]
- arda.refbuild.combinations.load_j_fr4_offsets(organism)[source]#
Parse the same aux file ->
{J allele: (cdr3_stop, extra_bp)}, both 0-based nt counts.IgBLAST’s aux carries five columns: allele, coding-frame start, chain type, CDR3 stop, and extra bp beyond the J coding end. arda only ever read column 2. Columns 4 and 5 pin FR4 inside the J exactly:
fwr4 = j_seq[cdr3_stop + 1 : len(j_seq) - extra_bp]
That is how a
J + Cscaffold gets an FR4 at all:igblastncannot annotate a V-less sequence, so those scaffolds are not routed through it (seerefbuild.build).Verified against every V-J scaffold arda builds: the string this yields is byte-identical to IgBLAST’s own
fwr4on all 125 human J alleles where both exist – including IgBLAST’s own non-multiple-of-3 cases (IGHJ6*02hasextra_bp = 0and a 34 nt FR4; 726 V-J scaffolds already carry one). Reproducing IgBLAST, quirks included, is the requirement here: the two scaffold kinds must agree, or a J->C read and a V-J read of the same clone disagree on FR4.Pseudogene J entries carry only three columns and are skipped – they have no FR4 to report.
- Parameters:
organism (str)
- Return type:
dict[str, tuple[int, int]]
- arda.refbuild.combinations.load_v_fwr3_stops(organism)[source]#
Parse
bin/internal_data/<organism>/<organism>.ndm.imgt-> {V allele: FWR3 stop}.IgBLAST’s own IMGT annotation of its V germlines. Column 11 is the 1-based FWR3 stop, and FR3-IMGT ends at position 104 – the conserved 2nd-CYS – so the Cys104 codon starts at
fwr3_stop - 3(0-based). This is the authoritative V junction anchor, and it is the same IgBLAST metadata the rest of the build already trusts.It does NOT cover every IMGT allele (IgBLAST ships a subset), hence the motif fallback in
refbuild.build._v_anchor.- Parameters:
organism (str)
- Return type:
dict[str, int]
- arda.refbuild.combinations.build_locus_scaffolds(locus, v_alleles, j_alleles, j_frames, *, d_spacer=None)[source]#
Build deduplicated V×J scaffolds for one locus.
- Parameters:
locus (Locus) – The locus definition.
v_alleles (dict[str, str]) –
{allele: ungapped V sequence}.j_alleles (dict[str, str]) –
{allele: ungapped J sequence}.j_frames (dict[str, int]) –
{J allele: 0-based coding frame}from the aux file.d_spacer (int | None) – N spacer length for VDJ loci (default
DEFAULT_D_SPACER_NT); forced to 0 for VJ loci.
- Returns:
Scaffolds, one per unique assembled sequence.
- Return type:
list[Scaffold]
Build a SEGMENT reference: V, J and J+C as separate targets, not a V×J product.
The shipped reference enumerates every V×J combination — 15,069 scaffolds from 775 V alleles and 124 J alleles. That costs, and it costs twice:
Speed. A read covering only V aligns against every scaffold carrying that V — a median of 13, and 67 for TRA. Measured on a TRA amplicon: 277 gapped alignments per hitting read, one kept. Aligning the same reads against 1,244 segment targets instead is 6.9× faster (TRA), 7.7× (TRB), 2.5× (bulk RNA-seq).
Accuracy. 81 % of hitting amplicon reads sit at the –max-seqs 300 cap, so the true scaffold is sometimes never even a candidate; and the V call is decided by a whole-scaffold bit score whose J half is arbitrary. Scored against IgBLAST truth on TRA, the segment reference takes V-gene concordance 99.00 % → 99.99 % (95 errors → 1) and J-gene 98.47 % → 99.90 %.
So this is not a speed/accuracy trade — the product reference was losing on both.
Derived from the built reference (markup.tsv + alleles.fasta), not from IMGT, so it needs no download and is reproducible from any checkout that can already map.
Coordinates carry over almost for free, which is why this is cheap to build correctly:
a V target is scaffold[:v_sequence_end], and fwr1/cdr1/fwr2/cdr2/fwr3 are already scaffold-relative, so they transfer unchanged. cdr3 is truncated at the V end (the read only ever sees the V-side stub of the junction here).
a J target is scaffold[j_sequence_start-1:vj_end], so every coordinate shifts by j_sequence_start - 1. It carries fwr4 and the J-side stub of cdr3.
a C target is scaffold[vj_end:] of a J+C scaffold — the constant region alone, one per distinct C allele. It carries no regions (a constant region has none of fwr1..fwr4) and no V or J call, only c_call.
The C side was the same cross-product, and this module used to leave it in place. The 345 J+C scaffolds were copied through verbatim, and they are a J×C product (IGH 14 J × 11 C, IGL 9 × 7, TRB 16 × 2) in which every scaffold of a locus ends in the same constant sequence. So a read reaching C was aligned against all of them to learn one c_call, at a redundancy factor equal to the locus’ J-allele count — 69× on TRA. Measured on a TRA amplicon: 345 of 1,244 targets (27.7 %) produced 76.4 % of all segment alignments, 4,977 alignments per target against 603 for a V target. Splitting them into the existing J| targets plus 25 C| targets takes the segment search to 1.89× and the alignment count to 4.23× fewer, with the V-and-J fast path down 0.81 %.
⚠ What this does NOT do. A read that spans the junction hits a V target and a J target separately, and something must merge the two into one AIRR record. That is 85.6 % of mapped amplicon reads but only 7.2 % of bulk RNA-seq reads, which is why the RNA-seq path is nearly free and the amplicon path is not. The merge lives in the mapper, not here; this module only builds the targets.
- arda.refbuild.segments.build_segment_reference(organism='human', *, out_dir=None)[source]#
Write
segments.fasta+segments.markup.tsvbeside the reference.One target per distinct (segment, allele): the longest scaffold instance of that allele is used as the donor, so a V target is never accidentally truncated by whichever J it happened to be paired with.
- Returns:
SegmentStats— target counts and the reduction factor vs the V×J reference.- Parameters:
organism (str)
out_dir (Path | None)
- Return type:
- class arda.refbuild.segments.SegmentStats(v_targets=0, j_targets=0, c_targets=0, source_scaffolds=0)[source]#
Bases:
objectWhat the build produced, for the report and for tests to assert on.
- Parameters:
v_targets (int)
j_targets (int)
c_targets (int)
source_scaffolds (int)
- v_targets: int = 0#
- j_targets: int = 0#
- c_targets: int = 0#
- source_scaffolds: int = 0#
- property total: int#
- property reduction: float#
Native nucleotide translation and reading-frame utilities.
No BioPython. The hot functions (translate, detect_coding_frame,
reverse_complement, back_translate) are implemented in the C++ extension
arda._markup and re-exported here; a pure-Python fallback keeps the module
importable if the extension is unavailable. These mirror mirpy’s mirseq API so
mirpy can later import arda and reuse them.
- arda.refbuild.translate.translate(nt, frame=0)[source]#
Translate a nucleotide string from
frame(0/1/2).- Parameters:
nt (str)
frame (int)
- Return type:
str
- arda.refbuild.translate.detect_coding_frame(nt)[source]#
Return the reading frame (0/1/2) with the fewest stop codons.
- Parameters:
nt (str)
- Return type:
int
- arda.refbuild.translate.reverse_complement(nt)[source]#
Reverse-complement a nucleotide string (non-ACGT ->
N).- Parameters:
nt (str)
- Return type:
str
- arda.refbuild.translate.back_translate(aa, unknown='NNN')[source]#
Mock back-translation via most-frequent human codons.
- Parameters:
aa (str)
unknown (str)
- Return type:
str
- arda.refbuild.translate.aa_coords_from_nt(nt_start, nt_end, coding_start)[source]#
Map a 1-based closed nt interval to 1-based closed aa coordinates.
- Parameters:
nt_start (int) – 1-based start of the region in the nucleotide sequence.
nt_end (int) – 1-based end (closed).
coding_start (int) – 1-based nt position where translation begins (frame origin).
- Returns:
(aa_start, aa_end)1-based closed, in the translated protein.- Return type:
tuple[int, int]
IMGT/V-QUEST germline reference download, parsing, and ungapping.
The IMGT V-QUEST reference directory ships gapped germline FASTAs laid out as
<Species>/<IG|TR>/<GENE>.fasta (e.g. Homo_sapiens/IG/IGHV.fasta).
Sequences carry IMGT-numbering gap dots; IgBLAST’s edit_imgt_file.pl ungaps
them and rewrites headers to bare allele names (what makeblastdb wants).
This module:
downloads & extracts the reference zip into
data/imgt(gitignored),parses the original gapped FASTA headers for per-allele functionality,
ungaps a gene file via
edit_imgt_file.plintodata/imgt/ungapped.
- class arda.refbuild.imgt.ImgtAllele(allele, functionality, sequence)[source]#
Bases:
objectA germline allele parsed from an IMGT FASTA header + sequence.
- Parameters:
allele (str)
functionality (str)
sequence (str)
- allele: str#
- functionality: str#
- sequence: str#
- property is_functional: bool#
- arda.refbuild.imgt.download_reference(*, force=False)[source]#
Download and extract the IMGT V-QUEST reference directory.
Returns the extraction root (containing the per-species directories). Idempotent unless
force.- Parameters:
force (bool)
- Return type:
Path
- arda.refbuild.imgt.gene_fasta_path(species_dir, group, gene_stem)[source]#
Path to a gene-type FASTA, e.g.
Homo_sapiens/IG/IGHV.fasta.Handles the occasional top-level wrapper directory inside the zip.
- Parameters:
species_dir (str)
group (str)
gene_stem (str)
- Return type:
Path
- arda.refbuild.imgt.parse_functionality(path)[source]#
Map allele name -> normalized functionality from gapped IMGT headers.
IMGT header:
accession|allele|species|functionality|region|.... The functionality field may be wrapped, e.g.(F)/[F]for inferred.- Parameters:
path (Path)
- Return type:
dict[str, str]
External tool wrappers#
Thin wrapper around the mmseqs binary.
Inspired by pymmseqs (MIT) but deliberately dependency-free: we only need
binary discovery, a subprocess runner, and the createdb / search /
convertalis (and easy-search) pipeline used by the annotator.
Discovery order for the binary: $ARDA_MMSEQS → the optional arda-mmseqs
companion wheel → <project>/bin/mmseqs → mmseqs on PATH. Candidates
after the explicit override are version-matched against the precompiled indexes
in database/: an index is only reusable by the mmseqs release that built it,
so accepting an arbitrary PATH binary silently discards the shipped index and
rebuilds a private cache. If nothing matches, a known-good static binary is
auto-fetched into <project>/bin/mmseqs (one-time, transparent) unless
$ARDA_NO_AUTO_FETCH is set — so neither pip nor conda users need to install
mmseqs manually – the binary is fetched on first use.
- exception arda.mmseqs.MMseqsError[source]#
Bases:
RuntimeErrorRaised when an
mmseqsinvocation exits non-zero.
- arda.mmseqs.mmseqs_binary()[source]#
Locate an mmseqs executable that can actually use the shipped indexes.
Resolution:
$ARDA_MMSEQS→ thearda-mmseqscompanion wheel →<project>/bin/mmseqs→mmseqsonPATH→ auto-fetched static build.Version-matched, not merely present. Taking whatever mmseqs happened to be on PATH was a silent correctness and performance bug: an index is only reusable by the release it was compiled with, so a mismatched binary makes every run reject
database/’s precompiled DBs and rebuild a private cache instead — no error, just a slow start and, if the two releases align differently, results that are not comparable with anyone else’s. Found in the wild: a cluster with a bare-git-hash build ahead of conda’s on PATH.$ARDA_MMSEQSis never version-checked — an explicit override is the user’s call. If nothing matches, the known-good static build is fetched (unless$ARDA_NO_AUTO_FETCH); if that also fails we fall back to the best candidate and warn, naming the consequence.- Return type:
str
- arda.mmseqs.run(args, *, check=True)[source]#
Run
mmseqs <args>capturing stdout/stderr.- Parameters:
args (list[str])
check (bool)
- Return type:
CompletedProcess
- arda.mmseqs.version_key(v)[source]#
Canonical form of a version string, for comparing an index marker to the running mmseqs.
mmseqs versionprints the same release+commit with different punctuation across builds: the official static binary says18-8cc5c, the bioconda build18.8cc5c. They are the same mmseqs and produce byte-compatible indexes; only the separator differs. Comparing the raw strings with==therefore rejected every committed index the moment the toolchain moved from conda’s mmseqs to the static one – so the precompiled DBs shipped indatabase/were never used and every run rebuilt a private cache instead.Fold each run of separator characters to a single
-and lowercase. This bridges the cosmetic difference while still distinguishing genuinely different versions (17-b804f!=18-8cc5c), so an incompatible index is never accepted.Not sufficient on its own – see
versions_compatible(), which is what callers should use.- Parameters:
v (str)
- Return type:
str
- arda.mmseqs.versions_compatible(a, b)[source]#
Do two
mmseqs versionstrings denote builds with interchangeable index formats?Punctuation is not the only way the same build spells itself. The official static release asset prints its full 40-char commit hash (
8cc5ce367b5638c4306c2d7cfc652dd099a4643f) while the bioconda build and the committed index marker print release+short-commit (18.8cc5c/18-8cc5c). Release 18 is commit8cc5c..., so those are one build – but no amount of separator folding makes the strings equal.That mattered concretely: arda’s own auto-fetched binary is the static asset, so a pure
version_key()comparison rejected the index arda itself ships, on every macOS install.So: compare commit hashes when both carry one, accepting a prefix match in either direction (short vs full form). Fall back to
version_key()when one has no hash at all. A genuinely different build has a different commit (76da68ad...is not8cc5c...) and is still rejected.- Parameters:
a (str)
b (str)
- Return type:
bool
- arda.mmseqs.createdb(fasta, db, *, dbtype=None)[source]#
Create an mmseqs sequence DB from a FASTA file.
dbtype:Noneauto-detect,1amino-acid,2nucleotide.- Parameters:
fasta (str | Path)
db (str | Path)
dbtype (int | None)
- Return type:
Path
- arda.mmseqs.search(query_db, target_db, result_db, tmp_dir, *, search_type=0, sensitivity=5.7, evalue=0.001, max_seqs=300, threads=1, kmer=None, extra=None)[source]#
Run
mmseqs searchwith backtrace enabled (-a).- Parameters:
kmer (int | None) – MMseqs2
-k. This is the memory knob. The nucleotide prefilter allocates a k-mer index table of 4**k entries, so the default k=15 costs 4**15 * 8 B ~ 8.6 GB regardless of database size, thread count or chunk size. k=13 costs ~0.7 GB. LeaveNonefor MMseqs2’s own default.query_db (str | Path)
target_db (str | Path)
result_db (str | Path)
tmp_dir (str | Path)
search_type (int)
sensitivity (float)
evalue (float)
max_seqs (int)
threads (int)
extra (list[str] | None)
- Return type:
Path
- arda.mmseqs.convertalis(query_db, target_db, result_db, out_tsv, *, format_output='query,target,qstart,qend,tstart,tend,qlen,tlen,alnlen,mismatch,gapopen,cigar,qaln,taln,evalue,bits,pident', threads=1, search_type=None)[source]#
Convert an alignment result DB to a TSV with the requested columns.
search_typemust be passed for nucleotide results (3) so convertalis can interpret the alignment; otherwise mmseqs cannot tell nt from translated.- Parameters:
query_db (str | Path)
target_db (str | Path)
result_db (str | Path)
out_tsv (str | Path)
format_output (str)
threads (int)
search_type (int | None)
- Return type:
Path
- arda.mmseqs.top_hit(result_db, out_db)[source]#
Reduce an alignment DB to the single best-scoring hit per query.
MMseqs2 already stores each query’s results sorted by descending score, so taking the first line per entry is the best hit – this is the idiom mmseqs’ own filterdb usage message shows. Verified on 100 k reads against the human reference: 4,101 queries, identical target and identical bit score to a full sort-and-dedupe in polars, on every one.
Why it matters: with –max-seqs 300, 4,101 hitting queries produced 804,341 alignment rows (194 MB of TSV, each row carrying cigar/qaln/taln). Parsing that dominated arda’s peak RSS – 877 MB, against 284 MB for the mmseqs subprocess itself. Reducing before convertalis writes 1.0 MB instead, and costs 0.04 s.
- Parameters:
result_db (str | Path)
out_db (str | Path)
- Return type:
Path
- arda.mmseqs.easy_search(query_fasta, target_fasta_or_db, out_tsv, tmp_dir, *, search_type=0, sensitivity=5.7, evalue=0.001, max_seqs=300, threads=1, format_output='query,target,qstart,qend,tstart,tend,qlen,tlen,alnlen,mismatch,gapopen,cigar,qaln,taln,evalue,bits,pident', strand=None, extra=None)[source]#
One-shot createdb+search+convertalis producing a TSV.
strand(nucleotide search only): 1 forward, 2 both strands;Nonelets mmseqs default (forward).- Parameters:
query_fasta (str | Path)
target_fasta_or_db (str | Path)
out_tsv (str | Path)
tmp_dir (str | Path)
search_type (int)
sensitivity (float)
evalue (float)
max_seqs (int)
threads (int)
format_output (str)
strand (int | None)
extra (list[str] | None)
- Return type:
Path
Wrapper around an IgBLAST release, fetched on demand.
Used at build time (Phase 1) to construct the curated reference DB, and by arda igblast,
which is how every gold-standard comparison in the benchmark is produced. The runtime annotator
does not depend on IgBLAST.
The release is a flat directory – executables plus the internal_data and optional_file
trees, with $IGDATA pointed at it:
igblastn igblastp makeblastdb edit_imgt_file.pl
internal_data/ optional_file/
Resolved by igblast_root(), in order:
$ARDA_IGBLAST– an explicit directory, never fetched;<project>/binif it already holds an IgBLAST (whatsetup.shproduces in a checkout);<project>/igblast, auto-fetched from NCBI on first use.
Step 3 is why this module changed shape. It used to resolve only through <project>/bin, so a
plain pip install arda-mapper – which has no checkout and never runs setup.sh – could
not run arda igblast at all. The failure was also misleading: it surfaced as
IgBlastError: IgBLAST ships no internal annotation for organism 'human', which names a
missing data file rather than a missing install, and so reads as a broken reference rather than
as “IgBLAST was never installed here”.
- exception arda.igblast.IgBlastError[source]#
Bases:
RuntimeErrorRaised when an IgBLAST tool invocation fails or is missing.
- arda.igblast.igblast_root()[source]#
The directory holding the IgBLAST executables and
internal_data.Fetches a release on first use;
$ARDA_NO_AUTO_FETCHturns that into an error instead.- Return type:
Path
- arda.igblast.igblast_version()[source]#
The auto-fetched IgBLAST release version, or None if it was not fetched by arda.
- Return type:
str | None
- arda.igblast.igdata_env()[source]#
Environment with
IGDATApointing at the IgBLAST data root.- Return type:
dict[str, str]
- arda.igblast.tool(name)[source]#
Resolve an IgBLAST executable, fetching the release if it is not installed yet.
- Parameters:
name (str)
- Return type:
Path
- arda.igblast.edit_imgt_file(imgt_fasta, out_fasta)[source]#
Ungap an IMGT germline FASTA via
edit_imgt_file.pl.- Parameters:
imgt_fasta (str | Path)
out_fasta (str | Path)
- Return type:
Path
- arda.igblast.makeblastdb(in_fasta, out_db, *, dbtype='nucl')[source]#
Build a germline BLAST database from an ungapped FASTA.
- Parameters:
in_fasta (str | Path)
out_db (str | Path)
dbtype (str)
- Return type:
Path
- arda.igblast.igblastn_airr(query_fasta, out_tsv, *, organism, germline_db_v, germline_db_j, germline_db_d=None, auxiliary_data=None, ig_seqtype='TCR', num_threads=1)[source]#
Run
igblastn -outfmt 19(AIRR rearrangement TSV).- Parameters:
query_fasta (str | Path)
out_tsv (str | Path)
organism (str)
germline_db_v (str | Path)
germline_db_j (str | Path)
germline_db_d (str | Path | None)
auxiliary_data (str | Path | None)
ig_seqtype (str)
num_threads (int)
- Return type:
Path
- arda.igblast.auxiliary_data(organism)[source]#
optional_file/<organism>_gl.aux— IgBLAST’s J-gene coding frames.⛔ Without this file IgBLAST silently emits no CDR3 and no junction. It is what tells igblastn each J allele’s reading frame, and with no frame there is nothing to place the Phe/Trp 118 anchor against. Everything else still works: V and J are called, v_score is normal, the process exits 0 — only cdr3*, junction and junction_aa come back empty, on every read.
It lives beside the executables, under
igblast_root(). Both callers used to look underpaths.bin_dir()and then fall back to passing nothing when the file was not there. Those two are THE SAME directory in a source checkout (setup.shinstalls IgBLAST into<repo>/bin), so it worked everywhere it was developed and failed on every auto-fetched install, where the root is$XDG_CACHE_HOME/arda/igblastwhilebin_dir()is$XDG_CACHE_HOME/arda/bin. Measured cost: a 10,000-read amplicon IgBLAST truth carrying j_call on 9,070 of 9,300 reads and junction_aa on zero — written up as an IgBLAST limitation at 151 bp before it was traced to here.- Raises:
IgBlastError – if the file is absent. A missing frame table must not degrade to “no junctions”: that is indistinguishable from a truth set which genuinely has none, which is exactly how this went unnoticed.
- Parameters:
organism (str)
- Return type:
Path
Cache layout and reference fetch#
Where arda keeps its reference and how a plain pip install acquires one.
Filesystem layout discovery for arda.
Two modes, resolved transparently:
Source checkout (
$ARDA_HOMEor an editable/development install) —bin/,data/anddatabase/live next topyproject.toml, exactly as committed.PyPI install (
pip install arda-mapper, no source tree) — there is no bundleddatabase/in the wheel (it is 50+ MB of curated references), so everything lives under a per-user cache ($XDG_CACHE_HOME/ardaor~/.cache/arda). The curated reference is auto-fetched once into the cache on first use (seearda._database_fetch), and mmseqs target DBs are built there on demand. NoARDA_HOMEneeded.
Set $ARDA_NO_AUTO_FETCH to disable the one-time reference download (e.g. air-gapped
runs where the cache was pre-populated).
- arda.paths.project_root()[source]#
Source checkout root if present, else the per-user cache root.
Unlike older arda, this never raises: a PyPI install with no source tree resolves to the cache dir, where the reference is auto-fetched and mmseqs DBs are built.
- Return type:
Path
- arda.paths.cache_root()[source]#
Per-user cache root (
$XDG_CACHE_HOME/ardaor~/.cache/arda).- Return type:
Path
- arda.paths.bin_dir()[source]#
Directory holding the downloaded mmseqs/IgBLAST binaries (gitignored / cached).
- Return type:
Path
- arda.paths.data_dir()[source]#
Writable scratch directory for downloads and built mmseqs DBs (gitignored / cached).
- Return type:
Path
- arda.paths.database_dir()[source]#
Curated reference database root.
Source checkout → the committed
database/. PyPI install →<cache>/database, with the reference auto-fetched on first call unless$ARDA_NO_AUTO_FETCHis set.- Return type:
Path
- arda.paths.vdj_dir(species=None)[source]#
database/vdj(or a single species subdirectory).- Parameters:
species (str | None)
- Return type:
Path
Auto-fetch the curated arda reference database (a GitHub release asset).
The wheel ships code only; the curated vdj/ reference (allele FASTAs + region markup, per
species, AA + NT; ~50 MB on disk, ~3 MB compressed) is published as the
arda-reference-vdj.tar.gz asset on the
matching vX.Y.Z GitHub release and downloaded once into the per-user cache on first use.
Version-sensitive precompiled mmseqs DBs are not shipped — they are built on demand from
the fetched FASTAs into <cache>/data.
- arda._database_fetch.reference_url(version=None)[source]#
Release-asset URL for the reference tarball.
Defaults to
_REFERENCE_TAG(the release that published the current reference), not to the running package version — see the note there.- Parameters:
version (str | None)
- Return type:
str
- arda._database_fetch.fetch_database(dest, *, force=False, version=None)[source]#
Download + extract the reference (
vdj/<org>/...) intodest; returndest.Skips the download if
dest/vdjalready exists (unlessforce).``vdj/`` must never be visible in a partial state, because its mere existence is the gate every other arda process tests (
paths.database_dir) — a half-populated tree is not a slow download, it is a silent wrong answer. Two things guarantee that:a
build_lock(), because arda is routinely run concurrently against the same cache (one Nextflow process per sample, one SLURM task per array index), and on first use in a fresh environment all of them would otherwise fetch into the same path at once;extraction into
destitself, then a singleos.replace(). The extraction directory has to be a sibling of the target for that to be a rename: the previous code extracted into/tmpand calledshutil.move, which silently degrades to a recursive copy across filesystems (/tmpand~/.cacheusually are different ones) — populatingvdj/file by file, in full view of every other process.
Extraction still rejects symlinks/hardlinks and any path escaping the staging dir.
- Parameters:
dest (Path)
force (bool)
version (str | None)
- Return type:
Path