deamtools 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deamtools/__main__.py +5 -0
- deamtools/align/__init__.py +6 -0
- deamtools/align/align.py +368 -0
- deamtools/align/index.py +144 -0
- deamtools/cli/__init__.py +0 -0
- deamtools/cli/main.py +1162 -0
- deamtools/footprint/__init__.py +5 -0
- deamtools/footprint/footprint.py +182 -0
- deamtools/motif/__init__.py +9 -0
- deamtools/motif/match.py +258 -0
- deamtools/preprocessing/__init__.py +0 -0
- deamtools/preprocessing/bam2bw.py +451 -0
- deamtools/preprocessing/bam2fragment.py +232 -0
- deamtools/preprocessing/fragment2bw.py +0 -0
- deamtools/qc/__init__.py +5 -0
- deamtools/qc/qc.py +910 -0
- deamtools/seq2edit/__init__.py +19 -0
- deamtools/seq2edit/dataset.py +59 -0
- deamtools/seq2edit/model.py +306 -0
- deamtools/seq2edit/train.py +253 -0
- deamtools/utils/__init__.py +16 -0
- deamtools/utils/_logging.py +11 -0
- deamtools/utils/chromosome.py +46 -0
- deamtools/utils/regions.py +120 -0
- deamtools/utils/version.py +18 -0
- deamtools-0.1.1.dist-info/METADATA +181 -0
- deamtools-0.1.1.dist-info/RECORD +30 -0
- deamtools-0.1.1.dist-info/WHEEL +4 -0
- deamtools-0.1.1.dist-info/entry_points.txt +2 -0
- deamtools-0.1.1.dist-info/licenses/LICENSE +21 -0
deamtools/__main__.py
ADDED
deamtools/align/align.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Deamination-aware alignment with a dual-conversion, take-best read converter.
|
|
2
|
+
|
|
3
|
+
FASTQ(s) --[convert x2]--> bwa mem -C --[group + pick best]--> <out_name>.sam
|
|
4
|
+
--[samtools sort]--> coordinate-sorted <out_name>.bam (+ .bai)
|
|
5
|
+
|
|
6
|
+
Because the ACCESS-ATAC deaminase edits cytosines on **both** strands, a single
|
|
7
|
+
read can carry both ``C->T`` and ``G->A`` deamination events, so converting it
|
|
8
|
+
in only one direction leaves the other as mismatches. Instead, every read is
|
|
9
|
+
emitted in **two** converted forms and bwa maps both; the better-scoring one is
|
|
10
|
+
kept:
|
|
11
|
+
|
|
12
|
+
* Single-end — two candidates per read: ``C->T`` (``YC:Z:ct``) and ``G->A``
|
|
13
|
+
(``YC:Z:ga``).
|
|
14
|
+
* Paired-end — two fragment orientations, with a consistent direction for the
|
|
15
|
+
whole pair: ``f`` = (read1 ``C->T``, read2 ``G->A``) and ``r`` =
|
|
16
|
+
(read1 ``G->A``, read2 ``C->T``).
|
|
17
|
+
|
|
18
|
+
Both candidates of a read/fragment share the original read name; the candidate
|
|
19
|
+
is marked with a ``YC:Z:`` tag and the original sequence stashed in ``YS:Z:``
|
|
20
|
+
(both carried through ``bwa mem -C``). In post-processing, records are grouped
|
|
21
|
+
by read name and the candidate with the higher primary alignment score (sum of
|
|
22
|
+
the mates' ``AS`` for pairs) is kept; the original SEQ is restored from ``YS``,
|
|
23
|
+
the ``f``/``r`` prefix is stripped from RNAME/RNEXT, and the ``YS``/``YC`` tags
|
|
24
|
+
are dropped. The restored SAM is written to ``<out_name>.sam`` and converted to
|
|
25
|
+
a coordinate-sorted, indexed BAM with samtools.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import io
|
|
31
|
+
import logging
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import shutil
|
|
35
|
+
import subprocess
|
|
36
|
+
import threading
|
|
37
|
+
|
|
38
|
+
import pysam
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
CT_TABLE = str.maketrans("Cc", "Tt")
|
|
43
|
+
GA_TABLE = str.maketrans("Gg", "Aa")
|
|
44
|
+
RC_TABLE = str.maketrans("ACGTNacgtn", "TGCANtgcan")
|
|
45
|
+
CIGAR_OP_RE = re.compile(r"(\d+)([MIDNSHP=X])")
|
|
46
|
+
|
|
47
|
+
# BWA-MEM options, matching bwa-meth 0.2.9 (`bwameth.py:bwa_mem`). A three-letter
|
|
48
|
+
# alignment leaves residual mismatches wherever the conversion runs the wrong way
|
|
49
|
+
# — for a dsDNA deaminase that is the minority-direction editing every read
|
|
50
|
+
# carries — so the defaults are miscalibrated for this data:
|
|
51
|
+
# -T 40 raise the minimum output score (with -B 2 a good alignment scores high)
|
|
52
|
+
# -B 2 halve the mismatch penalty; residual edits should not sink a read
|
|
53
|
+
# -L 10 raise the clipping penalty, so bwa resolves rather than clips them
|
|
54
|
+
# -M mark shorter split hits as secondary
|
|
55
|
+
# -C carry the YS/YC comment through into the SAM (deamtools relies on it)
|
|
56
|
+
# -U 100 (paired) heavily penalise unpaired placement
|
|
57
|
+
# Keeping these identical to bwa-meth's also makes the two directly comparable:
|
|
58
|
+
# any difference is then the read-conversion strategy, not parameter tuning.
|
|
59
|
+
BWA_MEM_OPTS = ("-T", "40", "-B", "2", "-L", "10", "-C", "-M")
|
|
60
|
+
BWA_MEM_PAIRED_OPTS = ("-U", "100")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _check_executable(name: str) -> None:
|
|
64
|
+
if shutil.which(name) is None:
|
|
65
|
+
raise RuntimeError(f"'{name}' was not found in PATH.")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _revcomp(seq: str) -> str:
|
|
69
|
+
return seq.translate(RC_TABLE)[::-1]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _hard_clip_offsets(cigar: str) -> tuple[int, int]:
|
|
73
|
+
if "H" not in cigar:
|
|
74
|
+
return 0, 0
|
|
75
|
+
ops = CIGAR_OP_RE.findall(cigar)
|
|
76
|
+
left = int(ops[0][0]) if ops and ops[0][1] == "H" else 0
|
|
77
|
+
right = int(ops[-1][0]) if ops and ops[-1][1] == "H" else 0
|
|
78
|
+
return left, right
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _write_record(
|
|
82
|
+
out: io.TextIOBase, name: str, seq: str, qual: str, candidate: str, original: str
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Write one converted FASTQ record.
|
|
85
|
+
|
|
86
|
+
The ``YS`` (original SEQ) and ``YC`` (candidate) tags are emitted as a
|
|
87
|
+
tab-separated comment so ``bwa mem -C`` copies each as its own SAM tag.
|
|
88
|
+
"""
|
|
89
|
+
out.write(f"@{name}\tYS:Z:{original}\tYC:Z:{candidate}\n{seq}\n+\n{qual}\n")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _feed_converted(
|
|
93
|
+
read1: str,
|
|
94
|
+
read2: str | None,
|
|
95
|
+
out: io.TextIOBase,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Stream both converted candidates of every read/fragment to ``out``."""
|
|
98
|
+
try:
|
|
99
|
+
if read2 is None:
|
|
100
|
+
with pysam.FastxFile(read1) as fq:
|
|
101
|
+
for r in fq:
|
|
102
|
+
seq = r.sequence
|
|
103
|
+
qual = r.quality if r.quality is not None else "I" * len(seq)
|
|
104
|
+
_write_record(out, r.name, seq.translate(CT_TABLE), qual, "ct", seq)
|
|
105
|
+
_write_record(out, r.name, seq.translate(GA_TABLE), qual, "ga", seq)
|
|
106
|
+
else:
|
|
107
|
+
with pysam.FastxFile(read1) as fq1, pysam.FastxFile(read2) as fq2:
|
|
108
|
+
for r1, r2 in zip(fq1, fq2, strict=True):
|
|
109
|
+
s1, s2 = r1.sequence, r2.sequence
|
|
110
|
+
q1 = r1.quality if r1.quality is not None else "I" * len(s1)
|
|
111
|
+
q2 = r2.quality if r2.quality is not None else "I" * len(s2)
|
|
112
|
+
# Orientation f: read1 C->T, read2 G->A (interleaved pair).
|
|
113
|
+
_write_record(out, r1.name, s1.translate(CT_TABLE), q1, "f", s1)
|
|
114
|
+
_write_record(out, r2.name, s2.translate(GA_TABLE), q2, "f", s2)
|
|
115
|
+
# Orientation r: read1 G->A, read2 C->T.
|
|
116
|
+
_write_record(out, r1.name, s1.translate(GA_TABLE), q1, "r", s1)
|
|
117
|
+
_write_record(out, r2.name, s2.translate(CT_TABLE), q2, "r", s2)
|
|
118
|
+
finally:
|
|
119
|
+
out.close()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _emit_clean_header(fasta_path: str, out: io.TextIOBase) -> None:
|
|
123
|
+
"""Write a fresh @HD + @SQ block from the original FASTA's .fai."""
|
|
124
|
+
out.write("@HD\tVN:1.6\tSO:coordinate\n")
|
|
125
|
+
with open(fasta_path + ".fai") as f:
|
|
126
|
+
for line in f:
|
|
127
|
+
chrom, length = line.split("\t")[:2]
|
|
128
|
+
out.write(f"@SQ\tSN:{chrom}\tLN:{length}\n")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _restore_alignment(line: str) -> str:
|
|
132
|
+
fields = line.rstrip("\n").split("\t")
|
|
133
|
+
if len(fields) < 11:
|
|
134
|
+
return line
|
|
135
|
+
|
|
136
|
+
rname = fields[2]
|
|
137
|
+
if rname not in ("", "*") and rname[0] in ("f", "r"):
|
|
138
|
+
fields[2] = rname[1:]
|
|
139
|
+
rnext = fields[6]
|
|
140
|
+
if rnext not in ("", "*", "=") and rnext[0] in ("f", "r"):
|
|
141
|
+
fields[6] = rnext[1:]
|
|
142
|
+
|
|
143
|
+
flag = int(fields[1])
|
|
144
|
+
cigar = fields[5]
|
|
145
|
+
seq_field = fields[9]
|
|
146
|
+
|
|
147
|
+
orig: str | None = None
|
|
148
|
+
kept_tags: list[str] = []
|
|
149
|
+
for tag in fields[11:]:
|
|
150
|
+
if tag.startswith("YS:Z:"):
|
|
151
|
+
orig = tag[5:]
|
|
152
|
+
elif tag.startswith("YC:Z:"):
|
|
153
|
+
continue # candidate marker; internal only
|
|
154
|
+
else:
|
|
155
|
+
kept_tags.append(tag)
|
|
156
|
+
|
|
157
|
+
if orig is not None and seq_field != "*":
|
|
158
|
+
if flag & 16:
|
|
159
|
+
orig = _revcomp(orig)
|
|
160
|
+
left, right = _hard_clip_offsets(cigar)
|
|
161
|
+
if left or right:
|
|
162
|
+
orig = orig[left : len(orig) - right]
|
|
163
|
+
if len(orig) == len(seq_field):
|
|
164
|
+
fields[9] = orig
|
|
165
|
+
|
|
166
|
+
fields = fields[:11] + kept_tags
|
|
167
|
+
return "\t".join(fields) + "\n"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _tag_value(fields: list[str], prefix: str) -> str | None:
|
|
171
|
+
"""Value of a SAM tag (e.g. ``"YC:Z:"`` or ``"AS:i:"``) in ``fields[11:]``."""
|
|
172
|
+
for tag in fields[11:]:
|
|
173
|
+
if tag.startswith(prefix):
|
|
174
|
+
return tag[len(prefix):]
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _primary_score(lines: list[str]) -> int:
|
|
179
|
+
"""Sum of ``AS`` over a candidate's primary records (mates of a pair).
|
|
180
|
+
|
|
181
|
+
Secondary (0x100) and supplementary (0x800) records are ignored; an
|
|
182
|
+
unmapped primary contributes -1 so any mapped placement outranks it.
|
|
183
|
+
"""
|
|
184
|
+
total = 0
|
|
185
|
+
for line in lines:
|
|
186
|
+
fields = line.rstrip("\n").split("\t")
|
|
187
|
+
flag = int(fields[1])
|
|
188
|
+
if flag & 0x100 or flag & 0x800:
|
|
189
|
+
continue
|
|
190
|
+
if flag & 0x4:
|
|
191
|
+
total += -1
|
|
192
|
+
continue
|
|
193
|
+
as_val = _tag_value(fields, "AS:i:")
|
|
194
|
+
total += int(as_val) if as_val is not None else 0
|
|
195
|
+
return total
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _flush_group(lines: list[str], out: io.TextIOBase) -> None:
|
|
199
|
+
"""Pick the best candidate among ``lines`` (one read name) and emit it.
|
|
200
|
+
|
|
201
|
+
Records are partitioned by their ``YC`` candidate tag; the candidate with
|
|
202
|
+
the highest primary alignment score is restored and written, the others are
|
|
203
|
+
dropped. On a tie the first-seen candidate wins (``ct``/``f``).
|
|
204
|
+
"""
|
|
205
|
+
by_candidate: dict[str, list[str]] = {}
|
|
206
|
+
for line in lines:
|
|
207
|
+
key = _tag_value(line.rstrip("\n").split("\t"), "YC:Z:") or ""
|
|
208
|
+
by_candidate.setdefault(key, []).append(line)
|
|
209
|
+
|
|
210
|
+
best = max(by_candidate, key=lambda k: _primary_score(by_candidate[k]))
|
|
211
|
+
for line in by_candidate[best]:
|
|
212
|
+
out.write(_restore_alignment(line))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _process_sam(
|
|
216
|
+
bwa_stdout: io.TextIOBase,
|
|
217
|
+
sort_stdin: io.TextIOBase,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Group bwa output by read name and write the best candidate of each.
|
|
220
|
+
|
|
221
|
+
bwa-mem preserves input order, so a read's two converted candidates (which
|
|
222
|
+
share the read name) are emitted consecutively; records are buffered until
|
|
223
|
+
the read name changes, then the best candidate is chosen and restored.
|
|
224
|
+
"""
|
|
225
|
+
group: list[str] = []
|
|
226
|
+
group_qname: str | None = None
|
|
227
|
+
for line in bwa_stdout:
|
|
228
|
+
if line.startswith("@"):
|
|
229
|
+
# @HD and @SQ are emitted from the FASTA index; pass through
|
|
230
|
+
# everything else (@PG, @RG, @CO).
|
|
231
|
+
if line.startswith(("@HD", "@SQ")):
|
|
232
|
+
continue
|
|
233
|
+
sort_stdin.write(line)
|
|
234
|
+
continue
|
|
235
|
+
tab = line.find("\t")
|
|
236
|
+
qname = line[:tab] if tab != -1 else line.rstrip("\n")
|
|
237
|
+
if group and qname != group_qname:
|
|
238
|
+
_flush_group(group, sort_stdin)
|
|
239
|
+
group = []
|
|
240
|
+
group_qname = qname
|
|
241
|
+
group.append(line)
|
|
242
|
+
if group:
|
|
243
|
+
_flush_group(group, sort_stdin)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def run_align(
|
|
247
|
+
fasta_path: str,
|
|
248
|
+
read1: str,
|
|
249
|
+
out_dir: str,
|
|
250
|
+
out_name: str,
|
|
251
|
+
read2: str | None = None,
|
|
252
|
+
threads: int = 1,
|
|
253
|
+
read_group: str | None = None,
|
|
254
|
+
index_path: str | None = None,
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Align deaminated reads and write a sorted, indexed BAM.
|
|
257
|
+
|
|
258
|
+
The BAM is written to ``<out_dir>/<out_name>.bam`` (with a companion
|
|
259
|
+
``.bai`` index). The reference must already have been prepared with
|
|
260
|
+
:func:`deamtools.align.index.run_index`.
|
|
261
|
+
|
|
262
|
+
Parameters
|
|
263
|
+
----------
|
|
264
|
+
fasta_path : str
|
|
265
|
+
Reference FASTA previously indexed with ``deamtools index``.
|
|
266
|
+
read1 : str
|
|
267
|
+
FASTQ for read 1 (or the only FASTQ for single-end input). Plain or
|
|
268
|
+
gzipped.
|
|
269
|
+
out_dir : str
|
|
270
|
+
Output directory. Created if it does not exist.
|
|
271
|
+
out_name : str
|
|
272
|
+
Base name (without extension) for the output; writes
|
|
273
|
+
``<out_dir>/<out_name>.bam``.
|
|
274
|
+
read2 : str, optional
|
|
275
|
+
FASTQ for read 2 (paired-end). Omit for single-end alignment.
|
|
276
|
+
threads : int, default 1
|
|
277
|
+
Total threads, split between ``bwa mem`` and ``samtools sort``.
|
|
278
|
+
read_group : str, optional
|
|
279
|
+
Read-group line passed to ``bwa mem -R``.
|
|
280
|
+
index_path : str, optional
|
|
281
|
+
Path to the converted reference built by ``deamtools index``
|
|
282
|
+
(``<out_dir>/<out_name>.deamtools.c2t``). Use this when the index was
|
|
283
|
+
built with a custom ``--out_dir`` / ``--out_name``. Defaults to
|
|
284
|
+
``<fasta>.deamtools.c2t`` (next to the FASTA).
|
|
285
|
+
"""
|
|
286
|
+
converted_path = (
|
|
287
|
+
index_path if index_path is not None else fasta_path + ".deamtools.c2t"
|
|
288
|
+
)
|
|
289
|
+
if not os.path.exists(converted_path + ".bwt"):
|
|
290
|
+
raise FileNotFoundError(
|
|
291
|
+
f"BWA index not found at {converted_path}.bwt — run "
|
|
292
|
+
f"'deamtools index --fasta {fasta_path}' first, and pass its "
|
|
293
|
+
f"--out_dir/--out_name location here via --index if it was custom."
|
|
294
|
+
)
|
|
295
|
+
if not os.path.exists(fasta_path + ".fai"):
|
|
296
|
+
raise FileNotFoundError(
|
|
297
|
+
f"FASTA index not found at {fasta_path}.fai — "
|
|
298
|
+
f"run 'deamtools index --fasta {fasta_path}' first."
|
|
299
|
+
)
|
|
300
|
+
if not os.path.exists(read1):
|
|
301
|
+
raise FileNotFoundError(f"FASTQ not found: {read1}")
|
|
302
|
+
if read2 is not None and not os.path.exists(read2):
|
|
303
|
+
raise FileNotFoundError(f"FASTQ not found: {read2}")
|
|
304
|
+
_check_executable("bwa")
|
|
305
|
+
_check_executable("samtools")
|
|
306
|
+
|
|
307
|
+
output_bam = os.path.join(out_dir, f"{out_name}.bam")
|
|
308
|
+
sam_path = os.path.join(out_dir, f"{out_name}.sam")
|
|
309
|
+
paired = read2 is not None
|
|
310
|
+
logger.info(f"Aligning {'paired' if paired else 'single'}-end reads")
|
|
311
|
+
logger.info(f" R1: {read1}")
|
|
312
|
+
if paired:
|
|
313
|
+
logger.info(f" R2: {read2}")
|
|
314
|
+
logger.info(f" Index: {converted_path}")
|
|
315
|
+
logger.info(f" Out: {output_bam}")
|
|
316
|
+
|
|
317
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
318
|
+
|
|
319
|
+
bwa_cmd = ["bwa", "mem", *BWA_MEM_OPTS, "-t", str(threads)]
|
|
320
|
+
if paired:
|
|
321
|
+
bwa_cmd += [*BWA_MEM_PAIRED_OPTS, "-p"]
|
|
322
|
+
if read_group is not None:
|
|
323
|
+
bwa_cmd += ["-R", read_group]
|
|
324
|
+
bwa_cmd += [converted_path, "-"]
|
|
325
|
+
|
|
326
|
+
# Step 1: bwa mem -> restore original sequences/names -> <out_name>.sam.
|
|
327
|
+
logger.info(f"bwa mem ({threads}t), dual conversion + take-best -> {sam_path}")
|
|
328
|
+
bwa_proc = subprocess.Popen(
|
|
329
|
+
bwa_cmd,
|
|
330
|
+
stdin=subprocess.PIPE,
|
|
331
|
+
stdout=subprocess.PIPE,
|
|
332
|
+
text=True,
|
|
333
|
+
bufsize=1 << 20,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
feeder_exc: list[BaseException] = []
|
|
337
|
+
|
|
338
|
+
def _feed():
|
|
339
|
+
try:
|
|
340
|
+
_feed_converted(read1, read2, bwa_proc.stdin)
|
|
341
|
+
except BaseException as e:
|
|
342
|
+
feeder_exc.append(e)
|
|
343
|
+
|
|
344
|
+
feeder = threading.Thread(target=_feed, daemon=True)
|
|
345
|
+
feeder.start()
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
with open(sam_path, "w") as sam:
|
|
349
|
+
_emit_clean_header(fasta_path, sam)
|
|
350
|
+
_process_sam(bwa_proc.stdout, sam)
|
|
351
|
+
finally:
|
|
352
|
+
feeder.join()
|
|
353
|
+
|
|
354
|
+
bwa_rc = bwa_proc.wait()
|
|
355
|
+
if feeder_exc:
|
|
356
|
+
raise feeder_exc[0]
|
|
357
|
+
if bwa_rc != 0:
|
|
358
|
+
raise subprocess.CalledProcessError(bwa_rc, bwa_cmd)
|
|
359
|
+
|
|
360
|
+
# Step 2: convert the SAM to a coordinate-sorted, indexed BAM.
|
|
361
|
+
logger.info(f"samtools sort ({threads}t) {sam_path} -> {output_bam}")
|
|
362
|
+
subprocess.run(
|
|
363
|
+
["samtools", "sort", "-@", str(threads), "-o", output_bam, sam_path],
|
|
364
|
+
check=True,
|
|
365
|
+
)
|
|
366
|
+
logger.info("samtools index ...")
|
|
367
|
+
subprocess.run(["samtools", "index", output_bam], check=True)
|
|
368
|
+
logger.info("Done")
|
deamtools/align/index.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Build a deamination-aware BWA index for a reference FASTA.
|
|
2
|
+
|
|
3
|
+
Following the bwa-meth strategy, the index is built on a *doubly converted*
|
|
4
|
+
copy of the reference: every chromosome appears twice — once with all
|
|
5
|
+
cytosines converted to thymine (``C->T``, prefixed ``f``) and once with all
|
|
6
|
+
guanines converted to adenine (``G->A``, prefixed ``r``). Both are conversions
|
|
7
|
+
of the *forward* sequence; the ``f``/``r`` prefixes denote which deaminated
|
|
8
|
+
read population maps there (top-strand-derived ``C->T`` reads to ``f``,
|
|
9
|
+
bottom-strand-derived ``G->A`` reads to ``r``). Reducing the alphabet this way
|
|
10
|
+
lets BWA-MEM map heavily deaminated reads, and both mates of a pair land on the
|
|
11
|
+
same converted contig so proper pairing is preserved. The ``f``/``r`` prefix is
|
|
12
|
+
stripped from the chromosome name during ``deamtools align``.
|
|
13
|
+
|
|
14
|
+
Outputs:
|
|
15
|
+
|
|
16
|
+
- ``<fasta>.fai`` — samtools faidx of the original
|
|
17
|
+
(always written next to the FASTA; required by the pysam-based subcommands)
|
|
18
|
+
- ``<out_dir>/<out_name>.deamtools.c2t`` — doubly-converted reference
|
|
19
|
+
- ``<out_dir>/<out_name>.deamtools.c2t.{amb,ann,bwt,pac,sa}`` — BWA-MEM index files
|
|
20
|
+
|
|
21
|
+
``out_dir`` / ``out_name`` default to the FASTA's own directory and file name.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
CT_TABLE = str.maketrans("Cc", "Tt")
|
|
34
|
+
GA_TABLE = str.maketrans("Gg", "Aa")
|
|
35
|
+
LINE_WIDTH = 80
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check_executable(name: str) -> None:
|
|
39
|
+
if shutil.which(name) is None:
|
|
40
|
+
raise RuntimeError(
|
|
41
|
+
f"'{name}' was not found in PATH. Install it before running 'deamtools index'."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _convert_fasta(fasta_path: str, output_path: str) -> None:
|
|
46
|
+
"""Stream FASTA, writing both ``f`` (C->T) and ``r`` (G->A) entries per chromosome."""
|
|
47
|
+
|
|
48
|
+
def flush(header: str | None, seq_parts: list[str], out) -> None:
|
|
49
|
+
if header is None:
|
|
50
|
+
return
|
|
51
|
+
seq = "".join(seq_parts)
|
|
52
|
+
out.write(f">f{header}\n")
|
|
53
|
+
for i in range(0, len(seq), LINE_WIDTH):
|
|
54
|
+
out.write(seq[i : i + LINE_WIDTH].translate(CT_TABLE) + "\n")
|
|
55
|
+
out.write(f">r{header}\n")
|
|
56
|
+
for i in range(0, len(seq), LINE_WIDTH):
|
|
57
|
+
out.write(seq[i : i + LINE_WIDTH].translate(GA_TABLE) + "\n")
|
|
58
|
+
|
|
59
|
+
header: str | None = None
|
|
60
|
+
seq_parts: list[str] = []
|
|
61
|
+
|
|
62
|
+
with open(fasta_path) as fin, open(output_path, "w") as fout:
|
|
63
|
+
for line in fin:
|
|
64
|
+
if line.startswith(">"):
|
|
65
|
+
flush(header, seq_parts, fout)
|
|
66
|
+
header = line[1:].split()[0].strip()
|
|
67
|
+
seq_parts = []
|
|
68
|
+
else:
|
|
69
|
+
seq_parts.append(line.strip())
|
|
70
|
+
flush(header, seq_parts, fout)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run_index(
|
|
74
|
+
fasta_path: str,
|
|
75
|
+
out_dir: str | None = None,
|
|
76
|
+
out_name: str | None = None,
|
|
77
|
+
force: bool = False,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Build the deamtools BWA index for ``fasta_path``.
|
|
80
|
+
|
|
81
|
+
The converted reference and its BWA-MEM index are written to
|
|
82
|
+
``<out_dir>/<out_name>.deamtools.c2t*``. When ``out_dir`` / ``out_name`` are
|
|
83
|
+
omitted they default to the FASTA's own directory and file name, so the
|
|
84
|
+
index lands next to the FASTA — the location :func:`deamtools.align.run_align`
|
|
85
|
+
looks in by default.
|
|
86
|
+
|
|
87
|
+
The standard FASTA index (``<fasta>.fai``) is always written next to the
|
|
88
|
+
original FASTA regardless of ``out_dir`` / ``out_name``, because the
|
|
89
|
+
pysam-based subcommands (``bam2bw``, ``bam2fragment``, ``qc``, ...) require
|
|
90
|
+
it there.
|
|
91
|
+
|
|
92
|
+
Skips work that has already been done unless ``force=True``.
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
fasta_path : str
|
|
97
|
+
Reference FASTA to index.
|
|
98
|
+
out_dir : str, optional
|
|
99
|
+
Directory for the converted reference + BWA index. Defaults to the
|
|
100
|
+
FASTA's directory.
|
|
101
|
+
out_name : str, optional
|
|
102
|
+
Base name for the converted reference + BWA index. Defaults to the
|
|
103
|
+
FASTA file name.
|
|
104
|
+
force : bool, default False
|
|
105
|
+
Rebuild outputs even if they already exist.
|
|
106
|
+
"""
|
|
107
|
+
if not os.path.exists(fasta_path):
|
|
108
|
+
raise FileNotFoundError(f"FASTA not found: {fasta_path}")
|
|
109
|
+
|
|
110
|
+
_check_executable("bwa")
|
|
111
|
+
_check_executable("samtools")
|
|
112
|
+
|
|
113
|
+
if out_dir is None:
|
|
114
|
+
out_dir = os.path.dirname(fasta_path) or "."
|
|
115
|
+
if out_name is None:
|
|
116
|
+
out_name = os.path.basename(fasta_path)
|
|
117
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
118
|
+
|
|
119
|
+
logger.info(f"Indexing {fasta_path}")
|
|
120
|
+
|
|
121
|
+
# The .fai must sit next to the original FASTA for pysam-based subcommands.
|
|
122
|
+
fai_path = fasta_path + ".fai"
|
|
123
|
+
if force or not os.path.exists(fai_path):
|
|
124
|
+
logger.info("samtools faidx ...")
|
|
125
|
+
subprocess.run(["samtools", "faidx", fasta_path], check=True)
|
|
126
|
+
else:
|
|
127
|
+
logger.info(f" {fai_path} exists; skipping faidx")
|
|
128
|
+
|
|
129
|
+
converted_path = os.path.join(out_dir, f"{out_name}.deamtools.c2t")
|
|
130
|
+
if force or not os.path.exists(converted_path):
|
|
131
|
+
logger.info(f" writing converted reference to {converted_path}")
|
|
132
|
+
_convert_fasta(fasta_path, converted_path)
|
|
133
|
+
else:
|
|
134
|
+
logger.info(f" {converted_path} exists; skipping conversion")
|
|
135
|
+
|
|
136
|
+
bwt_path = converted_path + ".bwt"
|
|
137
|
+
if force or not os.path.exists(bwt_path):
|
|
138
|
+
logger.info("bwa index (this may take a while) ...")
|
|
139
|
+
subprocess.run(["bwa", "index", converted_path], check=True)
|
|
140
|
+
else:
|
|
141
|
+
logger.info(f" {bwt_path} exists; skipping bwa index")
|
|
142
|
+
|
|
143
|
+
logger.info(f"Converted index: {converted_path}")
|
|
144
|
+
logger.info("Done")
|
|
File without changes
|