@@ -4414,7 +4414,11 @@ def cigar_aligned_bases(cigar: str) -> tuple:
44144414 total += n
44154415 return total, aligned_m
44164416
4417- sam_header_lines = []
4417+ # Store SAM header lines, but keep @SQ lines per-contig so we can write
4418+ # per-accession BAMs with a *single* @SQ in the header (important for
4419+ # downstream tools using `mpileup -aa` which otherwise may traverse all contigs).
4420+ sam_header_common = [] # all header lines except @SQ
4421+ sam_sq_by_accession = {} # canonical accession -> list[@SQ line]
44184422 bam_pipes = {} # accession -> dict(view_p, sort_p, stdin, ref_bam, ref_bai_path)
44194423 samtools_threads = max(1, min(int(threads or 1), 16))
44204424 read_ids_by_reference = {} # accession -> set(read_id)
@@ -4522,8 +4526,22 @@ def _start_bam_pipe(accession: str):
45224526 view_p.stdout.close()
45234527
45244528 assert view_p.stdin is not None
4525- for h in sam_header_lines:
4529+ # Write a single-contig header for this accession.
4530+ # Keep common header lines (@HD, @PG, @RG, etc.) and include only the matching @SQ.
4531+ sq_lines = sam_sq_by_accession.get(accession) or []
4532+ if not sq_lines:
4533+ # Fallback: if we can't find a matching @SQ (shouldn't happen), write all common lines
4534+ # without any @SQ. samtools may fail later; log for debugging.
4535+ logger.warning(f"Could not find @SQ header line for accession {accession}; writing header without @SQ")
4536+ # Keep @HD first (if present), then @SQ, then the remaining common headers.
4537+ for h in sam_header_common:
4538+ if h.startswith("@HD"):
4539+ view_p.stdin.write(h)
4540+ for h in sq_lines:
45264541 view_p.stdin.write(h)
4542+ for h in sam_header_common:
4543+ if not h.startswith("@HD"):
4544+ view_p.stdin.write(h)
45274545
45284546 bam_pipes[accession] = {"view_p": view_p, "sort_p": sort_p, "stdin": view_p.stdin, "ref_bam": ref_bam}
45294547
@@ -4538,7 +4556,6 @@ def _start_bam_pipe(accession: str):
45384556 if not line:
45394557 continue
45404558 if line.startswith("@SQ"):
4541- sam_header_lines.append(line)
45424559 parts = line.strip().split("\t")
45434560 header = None
45444561 length = None
@@ -4549,9 +4566,14 @@ def _start_bam_pipe(accession: str):
45494566 length = int(part[3:])
45504567 if header and length:
45514568 ref_lengths[header] = length
4569+ # Index @SQ lines by canonical accession (so we can write per-accession BAMs with a single @SQ).
4570+ acc_raw = extract_accession_from_header(header) or ""
4571+ if _sam_accession_in_curated(curated_acc_expanded, acc_raw):
4572+ acc_canon = _canonical_curated_accession(acc_to_canon, acc_raw)
4573+ sam_sq_by_accession.setdefault(acc_canon, []).append(line)
45524574 continue
45534575 if line.startswith("@"):
4554- sam_header_lines .append(line)
4576+ sam_header_common .append(line)
45554577 continue
45564578 if not line.strip():
45574579 continue
@@ -4961,7 +4983,11 @@ def create_per_reference_outputs(sample_name: str, curated_descriptions: list, s
49614983 read_ids_by_reference = {} # accession -> set of read IDs
49624984 curated_accessions = [stat.get("accession", "") for stat in curated_descriptions if stat.get("accession", "")]
49634985 curated_acc_expanded, acc_to_canon = _curated_accession_expansion_and_canon(curated_accessions)
4964- sam_header_lines = []
4986+ # Keep common header lines and store @SQ per contig so per-accession BAMs
4987+ # can be written with a single @SQ (prevents downstream tools from traversing
4988+ # unrelated contigs when using `mpileup -aa`).
4989+ sam_header_common = [] # all header lines except @SQ
4990+ sam_sq_by_accession = {} # canonical accession -> list[@SQ line]
49654991 bam_pipes = {} # accession -> dict(view_p, sort_p, stdin, ref_bam)
49664992 samtools_threads = max(1, min(int(threads or 1), 16))
49674993
@@ -4988,8 +5014,18 @@ def _start_bam_pipe(accession: str):
49885014 view_p.stdout.close()
49895015
49905016 assert view_p.stdin is not None
4991- for h in sam_header_lines:
5017+ sq_lines = sam_sq_by_accession.get(accession) or []
5018+ if not sq_lines:
5019+ logger.warning(f"Could not find @SQ header line for accession {accession}; writing header without @SQ")
5020+ # @HD first, then @SQ, then remaining common header lines
5021+ for h in sam_header_common:
5022+ if h.startswith("@HD"):
5023+ view_p.stdin.write(h)
5024+ for h in sq_lines:
49925025 view_p.stdin.write(h)
5026+ for h in sam_header_common:
5027+ if not h.startswith("@HD"):
5028+ view_p.stdin.write(h)
49935029
49945030 bam_pipes[accession] = {
49955031 "view_p": view_p,
@@ -5119,7 +5155,20 @@ def _non_overlapping_reads_and_bases_from_bam(bam_path: Path) -> tuple:
51195155 if not line:
51205156 continue
51215157 if line.startswith("@"):
5122- sam_header_lines.append(line)
5158+ if line.startswith("@SQ"):
5159+ # Index @SQ by canonical accession if it's a curated hit
5160+ header = None
5161+ for part in line.strip().split("\t"):
5162+ if part.startswith("SN:"):
5163+ header = part[3:]
5164+ break
5165+ if header:
5166+ acc_raw = extract_accession_from_header(header) or ""
5167+ if _sam_accession_in_curated(curated_acc_expanded, acc_raw):
5168+ acc_canon = _canonical_curated_accession(acc_to_canon, acc_raw)
5169+ sam_sq_by_accession.setdefault(acc_canon, []).append(line)
5170+ else:
5171+ sam_header_common.append(line)
51235172 continue
51245173 if not line.strip():
51255174 continue
0 commit comments