codonyat 1.3.1__tar.gz → 1.3.2__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 (37) hide show
  1. {codonyat-1.3.1 → codonyat-1.3.2}/PKG-INFO +1 -1
  2. codonyat-1.3.2/aa_caller/aavf.py +169 -0
  3. {codonyat-1.3.1 → codonyat-1.3.2}/aa_caller/cli.py +0 -2
  4. {codonyat-1.3.1 → codonyat-1.3.2}/aa_caller/constants.py +1 -1
  5. {codonyat-1.3.1 → codonyat-1.3.2}/aa_caller/container.py +197 -51
  6. {codonyat-1.3.1 → codonyat-1.3.2}/aa_caller/runner.py +0 -1
  7. codonyat-1.3.2/aa_caller/sam.py +219 -0
  8. {codonyat-1.3.1 → codonyat-1.3.2}/aa_caller/stats_writer.py +17 -9
  9. {codonyat-1.3.1 → codonyat-1.3.2}/aa_caller/validators.py +9 -5
  10. {codonyat-1.3.1 → codonyat-1.3.2}/codonyat.egg-info/PKG-INFO +1 -1
  11. {codonyat-1.3.1 → codonyat-1.3.2}/pyproject.toml +1 -1
  12. codonyat-1.3.1/aa_caller/aavf.py +0 -165
  13. codonyat-1.3.1/aa_caller/sam.py +0 -153
  14. {codonyat-1.3.1 → codonyat-1.3.2}/LICENSE +0 -0
  15. {codonyat-1.3.1 → codonyat-1.3.2}/README.md +0 -0
  16. {codonyat-1.3.1 → codonyat-1.3.2}/aa_caller/__init__.py +0 -0
  17. {codonyat-1.3.1 → codonyat-1.3.2}/aa_caller/__main__.py +0 -0
  18. {codonyat-1.3.1 → codonyat-1.3.2}/aa_caller/app.py +0 -0
  19. {codonyat-1.3.1 → codonyat-1.3.2}/aa_caller/cli_options.py +0 -0
  20. {codonyat-1.3.1 → codonyat-1.3.2}/aa_caller/genetic_code.py +0 -0
  21. {codonyat-1.3.1 → codonyat-1.3.2}/aa_caller/metrics.py +0 -0
  22. {codonyat-1.3.1 → codonyat-1.3.2}/aa_caller/models.py +0 -0
  23. {codonyat-1.3.1 → codonyat-1.3.2}/aa_caller/reference.py +0 -0
  24. {codonyat-1.3.1 → codonyat-1.3.2}/codonyat.egg-info/SOURCES.txt +0 -0
  25. {codonyat-1.3.1 → codonyat-1.3.2}/codonyat.egg-info/dependency_links.txt +0 -0
  26. {codonyat-1.3.1 → codonyat-1.3.2}/codonyat.egg-info/entry_points.txt +0 -0
  27. {codonyat-1.3.1 → codonyat-1.3.2}/codonyat.egg-info/requires.txt +0 -0
  28. {codonyat-1.3.1 → codonyat-1.3.2}/codonyat.egg-info/top_level.txt +0 -0
  29. {codonyat-1.3.1 → codonyat-1.3.2}/setup.cfg +0 -0
  30. {codonyat-1.3.1 → codonyat-1.3.2}/tests/test_app.py +0 -0
  31. {codonyat-1.3.1 → codonyat-1.3.2}/tests/test_integration.py +0 -0
  32. {codonyat-1.3.1 → codonyat-1.3.2}/tests/test_metrics.py +0 -0
  33. {codonyat-1.3.1 → codonyat-1.3.2}/tests/test_non_hiv.py +0 -0
  34. {codonyat-1.3.1 → codonyat-1.3.2}/tests/test_protein_selection.py +0 -0
  35. {codonyat-1.3.1 → codonyat-1.3.2}/tests/test_v1_2_0_features.py +0 -0
  36. {codonyat-1.3.1 → codonyat-1.3.2}/tests/test_v1_3_0_features.py +0 -0
  37. {codonyat-1.3.1 → codonyat-1.3.2}/tests/test_v1_3_1_features.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codonyat
3
- Version: 1.3.1
3
+ Version: 1.3.2
4
4
  Summary: codon-yat — A codon-aware (Yet Another) amino acid variant typer from SAM alignments of viral NGS data.
5
5
  Author-email: Marc Noguera Julian <info@treetopunder.com>
6
6
  License-Expression: MIT
@@ -0,0 +1,169 @@
1
+ """AAVF v1.0 writer. Research use only. Never emits CAT or SRVL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from collections.abc import Sequence
7
+ from datetime import date
8
+ from pathlib import Path
9
+
10
+ from .constants import PACKAGE_VERSION
11
+ from .genetic_code import codon_to_aminoacid
12
+ from .metrics import classify_flags, is_acgt_codon
13
+ from .models import Variant
14
+ from .reference import FullReference
15
+
16
+ _AAVF_AA = frozenset("ACDEFGHIKLMNPQRSTVWYX*")
17
+
18
+
19
+ def aavf_amino_acid(codon: str) -> str:
20
+ """Map a codon to an AAVF amino-acid letter (``*`` = stop, ``X`` = other)."""
21
+ aa = codon_to_aminoacid(codon)
22
+ if aa == "_":
23
+ return "*"
24
+ if aa in _AAVF_AA:
25
+ return aa
26
+ return "X"
27
+
28
+
29
+ def mark_changed_bases(ref_codon: str, alt_codon: str) -> str:
30
+ """Lowercase matches to the reference codon; uppercase substitutions.
31
+
32
+ Matches the AAVF v1.0 examples (``RC=aaa;AC=aaC``).
33
+ """
34
+ ref = ref_codon.lower()
35
+ alt = alt_codon.lower()
36
+ marked = []
37
+ for i, base in enumerate(alt):
38
+ if i < len(ref) and base == ref[i]:
39
+ marked.append(base)
40
+ else:
41
+ marked.append(base.upper())
42
+ return "".join(marked)
43
+
44
+
45
+ def _fmt_float(value: float) -> str:
46
+ return f"{value:.6g}"
47
+
48
+
49
+ def write_aavf(
50
+ sample_name: str,
51
+ reference: FullReference,
52
+ output_path: Path,
53
+ protein_pairs: Sequence[tuple[str, dict[int, dict]]],
54
+ *,
55
+ min_freq: float,
56
+ min_coverage: int,
57
+ sb_alpha: float,
58
+ file_date: str | None = None,
59
+ ) -> None:
60
+ """Write an AAVF v1.0 file for the counted proteins.
61
+
62
+ FILTER codes are the item-1 flags ``lowfreq``, ``sb`` and ``lowcov``.
63
+ CAT and SRVL are never written. Partial / gapped codons map to ``X``.
64
+ True amino-acid indels are not called.
65
+ """
66
+ dated = file_date or date.today().strftime("%Y%m%d")
67
+ fasta_name = Path(reference.path).name
68
+ contig_lines = [
69
+ f"##contig=<ID={record_id.replace(':', '|')},length={len(reference.sequences[record_id])}>"
70
+ for record_id in reference.record_ids
71
+ ]
72
+ header_lines = [
73
+ "##fileformat=AAVFv1.0",
74
+ f"##fileDate={dated}",
75
+ f"##source=codonyat-{PACKAGE_VERSION} (research use only)",
76
+ f"##reference={fasta_name}",
77
+ *contig_lines,
78
+ '##INFO=<ID=RC,Number=1,Type=String,Description="Reference codon">',
79
+ '##INFO=<ID=AC,Number=.,Type=String,Description="Alternate codon(s) encoding ALT">',
80
+ '##INFO=<ID=ACC,Number=.,Type=Integer,Description="Alternate codon counts, same order as AC">',
81
+ '##INFO=<ID=ACF,Number=.,Type=Float,Description="Alternate codon frequencies, same order as AC">',
82
+ '##INFO=<ID=FWC,Number=1,Type=Integer,Description="Forward-strand coverage at the codon">',
83
+ '##INFO=<ID=RVC,Number=1,Type=Integer,Description="Reverse-strand coverage at the codon">',
84
+ '##INFO=<ID=SR,Number=1,Type=Float,Description="Forward/reverse coverage ratio at the codon">',
85
+ '##FILTER=<ID=lowfreq,Description="ALT frequency below --min-freq">',
86
+ '##FILTER=<ID=sb,Description="Strand-bias Fisher p-value below --sb-alpha">',
87
+ '##FILTER=<ID=lowcov,Description="Codon coverage below --min-coverage">',
88
+ "#CHROM\tGENE\tPOS\tREF\tALT\tFILTER\tALT_FREQ\tCOVERAGE\tINFO",
89
+ ]
90
+
91
+ # Stream rows instead of accumulating the whole file in a list; output can
92
+ # be large when many variants are called and there is no need to hold it.
93
+ with open(output_path, "w") as handle:
94
+ handle.write("\n".join(header_lines) + "\n")
95
+
96
+ for protein_name, entries in protein_pairs:
97
+ # aa_pos then genomic position; keeps GENE blocks contiguous.
98
+ ordered = sorted(
99
+ entries.items(),
100
+ key=lambda item: (item[1].get("aa_pos", item[0]), item[0]),
101
+ )
102
+ for pos, entry in ordered:
103
+ depth = int(entry["depth"])
104
+ if depth <= 0:
105
+ continue
106
+ record_id = str(entry.get("record_id", reference.id)).replace(":", "|")
107
+ gene = protein_name.replace(":", "|")
108
+ aa_pos = int(entry.get("aa_pos", 0)) or "."
109
+ ref_codon = reference.get_seq_at(
110
+ pos, 3, entry.get("record_id"), positions=entry.get("codon_coords")
111
+ )
112
+ ref_aa = aavf_amino_acid(ref_codon) if ref_codon else "X"
113
+ fw_depth = int(entry["fw_depth"])
114
+ rv_depth = int(entry["rv_depth"])
115
+ sr = (fw_depth / rv_depth) if rv_depth else 0.0
116
+
117
+ by_aa: dict[str, list[Variant]] = defaultdict(list)
118
+ for variant in entry["variants"].values():
119
+ by_aa[aavf_amino_acid(variant.codon)].append(variant)
120
+
121
+ for alt_aa, variants in by_aa.items():
122
+ alt_count = sum(v.count for v in variants)
123
+ alt_fw = sum(v.fw_reads for v in variants)
124
+ alt_rv = sum(v.rv_reads for v in variants)
125
+ alt_freq = (alt_count / depth) if depth else 0.0
126
+ flags, _p = classify_flags(
127
+ freq=alt_freq,
128
+ coverage=depth,
129
+ fw=alt_fw,
130
+ rv=alt_rv,
131
+ other_fw=fw_depth - alt_fw,
132
+ other_rv=rv_depth - alt_rv,
133
+ min_freq=min_freq,
134
+ min_coverage=min_coverage,
135
+ sb_alpha=sb_alpha,
136
+ )
137
+ filter_field = ";".join(flags) if flags else "PASS"
138
+ ac_parts = []
139
+ acc_parts = []
140
+ acf_parts = []
141
+ for variant in variants:
142
+ codon = variant.codon
143
+ labelled = mark_changed_bases(ref_codon, codon) if is_acgt_codon(codon) else codon.lower()
144
+ ac_parts.append(labelled)
145
+ acc_parts.append(str(int(variant.count)))
146
+ acf_parts.append(_fmt_float(variant.count / depth if depth else 0.0))
147
+ info = (
148
+ f"RC={ref_codon.lower()};"
149
+ f"AC={','.join(ac_parts)};"
150
+ f"ACC={','.join(acc_parts)};"
151
+ f"ACF={','.join(acf_parts)};"
152
+ f"FWC={fw_depth};RVC={rv_depth};SR={_fmt_float(sr)}"
153
+ )
154
+ handle.write(
155
+ "\t".join(
156
+ [
157
+ record_id,
158
+ gene,
159
+ str(aa_pos),
160
+ ref_aa,
161
+ alt_aa,
162
+ filter_field,
163
+ _fmt_float(alt_freq),
164
+ str(depth),
165
+ info,
166
+ ]
167
+ )
168
+ + "\n"
169
+ )
@@ -108,8 +108,6 @@ def main() -> None:
108
108
  min_baseq=args.min_baseq,
109
109
  collapsed_read_counts=args.collapsed_read_counts,
110
110
  )
111
- container.load()
112
-
113
111
  logger.info("Calculating %s variant frequencies", ",".join(protein_names))
114
112
  container.calculate_for_proteins(proteins, reference)
115
113
 
@@ -6,7 +6,7 @@ DEFAULT_ENTROPY_THRESHOLD = 0.0
6
6
  DEFAULT_PROTEIN = "RT"
7
7
 
8
8
  # Opt-in research statistics (v1.3.0). Unused unless --stats / --aavf / --min-baseq is set.
9
- PACKAGE_VERSION = "1.3.1"
9
+ PACKAGE_VERSION = "1.3.2"
10
10
  WILSON_Z_95 = 1.959963984540054 # Φ^{-1}(0.975)
11
11
  DEFAULT_MIN_FREQ = 0.01
12
12
  DEFAULT_MIN_COVERAGE = 10
@@ -4,7 +4,8 @@ import csv
4
4
  import logging
5
5
  import math
6
6
  import xml.etree.ElementTree as ET
7
- from collections.abc import Sequence
7
+ from bisect import bisect_left, bisect_right
8
+ from collections.abc import Iterator, Sequence
8
9
  from pathlib import Path
9
10
 
10
11
  from .genetic_code import codon_to_aminoacid
@@ -15,6 +16,65 @@ from .sam import SamEntry
15
16
  logger = logging.getLogger(__name__)
16
17
 
17
18
 
19
+ class _ReadCollection(Sequence[SamEntry]):
20
+ """Lazy, re-iterable view over the mapped records in a SAM file.
21
+
22
+ ``SamContainer.load`` used to materialise every mapped read in a list. Each
23
+ ``SamEntry`` cached a reference-position → read-position dict, so a 1 kb
24
+ read cost roughly 100 KB. With a few million reads that easily exhausts
25
+ memory. This view keeps only the path and streams the file on each pass;
26
+ ``read`` aggregation therefore uses O(1) memory in the sample size.
27
+
28
+ It still behaves as a read-only sequence (``len``, iteration, indexing) so
29
+ existing code that inspects ``SamContainer.reads`` keeps working.
30
+ """
31
+
32
+ def __init__(self, path: Path, collapsed_read_counts: bool = False) -> None:
33
+ self.path = Path(path)
34
+ self.collapsed_read_counts = collapsed_read_counts
35
+ self._count: int | None = None
36
+
37
+ def __iter__(self) -> Iterator[SamEntry]:
38
+ count = 0
39
+ with open(self.path) as fh:
40
+ for line in fh:
41
+ if line.startswith("@"):
42
+ continue
43
+ entry = SamEntry(line, collapsed_read_counts=self.collapsed_read_counts)
44
+ if entry.is_mapped():
45
+ count += 1
46
+ yield entry
47
+ # Cache the count after a complete pass so ``len()`` does not rescan.
48
+ self._count = count
49
+
50
+ def __len__(self) -> int:
51
+ if self._count is None:
52
+ # Count without building SamEntry objects (which would decode the
53
+ # CIGAR and sequence for every alignment just to report a length).
54
+ count = 0
55
+ with open(self.path) as fh:
56
+ for line in fh:
57
+ if line.startswith("@"):
58
+ continue
59
+ fields = line.split("\t", 2)
60
+ if not (int(fields[1]) & 4):
61
+ count += 1
62
+ self._count = count
63
+ return self._count
64
+
65
+ def __getitem__(self, index: int | slice):
66
+ if isinstance(index, slice):
67
+ return list(self)[index]
68
+ if index < 0:
69
+ index += len(self)
70
+ if index < 0:
71
+ raise IndexError("read index out of range")
72
+ for current, entry in enumerate(self):
73
+ if current == index:
74
+ return entry
75
+ raise IndexError("read index out of range")
76
+
77
+
18
78
  class SamContainer:
19
79
  """Aggregates SAM entries and computes RT variant statistics for an amplicon set."""
20
80
 
@@ -29,7 +89,10 @@ class SamContainer:
29
89
  ) -> None:
30
90
  """Initialize the container with the SAM path, ratio window, and entropy threshold."""
31
91
  self.sam_path = sam_path
32
- self.reads: list[SamEntry] = []
92
+ # Streaming view: no alignment records are retained between passes.
93
+ self.reads: Sequence[SamEntry] = _ReadCollection(
94
+ sam_path, collapsed_read_counts=collapsed_read_counts
95
+ )
33
96
  self.variants: dict[int, dict] = {}
34
97
  self.protein_variants: dict[str, dict[int, dict]] = {}
35
98
  self._protein_result_list: list[tuple[str, dict[int, dict]]] = []
@@ -40,14 +103,13 @@ class SamContainer:
40
103
  self.collapsed_read_counts = collapsed_read_counts
41
104
 
42
105
  def load(self) -> None:
43
- """Load every mapped read from the SAM file so they can be aggregated."""
44
- with open(self.sam_path) as fh:
45
- for line in fh:
46
- if line.startswith("@"):
47
- continue
48
- entry = SamEntry(line, collapsed_read_counts=self.collapsed_read_counts)
49
- if entry.is_mapped():
50
- self.reads.append(entry)
106
+ """Prepare the streaming read view (kept for API compatibility).
107
+
108
+ Reads are parsed and aggregated in :meth:`calculate_for_proteins` rather
109
+ than retained. This method still validates the file by counting mapped
110
+ records without storing them, so ``len(container.reads)`` works as before.
111
+ """
112
+ len(self.reads)
51
113
 
52
114
  def calculate_variant_frequencies(
53
115
  self, protein_name: str, reference: FullReference, record_id: str | None = None
@@ -83,15 +145,24 @@ class SamContainer:
83
145
  self.calculate_for_proteins(proteins, reference)
84
146
 
85
147
  def calculate_for_proteins(self, proteins: Sequence[Protein], reference: FullReference) -> None:
86
- """Count every requested protein in one pass over ``self.reads``.
148
+ """Count every requested protein in one streaming pass over the SAM file.
87
149
 
88
150
  Supports multi-record references (reads are matched by RNAME) and
89
- multi-interval proteins.
151
+ multi-interval proteins. Only per-position aggregate state is retained;
152
+ alignment records are released as soon as they have been counted.
153
+
154
+ For each read we look up the codons whose coordinates lie inside the
155
+ read's aligned reference span instead of walking every codon of the
156
+ protein. That keeps both memory and CPU proportional to the covered
157
+ bases rather than to reads × protein length.
90
158
  """
91
159
  if not proteins:
92
160
  raise ValueError("At least one protein is required")
93
161
 
94
- jobs: list[tuple[Protein, dict[int, dict]]] = []
162
+ # Each job: (protein, entries, sorted codon starts, codon spans).
163
+ jobs: list[
164
+ tuple[Protein, dict[int, dict], list[int], list[tuple[int, int]]]
165
+ ] = []
95
166
  for protein in proteins:
96
167
  entries: dict[int, dict] = {}
97
168
  aa_pos = 0
@@ -107,18 +178,44 @@ class SamContainer:
107
178
  "aa_pos": aa_pos,
108
179
  "codon_coords": codon_coords,
109
180
  }
110
- jobs.append((protein, entries))
181
+ starts = sorted(entries)
182
+ spans = [
183
+ (
184
+ min(entries[pos]["codon_coords"]),
185
+ max(entries[pos]["codon_coords"]),
186
+ )
187
+ for pos in starts
188
+ ]
189
+ jobs.append((protein, entries, starts, spans))
190
+
191
+ jobs_by_record: dict[str, list[tuple[Protein, dict[int, dict], list[int], list[tuple[int, int]]]]] = {}
192
+ for job in jobs:
193
+ jobs_by_record.setdefault(job[0].record_id, []).append(job)
111
194
 
112
195
  for read in self.reads:
113
- for protein, entries in jobs:
114
- if read.reference != protein.record_id:
115
- continue
116
- for pos, entry in entries.items():
117
- self._accumulate_read_at_position(read, pos, entry)
196
+ if not read.is_mapped():
197
+ continue
198
+ read_jobs = jobs_by_record.get(read.reference)
199
+ if not read_jobs:
200
+ continue
201
+ span_start = read.coordinate
202
+ span_end = read.reference_end
203
+ for _protein, entries, starts, spans in read_jobs:
204
+ # Codons entirely outside the read cannot contribute. Using the
205
+ # first codon coordinate as the sorted key is sufficient: every
206
+ # other coordinate is checked before counting.
207
+ left = bisect_left(starts, span_start)
208
+ right = bisect_right(starts, span_end)
209
+ for index in range(left, right):
210
+ codon_min, codon_max = spans[index]
211
+ if codon_min < span_start or codon_max > span_end:
212
+ continue
213
+ pos = starts[index]
214
+ self._accumulate_read_at_position(read, pos, entries[pos])
118
215
 
119
216
  self.protein_variants = {}
120
217
  self._protein_result_list = []
121
- for protein, entries in jobs:
218
+ for protein, entries, _starts, _spans in jobs:
122
219
  self.variants = entries
123
220
  for pos in self.variants:
124
221
  self.calculate_ratios_nucleotide(pos)
@@ -134,8 +231,8 @@ class SamContainer:
134
231
  codon_coords = entry.get("codon_coords")
135
232
  if codon_coords is None:
136
233
  codon_coords = (pos, pos + 1, pos + 2)
137
- if not any(read.covers(coord) for coord in codon_coords):
138
- return
234
+ # ``codon_at`` returns None when any of the three coordinates is outside
235
+ # the read's aligned span, so a separate covers() sweep is unnecessary.
139
236
  codon = read.codon_at(pos, positions=codon_coords)
140
237
  if not codon or len(codon) < 3:
141
238
  return
@@ -179,14 +276,15 @@ class SamContainer:
179
276
  part coordinates (for example positions 10, 11, 12 of 1-10;11-30).
180
277
  Incomplete trailing bases at the end of the protein are dropped.
181
278
  """
182
- coding_bases: list[int] = []
279
+ positions: list[tuple] = []
280
+ pending: list[int] = []
183
281
  for interval in protein.intervals:
184
- coding_bases.extend(range(interval.start, interval.end + 1))
185
-
186
- positions = []
187
- for i in range(0, len(coding_bases) // 3 * 3, 3):
188
- codon_coords = (coding_bases[i], coding_bases[i + 1], coding_bases[i + 2])
189
- positions.append((codon_coords[0], protein.record_id, codon_coords))
282
+ for coordinate in range(interval.start, interval.end + 1):
283
+ pending.append(coordinate)
284
+ if len(pending) == 3:
285
+ codon_coords = (pending[0], pending[1], pending[2])
286
+ positions.append((codon_coords[0], protein.record_id, codon_coords))
287
+ pending.clear()
190
288
  return positions
191
289
 
192
290
  def calculate_ratios_nucleotide(self, position: int) -> None:
@@ -361,26 +459,73 @@ class SamContainer:
361
459
  return pairs
362
460
 
363
461
  @staticmethod
364
- def _add_positions(parent: ET.Element, entries: dict[int, dict]) -> None:
462
+ def _position_element(pos: int, entry: dict) -> ET.Element:
463
+ position_elem = ET.Element("Position", index=str(pos))
464
+ ET.SubElement(position_elem, "Depth").text = str(entry["depth"])
465
+ ET.SubElement(position_elem, "FwCover").text = str(entry["fw_depth"])
466
+ ET.SubElement(position_elem, "RvCover").text = str(entry["rv_depth"])
467
+ variants_elem = ET.SubElement(position_elem, "Variants")
468
+ for variant in entry["variants"].values():
469
+ # describe each variant using XML attributes for diagnostics
470
+ var_elem = ET.SubElement(variants_elem, "Variant", codon=variant.codon)
471
+ var_elem.set("count", str(variant.count))
472
+ var_elem.set("fw_reads", str(variant.fw_reads))
473
+ var_elem.set("rv_reads", str(variant.rv_reads))
474
+ return position_elem
475
+
476
+ @classmethod
477
+ def _iter_position_xml(cls, entries: dict[int, dict]) -> Iterator[str]:
478
+ """Serialise one position at a time so the full XML tree is never held."""
365
479
  for pos, entry in sorted(entries.items()):
366
- position_elem = ET.SubElement(parent, "Position", index=str(pos))
367
- ET.SubElement(position_elem, "Depth").text = str(entry["depth"])
368
- ET.SubElement(position_elem, "FwCover").text = str(entry["fw_depth"])
369
- ET.SubElement(position_elem, "RvCover").text = str(entry["rv_depth"])
370
- variants_elem = ET.SubElement(position_elem, "Variants")
371
- for variant in entry["variants"].values():
372
- # describe each variant using XML attributes for diagnostics
373
- var_elem = ET.SubElement(variants_elem, "Variant", codon=variant.codon)
374
- var_elem.set("count", str(variant.count))
375
- var_elem.set("fw_reads", str(variant.fw_reads))
376
- var_elem.set("rv_reads", str(variant.rv_reads))
480
+ yield ET.tostring(
481
+ cls._position_element(pos, entry),
482
+ encoding="unicode",
483
+ short_empty_elements=True,
484
+ )
485
+
486
+ @staticmethod
487
+ def _open_tag(tag: str, **attributes: str) -> str:
488
+ """Return the opening tag for an empty element, in ElementTree style."""
489
+ empty = ET.tostring(
490
+ ET.Element(tag, attributes), encoding="unicode", short_empty_elements=True
491
+ )
492
+ # ElementTree serialises an empty element as ``<tag ... />``.
493
+ return empty[:-3] + ">"
494
+
495
+ def _write_xml_tree(
496
+ self,
497
+ sample_name: str,
498
+ reference: FullReference,
499
+ output_path: Path,
500
+ protein_pairs: Sequence[tuple[str, dict[int, dict]]],
501
+ *,
502
+ wrap_proteins: bool,
503
+ ) -> None:
504
+ with open(output_path, "w", encoding="utf-8", newline="") as handle:
505
+ handle.write("<?xml version='1.0' encoding='utf-8'?>\n")
506
+ handle.write(
507
+ self._open_tag(
508
+ "SamContainer", sample=sample_name, reference=reference.id
509
+ )
510
+ )
511
+ for protein_name, entries in protein_pairs:
512
+ if wrap_proteins:
513
+ handle.write(self._open_tag("Protein", name=protein_name))
514
+ for chunk in self._iter_position_xml(entries):
515
+ handle.write(chunk)
516
+ if wrap_proteins:
517
+ handle.write("</Protein>")
518
+ handle.write("</SamContainer>")
377
519
 
378
520
  def write_xml(self, sample_name: str, reference: FullReference, output_path: Path) -> None:
379
521
  """Dump the per-position state of the (first) protein into XML for diagnostics."""
380
- root = ET.Element("SamContainer", sample=sample_name, reference=reference.id)
381
- self._add_positions(root, self.variants)
382
- tree = ET.ElementTree(root)
383
- tree.write(output_path, encoding="utf-8", xml_declaration=True)
522
+ self._write_xml_tree(
523
+ sample_name,
524
+ reference,
525
+ output_path,
526
+ [("", self.variants)],
527
+ wrap_proteins=False,
528
+ )
384
529
 
385
530
  def write_xml_proteins(
386
531
  self,
@@ -393,12 +538,13 @@ class SamContainer:
393
538
 
394
539
  For a single protein use :meth:`write_xml`, whose layout is unchanged.
395
540
  """
396
- root = ET.Element("SamContainer", sample=sample_name, reference=reference.id)
397
- for protein_name, entries in self._output_pairs(protein_names):
398
- protein_elem = ET.SubElement(root, "Protein", name=protein_name)
399
- self._add_positions(protein_elem, entries)
400
- tree = ET.ElementTree(root)
401
- tree.write(output_path, encoding="utf-8", xml_declaration=True)
541
+ self._write_xml_tree(
542
+ sample_name,
543
+ reference,
544
+ output_path,
545
+ self._output_pairs(protein_names),
546
+ wrap_proteins=True,
547
+ )
402
548
 
403
549
  def write_stats(
404
550
  self,
@@ -90,7 +90,6 @@ def call_variants(
90
90
  min_baseq=min_baseq,
91
91
  collapsed_read_counts=collapsed_read_counts,
92
92
  )
93
- container.load()
94
93
  proteins = resolve_protein_targets(protein_spec, reference)
95
94
  protein_names = [item.name for item in proteins]
96
95
  container.calculate_for_proteins(proteins, reference)
@@ -0,0 +1,219 @@
1
+ from __future__ import annotations
2
+
3
+ __all__ = ["SamEntry"]
4
+
5
+ import re
6
+ from bisect import bisect_right
7
+ from collections.abc import Sequence
8
+
9
+ from .models import Qual
10
+
11
+ # Compiled once at module load — reused across every SamEntry instance.
12
+ _CIGAR_RE = re.compile(r"(\d+)([MIDNSHP=X])")
13
+ _AMPLICON_RE = re.compile(r"(Amp_[0-9]+)")
14
+
15
+ # Sentinel returned by the CIGAR lookup for reference positions that the read
16
+ # does not span. A read index of -1 means the reference base is deleted/skipped.
17
+ _MISSING = None
18
+
19
+
20
+ class SamEntry:
21
+ """Wraps a SAM record with convenience helpers used during variant aggregation.
22
+
23
+ The CIGAR is represented as a small list of reference/query blocks instead
24
+ of a ``{reference_position: read_position}`` dictionary. The dictionary
25
+ version cost ~100 KB per 1 kb read, which made whole-SAM loading impractical
26
+ for real sample sizes. Blocks are indexed with ``bisect`` and use O(number
27
+ of CIGAR ops) memory per read.
28
+ """
29
+
30
+ def __init__(self, line: str, collapsed_read_counts: bool = False) -> None:
31
+ """Parse a single SAM line and cache derived attributes that the pipeline needs."""
32
+ self.raw = line.strip()
33
+ self.fields = self.raw.split("\t")
34
+ self.identifier = self.fields[0]
35
+ self.flag = int(self.fields[1])
36
+ self.reference = self.fields[2]
37
+ self.coordinate = int(self.fields[3])
38
+ self.map_quality = int(self.fields[4])
39
+ self.cigar = self.fields[5]
40
+ self.sequence = self.fields[9]
41
+ self.quality_string = self.fields[10]
42
+ self.options = self.fields[11:]
43
+ self.orientation = self._determine_orientation()
44
+ self.collapsed_read_counts = collapsed_read_counts
45
+ self.occurences = self._parse_occurences()
46
+
47
+ cigar_ops = _CIGAR_RE.findall(self.cigar)
48
+ self._reference_covered_length = self._compute_reference_covered_length(cigar_ops)
49
+ self._reference_end = (
50
+ self.coordinate + self._reference_covered_length - 1
51
+ if self._reference_covered_length
52
+ else self.coordinate - 1
53
+ )
54
+ self._cigar_blocks, self._block_starts = self._build_cigar_blocks(cigar_ops)
55
+
56
+ # Quality decoding is only needed when --min-baseq is enabled. Keep the
57
+ # raw string (already part of ``fields``) and build the numeric array lazily.
58
+ self._quality_cache: Qual | None = None
59
+ self.amplicon = self._parse_amplicon()
60
+
61
+ @property
62
+ def reference_end(self) -> int:
63
+ """Last reference position spanned by the CIGAR (inclusive)."""
64
+ return self._reference_end
65
+
66
+ @property
67
+ def _quality(self) -> Qual:
68
+ """Lazily decode the SAM quality string on first use."""
69
+ if self._quality_cache is None:
70
+ self._quality_cache = Qual(self.quality_string, "sanger")
71
+ return self._quality_cache
72
+
73
+ def _determine_orientation(self) -> str:
74
+ """Return whether the read is mapped to the forward or reverse strand."""
75
+ if self.flag & 16:
76
+ return "R"
77
+ return "F"
78
+
79
+ def _parse_occurences(self) -> int:
80
+ """Return how many observations this SAM row represents.
81
+
82
+ By default each row counts once. With ``collapsed_read_counts``, a
83
+ read name ending in ``_<digits>`` is treated as that many duplicates.
84
+ """
85
+ if not self.collapsed_read_counts:
86
+ return 1
87
+ parts = self.identifier.split("_")
88
+ tail = parts[-1]
89
+ if tail.isdigit():
90
+ return max(1, int(tail))
91
+ return 1
92
+
93
+ @staticmethod
94
+ def _compute_reference_covered_length(cigar_ops: list[tuple[str, str]]) -> int:
95
+ """Sum the CIGAR operations that consume reference bases to get coverage."""
96
+ total = 0
97
+ for length, op in cigar_ops:
98
+ if op in "MDN=X":
99
+ total += int(length)
100
+ return total
101
+
102
+ def _build_cigar_blocks(
103
+ self, cigar_ops: list[tuple[str, str]]
104
+ ) -> tuple[list[tuple[int, int, int, bool]], list[int]]:
105
+ """Build compact (ref_start, ref_end, read_start, is_gap) query blocks.
106
+
107
+ The previous implementation stored one dict entry per reference base.
108
+ Blocks keep exactly the same lookup semantics with much less memory:
109
+ reference positions outside every block are absent, positions inside a
110
+ D/N block map to a gap, and M/=/X positions map linearly to the query.
111
+ """
112
+ blocks: list[tuple[int, int, int, bool]] = []
113
+ starts: list[int] = []
114
+ ref_pos = self.coordinate
115
+ read_pos = 0
116
+ for length_str, op in cigar_ops:
117
+ length = int(length_str)
118
+ if op in "M=X":
119
+ blocks.append((ref_pos, ref_pos + length, read_pos, False))
120
+ starts.append(ref_pos)
121
+ ref_pos += length
122
+ read_pos += length
123
+ elif op == "I":
124
+ # I consumes the query only; D/N consume the reference only.
125
+ read_pos += length
126
+ elif op in "DN":
127
+ blocks.append((ref_pos, ref_pos + length, -1, True))
128
+ starts.append(ref_pos)
129
+ ref_pos += length
130
+ elif op == "S":
131
+ read_pos += length
132
+ # H and P consume neither
133
+ return blocks, starts
134
+
135
+ def _block_index(self, reference_position: int) -> int | None:
136
+ """Return the CIGAR block covering ``reference_position``, or None."""
137
+ index = bisect_right(self._block_starts, reference_position) - 1
138
+ if index < 0:
139
+ return _MISSING
140
+ if reference_position >= self._cigar_blocks[index][1]:
141
+ return _MISSING
142
+ return index
143
+
144
+ def _read_index(self, reference_position: int) -> int | None:
145
+ """Map a reference position to a query index, -1 for a gap, or None.
146
+
147
+ ``None`` means the position is outside the aligned reference span.
148
+ """
149
+ index = self._block_index(reference_position)
150
+ if index is None:
151
+ return _MISSING
152
+ ref_start, _ref_end, read_start, is_gap = self._cigar_blocks[index]
153
+ if is_gap:
154
+ return -1
155
+ return read_start + (reference_position - ref_start)
156
+
157
+ def _parse_amplicon(self) -> str:
158
+ """Extract the amplicon tag embedded in the read identifier, if present."""
159
+ match = _AMPLICON_RE.search(self.identifier)
160
+ if match:
161
+ return match.group(1)
162
+ return "Amp_NONE"
163
+
164
+ def covers(self, position: int) -> bool:
165
+ """Check whether this read spans the requested reference position."""
166
+ return self.coordinate <= position <= self._reference_end
167
+
168
+ def _codon_ref_positions(
169
+ self, position: int, positions: Sequence[int] | None = None
170
+ ) -> tuple[int, int, int]:
171
+ """Resolve the three reference bases of a codon.
172
+
173
+ ``positions`` is the actual part coordinates (needed when a codon
174
+ crosses a multi-interval junction). Consecutive ``position..+2`` is
175
+ used only when the caller does not supply them.
176
+ """
177
+ if positions is not None:
178
+ coords = tuple(int(p) for p in positions)
179
+ if len(coords) != 3:
180
+ raise ValueError("codon positions must contain exactly three coordinates")
181
+ return coords[0], coords[1], coords[2]
182
+ return position, position + 1, position + 2
183
+
184
+ def codon_at(self, position: int, positions: Sequence[int] | None = None) -> str | None:
185
+ """Return the codon at the given reference coordinates using the CIGAR-aware mapping."""
186
+ coords = self._codon_ref_positions(position, positions)
187
+
188
+ # Fast path: the common consecutive codon fully inside one M/=/X block.
189
+ if coords[1] == coords[0] + 1 and coords[2] == coords[0] + 2:
190
+ block = self._block_index(coords[0])
191
+ if block is not None:
192
+ ref_start, ref_end, read_start, is_gap = self._cigar_blocks[block]
193
+ if not is_gap and coords[2] < ref_end:
194
+ start = read_start + (coords[0] - ref_start)
195
+ return self.sequence[start : start + 3]
196
+
197
+ bases = []
198
+ for ref_pos in coords:
199
+ read_idx = self._read_index(ref_pos)
200
+ if read_idx is None:
201
+ return None
202
+ bases.append("-" if read_idx < 0 else self.sequence[read_idx])
203
+ return "".join(bases)
204
+
205
+ def codon_qualities_at(
206
+ self, position: int, positions: Sequence[int] | None = None
207
+ ) -> list[float] | None:
208
+ """Return Phred-like qualities for the three codon bases, or None if any is missing/gapped."""
209
+ quals: list[float] = []
210
+ for ref_pos in self._codon_ref_positions(position, positions):
211
+ read_idx = self._read_index(ref_pos)
212
+ if read_idx is None or read_idx < 0:
213
+ return None
214
+ quals.append(self._quality.return_quality_by_pos(read_idx))
215
+ return quals
216
+
217
+ def is_mapped(self) -> bool:
218
+ """True when the read is not flagged as unmapped."""
219
+ return not (self.flag & 4)
@@ -10,7 +10,6 @@ from .genetic_code import codon_to_aminoacid
10
10
  from .metrics import (
11
11
  classify_flags,
12
12
  diversity_from_codon_counts,
13
- mean_or_zero,
14
13
  wilson_ci,
15
14
  )
16
15
  from .reference import FullReference
@@ -70,7 +69,11 @@ def write_stats_tsv(
70
69
  writer = csv.DictWriter(handle, fieldnames=STATS_FIELDS, delimiter="\t")
71
70
  writer.writeheader()
72
71
  for protein_name, entries in protein_pairs:
73
- codon_divs: list[dict[str, float]] = []
72
+ # Running sums for the gene-level row: storing one dict per codon
73
+ # would scale with protein length for no benefit.
74
+ n_codons = 0
75
+ sum_shannon = sum_shannon_norm = 0.0
76
+ sum_gini = sum_pi_nt = sum_pi_aa = 0.0
74
77
  ordered = sorted(
75
78
  entries.items(),
76
79
  key=lambda item: (item[1].get("aa_pos", item[0]), item[0]),
@@ -84,7 +87,12 @@ def write_stats_tsv(
84
87
  codon_counts = {variant.codon: float(variant.count) for variant in entry["variants"].values()}
85
88
  diversity = diversity_from_codon_counts(codon_counts)
86
89
  if diversity["n_valid"] > 0:
87
- codon_divs.append(diversity)
90
+ n_codons += 1
91
+ sum_shannon += diversity["shannon"]
92
+ sum_shannon_norm += diversity["shannon_norm"]
93
+ sum_gini += diversity["gini_simpson"]
94
+ sum_pi_nt += diversity["pi_nt"]
95
+ sum_pi_aa += diversity["pi_aa"]
88
96
 
89
97
  for variant in entry["variants"].values():
90
98
  freq = (variant.count / depth) if depth else 0.0
@@ -181,12 +189,12 @@ def write_stats_tsv(
181
189
  "SB_P": _blank(),
182
190
  "FLAGS": _blank(),
183
191
  "COVERAGE": _blank(),
184
- "SHANNON": _fmt(mean_or_zero(d["shannon"] for d in codon_divs)),
185
- "SHANNON_NORM": _fmt(mean_or_zero(d["shannon_norm"] for d in codon_divs)),
186
- "GINI_SIMPSON": _fmt(mean_or_zero(d["gini_simpson"] for d in codon_divs)),
187
- "PI_NT": _fmt(mean_or_zero(d["pi_nt"] for d in codon_divs)),
188
- "PI_AA": _fmt(mean_or_zero(d["pi_aa"] for d in codon_divs)),
192
+ "SHANNON": _fmt(sum_shannon / n_codons if n_codons else 0.0),
193
+ "SHANNON_NORM": _fmt(sum_shannon_norm / n_codons if n_codons else 0.0),
194
+ "GINI_SIMPSON": _fmt(sum_gini / n_codons if n_codons else 0.0),
195
+ "PI_NT": _fmt(sum_pi_nt / n_codons if n_codons else 0.0),
196
+ "PI_AA": _fmt(sum_pi_aa / n_codons if n_codons else 0.0),
189
197
  "N_TYPES": _blank(),
190
- "N_CODONS": len(codon_divs),
198
+ "N_CODONS": n_codons,
191
199
  }
192
200
  )
@@ -33,16 +33,20 @@ def validate_reference_file(
33
33
  ``protein_name`` may be one name, a comma-separated list, or ``all``
34
34
  (which only requires the FASTA to be readable).
35
35
  """
36
- records = list(SeqIO.parse(str(path), "fasta"))
37
- if not records:
36
+ record_count = 0
37
+ annotated_names: set[str] = set()
38
+ for record in SeqIO.parse(str(path), "fasta"):
39
+ record_count += 1
40
+ if annotation_path is None:
41
+ annotated_names.update(
42
+ protein.name for protein in proteins_from_fasta_record(record)
43
+ )
44
+ if record_count == 0:
38
45
  raise ValueError(f"Reference file {path} is empty or not FASTA")
39
46
 
40
47
  if annotation_path:
41
48
  return
42
49
 
43
- annotated_names = {
44
- protein.name for record in records for protein in proteins_from_fasta_record(record)
45
- }
46
50
  for name in protein_name.split(","):
47
51
  name = name.strip()
48
52
  if not name or name.lower() == "all":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codonyat
3
- Version: 1.3.1
3
+ Version: 1.3.2
4
4
  Summary: codon-yat — A codon-aware (Yet Another) amino acid variant typer from SAM alignments of viral NGS data.
5
5
  Author-email: Marc Noguera Julian <info@treetopunder.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codonyat"
7
- version = "1.3.1"
7
+ version = "1.3.2"
8
8
  description = "codon-yat — A codon-aware (Yet Another) amino acid variant typer from SAM alignments of viral NGS data."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,165 +0,0 @@
1
- """AAVF v1.0 writer. Research use only. Never emits CAT or SRVL."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections import defaultdict
6
- from collections.abc import Sequence
7
- from datetime import date
8
- from pathlib import Path
9
-
10
- from .constants import PACKAGE_VERSION
11
- from .genetic_code import codon_to_aminoacid
12
- from .metrics import classify_flags, is_acgt_codon
13
- from .models import Variant
14
- from .reference import FullReference
15
-
16
- _AAVF_AA = frozenset("ACDEFGHIKLMNPQRSTVWYX*")
17
-
18
-
19
- def aavf_amino_acid(codon: str) -> str:
20
- """Map a codon to an AAVF amino-acid letter (``*`` = stop, ``X`` = other)."""
21
- aa = codon_to_aminoacid(codon)
22
- if aa == "_":
23
- return "*"
24
- if aa in _AAVF_AA:
25
- return aa
26
- return "X"
27
-
28
-
29
- def mark_changed_bases(ref_codon: str, alt_codon: str) -> str:
30
- """Lowercase matches to the reference codon; uppercase substitutions.
31
-
32
- Matches the AAVF v1.0 examples (``RC=aaa;AC=aaC``).
33
- """
34
- ref = ref_codon.lower()
35
- alt = alt_codon.lower()
36
- marked = []
37
- for i, base in enumerate(alt):
38
- if i < len(ref) and base == ref[i]:
39
- marked.append(base)
40
- else:
41
- marked.append(base.upper())
42
- return "".join(marked)
43
-
44
-
45
- def _fmt_float(value: float) -> str:
46
- return f"{value:.6g}"
47
-
48
-
49
- def write_aavf(
50
- sample_name: str,
51
- reference: FullReference,
52
- output_path: Path,
53
- protein_pairs: Sequence[tuple[str, dict[int, dict]]],
54
- *,
55
- min_freq: float,
56
- min_coverage: int,
57
- sb_alpha: float,
58
- file_date: str | None = None,
59
- ) -> None:
60
- """Write an AAVF v1.0 file for the counted proteins.
61
-
62
- FILTER codes are the item-1 flags ``lowfreq``, ``sb`` and ``lowcov``.
63
- CAT and SRVL are never written. Partial / gapped codons map to ``X``.
64
- True amino-acid indels are not called.
65
- """
66
- dated = file_date or date.today().strftime("%Y%m%d")
67
- fasta_name = Path(reference.path).name
68
- contig_lines = [
69
- f"##contig=<ID={record_id.replace(':', '|')},length={len(reference.sequences[record_id])}>"
70
- for record_id in reference.record_ids
71
- ]
72
- lines = [
73
- "##fileformat=AAVFv1.0",
74
- f"##fileDate={dated}",
75
- f"##source=codonyat-{PACKAGE_VERSION} (research use only)",
76
- f"##reference={fasta_name}",
77
- *contig_lines,
78
- '##INFO=<ID=RC,Number=1,Type=String,Description="Reference codon">',
79
- '##INFO=<ID=AC,Number=.,Type=String,Description="Alternate codon(s) encoding ALT">',
80
- '##INFO=<ID=ACC,Number=.,Type=Integer,Description="Alternate codon counts, same order as AC">',
81
- '##INFO=<ID=ACF,Number=.,Type=Float,Description="Alternate codon frequencies, same order as AC">',
82
- '##INFO=<ID=FWC,Number=1,Type=Integer,Description="Forward-strand coverage at the codon">',
83
- '##INFO=<ID=RVC,Number=1,Type=Integer,Description="Reverse-strand coverage at the codon">',
84
- '##INFO=<ID=SR,Number=1,Type=Float,Description="Forward/reverse coverage ratio at the codon">',
85
- '##FILTER=<ID=lowfreq,Description="ALT frequency below --min-freq">',
86
- '##FILTER=<ID=sb,Description="Strand-bias Fisher p-value below --sb-alpha">',
87
- '##FILTER=<ID=lowcov,Description="Codon coverage below --min-coverage">',
88
- "#CHROM\tGENE\tPOS\tREF\tALT\tFILTER\tALT_FREQ\tCOVERAGE\tINFO",
89
- ]
90
-
91
- for protein_name, entries in protein_pairs:
92
- # aa_pos then genomic position; keeps GENE blocks contiguous.
93
- ordered = sorted(
94
- entries.items(),
95
- key=lambda item: (item[1].get("aa_pos", item[0]), item[0]),
96
- )
97
- for pos, entry in ordered:
98
- depth = int(entry["depth"])
99
- if depth <= 0:
100
- continue
101
- record_id = str(entry.get("record_id", reference.id)).replace(":", "|")
102
- gene = protein_name.replace(":", "|")
103
- aa_pos = int(entry.get("aa_pos", 0)) or "."
104
- ref_codon = reference.get_seq_at(
105
- pos, 3, entry.get("record_id"), positions=entry.get("codon_coords")
106
- )
107
- ref_aa = aavf_amino_acid(ref_codon) if ref_codon else "X"
108
- fw_depth = int(entry["fw_depth"])
109
- rv_depth = int(entry["rv_depth"])
110
- sr = (fw_depth / rv_depth) if rv_depth else 0.0
111
-
112
- by_aa: dict[str, list[Variant]] = defaultdict(list)
113
- for variant in entry["variants"].values():
114
- by_aa[aavf_amino_acid(variant.codon)].append(variant)
115
-
116
- for alt_aa, variants in by_aa.items():
117
- alt_count = sum(v.count for v in variants)
118
- alt_fw = sum(v.fw_reads for v in variants)
119
- alt_rv = sum(v.rv_reads for v in variants)
120
- alt_freq = (alt_count / depth) if depth else 0.0
121
- flags, _p = classify_flags(
122
- freq=alt_freq,
123
- coverage=depth,
124
- fw=alt_fw,
125
- rv=alt_rv,
126
- other_fw=fw_depth - alt_fw,
127
- other_rv=rv_depth - alt_rv,
128
- min_freq=min_freq,
129
- min_coverage=min_coverage,
130
- sb_alpha=sb_alpha,
131
- )
132
- filter_field = ";".join(flags) if flags else "PASS"
133
- ac_parts = []
134
- acc_parts = []
135
- acf_parts = []
136
- for variant in variants:
137
- codon = variant.codon
138
- labelled = mark_changed_bases(ref_codon, codon) if is_acgt_codon(codon) else codon.lower()
139
- ac_parts.append(labelled)
140
- acc_parts.append(str(int(variant.count)))
141
- acf_parts.append(_fmt_float(variant.count / depth if depth else 0.0))
142
- info = (
143
- f"RC={ref_codon.lower()};"
144
- f"AC={','.join(ac_parts)};"
145
- f"ACC={','.join(acc_parts)};"
146
- f"ACF={','.join(acf_parts)};"
147
- f"FWC={fw_depth};RVC={rv_depth};SR={_fmt_float(sr)}"
148
- )
149
- lines.append(
150
- "\t".join(
151
- [
152
- record_id,
153
- gene,
154
- str(aa_pos),
155
- ref_aa,
156
- alt_aa,
157
- filter_field,
158
- _fmt_float(alt_freq),
159
- str(depth),
160
- info,
161
- ]
162
- )
163
- )
164
-
165
- output_path.write_text("\n".join(lines) + "\n")
@@ -1,153 +0,0 @@
1
- from __future__ import annotations
2
-
3
- __all__ = ["SamEntry"]
4
-
5
- import re
6
- from collections.abc import Sequence
7
-
8
- from .models import Qual
9
-
10
- # Compiled once at module load — reused across every SamEntry instance.
11
- _CIGAR_RE = re.compile(r"(\d+)([MIDNSHP=X])")
12
-
13
-
14
- class SamEntry:
15
- """Wraps a SAM record with convenience helpers used during variant aggregation."""
16
-
17
- def __init__(self, line: str, collapsed_read_counts: bool = False) -> None:
18
- """Parse a single SAM line and cache derived attributes that the pipeline needs."""
19
- self.raw = line.strip()
20
- self.fields = self.raw.split("\t")
21
- self.identifier = self.fields[0]
22
- self.flag = int(self.fields[1])
23
- self.reference = self.fields[2]
24
- self.coordinate = int(self.fields[3])
25
- self.map_quality = int(self.fields[4])
26
- self.cigar = self.fields[5]
27
- self.sequence = self.fields[9]
28
- self.quality_string = self.fields[10]
29
- self.options = self.fields[11:]
30
- self.orientation = self._determine_orientation()
31
- self.collapsed_read_counts = collapsed_read_counts
32
- self.occurences = self._parse_occurences()
33
- self._reference_covered_length = self._compute_reference_covered_length()
34
- self._ref_to_read = self._build_ref_to_read_map()
35
- self._quality = Qual(self.quality_string, "sanger")
36
- self.amplicon = self._parse_amplicon()
37
-
38
- def _determine_orientation(self) -> str:
39
- """Return whether the read is mapped to the forward or reverse strand."""
40
- if self.flag & 16:
41
- return "R"
42
- return "F"
43
-
44
- def _parse_occurences(self) -> int:
45
- """Return how many observations this SAM row represents.
46
-
47
- By default each row counts once. With ``collapsed_read_counts``, a
48
- read name ending in ``_<digits>`` is treated as that many duplicates.
49
- """
50
- if not self.collapsed_read_counts:
51
- return 1
52
- parts = self.identifier.split("_")
53
- tail = parts[-1]
54
- if tail.isdigit():
55
- return max(1, int(tail))
56
- return 1
57
-
58
- def _compute_reference_covered_length(self) -> int:
59
- """Sum the CIGAR operations that consume reference bases to get coverage."""
60
- ops = _CIGAR_RE.findall(self.cigar)
61
- total = 0
62
- for length, op in ops:
63
- if op in "MDN=X":
64
- total += int(length)
65
- return total
66
-
67
- def _build_ref_to_read_map(self) -> dict[int, int | None]:
68
- """Walk the CIGAR string and build a mapping from reference positions to read positions.
69
-
70
- CIGAR operations:
71
- - M/=/X: consume both reference and query
72
- - I: consume query only (skip in reference mapping)
73
- - D/N: consume reference only (map to None = gap)
74
- - S: consume query only (skip in reference mapping)
75
- - H/P: consume neither
76
- """
77
- ref_to_read: dict[int, Optional[int]] = {}
78
- ops = _CIGAR_RE.findall(self.cigar)
79
- ref_pos = self.coordinate
80
- read_pos = 0
81
- for length_str, op in ops:
82
- length = int(length_str)
83
- if op in "M=X":
84
- for i in range(length):
85
- ref_to_read[ref_pos + i] = read_pos + i
86
- ref_pos += length
87
- read_pos += length
88
- elif op == "I":
89
- read_pos += length
90
- elif op in "DN":
91
- for i in range(length):
92
- ref_to_read[ref_pos + i] = None
93
- ref_pos += length
94
- elif op == "S":
95
- read_pos += length
96
- # H and P consume neither
97
- return ref_to_read
98
-
99
- def _parse_amplicon(self) -> str:
100
- """Extract the amplicon tag embedded in the read identifier, if present."""
101
- match = re.search(r"(Amp_[0-9]+)", self.identifier)
102
- if match:
103
- return match.group(1)
104
- return "Amp_NONE"
105
-
106
- def covers(self, position: int) -> bool:
107
- """Check whether this read spans the requested reference position."""
108
- return self.coordinate <= position <= self.coordinate + self._reference_covered_length - 1
109
-
110
- def _codon_ref_positions(
111
- self, position: int, positions: Sequence[int] | None = None
112
- ) -> tuple[int, int, int]:
113
- """Resolve the three reference bases of a codon.
114
-
115
- ``positions`` is the actual part coordinates (needed when a codon
116
- crosses a multi-interval junction). Consecutive ``position..+2`` is
117
- used only when the caller does not supply them.
118
- """
119
- if positions is not None:
120
- coords = tuple(int(p) for p in positions)
121
- if len(coords) != 3:
122
- raise ValueError("codon positions must contain exactly three coordinates")
123
- return coords[0], coords[1], coords[2]
124
- return position, position + 1, position + 2
125
-
126
- def codon_at(self, position: int, positions: Sequence[int] | None = None) -> str | None:
127
- """Return the codon at the given reference coordinates using the CIGAR-aware mapping."""
128
- bases = []
129
- for ref_pos in self._codon_ref_positions(position, positions):
130
- read_idx = self._ref_to_read.get(ref_pos)
131
- if read_idx is None and ref_pos in self._ref_to_read:
132
- bases.append("-")
133
- elif read_idx is None:
134
- return None
135
- else:
136
- bases.append(self.sequence[read_idx])
137
- return "".join(bases)
138
-
139
- def codon_qualities_at(
140
- self, position: int, positions: Sequence[int] | None = None
141
- ) -> list[float] | None:
142
- """Return Phred-like qualities for the three codon bases, or None if any is missing/gapped."""
143
- quals: list[float] = []
144
- for ref_pos in self._codon_ref_positions(position, positions):
145
- read_idx = self._ref_to_read.get(ref_pos)
146
- if read_idx is None:
147
- return None
148
- quals.append(self._quality.return_quality_by_pos(read_idx))
149
- return quals
150
-
151
- def is_mapped(self) -> bool:
152
- """True when the read is not flagged as unmapped."""
153
- return not (self.flag & 4)
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