directclean 0.1.0__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.
- directclean/__init__.py +14 -0
- directclean/__main__.py +6 -0
- directclean/cli/__init__.py +1 -0
- directclean/cli/app.py +289 -0
- directclean/external/__init__.py +1 -0
- directclean/external/breakinator.py +386 -0
- directclean/external/configs/PCB109.json +20 -0
- directclean/external/dependencies.py +124 -0
- directclean/external/minimap2.py +275 -0
- directclean/external/restrander.py +282 -0
- directclean/filter/__init__.py +48 -0
- directclean/filter/artifact_classifier.py +556 -0
- directclean/filter/homopolymer.py +168 -0
- directclean/filter/junction_parser.py +392 -0
- directclean/pipeline.py +542 -0
- directclean/report/__init__.py +10 -0
- directclean/report/html_report.py +1129 -0
- directclean/rescuer/__init__.py +48 -0
- directclean/rescuer/adapter_finder.py +445 -0
- directclean/rescuer/adaptor_seq.py +98 -0
- directclean/rescuer/chopper.py +465 -0
- directclean/rescuer/unknowns_rescuer.py +321 -0
- directclean/utils/__init__.py +0 -0
- directclean/utils/io.py +339 -0
- directclean/utils/sequence_operator.py +308 -0
- directclean/utils/stats.py +77 -0
- directclean-0.1.0.dist-info/LICENSE +0 -0
- directclean-0.1.0.dist-info/METADATA +184 -0
- directclean-0.1.0.dist-info/RECORD +31 -0
- directclean-0.1.0.dist-info/WHEEL +4 -0
- directclean-0.1.0.dist-info/entry_points.txt +3 -0
directclean/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DirectClean — Remove RT artifacts from Oxford Nanopore Direct-cDNA sequencing.
|
|
3
|
+
|
|
4
|
+
A comprehensive preprocessing pipeline that detects and removes:
|
|
5
|
+
1. Internal TSO/RTP adapter chimeras (Rescuer module)
|
|
6
|
+
2. Homopolymer-mediated RT template switching artifacts (Filter module)
|
|
7
|
+
|
|
8
|
+
Typical CLI usage::
|
|
9
|
+
|
|
10
|
+
directclean -i reads.fastq -r genome.fa -o results/ -t 8
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
__author__ = "Qingxiang Guo"
|
directclean/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""DirectClean CLI package."""
|
directclean/cli/app.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DirectClean command-line interface.
|
|
3
|
+
|
|
4
|
+
Single-command design — no subcommands::
|
|
5
|
+
|
|
6
|
+
directclean -i reads.fastq -r genome.fa -o results/ -t 8
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.logging import RichHandler
|
|
18
|
+
|
|
19
|
+
from directclean import __version__
|
|
20
|
+
from directclean.pipeline import DirectCleanPipeline, PipelineConfig
|
|
21
|
+
from directclean.rescuer.adaptor_seq import AdapterConfig
|
|
22
|
+
from directclean.filter.homopolymer import HomopolymerConfig
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
LOGO = """
|
|
27
|
+
[bold cyan]╔══════════════════════════════════════════════════════════════╗
|
|
28
|
+
║ ____ _ _ ____ _ ║
|
|
29
|
+
║ | _ \\(_)_ __ ___ ___| |_ / ___| | ___ __ _ _ __ ║
|
|
30
|
+
║ | | | | | '__/ _ \\/ __| __| | | |/ _ \\/ _` | '_ \\ ║
|
|
31
|
+
║ | |_| | | | | __/ (__| |_| |___| | __/ (_| | | | | ║
|
|
32
|
+
║ |____/|_|_| \\___|\\___|\\__|\\____|_|\\___|\\__,_|_| |_| ║
|
|
33
|
+
╚══════════════════════════════════════════════════════════════╝[/]
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
HELP_TEXT = (
|
|
37
|
+
"DirectClean — Strand orientation, artifact removal, and chimeric "
|
|
38
|
+
"read rescue for Oxford Nanopore direct-cDNA sequencing.\n\n"
|
|
39
|
+
"Removes foldback inversions and homopolymer-mediated RT template "
|
|
40
|
+
"switching artifacts that existing tools do not address. "
|
|
41
|
+
"Chimeric reads are chopped at artifact junctions and flanking "
|
|
42
|
+
"sub-reads are rescued.\n\n"
|
|
43
|
+
"Pipeline: Breakinator → Restrander → Unknowns Rescue → "
|
|
44
|
+
"Adapter Rescue → Homopolymer Rescue"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
app = typer.Typer(
|
|
48
|
+
name="directclean",
|
|
49
|
+
help=HELP_TEXT,
|
|
50
|
+
add_completion=False,
|
|
51
|
+
no_args_is_help=True,
|
|
52
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _setup_logging(verbose: bool) -> None:
|
|
57
|
+
"""Configure logging with Rich handler."""
|
|
58
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
59
|
+
logging.basicConfig(
|
|
60
|
+
level=level,
|
|
61
|
+
format="%(message)s",
|
|
62
|
+
datefmt="[%X]",
|
|
63
|
+
handlers=[
|
|
64
|
+
RichHandler(
|
|
65
|
+
console=console,
|
|
66
|
+
show_path=False,
|
|
67
|
+
rich_tracebacks=True,
|
|
68
|
+
)
|
|
69
|
+
],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _version_callback(value: bool) -> None:
|
|
74
|
+
if value:
|
|
75
|
+
console.print(LOGO)
|
|
76
|
+
console.print(f" v{__version__}")
|
|
77
|
+
raise typer.Exit()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Main command
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.callback(invoke_without_command=True)
|
|
86
|
+
def main(
|
|
87
|
+
# ---- Required ----
|
|
88
|
+
input_fastq: Path = typer.Option(
|
|
89
|
+
...,
|
|
90
|
+
"--input",
|
|
91
|
+
"-i",
|
|
92
|
+
help="Raw input FASTQ file from Oxford Nanopore Direct-cDNA sequencing.",
|
|
93
|
+
exists=True,
|
|
94
|
+
dir_okay=False,
|
|
95
|
+
readable=True,
|
|
96
|
+
),
|
|
97
|
+
reference: Path = typer.Option(
|
|
98
|
+
...,
|
|
99
|
+
"--reference",
|
|
100
|
+
"-r",
|
|
101
|
+
help="Reference genome FASTA file.",
|
|
102
|
+
exists=True,
|
|
103
|
+
dir_okay=False,
|
|
104
|
+
readable=True,
|
|
105
|
+
),
|
|
106
|
+
output_dir: Path = typer.Option(
|
|
107
|
+
...,
|
|
108
|
+
"--output",
|
|
109
|
+
"-o",
|
|
110
|
+
help="Output directory for results.",
|
|
111
|
+
),
|
|
112
|
+
# ---- General ----
|
|
113
|
+
threads: int = typer.Option(
|
|
114
|
+
4,
|
|
115
|
+
"--threads",
|
|
116
|
+
"-t",
|
|
117
|
+
help="Number of threads for minimap2, samtools, and breakinator.",
|
|
118
|
+
min=1,
|
|
119
|
+
),
|
|
120
|
+
prefix: str = typer.Option(
|
|
121
|
+
"directclean",
|
|
122
|
+
"--prefix",
|
|
123
|
+
"-p",
|
|
124
|
+
help="Filename prefix for output files.",
|
|
125
|
+
),
|
|
126
|
+
# ---- Breakinator parameters ----
|
|
127
|
+
junc_bed: Path | None = typer.Option(
|
|
128
|
+
None,
|
|
129
|
+
"--junc-bed",
|
|
130
|
+
"-j",
|
|
131
|
+
help=(
|
|
132
|
+
"Junction BED12 file for guided minimap2 alignment "
|
|
133
|
+
"(used by Breakinator stage). Recommended: GENCODE annotation."
|
|
134
|
+
),
|
|
135
|
+
exists=True,
|
|
136
|
+
dir_okay=False,
|
|
137
|
+
readable=True,
|
|
138
|
+
),
|
|
139
|
+
# ---- Rescuer parameters ----
|
|
140
|
+
max_edit_distance: int = typer.Option(
|
|
141
|
+
3,
|
|
142
|
+
"--max-edit-dist",
|
|
143
|
+
help="Maximum edit distance for adapter fuzzy matching.",
|
|
144
|
+
min=0,
|
|
145
|
+
max=5,
|
|
146
|
+
),
|
|
147
|
+
min_confidence: int = typer.Option(
|
|
148
|
+
2,
|
|
149
|
+
"--min-confidence",
|
|
150
|
+
help=(
|
|
151
|
+
"Minimum signals (1-3) to chop: "
|
|
152
|
+
"1=TSO only, 2=two of polyA/RTP/TSO, 3=all three."
|
|
153
|
+
),
|
|
154
|
+
min=1,
|
|
155
|
+
max=3,
|
|
156
|
+
),
|
|
157
|
+
min_segment_length: int = typer.Option(
|
|
158
|
+
50,
|
|
159
|
+
"--min-segment-len",
|
|
160
|
+
help="Minimum sub-read length to keep after chopping (bp).",
|
|
161
|
+
min=10,
|
|
162
|
+
),
|
|
163
|
+
# ---- Homopolymer filter parameters ----
|
|
164
|
+
scan_window: int = typer.Option(
|
|
165
|
+
10,
|
|
166
|
+
"--scan-window",
|
|
167
|
+
help="Sliding window size for A/T density scan (bp).",
|
|
168
|
+
min=5,
|
|
169
|
+
),
|
|
170
|
+
density_threshold: float = typer.Option(
|
|
171
|
+
0.85,
|
|
172
|
+
"--density-threshold",
|
|
173
|
+
help="Minimum A/T fraction in scanning window to flag.",
|
|
174
|
+
min=0.5,
|
|
175
|
+
max=1.0,
|
|
176
|
+
),
|
|
177
|
+
min_run: int = typer.Option(
|
|
178
|
+
5,
|
|
179
|
+
"--min-run",
|
|
180
|
+
help="Minimum consecutive A or T to flag.",
|
|
181
|
+
min=2,
|
|
182
|
+
),
|
|
183
|
+
context_window: int = typer.Option(
|
|
184
|
+
50,
|
|
185
|
+
"--context-window",
|
|
186
|
+
help="Bases to extract on each side of a junction.",
|
|
187
|
+
min=10,
|
|
188
|
+
),
|
|
189
|
+
# ---- Flags ----
|
|
190
|
+
verbose: bool = typer.Option(
|
|
191
|
+
False,
|
|
192
|
+
"--verbose",
|
|
193
|
+
"-v",
|
|
194
|
+
help="Enable debug logging.",
|
|
195
|
+
),
|
|
196
|
+
html_report: bool = typer.Option(
|
|
197
|
+
True,
|
|
198
|
+
"--html-report/--no-html-report",
|
|
199
|
+
help="Generate an interactive HTML summary report with charts.",
|
|
200
|
+
),
|
|
201
|
+
version: bool | None = typer.Option(
|
|
202
|
+
None,
|
|
203
|
+
"--version",
|
|
204
|
+
"-V",
|
|
205
|
+
help="Show version and exit.",
|
|
206
|
+
callback=_version_callback,
|
|
207
|
+
is_eager=True,
|
|
208
|
+
),
|
|
209
|
+
) -> None:
|
|
210
|
+
"""
|
|
211
|
+
Strand orientation, artifact removal, and chimeric read rescue
|
|
212
|
+
for Oxford Nanopore direct-cDNA sequencing.
|
|
213
|
+
|
|
214
|
+
\b
|
|
215
|
+
Pipeline stages:
|
|
216
|
+
1. BREAKINATOR Remove foldback inversion artifacts
|
|
217
|
+
2. RESTRANDER Orient reads 5'→3', remove primer artifacts
|
|
218
|
+
3. UNKNOWNS RESCUE Recover orientable reads from Restrander unknowns
|
|
219
|
+
4. ADAPTER RESCUE Detect internal TSO/RTP adapters, chop and rescue
|
|
220
|
+
5. HOMOPOLYMER RESCUE Detect RT template switching at A/T-rich junctions
|
|
221
|
+
|
|
222
|
+
\b
|
|
223
|
+
Stages 1-2 remove artifactual reads.
|
|
224
|
+
Stages 3-5 chop chimeric reads at artifact junctions and rescue
|
|
225
|
+
flanking sub-reads — no reads are discarded.
|
|
226
|
+
"""
|
|
227
|
+
_setup_logging(verbose)
|
|
228
|
+
|
|
229
|
+
# Print logo at pipeline start
|
|
230
|
+
console.print(LOGO)
|
|
231
|
+
|
|
232
|
+
# Build configuration from CLI options
|
|
233
|
+
adapter_cfg = AdapterConfig(
|
|
234
|
+
max_edit_distance=max_edit_distance,
|
|
235
|
+
min_segment_length=min_segment_length,
|
|
236
|
+
)
|
|
237
|
+
homopolymer_cfg = HomopolymerConfig(
|
|
238
|
+
scan_window=scan_window,
|
|
239
|
+
density_threshold=density_threshold,
|
|
240
|
+
min_run=min_run,
|
|
241
|
+
context_window=context_window,
|
|
242
|
+
)
|
|
243
|
+
pipeline_cfg = PipelineConfig(
|
|
244
|
+
adapter_config=adapter_cfg,
|
|
245
|
+
homopolymer_config=homopolymer_cfg,
|
|
246
|
+
min_confidence=min_confidence,
|
|
247
|
+
context_window=context_window,
|
|
248
|
+
threads=threads,
|
|
249
|
+
junc_bed=junc_bed,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Run pipeline
|
|
253
|
+
try:
|
|
254
|
+
pipeline = DirectCleanPipeline(
|
|
255
|
+
input_fastq=input_fastq,
|
|
256
|
+
reference=reference,
|
|
257
|
+
output_dir=output_dir,
|
|
258
|
+
config=pipeline_cfg,
|
|
259
|
+
prefix=prefix,
|
|
260
|
+
)
|
|
261
|
+
report = pipeline.run()
|
|
262
|
+
|
|
263
|
+
console.print()
|
|
264
|
+
console.print("[bold green]✓ DirectClean completed successfully.[/]")
|
|
265
|
+
console.print(f" Cleaned reads: [cyan]{pipeline.cleaned_fastq}[/]")
|
|
266
|
+
console.print(f" Rescued reads: [cyan]{pipeline.rescued_fastq}[/]")
|
|
267
|
+
|
|
268
|
+
if html_report:
|
|
269
|
+
from directclean.report import HtmlReportGenerator
|
|
270
|
+
|
|
271
|
+
report_path = output_dir / f"{prefix}.report.html"
|
|
272
|
+
generator = HtmlReportGenerator(
|
|
273
|
+
report=report,
|
|
274
|
+
config=pipeline_cfg,
|
|
275
|
+
input_fastq=input_fastq,
|
|
276
|
+
output_dir=output_dir,
|
|
277
|
+
prefix=prefix,
|
|
278
|
+
)
|
|
279
|
+
generator.write(report_path)
|
|
280
|
+
console.print(f" HTML report: [cyan]{report_path}[/]")
|
|
281
|
+
|
|
282
|
+
except FileNotFoundError as e:
|
|
283
|
+
console.print(f"[bold red]Error:[/] {e}")
|
|
284
|
+
raise typer.Exit(code=1)
|
|
285
|
+
except Exception as e:
|
|
286
|
+
console.print(f"[bold red]Error:[/] {e}")
|
|
287
|
+
if verbose:
|
|
288
|
+
console.print_exception()
|
|
289
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""External tool wrappers for DirectClean."""
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Breakinator wrapper — remove foldback inversion artifacts.
|
|
3
|
+
|
|
4
|
+
Breakinator detects two types of artifacts in Direct-cDNA reads:
|
|
5
|
+
*foldback inversions* (hairpin structures from sequencing) and
|
|
6
|
+
*chimeric reads* (ligation artifacts). DirectClean only removes
|
|
7
|
+
**foldback** reads because chimeric reads are handled downstream
|
|
8
|
+
by our own Rescuer and Homopolymer Filter modules.
|
|
9
|
+
|
|
10
|
+
The workflow mirrors the manual steps::
|
|
11
|
+
|
|
12
|
+
1. minimap2 → raw name-sorted SAM (Breakinator needs SAM input)
|
|
13
|
+
2. breakinator → artifacts.txt (tabular classification)
|
|
14
|
+
3. Parse artifacts.txt → collect Foldback read IDs
|
|
15
|
+
4. split_fastq_by_ids → keep non-foldback reads
|
|
16
|
+
|
|
17
|
+
Note: This minimap2 run is independent from Stage 4 (the alignment
|
|
18
|
+
used by the Homopolymer Filter). Breakinator requires a SAM file,
|
|
19
|
+
not a coordinate-sorted BAM.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from directclean.external.dependencies import check_binary
|
|
31
|
+
from directclean.utils.io import split_fastq_by_ids
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
_MIN_BREAKINATOR_VERSION = (1, 1, 1)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check_breakinator_version(binary: str) -> None:
|
|
39
|
+
"""Verify that the installed breakinator is >= 1.1.1 (Rust version).
|
|
40
|
+
|
|
41
|
+
The old Python-based breakinator (<=1.0.x) expects PAF input and will
|
|
42
|
+
crash with an IndexError when given SAM. DirectClean requires the
|
|
43
|
+
Rust rewrite (>=1.1.1) which natively accepts SAM/BAM/CRAM.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
proc = subprocess.run(
|
|
47
|
+
[binary, "--version"],
|
|
48
|
+
stdout=subprocess.PIPE,
|
|
49
|
+
stderr=subprocess.PIPE,
|
|
50
|
+
text=True,
|
|
51
|
+
)
|
|
52
|
+
output = (proc.stdout + proc.stderr).strip()
|
|
53
|
+
except Exception:
|
|
54
|
+
return # If we can't check, let the actual run surface errors
|
|
55
|
+
|
|
56
|
+
# Match version strings like "breakinator 1.1.1" or "1.1.1"
|
|
57
|
+
match = re.search(r"(\d+)\.(\d+)\.(\d+)", output)
|
|
58
|
+
if match is None:
|
|
59
|
+
logger.warning(f"Could not parse breakinator version from: {output!r}")
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
version: tuple[int, ...] = tuple(int(x) for x in match.groups())
|
|
63
|
+
if version < _MIN_BREAKINATOR_VERSION:
|
|
64
|
+
min_ver = ".".join(str(v) for v in _MIN_BREAKINATOR_VERSION)
|
|
65
|
+
cur_ver = ".".join(str(v) for v in version)
|
|
66
|
+
raise RuntimeError(
|
|
67
|
+
f"breakinator {cur_ver} is too old. DirectClean requires "
|
|
68
|
+
f">= {min_ver} (the Rust version) which accepts SAM input. "
|
|
69
|
+
f"The old Python version expects PAF and will fail. "
|
|
70
|
+
f"Please update: conda install -c bioconda 'breakinator>={min_ver}'"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Report
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class BreakReport:
|
|
81
|
+
"""Summary statistics from the Breakinator stage.
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
total_breakpoints: Total breakpoints classified by Breakinator.
|
|
85
|
+
foldback_count: Breakpoints classified as Foldback.
|
|
86
|
+
chimeric_count: Breakpoints classified as Chimeric.
|
|
87
|
+
pass_count: Breakpoints classified as Pass.
|
|
88
|
+
foldback_read_ids: Number of unique reads with Foldback.
|
|
89
|
+
input_reads: Total reads in input FASTQ.
|
|
90
|
+
kept_reads: Reads after removing foldback.
|
|
91
|
+
removed_reads: Foldback reads removed.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
total_breakpoints: int = 0
|
|
95
|
+
foldback_count: int = 0
|
|
96
|
+
chimeric_count: int = 0
|
|
97
|
+
pass_count: int = 0
|
|
98
|
+
foldback_read_ids: int = 0
|
|
99
|
+
input_reads: int = 0
|
|
100
|
+
kept_reads: int = 0
|
|
101
|
+
removed_reads: int = 0
|
|
102
|
+
|
|
103
|
+
def __str__(self) -> str:
|
|
104
|
+
pct = (
|
|
105
|
+
f"{self.removed_reads / self.input_reads * 100:.1f}%"
|
|
106
|
+
if self.input_reads > 0
|
|
107
|
+
else "N/A"
|
|
108
|
+
)
|
|
109
|
+
return (
|
|
110
|
+
"=== Breakinator Report ===\n"
|
|
111
|
+
f" Breakpoints total : {self.total_breakpoints:,}\n"
|
|
112
|
+
f" Foldback : {self.foldback_count:,}\n"
|
|
113
|
+
f" Chimeric : {self.chimeric_count:,}\n"
|
|
114
|
+
f" Pass : {self.pass_count:,}\n"
|
|
115
|
+
f" ---\n"
|
|
116
|
+
f" Input reads : {self.input_reads:,}\n"
|
|
117
|
+
f" Foldback reads removed : {self.removed_reads:,} ({pct})\n"
|
|
118
|
+
f" Reads kept : {self.kept_reads:,}\n"
|
|
119
|
+
"=========================="
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Internal helpers
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _run_minimap2_for_breakinator(
|
|
129
|
+
reference: Path,
|
|
130
|
+
input_fastq: Path,
|
|
131
|
+
output_sam: Path,
|
|
132
|
+
threads: int,
|
|
133
|
+
junc_bed: Path | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Run minimap2 to produce a SAM file for Breakinator.
|
|
136
|
+
|
|
137
|
+
This is NOT coordinate-sorted — Breakinator reads SAM directly.
|
|
138
|
+
Uses the same Direct-cDNA splice parameters as the main alignment
|
|
139
|
+
but outputs SAM instead of piping to samtools sort.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
reference: Reference genome FASTA.
|
|
143
|
+
input_fastq: Raw input FASTQ.
|
|
144
|
+
output_sam: Output SAM path.
|
|
145
|
+
threads: Number of threads.
|
|
146
|
+
junc_bed: Optional junction BED for guided alignment.
|
|
147
|
+
"""
|
|
148
|
+
minimap2_bin = check_binary("minimap2")
|
|
149
|
+
|
|
150
|
+
cmd = [
|
|
151
|
+
minimap2_bin,
|
|
152
|
+
"-Y", # soft-clip with original sequence
|
|
153
|
+
"-t",
|
|
154
|
+
str(threads),
|
|
155
|
+
"-ax",
|
|
156
|
+
"splice", # splice-aware
|
|
157
|
+
"-uf", # forward strand for Direct-cDNA
|
|
158
|
+
"-k14", # k-mer size
|
|
159
|
+
"--secondary=no", # no secondary alignments
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
if junc_bed is not None:
|
|
163
|
+
cmd.extend(["--junc-bed", str(junc_bed)])
|
|
164
|
+
|
|
165
|
+
cmd.extend([str(reference), str(input_fastq)])
|
|
166
|
+
|
|
167
|
+
logger.info(f"Running minimap2 for Breakinator: {' '.join(cmd[:6])}...")
|
|
168
|
+
|
|
169
|
+
with open(output_sam, "w") as sam_fh:
|
|
170
|
+
proc = subprocess.run(
|
|
171
|
+
cmd,
|
|
172
|
+
stdout=sam_fh,
|
|
173
|
+
stderr=subprocess.PIPE,
|
|
174
|
+
text=True,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if proc.returncode != 0:
|
|
178
|
+
raise RuntimeError(f"minimap2 failed (exit {proc.returncode}):\n{proc.stderr}")
|
|
179
|
+
|
|
180
|
+
logger.info(f"SAM written: {output_sam}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _run_breakinator(
|
|
184
|
+
input_sam: Path,
|
|
185
|
+
output_artifacts: Path,
|
|
186
|
+
threads: int,
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Run Breakinator on a SAM file.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
input_sam: Input SAM from minimap2.
|
|
192
|
+
output_artifacts: Output tabular artifacts file.
|
|
193
|
+
threads: Number of threads.
|
|
194
|
+
"""
|
|
195
|
+
breakinator_bin = check_binary("breakinator")
|
|
196
|
+
_check_breakinator_version(breakinator_bin)
|
|
197
|
+
|
|
198
|
+
cmd = [
|
|
199
|
+
breakinator_bin,
|
|
200
|
+
"-i",
|
|
201
|
+
str(input_sam),
|
|
202
|
+
"-t",
|
|
203
|
+
str(threads),
|
|
204
|
+
"-o",
|
|
205
|
+
str(output_artifacts),
|
|
206
|
+
"--tabular",
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
logger.info(f"Running Breakinator: {' '.join(cmd[:4])}...")
|
|
210
|
+
|
|
211
|
+
proc = subprocess.run(
|
|
212
|
+
cmd,
|
|
213
|
+
stdout=subprocess.PIPE,
|
|
214
|
+
stderr=subprocess.PIPE,
|
|
215
|
+
text=True,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if proc.returncode != 0:
|
|
219
|
+
raise RuntimeError(
|
|
220
|
+
f"Breakinator failed (exit {proc.returncode}):\n{proc.stderr}"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
logger.info(f"Artifacts written: {output_artifacts}")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _parse_foldback_ids(artifacts_file: Path) -> tuple[set[str], dict]:
|
|
227
|
+
"""Parse Breakinator tabular output and extract Foldback read IDs.
|
|
228
|
+
|
|
229
|
+
Only Foldback reads are removed — Chimeric reads are left for
|
|
230
|
+
DirectClean's own Rescuer and Homopolymer Filter to handle.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
artifacts_file: Path to Breakinator --tabular output.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
(foldback_ids, breakpoint_stats) where breakpoint_stats has
|
|
237
|
+
counts for each classification.
|
|
238
|
+
"""
|
|
239
|
+
foldback_ids: set[str] = set()
|
|
240
|
+
stats = {"total": 0, "Foldback": 0, "Chimeric": 0, "Pass": 0}
|
|
241
|
+
|
|
242
|
+
with open(artifacts_file) as fh:
|
|
243
|
+
for line in fh:
|
|
244
|
+
if line.startswith("#"):
|
|
245
|
+
continue
|
|
246
|
+
fields = line.strip().split("\t")
|
|
247
|
+
if len(fields) < 8:
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
read_id = fields[6]
|
|
251
|
+
classification = fields[7]
|
|
252
|
+
|
|
253
|
+
stats["total"] += 1
|
|
254
|
+
if classification in stats:
|
|
255
|
+
stats[classification] += 1
|
|
256
|
+
|
|
257
|
+
if classification == "Foldback":
|
|
258
|
+
foldback_ids.add(read_id)
|
|
259
|
+
|
|
260
|
+
logger.info(
|
|
261
|
+
f"Breakinator: {stats['total']:,} breakpoints, "
|
|
262
|
+
f"{len(foldback_ids):,} unique foldback reads"
|
|
263
|
+
)
|
|
264
|
+
return foldback_ids, stats
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# Public API
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class BreakinatorRunner:
|
|
273
|
+
"""End-to-end Breakinator wrapper for foldback removal.
|
|
274
|
+
|
|
275
|
+
Runs minimap2 → breakinator → foldback filtering as a single
|
|
276
|
+
stage in the DirectClean pipeline.
|
|
277
|
+
|
|
278
|
+
Usage::
|
|
279
|
+
|
|
280
|
+
runner = BreakinatorRunner(
|
|
281
|
+
reference=Path("genome.fa"),
|
|
282
|
+
threads=8,
|
|
283
|
+
)
|
|
284
|
+
report = runner.run(
|
|
285
|
+
input_fastq=Path("raw.fastq"),
|
|
286
|
+
output_fastq=Path("no_foldback.fastq"),
|
|
287
|
+
work_dir=Path("output/"),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
reference: Reference genome FASTA.
|
|
292
|
+
threads: Number of threads for minimap2 and breakinator.
|
|
293
|
+
junc_bed: Optional junction BED file for guided alignment.
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
def __init__(
|
|
297
|
+
self,
|
|
298
|
+
reference: Path,
|
|
299
|
+
threads: int = 4,
|
|
300
|
+
junc_bed: Path | None = None,
|
|
301
|
+
) -> None:
|
|
302
|
+
self.reference = Path(reference)
|
|
303
|
+
self.threads = threads
|
|
304
|
+
self.junc_bed = Path(junc_bed) if junc_bed is not None else None
|
|
305
|
+
|
|
306
|
+
def run(
|
|
307
|
+
self,
|
|
308
|
+
input_fastq: Path,
|
|
309
|
+
output_fastq: Path,
|
|
310
|
+
work_dir: Path,
|
|
311
|
+
prefix: str = "directclean",
|
|
312
|
+
) -> BreakReport:
|
|
313
|
+
"""Execute the full Breakinator stage.
|
|
314
|
+
|
|
315
|
+
Steps:
|
|
316
|
+
1. minimap2 alignment → SAM
|
|
317
|
+
2. Breakinator → artifacts.txt
|
|
318
|
+
3. Parse foldback IDs
|
|
319
|
+
4. Split FASTQ → kept + removed
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
input_fastq: Raw input FASTQ.
|
|
323
|
+
output_fastq: Output FASTQ with foldback reads removed.
|
|
324
|
+
work_dir: Working directory for intermediate files.
|
|
325
|
+
prefix: Filename prefix for intermediates.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
BreakReport with statistics.
|
|
329
|
+
"""
|
|
330
|
+
work_dir = Path(work_dir)
|
|
331
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
332
|
+
|
|
333
|
+
report = BreakReport()
|
|
334
|
+
|
|
335
|
+
# Intermediate file paths
|
|
336
|
+
sam_path = work_dir / f"{prefix}.breakinator.sam"
|
|
337
|
+
artifacts_path = work_dir / f"{prefix}.breakinator_artifacts.txt"
|
|
338
|
+
removed_path = work_dir / f"{prefix}.foldback_removed.fastq"
|
|
339
|
+
|
|
340
|
+
# Step 1: minimap2 → SAM
|
|
341
|
+
logger.info("Step 1/3: Aligning reads for Breakinator...")
|
|
342
|
+
_run_minimap2_for_breakinator(
|
|
343
|
+
reference=self.reference,
|
|
344
|
+
input_fastq=input_fastq,
|
|
345
|
+
output_sam=sam_path,
|
|
346
|
+
threads=self.threads,
|
|
347
|
+
junc_bed=self.junc_bed,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Step 2: Breakinator → artifacts.txt
|
|
351
|
+
logger.info("Step 2/3: Running Breakinator...")
|
|
352
|
+
_run_breakinator(
|
|
353
|
+
input_sam=sam_path,
|
|
354
|
+
output_artifacts=artifacts_path,
|
|
355
|
+
threads=self.threads,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# Step 3: Parse foldback IDs and filter FASTQ
|
|
359
|
+
logger.info("Step 3/3: Filtering foldback reads...")
|
|
360
|
+
foldback_ids, bp_stats = _parse_foldback_ids(artifacts_path)
|
|
361
|
+
|
|
362
|
+
report.total_breakpoints = bp_stats["total"]
|
|
363
|
+
report.foldback_count = bp_stats["Foldback"]
|
|
364
|
+
report.chimeric_count = bp_stats["Chimeric"]
|
|
365
|
+
report.pass_count = bp_stats["Pass"]
|
|
366
|
+
report.foldback_read_ids = len(foldback_ids)
|
|
367
|
+
|
|
368
|
+
# Use our own split_fastq_by_ids — no seqkit dependency
|
|
369
|
+
kept, removed = split_fastq_by_ids(
|
|
370
|
+
input_fastq=input_fastq,
|
|
371
|
+
remove_ids=foldback_ids,
|
|
372
|
+
output_kept=output_fastq,
|
|
373
|
+
output_removed=removed_path,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
report.input_reads = kept + removed
|
|
377
|
+
report.kept_reads = kept
|
|
378
|
+
report.removed_reads = removed
|
|
379
|
+
|
|
380
|
+
# Clean up large SAM file (user still has artifacts.txt for reference)
|
|
381
|
+
if sam_path.exists():
|
|
382
|
+
sam_path.unlink()
|
|
383
|
+
logger.debug(f"Cleaned up intermediate SAM: {sam_path}")
|
|
384
|
+
|
|
385
|
+
logger.info(f"Breakinator stage complete.\n{report}")
|
|
386
|
+
return report
|