Skip to content

Commit 7ecfe1e

Browse files
committed
feat(pipeline): organize step outputs and emit protospacer BED spans
1 parent 258bb9c commit 7ecfe1e

4 files changed

Lines changed: 133 additions & 39 deletions

File tree

docs/python/tutorial-guide-alignment-qc.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,29 @@ python scripts/run_guide_alignment_qc.py \
4747

4848
In `results/guide_alignment_qc/`:
4949

50-
- `guides_input.fastq`
50+
- `logs/pipeline.log`
51+
52+
In `results/guide_alignment_qc/gem_index/`:
53+
5154
- `genome_index.gem` (+ index sidecar files)
55+
- `gem_index.log`
56+
- `index_command.sh`
57+
- `index_inputs.txt`
58+
59+
In `results/guide_alignment_qc/alignment_outputs/`:
60+
61+
- `guides_input.fastq`
5262
- `guides_mapped.sam`
5363
- `guides_mapped.log`
54-
- `guides_valid_unique.sam`
55-
- `guides_valid_multi.sam`
56-
- `valid_alignments.bed`
64+
65+
In `results/guide_alignment_qc/guide_alignments_outputs/`:
66+
67+
- `valid_alignments.bed` (protospacer coordinates; excludes PAM)
5768
- `discarded_alignments.tsv`
5869
- `unmapped.tsv`
5970
- `guide_alignment_log.tsv`
6071
- `invalid_alignments.tsv`
72+
- `alignment_summary.tsv`
6173

6274
## 6. Run on SLURM (one Python script)
6375

scripts/run_guide_alignment_qc.py

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from datetime import datetime
1515
import logging
1616
from pathlib import Path
17+
import shlex
1718
import time
1819

1920
import crispr_analysis_utils as cau
@@ -46,6 +47,13 @@ def main() -> None:
4647

4748
outdir = Path(args.outdir)
4849
outdir.mkdir(parents=True, exist_ok=True)
50+
51+
gem_index_dir = outdir / "gem_index"
52+
alignment_dir = outdir / "alignment_outputs"
53+
filtering_dir = outdir / "guide_alignments_outputs"
54+
for p in (gem_index_dir, alignment_dir, filtering_dir):
55+
p.mkdir(parents=True, exist_ok=True)
56+
4957
log_dir = outdir / "logs"
5058
log_dir.mkdir(parents=True, exist_ok=True)
5159
log_path = log_dir / "pipeline.log"
@@ -65,12 +73,10 @@ def main() -> None:
6573
logging.info("Output directory: %s", outdir)
6674
logging.info("Log file: %s", log_path)
6775

68-
guides_fastq = outdir / "guides_input.fastq"
69-
gem_index_prefix = outdir / "genome_index"
70-
gem_index = outdir / "genome_index.gem"
71-
mapped_sam = outdir / "guides_mapped.sam"
72-
valid_unique_sam = outdir / "guides_valid_unique.sam"
73-
valid_multi_sam = outdir / "guides_valid_multi.sam"
76+
guides_fastq = alignment_dir / "guides_input.fastq"
77+
gem_index_prefix = gem_index_dir / "genome_index"
78+
gem_index = gem_index_dir / "genome_index.gem"
79+
mapped_sam = alignment_dir / "guides_mapped.sam"
7480

7581
logging.info("Step 1/4: Build guide FASTQ")
7682
cau.guide_qc.guides_to_fastq(
@@ -81,10 +87,26 @@ def main() -> None:
8187
)
8288

8389
logging.info("Step 2/4: Build GEM index")
90+
index_cmd_parts = [
91+
"gem-indexer",
92+
"-i",
93+
str(args.reference_fasta),
94+
"-o",
95+
str(gem_index_prefix),
96+
"-t",
97+
str(args.threads),
98+
]
99+
index_cmd = " ".join(shlex.quote(x) for x in index_cmd_parts)
100+
(gem_index_dir / "index_command.sh").write_text(index_cmd + "\n", encoding="utf-8")
101+
(gem_index_dir / "index_inputs.txt").write_text(
102+
f"reference_fasta={Path(args.reference_fasta).resolve()}\n",
103+
encoding="utf-8",
104+
)
84105
cau.gem_mapper.build_gem_index(
85106
args.reference_fasta,
86107
gem_index_prefix,
87108
threads=args.threads,
109+
log_path=gem_index_dir / "gem_index.log",
88110
)
89111

90112
logging.info("Step 3/4: Map guides with GEM")
@@ -95,16 +117,23 @@ def main() -> None:
95117
threads=args.threads,
96118
mapping_mode=args.mapping_mode,
97119
sam_compact=False,
120+
log_path=alignment_dir / "guides_mapped.log",
98121
)
99122

100123
logging.info("Step 4/4: Filter alignments and compute QC outputs")
101124
summary = cau.guide_qc.filter_guide_alignments(
102125
mapped_sam,
103-
valid_unique_sam,
104-
valid_multi_sam,
126+
None,
127+
None,
105128
pam=args.pam,
106129
chromsizes=args.chromsizes,
107130
allow_leading_g_softclip=args.allow_leading_g_softclip,
131+
output_valid_bed=filtering_dir / "valid_alignments.bed",
132+
output_discarded_tsv=filtering_dir / "discarded_alignments.tsv",
133+
output_unmapped_tsv=filtering_dir / "unmapped.tsv",
134+
output_guide_log_tsv=filtering_dir / "guide_alignment_log.tsv",
135+
output_invalid_tsv=filtering_dir / "invalid_alignments.tsv",
136+
output_summary_tsv=filtering_dir / "alignment_summary.tsv",
108137
)
109138
finished = time.time()
110139
finish_iso = datetime.now().isoformat(timespec="seconds")

src/crispr_analysis_utils/gem_mapper.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def build_gem_index(
1414
*,
1515
threads: int | None = None,
1616
gem_indexer_bin: str = "gem-indexer",
17+
log_path: str | Path | None = None,
1718
) -> str:
1819
"""Build a GEM index from a reference FASTA."""
1920
cmd = [
@@ -25,7 +26,10 @@ def build_gem_index(
2526
]
2627
if threads is not None:
2728
cmd.extend(["-t", str(threads)])
28-
return run_shell_cmd(" ".join(shlex.quote(token) for token in cmd))
29+
cmd_str = " ".join(shlex.quote(token) for token in cmd)
30+
if log_path is not None:
31+
cmd_str = f"{cmd_str} > {shlex.quote(str(log_path))} 2>&1"
32+
return run_shell_cmd(cmd_str)
2933

3034

3135
def map_guides_with_gem(

src/crispr_analysis_utils/guide_qc.py

Lines changed: 75 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,8 @@ def _evaluate_alignment_layout(
183183

184184
def filter_guide_alignments(
185185
input_sam: str | Path,
186-
output_unique_sam: str | Path,
187-
output_multi_sam: str | Path,
186+
output_unique_sam: str | Path | None = None,
187+
output_multi_sam: str | Path | None = None,
188188
*,
189189
output_invalid_tsv: str | Path = "auto",
190190
output_valid_bed: str | Path = "auto",
@@ -270,14 +270,20 @@ def filter_guide_alignments(
270270
if is_valid:
271271
valid_by_guide.setdefault(guide_id, []).append(aln)
272272
guide_stats[guide_id]["n_valid"] += 1
273-
if aln.reference_end is not None:
274-
nm_tag = int(aln.get_tag("NM")) if aln.has_tag("NM") else -1
275-
as_tag = int(aln.get_tag("AS")) if aln.has_tag("AS") else -1
273+
nm_tag = int(aln.get_tag("NM")) if aln.has_tag("NM") else -1
274+
as_tag = int(aln.get_tag("AS")) if aln.has_tag("AS") else -1
275+
bed_span = _protospacer_bed_span(
276+
aln,
277+
query_len=query_len,
278+
pam_len=len(pam),
279+
allow_leading_g_softclip=allow_leading_g_softclip,
280+
)
281+
if bed_span is not None:
276282
valid_bed_rows.append(
277283
(
278284
contig,
279-
int(aln.reference_start),
280-
int(aln.reference_end),
285+
bed_span[0],
286+
bed_span[1],
281287
guide_id,
282288
int(aln.mapping_quality),
283289
"-" if aln.is_reverse else "+",
@@ -312,22 +318,31 @@ def filter_guide_alignments(
312318
)
313319
)
314320

315-
unique_path = Path(output_unique_sam)
316-
multi_path = Path(output_multi_sam)
317-
unique_path.parent.mkdir(parents=True, exist_ok=True)
318-
multi_path.parent.mkdir(parents=True, exist_ok=True)
319-
320-
with (
321-
pysam.AlignmentFile(str(unique_path), "w", header=header) as unique_sam,
322-
pysam.AlignmentFile(str(multi_path), "w", header=header) as multi_sam,
323-
):
324-
for alignments in valid_by_guide.values():
325-
target = unique_sam if len(alignments) == 1 else multi_sam
326-
for aln in alignments:
327-
target.write(aln)
321+
base_output_dir = Path(".")
322+
if output_unique_sam is not None:
323+
unique_path = Path(output_unique_sam)
324+
base_output_dir = unique_path.parent
325+
elif output_multi_sam is not None:
326+
multi_path = Path(output_multi_sam)
327+
base_output_dir = multi_path.parent
328+
329+
if output_unique_sam is not None and output_multi_sam is not None:
330+
unique_path = Path(output_unique_sam)
331+
multi_path = Path(output_multi_sam)
332+
unique_path.parent.mkdir(parents=True, exist_ok=True)
333+
multi_path.parent.mkdir(parents=True, exist_ok=True)
334+
335+
with (
336+
pysam.AlignmentFile(str(unique_path), "w", header=header) as unique_sam,
337+
pysam.AlignmentFile(str(multi_path), "w", header=header) as multi_sam,
338+
):
339+
for alignments in valid_by_guide.values():
340+
target = unique_sam if len(alignments) == 1 else multi_sam
341+
for aln in alignments:
342+
target.write(aln)
328343

329344
if output_invalid_tsv == "auto":
330-
invalid_path = unique_path.with_name("invalid_alignments.tsv")
345+
invalid_path = base_output_dir / "invalid_alignments.tsv"
331346
else:
332347
invalid_path = Path(output_invalid_tsv)
333348
invalid_path.parent.mkdir(parents=True, exist_ok=True)
@@ -337,7 +352,7 @@ def filter_guide_alignments(
337352
handle.write("\t".join(row) + "\n")
338353

339354
if output_valid_bed == "auto":
340-
valid_bed_path = unique_path.with_name("valid_alignments.bed")
355+
valid_bed_path = base_output_dir / "valid_alignments.bed"
341356
else:
342357
valid_bed_path = Path(output_valid_bed)
343358
valid_bed_path.parent.mkdir(parents=True, exist_ok=True)
@@ -346,7 +361,7 @@ def filter_guide_alignments(
346361
handle.write("\t".join(map(str, row)) + "\n")
347362

348363
if output_discarded_tsv == "auto":
349-
discarded_path = unique_path.with_name("discarded_alignments.tsv")
364+
discarded_path = base_output_dir / "discarded_alignments.tsv"
350365
else:
351366
discarded_path = Path(output_discarded_tsv)
352367
discarded_path.parent.mkdir(parents=True, exist_ok=True)
@@ -358,7 +373,7 @@ def filter_guide_alignments(
358373
handle.write("\t".join(map(str, row)) + "\n")
359374

360375
if output_unmapped_tsv == "auto":
361-
unmapped_path = unique_path.with_name("unmapped.tsv")
376+
unmapped_path = base_output_dir / "unmapped.tsv"
362377
else:
363378
unmapped_path = Path(output_unmapped_tsv)
364379
unmapped_path.parent.mkdir(parents=True, exist_ok=True)
@@ -368,7 +383,7 @@ def filter_guide_alignments(
368383
handle.write("\t".join(map(str, row)) + "\n")
369384

370385
if output_guide_log_tsv == "auto":
371-
guide_log_path = unique_path.with_name("guide_alignment_log.tsv")
386+
guide_log_path = base_output_dir / "guide_alignment_log.tsv"
372387
else:
373388
guide_log_path = Path(output_guide_log_tsv)
374389
guide_log_path.parent.mkdir(parents=True, exist_ok=True)
@@ -402,7 +417,7 @@ def filter_guide_alignments(
402417
n_guides_multi_valid += 1
403418

404419
if output_summary_tsv == "auto":
405-
summary_path = unique_path.with_name("alignment_summary.tsv")
420+
summary_path = base_output_dir / "alignment_summary.tsv"
406421
else:
407422
summary_path = Path(output_summary_tsv)
408423
summary_path.parent.mkdir(parents=True, exist_ok=True)
@@ -433,6 +448,40 @@ def filter_guide_alignments(
433448
}
434449

435450

451+
def _protospacer_bed_span(
452+
aln,
453+
*,
454+
query_len: int,
455+
pam_len: int,
456+
allow_leading_g_softclip: bool,
457+
) -> tuple[int, int] | None:
458+
"""Map protospacer query region (no PAM) to genomic BED span."""
459+
if query_len <= pam_len:
460+
return None
461+
462+
query_ops = [None] * query_len
463+
qpos = 0
464+
for op, length in (aln.cigartuples or []):
465+
if op in _CIGAR_MATCH | {_CIGAR_INS, _CIGAR_SOFT}:
466+
for _ in range(length):
467+
if qpos < query_len:
468+
query_ops[qpos] = op
469+
qpos += 1
470+
471+
protospacer_start = 0
472+
if allow_leading_g_softclip and query_ops and query_ops[0] == _CIGAR_SOFT:
473+
protospacer_start = 1
474+
protospacer_end = query_len - pam_len
475+
if protospacer_start >= protospacer_end:
476+
return None
477+
478+
q2r = {q: r for q, r in aln.get_aligned_pairs(matches_only=True)}
479+
ref_positions = [q2r[i] for i in range(protospacer_start, protospacer_end) if i in q2r]
480+
if not ref_positions:
481+
return None
482+
return min(ref_positions), max(ref_positions) + 1
483+
484+
436485
def _resolve_allowed_contigs(
437486
*,
438487
primary_contigs: set[str] | list[str] | tuple[str, ...] | None,

0 commit comments

Comments
 (0)