codonyat 1.2.0__tar.gz → 1.2.1__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 (28) hide show
  1. {codonyat-1.2.0/codonyat.egg-info → codonyat-1.2.1}/PKG-INFO +6 -5
  2. {codonyat-1.2.0 → codonyat-1.2.1}/README.md +5 -4
  3. {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/app.py +6 -1
  4. {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/cli.py +28 -8
  5. codonyat-1.2.1/aa_caller/container.py +392 -0
  6. {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/reference.py +75 -0
  7. {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/runner.py +34 -11
  8. {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/validators.py +13 -9
  9. {codonyat-1.2.0 → codonyat-1.2.1/codonyat.egg-info}/PKG-INFO +6 -5
  10. {codonyat-1.2.0 → codonyat-1.2.1}/codonyat.egg-info/SOURCES.txt +1 -0
  11. {codonyat-1.2.0 → codonyat-1.2.1}/pyproject.toml +1 -1
  12. codonyat-1.2.1/tests/test_protein_selection.py +259 -0
  13. codonyat-1.2.0/aa_caller/container.py +0 -263
  14. {codonyat-1.2.0 → codonyat-1.2.1}/LICENSE +0 -0
  15. {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/__init__.py +0 -0
  16. {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/__main__.py +0 -0
  17. {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/constants.py +0 -0
  18. {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/genetic_code.py +0 -0
  19. {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/models.py +0 -0
  20. {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/sam.py +0 -0
  21. {codonyat-1.2.0 → codonyat-1.2.1}/codonyat.egg-info/dependency_links.txt +0 -0
  22. {codonyat-1.2.0 → codonyat-1.2.1}/codonyat.egg-info/entry_points.txt +0 -0
  23. {codonyat-1.2.0 → codonyat-1.2.1}/codonyat.egg-info/requires.txt +0 -0
  24. {codonyat-1.2.0 → codonyat-1.2.1}/codonyat.egg-info/top_level.txt +0 -0
  25. {codonyat-1.2.0 → codonyat-1.2.1}/setup.cfg +0 -0
  26. {codonyat-1.2.0 → codonyat-1.2.1}/tests/test_app.py +0 -0
  27. {codonyat-1.2.0 → codonyat-1.2.1}/tests/test_integration.py +0 -0
  28. {codonyat-1.2.0 → codonyat-1.2.1}/tests/test_v1_2_0_features.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codonyat
3
- Version: 1.2.0
3
+ Version: 1.2.1
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
@@ -43,7 +43,7 @@ A Python package for codon-level amino acid variant calling from SAM alignments
43
43
  - Produces consistent TSV/XML diagnostics while exposing Python objects (`SamContainer`, `FullReference`, etc.) that downstream tooling can import.
44
44
  - Validates every input file before parsing to surface malformed SAM/FASTA/amplicon data early.
45
45
  - Logs per-position entropy and strand balance for easier debugging in CI or local runs.
46
- - Supports any protein target via `--protein` (defaults to RT).
46
+ - Supports any protein target via `--protein` (one name, a comma-separated list, or `all`; defaults to RT).
47
47
 
48
48
  ## Installation
49
49
 
@@ -79,7 +79,7 @@ codonyat sample.sam reference.fasta amplicons.tsv --annotation annotations.tsv -
79
79
  | Flag | Default | Description |
80
80
  |------|---------|-------------|
81
81
  | `--annotation` | None | Optional TSV annotation file (record, protein, description, intervals, strand) |
82
- | `--protein` | `RT` | Target protein name (must match a FASTA header or TSV annotation) |
82
+ | `--protein` | `RT` | Protein(s) to analyse: one name, a comma-separated list (e.g. `PR,RT,INT`, counted in a single pass) or `all` (every protein annotated in the reference header or TSV). Names must match the FASTA header annotations |
83
83
  | `--ratio-upper` | `3.162` | Upper bound for strand-ratio balancing |
84
84
  | `--ratio-lower` | `0.316` | Lower bound for strand-ratio balancing |
85
85
  | `--entropy-threshold` | `0.0` | Minimum Shannon entropy to mark a position as diverse |
@@ -94,6 +94,7 @@ The CLI writes two files alongside the input SAM:
94
94
  - Rows are ordered by genomic position (codon index) along the protein's intervals
95
95
  - For multi-interval proteins, this follows the biological reading order through all segments
96
96
  - `[sam-file].xml` — per-position `<Depth>`, `<FwCover>`, `<RvCover>`, `<Variants>`
97
+ - With several proteins, each protein's positions are wrapped in `<Protein name="…">`; single-protein XML is unchanged
97
98
 
98
99
  ### Reference FASTA format
99
100
 
@@ -152,7 +153,7 @@ result = call_variants(
152
153
  sam_path=Path("reads.sam"),
153
154
  reference_path=Path("reference.fasta"),
154
155
  amplicons_path=Path("amps.tsv"),
155
- protein="RT",
156
+ protein="PR,RT,INT", # or "all", "RT", or ["PR", "RT"]
156
157
  ratio_upper=3.2,
157
158
  )
158
159
 
@@ -181,7 +182,7 @@ call_variants_from_args(args)
181
182
  A lightweight runner at `codonyat-runner` exposes the same arguments as `call_variants_from_args`:
182
183
 
183
184
  ```bash
184
- codonyat-runner reads.sam reference.fasta amps.tsv --protein RT --ratio-upper 3.1 --csv-path results.tsv
185
+ codonyat-runner reads.sam reference.fasta amps.tsv --protein PR,RT,INT --ratio-upper 3.1 --csv-path results.tsv
185
186
  ```
186
187
 
187
188
  ## Development
@@ -14,7 +14,7 @@ A Python package for codon-level amino acid variant calling from SAM alignments
14
14
  - Produces consistent TSV/XML diagnostics while exposing Python objects (`SamContainer`, `FullReference`, etc.) that downstream tooling can import.
15
15
  - Validates every input file before parsing to surface malformed SAM/FASTA/amplicon data early.
16
16
  - Logs per-position entropy and strand balance for easier debugging in CI or local runs.
17
- - Supports any protein target via `--protein` (defaults to RT).
17
+ - Supports any protein target via `--protein` (one name, a comma-separated list, or `all`; defaults to RT).
18
18
 
19
19
  ## Installation
20
20
 
@@ -50,7 +50,7 @@ codonyat sample.sam reference.fasta amplicons.tsv --annotation annotations.tsv -
50
50
  | Flag | Default | Description |
51
51
  |------|---------|-------------|
52
52
  | `--annotation` | None | Optional TSV annotation file (record, protein, description, intervals, strand) |
53
- | `--protein` | `RT` | Target protein name (must match a FASTA header or TSV annotation) |
53
+ | `--protein` | `RT` | Protein(s) to analyse: one name, a comma-separated list (e.g. `PR,RT,INT`, counted in a single pass) or `all` (every protein annotated in the reference header or TSV). Names must match the FASTA header annotations |
54
54
  | `--ratio-upper` | `3.162` | Upper bound for strand-ratio balancing |
55
55
  | `--ratio-lower` | `0.316` | Lower bound for strand-ratio balancing |
56
56
  | `--entropy-threshold` | `0.0` | Minimum Shannon entropy to mark a position as diverse |
@@ -65,6 +65,7 @@ The CLI writes two files alongside the input SAM:
65
65
  - Rows are ordered by genomic position (codon index) along the protein's intervals
66
66
  - For multi-interval proteins, this follows the biological reading order through all segments
67
67
  - `[sam-file].xml` — per-position `<Depth>`, `<FwCover>`, `<RvCover>`, `<Variants>`
68
+ - With several proteins, each protein's positions are wrapped in `<Protein name="…">`; single-protein XML is unchanged
68
69
 
69
70
  ### Reference FASTA format
70
71
 
@@ -123,7 +124,7 @@ result = call_variants(
123
124
  sam_path=Path("reads.sam"),
124
125
  reference_path=Path("reference.fasta"),
125
126
  amplicons_path=Path("amps.tsv"),
126
- protein="RT",
127
+ protein="PR,RT,INT", # or "all", "RT", or ["PR", "RT"]
127
128
  ratio_upper=3.2,
128
129
  )
129
130
 
@@ -152,7 +153,7 @@ call_variants_from_args(args)
152
153
  A lightweight runner at `codonyat-runner` exposes the same arguments as `call_variants_from_args`:
153
154
 
154
155
  ```bash
155
- codonyat-runner reads.sam reference.fasta amps.tsv --protein RT --ratio-upper 3.1 --csv-path results.tsv
156
+ codonyat-runner reads.sam reference.fasta amps.tsv --protein PR,RT,INT --ratio-upper 3.1 --csv-path results.tsv
156
157
  ```
157
158
 
158
159
  ## Development
@@ -13,7 +13,12 @@ from .constants import ( # noqa: F401
13
13
  from .container import SamContainer # noqa: F401
14
14
  from .genetic_code import GENETIC_CODE, codon_to_aminoacid # noqa: F401
15
15
  from .models import Amplicon, Protein, Qual, Variant # noqa: F401
16
- from .reference import FullReference # noqa: F401
16
+ from .reference import ( # noqa: F401
17
+ FullReference,
18
+ parse_protein_spec,
19
+ resolve_protein_targets,
20
+ resolve_proteins,
21
+ )
17
22
  from .sam import SamEntry # noqa: F401
18
23
  from .validators import ( # noqa: F401
19
24
  parse_amplicons,
@@ -11,7 +11,7 @@ from .constants import (
11
11
  DEFAULT_RATIO_UPPER,
12
12
  )
13
13
  from .container import SamContainer
14
- from .reference import FullReference
14
+ from .reference import FullReference, parse_protein_spec, resolve_protein_targets
15
15
  from .validators import (
16
16
  parse_amplicons,
17
17
  validate_amplicon_file,
@@ -58,7 +58,11 @@ def main() -> None:
58
58
  "--protein",
59
59
  type=str,
60
60
  default=DEFAULT_PROTEIN,
61
- help="Target protein name to analyze (default: RT).",
61
+ help=(
62
+ "Protein(s) to analyze: one name (default: RT), a comma-separated list "
63
+ "(e.g. PR,RT,INT; all counted in one pass over the SAM) or 'all' for "
64
+ "every protein annotated in the reference header."
65
+ ),
62
66
  )
63
67
  args = parser.parse_args()
64
68
 
@@ -73,7 +77,13 @@ def main() -> None:
73
77
 
74
78
  logger.info("Validating input formats before parsing")
75
79
  validate_sam_file(args.sam_file)
76
- validate_reference_file(args.reference_file, protein_name=args.protein, annotation_path=args.annotation)
80
+ try:
81
+ parse_protein_spec(args.protein)
82
+ except ValueError as exc:
83
+ parser.error(str(exc))
84
+ validate_reference_file(
85
+ args.reference_file, protein_name=args.protein, annotation_path=args.annotation
86
+ )
77
87
  validate_amplicon_file(args.amplicons_file)
78
88
 
79
89
  logger.info("Reading amplicon configuration")
@@ -81,6 +91,11 @@ def main() -> None:
81
91
 
82
92
  logger.info("Loading reference")
83
93
  reference = FullReference(args.reference_file, annotation_path=args.annotation)
94
+ try:
95
+ proteins = resolve_protein_targets(args.protein, reference)
96
+ except ValueError as exc:
97
+ parser.error(str(exc))
98
+ protein_names = [protein.name for protein in proteins]
84
99
 
85
100
  logger.info("Parsing SAM entries")
86
101
  container = SamContainer(
@@ -91,12 +106,17 @@ def main() -> None:
91
106
  )
92
107
  container.load()
93
108
 
94
- logger.info("Calculating %s variant frequencies", args.protein)
95
- container.calculate_variant_frequencies(args.protein, reference)
109
+ logger.info("Calculating %s variant frequencies", ",".join(protein_names))
110
+ container.calculate_for_proteins(proteins, reference)
96
111
 
97
112
  csv_path = args.sam_file.with_suffix(args.sam_file.suffix + ".tsv")
98
113
  xml_path = args.sam_file.with_suffix(args.sam_file.suffix + ".xml")
99
114
  logger.info("Writing TSV results")
100
- container.write_csv(str(args.sam_file), reference, csv_path, protein_name=args.protein)
101
- logger.info("Writing XML dump")
102
- container.write_xml(str(args.sam_file), reference, xml_path)
115
+ if len(proteins) == 1:
116
+ container.write_csv(str(args.sam_file), reference, csv_path, protein_name=protein_names[0])
117
+ logger.info("Writing XML dump")
118
+ container.write_xml(str(args.sam_file), reference, xml_path)
119
+ else:
120
+ container.write_csv_proteins(str(args.sam_file), reference, csv_path, protein_names)
121
+ logger.info("Writing XML dump")
122
+ container.write_xml_proteins(str(args.sam_file), reference, xml_path, protein_names)
@@ -0,0 +1,392 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import logging
5
+ import math
6
+ import xml.etree.ElementTree as ET
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+
10
+ from .genetic_code import codon_to_aminoacid
11
+ from .models import Protein, Variant
12
+ from .reference import FullReference
13
+ from .sam import SamEntry
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class SamContainer:
19
+ """Aggregates SAM entries and computes RT variant statistics for an amplicon set."""
20
+
21
+ def __init__(
22
+ self,
23
+ sam_path: Path,
24
+ ratio_upper: float,
25
+ ratio_lower: float,
26
+ entropy_threshold: float,
27
+ ) -> None:
28
+ """Initialize the container with the SAM path, ratio window, and entropy threshold."""
29
+ self.sam_path = sam_path
30
+ self.reads: list[SamEntry] = []
31
+ self.variants: dict[int, dict] = {}
32
+ self.protein_variants: dict[str, dict[int, dict]] = {}
33
+ self._protein_result_list: list[tuple[str, dict[int, dict]]] = []
34
+ self.ratio_upper = ratio_upper
35
+ self.ratio_lower = ratio_lower
36
+ self.entropy_threshold = entropy_threshold
37
+
38
+ def load(self) -> None:
39
+ """Load every mapped read from the SAM file so they can be aggregated."""
40
+ with open(self.sam_path) as fh:
41
+ for line in fh:
42
+ if line.startswith("@"):
43
+ continue
44
+ entry = SamEntry(line)
45
+ if entry.is_mapped():
46
+ self.reads.append(entry)
47
+
48
+ def calculate_variant_frequencies(
49
+ self, protein_name: str, reference: FullReference, record_id: str | None = None
50
+ ) -> None:
51
+ """Walk every position of the requested protein and build variant histories.
52
+
53
+ Equivalent to ``calculate_variant_frequencies_multi([protein_name], reference)``
54
+ when ``record_id`` is omitted. Supports multi-interval proteins
55
+ (frameshift, splicing, circular).
56
+ """
57
+ protein = reference.get_protein(protein_name, record_id)
58
+ if protein is None:
59
+ raise ValueError(f"Protein {protein_name} not found in reference")
60
+ self.calculate_for_proteins([protein], reference)
61
+
62
+ def calculate_variant_frequencies_multi(
63
+ self, protein_names: Sequence[str], reference: FullReference
64
+ ) -> None:
65
+ """Count codons for several proteins in a single pass over loaded reads.
66
+
67
+ Results are stored per protein in ``self.protein_variants``;
68
+ ``self.variants`` points at the first requested protein (the only one in
69
+ single-protein runs), as in 1.1.0.
70
+ """
71
+ if not protein_names:
72
+ raise ValueError("At least one protein is required")
73
+ proteins: list[Protein] = []
74
+ for name in protein_names:
75
+ protein = reference.get_protein(name)
76
+ if protein is None:
77
+ raise ValueError(f"Protein {name} not found in reference")
78
+ proteins.append(protein)
79
+ self.calculate_for_proteins(proteins, reference)
80
+
81
+ def calculate_for_proteins(self, proteins: Sequence[Protein], reference: FullReference) -> None:
82
+ """Count every requested protein in one pass over ``self.reads``.
83
+
84
+ Supports multi-record references (reads are matched by RNAME) and
85
+ multi-interval proteins.
86
+ """
87
+ if not proteins:
88
+ raise ValueError("At least one protein is required")
89
+
90
+ jobs: list[tuple[Protein, dict[int, dict]]] = []
91
+ for protein in proteins:
92
+ entries: dict[int, dict] = {}
93
+ for pos, record_id in self._get_codon_positions(protein, reference):
94
+ entries[pos] = {
95
+ "variants": {},
96
+ "fw_depth": 0,
97
+ "rv_depth": 0,
98
+ "depth": 0,
99
+ "amplicons": {},
100
+ "record_id": record_id,
101
+ }
102
+ jobs.append((protein, entries))
103
+
104
+ for read in self.reads:
105
+ for protein, entries in jobs:
106
+ if read.reference != protein.record_id:
107
+ continue
108
+ for pos, entry in entries.items():
109
+ self._accumulate_read_at_position(read, pos, entry)
110
+
111
+ self.protein_variants = {}
112
+ self._protein_result_list = []
113
+ for protein, entries in jobs:
114
+ self.variants = entries
115
+ for pos in self.variants:
116
+ self.calculate_ratios_nucleotide(pos)
117
+ self.calculate_ratios_aminoacid(pos)
118
+ self.calculate_shannon_entropy_single_position(pos)
119
+ self.log_position_stats(pos)
120
+ self.protein_variants[protein.name] = self.variants
121
+ self._protein_result_list.append((protein.name, self.variants))
122
+ self.variants = jobs[0][1]
123
+
124
+ def _accumulate_read_at_position(self, read: SamEntry, pos: int, entry: dict) -> None:
125
+ """Add one mapped read's codon to a protein position, if it covers it."""
126
+ if not read.covers(pos):
127
+ return
128
+ codon = read.codon_at(pos)
129
+ if not codon or len(codon) < 3:
130
+ return
131
+ depth_increment = read.occurences
132
+ variants: dict[str, Variant] = entry["variants"]
133
+ if read.orientation == "F":
134
+ entry["fw_depth"] += depth_increment
135
+ else:
136
+ entry["rv_depth"] += depth_increment
137
+ entry["depth"] += depth_increment
138
+ variant = variants.setdefault(codon, Variant(codon=codon))
139
+ variant.count += depth_increment
140
+ if read.orientation == "F":
141
+ variant.fw_reads += depth_increment
142
+ else:
143
+ variant.rv_reads += depth_increment
144
+ amplicon_label = read.amplicon or "Amp_NONE"
145
+ amp_variant = variant.amplicons.setdefault(amplicon_label, {"fw": 0, "rv": 0})
146
+ if read.orientation == "F":
147
+ amp_variant["fw"] += depth_increment
148
+ else:
149
+ amp_variant["rv"] += depth_increment
150
+ amp_position = entry["amplicons"].setdefault(
151
+ amplicon_label, {"fw_depth": 0, "rv_depth": 0, "depth": 0}
152
+ )
153
+ if read.orientation == "F":
154
+ amp_position["fw_depth"] += depth_increment
155
+ else:
156
+ amp_position["rv_depth"] += depth_increment
157
+ amp_position["depth"] += depth_increment
158
+
159
+ def _get_codon_positions(self, protein, reference: FullReference) -> list[tuple]:
160
+ """Generate list of (position, record_id) tuples for all codons in the protein.
161
+
162
+ Handles multi-interval proteins including frameshifts.
163
+ """
164
+ positions = []
165
+ current_offset = 0
166
+
167
+ for interval in protein.intervals:
168
+ interval_length = interval.end - interval.start + 1
169
+ pos = interval.start
170
+
171
+ while pos <= interval.end:
172
+ remaining_in_interval = interval.end - pos + 1
173
+ if remaining_in_interval >= 3:
174
+ positions.append((pos, protein.record_id))
175
+ pos += 3
176
+ else:
177
+ break
178
+
179
+ current_offset += interval_length
180
+
181
+ return positions
182
+
183
+ def calculate_ratios_nucleotide(self, position: int) -> None:
184
+ """Determine strand-specific ratios per variant and flag the balanced reads."""
185
+ entry = self.variants.get(position)
186
+ if not entry or entry["fw_depth"] == 0 or entry["rv_depth"] == 0:
187
+ return
188
+ for variant in entry["variants"].values():
189
+ # compute the per-strand frequency for each codon then derive the ratio
190
+ variant.fw_freq = variant.fw_reads / entry["fw_depth"] if entry["fw_depth"] else 0
191
+ variant.rv_freq = variant.rv_reads / entry["rv_depth"] if entry["rv_depth"] else 0
192
+ variant.ratio = (variant.fw_freq / variant.rv_freq) if variant.rv_freq else 0
193
+ # only mark variants within the balanced ratio window
194
+ if self.ratio_lower <= variant.ratio <= self.ratio_upper:
195
+ variant.balanced_fw += variant.fw_reads
196
+ variant.balanced_rv += variant.rv_reads
197
+
198
+ def has_balanced_variants(self, position: int) -> bool:
199
+ """Return True when at least one variant falls within the configured strand-ratio window."""
200
+ entry = self.variants.get(position)
201
+ if not entry:
202
+ return False
203
+ return any(self.ratio_lower <= variant.ratio <= self.ratio_upper for variant in entry["variants"].values())
204
+
205
+ def calculate_ratios_aminoacid(self, position: int) -> None:
206
+ """Aggregate amino-acid frequencies across codon variants and capture balance metadata."""
207
+ entry = self.variants.get(position)
208
+ if not entry:
209
+ return
210
+ aa_stats: dict[str, dict[str, float]] = {}
211
+ for variant in entry["variants"].values():
212
+ aa = codon_to_aminoacid(variant.codon)
213
+ stats = aa_stats.setdefault(aa, {"count": 0.0, "fw": 0.0, "rv": 0.0})
214
+ stats["count"] += variant.count
215
+ stats["fw"] += variant.fw_reads
216
+ stats["rv"] += variant.rv_reads
217
+ for aa, stats in aa_stats.items():
218
+ fw = stats["fw"]
219
+ rv = stats["rv"]
220
+ ratio = (fw / rv) if rv else 0
221
+ # flag balanced amino-acid counts using the same ratio window as nucleotides
222
+ stats["ratio"] = ratio
223
+ stats["balanced"] = (fw + rv) if self.ratio_lower <= ratio <= self.ratio_upper else 0
224
+ entry["aminoacid_stats"] = aa_stats
225
+
226
+ def calculate_shannon_entropy_single_position(self, position: int) -> float:
227
+ """Compute the Shannon entropy for the observed codon mixture at a position."""
228
+ entry = self.variants.get(position)
229
+ if not entry:
230
+ return 0.0
231
+ depth = entry.get("depth", 0)
232
+ if depth == 0:
233
+ entry["shannon_entropy"] = 0.0
234
+ return 0.0
235
+ entropy = 0.0
236
+ for variant in entry["variants"].values():
237
+ if variant.count == 0:
238
+ continue
239
+ p = variant.count / depth
240
+ # standard Shannon formula: sum of -p log(p) for each variant
241
+ entropy -= p * math.log(p)
242
+ entry["shannon_entropy"] = entropy
243
+ entry["entropy_pass"] = entropy >= self.entropy_threshold
244
+ return entropy
245
+
246
+ def log_position_stats(self, position: int) -> None:
247
+ """Log strand balance and entropy so downstream debugging is easier."""
248
+ entry = self.variants.get(position)
249
+ if not entry:
250
+ return
251
+ balanced = [v.codon for v in entry["variants"].values() if self.ratio_lower <= v.ratio <= self.ratio_upper]
252
+ entropy = entry.get("shannon_entropy", 0.0)
253
+ logger.debug(
254
+ "Position %s -- depth=%s, fw=%s, rv=%s, entropy=%.3f, balanced=%s",
255
+ position,
256
+ entry["depth"],
257
+ entry["fw_depth"],
258
+ entry["rv_depth"],
259
+ entropy,
260
+ balanced,
261
+ )
262
+
263
+ def _entries_for(self, protein_name: str) -> dict[int, dict]:
264
+ """Per-position results for a protein (falls back to ``self.variants``)."""
265
+ return self.protein_variants.get(protein_name, self.variants)
266
+
267
+ def _csv_fieldnames(self, reference: FullReference) -> list[str]:
268
+ has_multiple_records = len(reference.record_ids) > 1
269
+ fieldnames = ["FILE", "REFERENCE"]
270
+ if has_multiple_records:
271
+ fieldnames.append("RECORD")
272
+ fieldnames.extend(
273
+ ["PROTEIN", "VARIANT", "POSITION", "FREQ", "FWCOV", "RVCOV", "TOTALCOV", "RATIO"]
274
+ )
275
+ return fieldnames
276
+
277
+ def _write_csv_rows(
278
+ self,
279
+ writer: csv.DictWriter,
280
+ sample_name: str,
281
+ reference: FullReference,
282
+ protein_name: str,
283
+ entries: dict[int, dict] | None = None,
284
+ ) -> None:
285
+ has_multiple_records = len(reference.record_ids) > 1
286
+ if entries is None:
287
+ entries = self._entries_for(protein_name)
288
+ for pos, entry in sorted(entries.items()):
289
+ depth = entry["depth"]
290
+ ratio = entry["fw_depth"] / entry["rv_depth"] if entry["rv_depth"] else 0
291
+ record_id = entry.get("record_id", reference.id)
292
+ for variant in entry["variants"].values():
293
+ row = {
294
+ "FILE": sample_name,
295
+ "REFERENCE": record_id,
296
+ "PROTEIN": protein_name,
297
+ "VARIANT": variant.codon,
298
+ "POSITION": pos,
299
+ "FREQ": round((variant.count / depth * 100) if depth else 0, 3),
300
+ "FWCOV": entry["fw_depth"],
301
+ "RVCOV": entry["rv_depth"],
302
+ "TOTALCOV": depth,
303
+ "RATIO": round(ratio, 3),
304
+ }
305
+ if has_multiple_records:
306
+ row["RECORD"] = record_id
307
+ writer.writerow(row)
308
+
309
+ def write_csv(
310
+ self, sample_name: str, reference: FullReference, output_path: Path, *, protein_name: str = "RT"
311
+ ) -> None:
312
+ """Emit the variant summary as a TSV file that mirrors the Perl outputs.
313
+
314
+ For multi-record references, adds a RECORD column after REFERENCE.
315
+ Single-record outputs remain byte-identical to 1.1.0.
316
+ """
317
+ self.write_csv_proteins(sample_name, reference, output_path, [protein_name])
318
+
319
+ def write_csv_proteins(
320
+ self,
321
+ sample_name: str,
322
+ reference: FullReference,
323
+ output_path: Path,
324
+ protein_names: Sequence[str],
325
+ ) -> None:
326
+ """Emit one TSV with a single header and the rows of each protein, in the given order."""
327
+ fieldnames = self._csv_fieldnames(reference)
328
+ pairs = self._output_pairs(protein_names)
329
+ with open(output_path, "w", newline="") as csvfile:
330
+ writer = csv.DictWriter(csvfile, fieldnames=fieldnames, delimiter="\t")
331
+ writer.writeheader()
332
+ for protein_name, entries in pairs:
333
+ self._write_csv_rows(writer, sample_name, reference, protein_name, entries)
334
+
335
+ def _output_pairs(self, protein_names: Sequence[str]) -> list[tuple[str, dict[int, dict]]]:
336
+ """Map requested names to counted entries, preserving duplicate-name order."""
337
+ calculated = [name for name, _entries in self._protein_result_list]
338
+ if self._protein_result_list and calculated == list(protein_names):
339
+ return list(self._protein_result_list)
340
+ used: set[int] = set()
341
+ pairs: list[tuple[str, dict[int, dict]]] = []
342
+ for name in protein_names:
343
+ found = False
344
+ for index, (result_name, entries) in enumerate(self._protein_result_list):
345
+ if result_name == name and index not in used:
346
+ used.add(index)
347
+ pairs.append((result_name, entries))
348
+ found = True
349
+ break
350
+ if not found:
351
+ pairs.append((name, self._entries_for(name)))
352
+ return pairs
353
+
354
+ @staticmethod
355
+ def _add_positions(parent: ET.Element, entries: dict[int, dict]) -> None:
356
+ for pos, entry in sorted(entries.items()):
357
+ position_elem = ET.SubElement(parent, "Position", index=str(pos))
358
+ ET.SubElement(position_elem, "Depth").text = str(entry["depth"])
359
+ ET.SubElement(position_elem, "FwCover").text = str(entry["fw_depth"])
360
+ ET.SubElement(position_elem, "RvCover").text = str(entry["rv_depth"])
361
+ variants_elem = ET.SubElement(position_elem, "Variants")
362
+ for variant in entry["variants"].values():
363
+ # describe each variant using XML attributes for diagnostics
364
+ var_elem = ET.SubElement(variants_elem, "Variant", codon=variant.codon)
365
+ var_elem.set("count", str(variant.count))
366
+ var_elem.set("fw_reads", str(variant.fw_reads))
367
+ var_elem.set("rv_reads", str(variant.rv_reads))
368
+
369
+ def write_xml(self, sample_name: str, reference: FullReference, output_path: Path) -> None:
370
+ """Dump the per-position state of the (first) protein into XML for diagnostics."""
371
+ root = ET.Element("SamContainer", sample=sample_name, reference=reference.id)
372
+ self._add_positions(root, self.variants)
373
+ tree = ET.ElementTree(root)
374
+ tree.write(output_path, encoding="utf-8", xml_declaration=True)
375
+
376
+ def write_xml_proteins(
377
+ self,
378
+ sample_name: str,
379
+ reference: FullReference,
380
+ output_path: Path,
381
+ protein_names: Sequence[str],
382
+ ) -> None:
383
+ """Multi-protein XML: one ``<Protein name=...>`` element per protein wrapping its positions.
384
+
385
+ For a single protein use :meth:`write_xml`, whose layout is unchanged.
386
+ """
387
+ root = ET.Element("SamContainer", sample=sample_name, reference=reference.id)
388
+ for protein_name, entries in self._output_pairs(protein_names):
389
+ protein_elem = ET.SubElement(root, "Protein", name=protein_name)
390
+ self._add_positions(protein_elem, entries)
391
+ tree = ET.ElementTree(root)
392
+ tree.write(output_path, encoding="utf-8", xml_declaration=True)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import csv
4
+ from collections.abc import Sequence
4
5
  from pathlib import Path
5
6
 
6
7
  from Bio import SeqIO
@@ -139,3 +140,77 @@ class FullReference:
139
140
 
140
141
  seq = self.sequences.get(record_id, "")
141
142
  return seq[position - 1 : position - 1 + length]
143
+
144
+ def proteins_in_order(self) -> list[Protein]:
145
+ """Return annotated proteins in FASTA-header / TSV order."""
146
+ return list(self.proteins.values())
147
+
148
+
149
+ def parse_protein_spec(spec: str | Sequence[str]) -> list[str]:
150
+ """Split a protein selection (``"RT"``, ``"PR,RT,INT"``, ``"all"`` or a list) into names.
151
+
152
+ Order is kept and duplicates are dropped. ``"all"`` is returned as ``["all"]``
153
+ and expanded by :func:`resolve_proteins` once the reference is loaded.
154
+ """
155
+ if isinstance(spec, str):
156
+ items = [part.strip() for part in spec.split(",")]
157
+ else:
158
+ items = [str(part).strip() for part in spec]
159
+ names: list[str] = []
160
+ for item in items:
161
+ if item and item not in names:
162
+ names.append(item)
163
+ if not names:
164
+ raise ValueError("No protein selected")
165
+ if "all" in (name.lower() for name in names) and len(names) > 1:
166
+ raise ValueError("'all' cannot be combined with other protein names")
167
+ return names
168
+
169
+
170
+ def resolve_protein_targets(spec: str | Sequence[str], reference: FullReference) -> list[Protein]:
171
+ """Resolve a protein selection to annotated Protein objects.
172
+
173
+ ``"all"`` selects every annotated protein in FASTA-header / TSV order.
174
+ A comma-separated list keeps the requested order. On multi-record
175
+ references, each requested name includes every matching record.
176
+ """
177
+ names = parse_protein_spec(spec)
178
+ if len(names) == 1 and names[0].lower() == "all":
179
+ proteins = reference.proteins_in_order()
180
+ if not proteins:
181
+ raise ValueError(f"Reference {reference.path} has no annotated proteins")
182
+ return proteins
183
+ selected: list[Protein] = []
184
+ missing: list[str] = []
185
+ for name in names:
186
+ matches = [protein for key, protein in reference.proteins.items() if key[1] == name]
187
+ if not matches:
188
+ missing.append(name)
189
+ else:
190
+ selected.extend(matches)
191
+ if missing:
192
+ raise ValueError(f"Protein(s) not found in reference: {', '.join(missing)}")
193
+ return selected
194
+
195
+
196
+ def resolve_proteins(spec: str | Sequence[str], reference: FullReference) -> list[str]:
197
+ """Resolve a protein selection to protein names.
198
+
199
+ ``"all"`` selects every annotated protein in FASTA-header / TSV order.
200
+ Names are unique; duplicate names across records are collapsed in
201
+ first-seen order. Use :func:`resolve_protein_targets` when record
202
+ identity must be preserved.
203
+ """
204
+ names = parse_protein_spec(spec)
205
+ if len(names) == 1 and names[0].lower() == "all":
206
+ if not reference.proteins:
207
+ raise ValueError(f"Reference {reference.path} has no annotated proteins")
208
+ seen: list[str] = []
209
+ for _record_id, protein_name in reference.proteins:
210
+ if protein_name not in seen:
211
+ seen.append(protein_name)
212
+ return seen
213
+ missing = [name for name in names if reference.get_protein(name) is None]
214
+ if missing:
215
+ raise ValueError(f"Protein(s) not found in reference: {', '.join(missing)}")
216
+ return names