@@ -183,8 +183,8 @@ def _evaluate_alignment_layout(
183183
184184def 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+
436485def _resolve_allowed_contigs (
437486 * ,
438487 primary_contigs : set [str ] | list [str ] | tuple [str , ...] | None ,
0 commit comments