countmut 0.3.2__tar.gz → 0.3.3__tar.gz

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.
Files changed (39) hide show
  1. {countmut-0.3.2/countmut.egg-info → countmut-0.3.3}/PKG-INFO +1 -1
  2. {countmut-0.3.2 → countmut-0.3.3}/backend/countmut_core.c +28 -26
  3. {countmut-0.3.2 → countmut-0.3.3}/countmut/_core/countmut_core +0 -0
  4. {countmut-0.3.2 → countmut-0.3.3/countmut.egg-info}/PKG-INFO +1 -1
  5. {countmut-0.3.2 → countmut-0.3.3}/pyproject.toml +1 -1
  6. {countmut-0.3.2 → countmut-0.3.3}/tests/test_correctness.py +18 -23
  7. {countmut-0.3.2 → countmut-0.3.3}/tests/test_router.py +7 -5
  8. {countmut-0.3.2 → countmut-0.3.3}/LICENSE +0 -0
  9. {countmut-0.3.2 → countmut-0.3.3}/MANIFEST.in +0 -0
  10. {countmut-0.3.2 → countmut-0.3.3}/README.md +0 -0
  11. {countmut-0.3.2 → countmut-0.3.3}/backend/Makefile +0 -0
  12. {countmut-0.3.2 → countmut-0.3.3}/backend/bedidx.c +0 -0
  13. {countmut-0.3.2 → countmut-0.3.3}/backend/countmut_core.h +0 -0
  14. {countmut-0.3.2 → countmut-0.3.3}/backend/countmut_core_main.c +0 -0
  15. {countmut-0.3.2 → countmut-0.3.3}/backend/countmut_expr.c +0 -0
  16. {countmut-0.3.2 → countmut-0.3.3}/backend/countmut_expr.h +0 -0
  17. {countmut-0.3.2 → countmut-0.3.3}/backend/ketopt.h +0 -0
  18. {countmut-0.3.2 → countmut-0.3.3}/backend/khash.h +0 -0
  19. {countmut-0.3.2 → countmut-0.3.3}/backend/kseq.h +0 -0
  20. {countmut-0.3.2 → countmut-0.3.3}/backend/ksort.h +0 -0
  21. {countmut-0.3.2 → countmut-0.3.3}/backend/kstdint.h +0 -0
  22. {countmut-0.3.2 → countmut-0.3.3}/backend/kstring.h +0 -0
  23. {countmut-0.3.2 → countmut-0.3.3}/countmut/__init__.py +0 -0
  24. {countmut-0.3.2 → countmut-0.3.3}/countmut/backend.py +0 -0
  25. {countmut-0.3.2 → countmut-0.3.3}/countmut/bam_tags.py +0 -0
  26. {countmut-0.3.2 → countmut-0.3.3}/countmut/cli.py +0 -0
  27. {countmut-0.3.2 → countmut-0.3.3}/countmut/core.py +0 -0
  28. {countmut-0.3.2 → countmut-0.3.3}/countmut/model.py +0 -0
  29. {countmut-0.3.2 → countmut-0.3.3}/countmut/utils.py +0 -0
  30. {countmut-0.3.2 → countmut-0.3.3}/countmut.egg-info/SOURCES.txt +0 -0
  31. {countmut-0.3.2 → countmut-0.3.3}/countmut.egg-info/dependency_links.txt +0 -0
  32. {countmut-0.3.2 → countmut-0.3.3}/countmut.egg-info/entry_points.txt +0 -0
  33. {countmut-0.3.2 → countmut-0.3.3}/countmut.egg-info/requires.txt +0 -0
  34. {countmut-0.3.2 → countmut-0.3.3}/countmut.egg-info/top_level.txt +0 -0
  35. {countmut-0.3.2 → countmut-0.3.3}/setup.cfg +0 -0
  36. {countmut-0.3.2 → countmut-0.3.3}/tests/test_cli.py +0 -0
  37. {countmut-0.3.2 → countmut-0.3.3}/tests/test_core.py +0 -0
  38. {countmut-0.3.2 → countmut-0.3.3}/tests/test_unified.py +0 -0
  39. {countmut-0.3.2 → countmut-0.3.3}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: countmut
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Unified ultra-fast strand-aware mutation counter (C backend + samtools-style -e/-p filters)
5
5
  Author-email: Ye Chang <yech1990@gmail.com>
6
6
  License-Expression: MIT
@@ -96,6 +96,22 @@ static int base_to_index(char c) {
96
96
  }
97
97
  }
98
98
 
99
+ /* Reference-frame base index for a read at a CIGAR query offset `qpos`.
100
+ *
101
+ * `qpos` is the query offset walked left->right along the CIGAR (reference
102
+ * order). bwa / minibwa write the SEQ of a reverse-strand read already in
103
+ * reference-forward orientation (the stored SEQ is the reverse complement of
104
+ * the original read, matching the reference), so SEQ[qpos] is directly the
105
+ * reference-frame base -- no reverse-complement or index reversal is needed.
106
+ *
107
+ * Returns 0..3 for A/C/G/T, or 4 for any other symbol.
108
+ */
109
+ static int ref_frame_base(const bam1_t *b, uint32_t qpos) {
110
+ uint32_t qlen = b->core.l_qseq;
111
+ if (qpos >= qlen) return 4;
112
+ return nt16_index(bam_seqi(bam_get_seq(b), qpos));
113
+ }
114
+
99
115
  /* Reverse-complement a single uppercase motif base (A<->T, C<->G, else N). */
100
116
  static char rc_nt(char c) {
101
117
  switch (c) {
@@ -567,13 +583,10 @@ static void count_interval(worker_t *w, const cm_config *cfg, bam_hdr_t *hdr, FI
567
583
  const bam_pileup1_t *p = &plp[0][i];
568
584
  const bam1_t *b = p->b;
569
585
  int s = bio_strand(b);
570
- uint8_t nt = bam_seqi(bam_get_seq(b), p->qpos);
571
- int base_i = nt16_index(nt);
572
- /* Reverse-oriented reads: stored SEQ is 5'->3' (SAM spec) but qpos
573
- * walks CIGAR order (left->right); complement the base into the
574
- * reference frame. Key on bam_is_rev (read orientation), not the
575
- * biological strand s (which differs for R2 in paired data). */
576
- if (bam_is_rev(b) && base_i < 4) base_i = 3 - base_i;
586
+ /* Reference-frame base: for a reverse read the stored SEQ is the
587
+ * reverse complement, so the base lives at SEQ index qlen-1-qpos
588
+ * and must be complemented (see ref_frame_base). */
589
+ int base_i = ref_frame_base(b, (uint32_t)p->qpos);
577
590
  /* router-assigned category slot (0..CM_CAT_MAX-1); g_a set in
578
591
  * the selection loop for every kept candidate. */
579
592
  int cat = w->g_a[i];
@@ -763,15 +776,12 @@ static rw_w *rw_add_base(worker_t *w, const cm_config *cfg, bam_hdr_t *hdr, int
763
776
  (ref_pos >= 0 && ref_pos < w->chr_len) ? w->chr_seq[ref_pos] : 'N');
764
777
  if (rslot < 0) return wins;
765
778
  }
766
- uint8_t nt = bam_seqi(bam_get_seq(b), qpos);
767
- int base_i = nt16_index(nt);
768
- /* Reverse-oriented reads: stored SEQ is 5'->3' (SAM spec) but qpos walks
769
- * CIGAR order (left->right); complement the base into the reference frame
770
- * (parity with countmut 0.0.x + pysam pairs). This must key on the read's
771
- * own orientation (bam_is_rev), NOT on the biological strand s: for paired
772
- * reads an R2 mapped forward has bio_strand=1 yet is NOT reverse-oriented,
773
- * so complementing on s would wrongly flip its bases (A<->T). */
774
- if (bam_is_rev(b) && base_i < 4) base_i = 3 - base_i;
779
+ /* Reference-frame base: for a reverse read the stored SEQ is the reverse
780
+ * complement, so the base lives at SEQ index qlen-1-qpos and must be
781
+ * complemented (see ref_frame_base). Keying on the read's own orientation
782
+ * (bam_is_rev) is correct; the biological strand s differs for R2 in
783
+ * paired data. */
784
+ int base_i = ref_frame_base(b, qpos);
775
785
  int qual = (int)bam_get_qual(b)[qpos];
776
786
  if (direct) {
777
787
  int cat = rslot;
@@ -1042,11 +1052,7 @@ static void count_interval_readwalk(worker_t *w, const cm_config *cfg, bam_hdr_t
1042
1052
  if (ref_pos < beg || ref_pos >= end) continue;
1043
1053
  uint32_t qpos = qcur + (uint32_t)k;
1044
1054
  if (qpos >= qlen) break;
1045
- uint8_t nt = bam_seqi(bam_get_seq(b), qpos);
1046
- int base_i = nt16_index(nt);
1047
- /* Complement on read orientation (bam_is_rev), not
1048
- * the biological strand s (R2 differs). */
1049
- if (bam_is_rev(b) && base_i < 4) base_i = 3 - base_i;
1055
+ int base_i = ref_frame_base(b, qpos);
1050
1056
  int cat = 0;
1051
1057
  if (w->expr && cm_expr_has_read(w->expr) && !cm_expr_read_constant(w->expr)) {
1052
1058
  cat = cm_expr_route(w->expr, b, hdr->target_name[tid],
@@ -1114,11 +1120,7 @@ static void count_interval_readwalk(worker_t *w, const cm_config *cfg, bam_hdr_t
1114
1120
  uint32_t qpos = qcur + (uint32_t)k;
1115
1121
  if (qpos >= qlen) break;
1116
1122
  int in_ovl = ((int)qpos >= olo && (int)qpos < ohi);
1117
- uint8_t nt = bam_seqi(bam_get_seq(b), qpos);
1118
- int base_i = nt16_index(nt);
1119
- /* Complement on read orientation (bam_is_rev),
1120
- * not the biological strand s (R2 differs). */
1121
- if (bam_is_rev(b) && base_i < 4) base_i = 3 - base_i;
1123
+ int base_i = ref_frame_base(b, qpos);
1122
1124
  int cat = 0;
1123
1125
  if (w->expr && cm_expr_has_read(w->expr) && !cm_expr_read_constant(w->expr)) {
1124
1126
  cat = cm_expr_route(w->expr, b, hdr->target_name[tid],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: countmut
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Unified ultra-fast strand-aware mutation counter (C backend + samtools-style -e/-p filters)
5
5
  Author-email: Ye Chang <yech1990@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "countmut"
7
- version = "0.3.2"
7
+ version = "0.3.3"
8
8
  description = "Unified ultra-fast strand-aware mutation counter (C backend + samtools-style -e/-p filters)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -92,37 +92,33 @@ def indel_data(tmp_path_factory):
92
92
 
93
93
 
94
94
  # ---------------------------------------------------------------------------
95
- # BUG: base complement keyed on biological strand (s) instead of read
96
- # orientation (bam_is_rev). For paired data an R2 read mapped forward has
97
- # bio_strand=1 yet is NOT reverse-oriented, so its bases were wrongly
98
- # reverse-complemented (A<->T), turning a pure-reference site into a 50/50
99
- # A/T mix. Regression: R2-forward reads must count their stored bases as-is.
95
+ # BUG: base complement applied to reverse-strand reads. bwa / minibwa write
96
+ # the SEQ of a reverse-strand read already in reference-forward orientation
97
+ # (the stored SEQ is the reverse complement of the original read, matching the
98
+ # reference), so the reference-frame base is simply SEQ[qpos]. Complementing
99
+ # or index-reversing it (as an earlier fix did) wrongly flipped A<->T for every
100
+ # reverse read, turning a pure-reference site into a ~50/50 A/T mix.
101
+ # Regression: reverse-strand reads must be counted as-is.
100
102
  # ---------------------------------------------------------------------------
101
- def test_r2_forward_not_complemented(tmp_path):
102
- root = str(tmp_path / "r2f")
103
+ def test_reverse_read_not_complemented(tmp_path):
104
+ root = str(tmp_path / "rev")
103
105
  os.makedirs(root, exist_ok=True)
104
- # reference base at 0-based 0 is 'A'. An R2 read mapped FORWARD stores the
105
- # base as-is (A), so a pure-A site must stay A, not flip to T.
106
+ # reference base at 0-based 0 is 'A'. A reverse-strand read (flag 16) whose
107
+ # stored SEQ is reference-forward (as bwa/minibwa write it) carries 'A' at
108
+ # 0-based 0; it must stay A, not flip to T.
106
109
  bam, fa = _write_bam(
107
110
  root,
108
111
  [
109
- # R1 forward (flag 99) and R2 forward (flag 35) covering 0-based 0.
110
- lambda h: _mk(h, "frag1", 99, 0, "ACGTACGTAC"),
111
- lambda h: _mk(h, "frag1", 35, 0, "ACGTACGTAC"),
112
- # R1 reverse (flag 83) and R2 reverse (flag 147): stored SEQ is the
113
- # reverse complement, so they must be complemented back to A.
114
- lambda h: _mk(h, "frag2", 83, 0, "ACGTACGTAC"),
115
- lambda h: _mk(h, "frag2", 147, 0, "ACGTACGTAC"),
112
+ lambda h: _mk(h, "fwd", 0, 0, "ACGTACGTAC"),
113
+ lambda h: _mk(h, "rev", 16, 0, "ACGTACGTAC"),
116
114
  ],
117
115
  )
118
- _h, rows = run_c(bam, fa, engine="read-walk", region="chr1:1-5")
119
- # rows: [chrom, pos, ref, depth, a, c, g, t, n] (strandless default)
116
+ _h, rows = run_c(bam, fa, engine="read-walk", region="chr1:1-5",
117
+ extra=["--strandless"])
120
118
  row = [r for r in rows if r[1] == 1][0]
121
119
  a, c, g, t = row[4], row[5], row[6], row[7]
122
- # All four reads carry reference 'A' at 0-based 0 -> A must dominate.
123
- assert a > 0 and a >= c + g + t, (row)
124
- # The bug produced ~50/50 A/T; assert T is a small minority.
125
- assert t <= a * 0.1, (row)
120
+ # Both reads carry reference 'A' at 0-based 0 -> A must dominate.
121
+ assert a == 2 and t == 0, (row)
126
122
 
127
123
 
128
124
  # ---------------------------------------------------------------------------
@@ -421,7 +417,6 @@ def _write_reference_read_bam(tmp_path, chrom, length):
421
417
  return bam, fa
422
418
 
423
419
 
424
- @pytest.mark.xfail(reason="full htslib SAM transcode produces non-BGZF temp BAM; CRAM/BAM paths are covered")
425
420
  def test_sam_input_matches_bam(motif_data, tmp_path):
426
421
  """SAM (plain, and gzipped) input must produce byte-identical output to the
427
422
  equivalent BAM (it is auto-transcoded to a temp BAM + index)."""
@@ -80,13 +80,15 @@ def data(tmp_path_factory):
80
80
  mk("p_g", 0, 0, "GGGGGAATT", Q40, ok),
81
81
  # p_x: NS > 1 -> dropped entirely
82
82
  mk("p_x", 0, 0, "GGGGAAATT", Q40, [("NS", 2), ("Yf", 1), ("Zf", 0)]),
83
- # minus reads, 9M at 0-based 4..12; stored T -> reference-frame A.
84
- # qpos = pos - 4, so group 1 (qpos 2..6) = 0-based 6..10.
85
- mk("m_hi", 16, 4, "TTTTTTTTT", Q40, ok),
83
+ # minus reads, 9M at 0-based 4..12. bwa/minibwa store the SEQ of a
84
+ # reverse-strand read in reference-forward orientation, so the stored
85
+ # base equals the reference base (no complement needed). reference
86
+ # [4:13] = AAAACCCC + 1bp past the contig end.
87
+ mk("m_hi", 16, 4, "AAAACCCCC", Q40, ok),
86
88
  # m_lo: no Yf -> group 0 at every base
87
- mk("m_lo", 16, 4, "TTTTTTTTT", Q40, [("NS", 0), ("Yf", 0), ("Zf", 0)]),
89
+ mk("m_lo", 16, 4, "AAAACCCCC", Q40, [("NS", 0), ("Yf", 0), ("Zf", 0)]),
88
90
  # m_x: NS > 1 -> dropped entirely
89
- mk("m_x", 16, 4, "TTTTTTTTT", Q40, [("NS", 2), ("Yf", 1), ("Zf", 0)]),
91
+ mk("m_x", 16, 4, "AAAACCCCC", Q40, [("NS", 2), ("Yf", 1), ("Zf", 0)]),
90
92
  ]
91
93
  reads.sort(key=lambda r: r.reference_start)
92
94
  with pysam.AlignmentFile(bam, "wb", header=header) as out:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes