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.
@@ -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"
@@ -0,0 +1,6 @@
1
+ """Allow running DirectClean as ``python -m directclean``."""
2
+
3
+ from directclean.cli.app import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -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