codonyat 1.2.0__tar.gz → 1.3.0__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 (35) hide show
  1. {codonyat-1.2.0 → codonyat-1.3.0}/PKG-INFO +36 -6
  2. {codonyat-1.2.0 → codonyat-1.3.0}/README.md +35 -5
  3. {codonyat-1.2.0 → codonyat-1.3.0}/aa_caller/__init__.py +3 -0
  4. codonyat-1.3.0/aa_caller/aavf.py +163 -0
  5. {codonyat-1.2.0 → codonyat-1.3.0}/aa_caller/app.py +10 -1
  6. {codonyat-1.2.0 → codonyat-1.3.0}/aa_caller/cli.py +54 -8
  7. codonyat-1.3.0/aa_caller/cli_options.py +52 -0
  8. codonyat-1.3.0/aa_caller/constants.py +13 -0
  9. codonyat-1.3.0/aa_caller/container.py +451 -0
  10. codonyat-1.3.0/aa_caller/metrics.py +198 -0
  11. {codonyat-1.2.0 → codonyat-1.3.0}/aa_caller/reference.py +75 -0
  12. {codonyat-1.2.0 → codonyat-1.3.0}/aa_caller/runner.py +85 -11
  13. {codonyat-1.2.0 → codonyat-1.3.0}/aa_caller/sam.py +11 -0
  14. codonyat-1.3.0/aa_caller/stats_writer.py +192 -0
  15. {codonyat-1.2.0 → codonyat-1.3.0}/aa_caller/validators.py +13 -9
  16. {codonyat-1.2.0 → codonyat-1.3.0}/codonyat.egg-info/PKG-INFO +36 -6
  17. {codonyat-1.2.0 → codonyat-1.3.0}/codonyat.egg-info/SOURCES.txt +8 -1
  18. {codonyat-1.2.0 → codonyat-1.3.0}/pyproject.toml +1 -1
  19. codonyat-1.3.0/tests/test_metrics.py +154 -0
  20. codonyat-1.3.0/tests/test_protein_selection.py +259 -0
  21. codonyat-1.3.0/tests/test_v1_3_0_features.py +290 -0
  22. codonyat-1.2.0/aa_caller/constants.py +0 -6
  23. codonyat-1.2.0/aa_caller/container.py +0 -263
  24. {codonyat-1.2.0 → codonyat-1.3.0}/LICENSE +0 -0
  25. {codonyat-1.2.0 → codonyat-1.3.0}/aa_caller/__main__.py +0 -0
  26. {codonyat-1.2.0 → codonyat-1.3.0}/aa_caller/genetic_code.py +0 -0
  27. {codonyat-1.2.0 → codonyat-1.3.0}/aa_caller/models.py +0 -0
  28. {codonyat-1.2.0 → codonyat-1.3.0}/codonyat.egg-info/dependency_links.txt +0 -0
  29. {codonyat-1.2.0 → codonyat-1.3.0}/codonyat.egg-info/entry_points.txt +0 -0
  30. {codonyat-1.2.0 → codonyat-1.3.0}/codonyat.egg-info/requires.txt +0 -0
  31. {codonyat-1.2.0 → codonyat-1.3.0}/codonyat.egg-info/top_level.txt +0 -0
  32. {codonyat-1.2.0 → codonyat-1.3.0}/setup.cfg +0 -0
  33. {codonyat-1.2.0 → codonyat-1.3.0}/tests/test_app.py +0 -0
  34. {codonyat-1.2.0 → codonyat-1.3.0}/tests/test_integration.py +0 -0
  35. {codonyat-1.2.0 → codonyat-1.3.0}/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.3.0
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
@@ -37,13 +37,14 @@ A Python package for codon-level amino acid variant calling from SAM alignments
37
37
  - CIGAR-aware codon extraction that correctly handles insertions, deletions, and soft clips.
38
38
  - Ratio balancing, entropy tracking, and TSV/XML exporters mirroring the legacy Perl outputs.
39
39
  - Configurable CLI flags for strand ratio bounds, entropy sensitivity, and target protein.
40
+ - Opt-in research statistics (Wilson CI, strand-bias test, diversity indices) and AAVF v1.0. Research use only.
40
41
 
41
42
  ## Highlights
42
43
 
43
44
  - Produces consistent TSV/XML diagnostics while exposing Python objects (`SamContainer`, `FullReference`, etc.) that downstream tooling can import.
44
45
  - Validates every input file before parsing to surface malformed SAM/FASTA/amplicon data early.
45
46
  - 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).
47
+ - Supports any protein target via `--protein` (one name, a comma-separated list, or `all`; defaults to RT).
47
48
 
48
49
  ## Installation
49
50
 
@@ -79,10 +80,16 @@ codonyat sample.sam reference.fasta amplicons.tsv --annotation annotations.tsv -
79
80
  | Flag | Default | Description |
80
81
  |------|---------|-------------|
81
82
  | `--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) |
83
+ | `--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
84
  | `--ratio-upper` | `3.162` | Upper bound for strand-ratio balancing |
84
85
  | `--ratio-lower` | `0.316` | Lower bound for strand-ratio balancing |
85
86
  | `--entropy-threshold` | `0.0` | Minimum Shannon entropy to mark a position as diverse |
87
+ | `--min-baseq` | off | Count a codon only if all 3 bases have Phred-like quality ≥ this value |
88
+ | `--stats` | off | Optional stats TSV (CI, strand-bias, flags, diversity) |
89
+ | `--aavf` | off | Optional AAVF v1.0 path (no CAT/SRVL) |
90
+ | `--min-freq` | `0.01` | Frequency floor (fraction) for `lowfreq` / AAVF FILTER |
91
+ | `--min-coverage` | `10` | Coverage floor for `lowcov` / AAVF FILTER |
92
+ | `--sb-alpha` | `0.05` | Fisher p-value cutoff for the `sb` flag |
86
93
 
87
94
  ### Output
88
95
 
@@ -94,6 +101,25 @@ The CLI writes two files alongside the input SAM:
94
101
  - Rows are ordered by genomic position (codon index) along the protein's intervals
95
102
  - For multi-interval proteins, this follows the biological reading order through all segments
96
103
  - `[sam-file].xml` — per-position `<Depth>`, `<FwCover>`, `<RvCover>`, `<Variants>`
104
+ - With several proteins, each protein's positions are wrapped in `<Protein name="…">`; single-protein XML is unchanged
105
+
106
+ Default TSV/XML are byte-identical to 1.2.1 when the opt-in flags are omitted.
107
+
108
+ ### Research statistics (opt-in)
109
+
110
+ `--stats PATH` writes a sidecar TSV with `LEVEL` = `variant`, `codon`, or `gene`:
111
+
112
+ - **variant**: frequency as a fraction, Wilson 95% CI, Fisher strand-bias p, flags (`lowfreq`, `sb`, `lowcov`)
113
+ - **codon / gene**: Shannon entropy (raw and normalised), Gini–Simpson, nucleotide π, amino-acid π
114
+
115
+ `--aavf PATH` writes AAVF v1.0. FILTER is taken from the flags above. The file
116
+ does not contain resistance or surveillance fields. Formulas and citations:
117
+ [docs/methods.md](docs/methods.md).
118
+
119
+ ```bash
120
+ codonyat-runner reads.sam reference.fasta amps.tsv \
121
+ --protein all --stats sample.stats.tsv --aavf sample.aavf
122
+ ```
97
123
 
98
124
  ### Reference FASTA format
99
125
 
@@ -152,7 +178,7 @@ result = call_variants(
152
178
  sam_path=Path("reads.sam"),
153
179
  reference_path=Path("reference.fasta"),
154
180
  amplicons_path=Path("amps.tsv"),
155
- protein="RT",
181
+ protein="PR,RT,INT", # or "all", "RT", or ["PR", "RT"]
156
182
  ratio_upper=3.2,
157
183
  )
158
184
 
@@ -181,7 +207,7 @@ call_variants_from_args(args)
181
207
  A lightweight runner at `codonyat-runner` exposes the same arguments as `call_variants_from_args`:
182
208
 
183
209
  ```bash
184
- codonyat-runner reads.sam reference.fasta amps.tsv --protein RT --ratio-upper 3.1 --csv-path results.tsv
210
+ codonyat-runner reads.sam reference.fasta amps.tsv --protein PR,RT,INT --ratio-upper 3.1 --csv-path results.tsv
185
211
  ```
186
212
 
187
213
  ## Development
@@ -214,9 +240,13 @@ codonyat/
214
240
  │ ├── models.py # Protein, Amplicon, Variant, Qual dataclasses
215
241
  │ ├── validators.py # Input file validation
216
242
  │ ├── genetic_code.py # Codon translation table
217
- │ └── constants.py # Default thresholds
243
+ │ ├── constants.py # Default thresholds
244
+ │ ├── metrics.py # Wilson CI, Fisher, diversity indices
245
+ │ ├── stats_writer.py # --stats TSV
246
+ │ └── aavf.py # AAVF v1.0 writer
218
247
  ├── tests/ # pytest suite (unit + integration)
219
248
  ├── docs/index.html # Project page (GitHub Pages)
249
+ ├── docs/methods.md # Formulas and citations for 1.3.0 outputs
220
250
  └── pyproject.toml # Package metadata
221
251
  ```
222
252
 
@@ -8,13 +8,14 @@ A Python package for codon-level amino acid variant calling from SAM alignments
8
8
  - CIGAR-aware codon extraction that correctly handles insertions, deletions, and soft clips.
9
9
  - Ratio balancing, entropy tracking, and TSV/XML exporters mirroring the legacy Perl outputs.
10
10
  - Configurable CLI flags for strand ratio bounds, entropy sensitivity, and target protein.
11
+ - Opt-in research statistics (Wilson CI, strand-bias test, diversity indices) and AAVF v1.0. Research use only.
11
12
 
12
13
  ## Highlights
13
14
 
14
15
  - Produces consistent TSV/XML diagnostics while exposing Python objects (`SamContainer`, `FullReference`, etc.) that downstream tooling can import.
15
16
  - Validates every input file before parsing to surface malformed SAM/FASTA/amplicon data early.
16
17
  - 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).
18
+ - Supports any protein target via `--protein` (one name, a comma-separated list, or `all`; defaults to RT).
18
19
 
19
20
  ## Installation
20
21
 
@@ -50,10 +51,16 @@ codonyat sample.sam reference.fasta amplicons.tsv --annotation annotations.tsv -
50
51
  | Flag | Default | Description |
51
52
  |------|---------|-------------|
52
53
  | `--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) |
54
+ | `--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
55
  | `--ratio-upper` | `3.162` | Upper bound for strand-ratio balancing |
55
56
  | `--ratio-lower` | `0.316` | Lower bound for strand-ratio balancing |
56
57
  | `--entropy-threshold` | `0.0` | Minimum Shannon entropy to mark a position as diverse |
58
+ | `--min-baseq` | off | Count a codon only if all 3 bases have Phred-like quality ≥ this value |
59
+ | `--stats` | off | Optional stats TSV (CI, strand-bias, flags, diversity) |
60
+ | `--aavf` | off | Optional AAVF v1.0 path (no CAT/SRVL) |
61
+ | `--min-freq` | `0.01` | Frequency floor (fraction) for `lowfreq` / AAVF FILTER |
62
+ | `--min-coverage` | `10` | Coverage floor for `lowcov` / AAVF FILTER |
63
+ | `--sb-alpha` | `0.05` | Fisher p-value cutoff for the `sb` flag |
57
64
 
58
65
  ### Output
59
66
 
@@ -65,6 +72,25 @@ The CLI writes two files alongside the input SAM:
65
72
  - Rows are ordered by genomic position (codon index) along the protein's intervals
66
73
  - For multi-interval proteins, this follows the biological reading order through all segments
67
74
  - `[sam-file].xml` — per-position `<Depth>`, `<FwCover>`, `<RvCover>`, `<Variants>`
75
+ - With several proteins, each protein's positions are wrapped in `<Protein name="…">`; single-protein XML is unchanged
76
+
77
+ Default TSV/XML are byte-identical to 1.2.1 when the opt-in flags are omitted.
78
+
79
+ ### Research statistics (opt-in)
80
+
81
+ `--stats PATH` writes a sidecar TSV with `LEVEL` = `variant`, `codon`, or `gene`:
82
+
83
+ - **variant**: frequency as a fraction, Wilson 95% CI, Fisher strand-bias p, flags (`lowfreq`, `sb`, `lowcov`)
84
+ - **codon / gene**: Shannon entropy (raw and normalised), Gini–Simpson, nucleotide π, amino-acid π
85
+
86
+ `--aavf PATH` writes AAVF v1.0. FILTER is taken from the flags above. The file
87
+ does not contain resistance or surveillance fields. Formulas and citations:
88
+ [docs/methods.md](docs/methods.md).
89
+
90
+ ```bash
91
+ codonyat-runner reads.sam reference.fasta amps.tsv \
92
+ --protein all --stats sample.stats.tsv --aavf sample.aavf
93
+ ```
68
94
 
69
95
  ### Reference FASTA format
70
96
 
@@ -123,7 +149,7 @@ result = call_variants(
123
149
  sam_path=Path("reads.sam"),
124
150
  reference_path=Path("reference.fasta"),
125
151
  amplicons_path=Path("amps.tsv"),
126
- protein="RT",
152
+ protein="PR,RT,INT", # or "all", "RT", or ["PR", "RT"]
127
153
  ratio_upper=3.2,
128
154
  )
129
155
 
@@ -152,7 +178,7 @@ call_variants_from_args(args)
152
178
  A lightweight runner at `codonyat-runner` exposes the same arguments as `call_variants_from_args`:
153
179
 
154
180
  ```bash
155
- codonyat-runner reads.sam reference.fasta amps.tsv --protein RT --ratio-upper 3.1 --csv-path results.tsv
181
+ codonyat-runner reads.sam reference.fasta amps.tsv --protein PR,RT,INT --ratio-upper 3.1 --csv-path results.tsv
156
182
  ```
157
183
 
158
184
  ## Development
@@ -185,9 +211,13 @@ codonyat/
185
211
  │ ├── models.py # Protein, Amplicon, Variant, Qual dataclasses
186
212
  │ ├── validators.py # Input file validation
187
213
  │ ├── genetic_code.py # Codon translation table
188
- │ └── constants.py # Default thresholds
214
+ │ ├── constants.py # Default thresholds
215
+ │ ├── metrics.py # Wilson CI, Fisher, diversity indices
216
+ │ ├── stats_writer.py # --stats TSV
217
+ │ └── aavf.py # AAVF v1.0 writer
189
218
  ├── tests/ # pytest suite (unit + integration)
190
219
  ├── docs/index.html # Project page (GitHub Pages)
220
+ ├── docs/methods.md # Formulas and citations for 1.3.0 outputs
191
221
  └── pyproject.toml # Package metadata
192
222
  ```
193
223
 
@@ -1,6 +1,7 @@
1
1
  """Light wrapper to expose the RT variant caller as a package."""
2
2
 
3
3
  from .cli import main
4
+ from .constants import PACKAGE_VERSION
4
5
  from .container import SamContainer
5
6
  from .reference import FullReference
6
7
  from .runner import (
@@ -23,3 +24,5 @@ __all__ = [
23
24
  "SamEntry",
24
25
  "parse_amplicons",
25
26
  ]
27
+
28
+ __version__ = PACKAGE_VERSION
@@ -0,0 +1,163 @@
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(pos, 3, entry.get("record_id"))
105
+ ref_aa = aavf_amino_acid(ref_codon) if ref_codon else "X"
106
+ fw_depth = int(entry["fw_depth"])
107
+ rv_depth = int(entry["rv_depth"])
108
+ sr = (fw_depth / rv_depth) if rv_depth else 0.0
109
+
110
+ by_aa: dict[str, list[Variant]] = defaultdict(list)
111
+ for variant in entry["variants"].values():
112
+ by_aa[aavf_amino_acid(variant.codon)].append(variant)
113
+
114
+ for alt_aa, variants in by_aa.items():
115
+ alt_count = sum(v.count for v in variants)
116
+ alt_fw = sum(v.fw_reads for v in variants)
117
+ alt_rv = sum(v.rv_reads for v in variants)
118
+ alt_freq = (alt_count / depth) if depth else 0.0
119
+ flags, _p = classify_flags(
120
+ freq=alt_freq,
121
+ coverage=depth,
122
+ fw=alt_fw,
123
+ rv=alt_rv,
124
+ other_fw=fw_depth - alt_fw,
125
+ other_rv=rv_depth - alt_rv,
126
+ min_freq=min_freq,
127
+ min_coverage=min_coverage,
128
+ sb_alpha=sb_alpha,
129
+ )
130
+ filter_field = ";".join(flags) if flags else "PASS"
131
+ ac_parts = []
132
+ acc_parts = []
133
+ acf_parts = []
134
+ for variant in variants:
135
+ codon = variant.codon
136
+ labelled = mark_changed_bases(ref_codon, codon) if is_acgt_codon(codon) else codon.lower()
137
+ ac_parts.append(labelled)
138
+ acc_parts.append(str(int(variant.count)))
139
+ acf_parts.append(_fmt_float(variant.count / depth if depth else 0.0))
140
+ info = (
141
+ f"RC={ref_codon.lower()};"
142
+ f"AC={','.join(ac_parts)};"
143
+ f"ACC={','.join(acc_parts)};"
144
+ f"ACF={','.join(acf_parts)};"
145
+ f"FWC={fw_depth};RVC={rv_depth};SR={_fmt_float(sr)}"
146
+ )
147
+ lines.append(
148
+ "\t".join(
149
+ [
150
+ record_id,
151
+ gene,
152
+ str(aa_pos),
153
+ ref_aa,
154
+ alt_aa,
155
+ filter_field,
156
+ _fmt_float(alt_freq),
157
+ str(depth),
158
+ info,
159
+ ]
160
+ )
161
+ )
162
+
163
+ output_path.write_text("\n".join(lines) + "\n")
@@ -6,14 +6,23 @@ Existing code that does ``from aa_caller.app import X`` continues to work.
6
6
  from .cli import main # noqa: F401
7
7
  from .constants import ( # noqa: F401
8
8
  DEFAULT_ENTROPY_THRESHOLD,
9
+ DEFAULT_MIN_COVERAGE,
10
+ DEFAULT_MIN_FREQ,
9
11
  DEFAULT_PROTEIN,
10
12
  DEFAULT_RATIO_LOWER,
11
13
  DEFAULT_RATIO_UPPER,
14
+ DEFAULT_SB_ALPHA,
15
+ PACKAGE_VERSION,
12
16
  )
13
17
  from .container import SamContainer # noqa: F401
14
18
  from .genetic_code import GENETIC_CODE, codon_to_aminoacid # noqa: F401
15
19
  from .models import Amplicon, Protein, Qual, Variant # noqa: F401
16
- from .reference import FullReference # noqa: F401
20
+ from .reference import ( # noqa: F401
21
+ FullReference,
22
+ parse_protein_spec,
23
+ resolve_protein_targets,
24
+ resolve_proteins,
25
+ )
17
26
  from .sam import SamEntry # noqa: F401
18
27
  from .validators import ( # noqa: F401
19
28
  parse_amplicons,
@@ -4,6 +4,7 @@ import argparse
4
4
  import logging
5
5
  from pathlib import Path
6
6
 
7
+ from .cli_options import add_research_output_args
7
8
  from .constants import (
8
9
  DEFAULT_ENTROPY_THRESHOLD,
9
10
  DEFAULT_PROTEIN,
@@ -11,7 +12,7 @@ from .constants import (
11
12
  DEFAULT_RATIO_UPPER,
12
13
  )
13
14
  from .container import SamContainer
14
- from .reference import FullReference
15
+ from .reference import FullReference, parse_protein_spec, resolve_protein_targets
15
16
  from .validators import (
16
17
  parse_amplicons,
17
18
  validate_amplicon_file,
@@ -58,8 +59,13 @@ def main() -> None:
58
59
  "--protein",
59
60
  type=str,
60
61
  default=DEFAULT_PROTEIN,
61
- help="Target protein name to analyze (default: RT).",
62
+ help=(
63
+ "Protein(s) to analyze: one name (default: RT), a comma-separated list "
64
+ "(e.g. PR,RT,INT; all counted in one pass over the SAM) or 'all' for "
65
+ "every protein annotated in the reference header."
66
+ ),
62
67
  )
68
+ add_research_output_args(parser)
63
69
  args = parser.parse_args()
64
70
 
65
71
  if not args.sam_file.exists():
@@ -73,7 +79,13 @@ def main() -> None:
73
79
 
74
80
  logger.info("Validating input formats before parsing")
75
81
  validate_sam_file(args.sam_file)
76
- validate_reference_file(args.reference_file, protein_name=args.protein, annotation_path=args.annotation)
82
+ try:
83
+ parse_protein_spec(args.protein)
84
+ except ValueError as exc:
85
+ parser.error(str(exc))
86
+ validate_reference_file(
87
+ args.reference_file, protein_name=args.protein, annotation_path=args.annotation
88
+ )
77
89
  validate_amplicon_file(args.amplicons_file)
78
90
 
79
91
  logger.info("Reading amplicon configuration")
@@ -81,6 +93,11 @@ def main() -> None:
81
93
 
82
94
  logger.info("Loading reference")
83
95
  reference = FullReference(args.reference_file, annotation_path=args.annotation)
96
+ try:
97
+ proteins = resolve_protein_targets(args.protein, reference)
98
+ except ValueError as exc:
99
+ parser.error(str(exc))
100
+ protein_names = [protein.name for protein in proteins]
84
101
 
85
102
  logger.info("Parsing SAM entries")
86
103
  container = SamContainer(
@@ -88,15 +105,44 @@ def main() -> None:
88
105
  ratio_upper=args.ratio_upper,
89
106
  ratio_lower=args.ratio_lower,
90
107
  entropy_threshold=args.entropy_threshold,
108
+ min_baseq=args.min_baseq,
91
109
  )
92
110
  container.load()
93
111
 
94
- logger.info("Calculating %s variant frequencies", args.protein)
95
- container.calculate_variant_frequencies(args.protein, reference)
112
+ logger.info("Calculating %s variant frequencies", ",".join(protein_names))
113
+ container.calculate_for_proteins(proteins, reference)
96
114
 
97
115
  csv_path = args.sam_file.with_suffix(args.sam_file.suffix + ".tsv")
98
116
  xml_path = args.sam_file.with_suffix(args.sam_file.suffix + ".xml")
99
117
  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)
118
+ if len(proteins) == 1:
119
+ container.write_csv(str(args.sam_file), reference, csv_path, protein_name=protein_names[0])
120
+ logger.info("Writing XML dump")
121
+ container.write_xml(str(args.sam_file), reference, xml_path)
122
+ else:
123
+ container.write_csv_proteins(str(args.sam_file), reference, csv_path, protein_names)
124
+ logger.info("Writing XML dump")
125
+ container.write_xml_proteins(str(args.sam_file), reference, xml_path, protein_names)
126
+
127
+ if args.stats:
128
+ logger.info("Writing stats TSV")
129
+ container.write_stats(
130
+ str(args.sam_file),
131
+ reference,
132
+ args.stats,
133
+ protein_names,
134
+ min_freq=args.min_freq,
135
+ min_coverage=args.min_coverage,
136
+ sb_alpha=args.sb_alpha,
137
+ )
138
+ if args.aavf:
139
+ logger.info("Writing AAVF")
140
+ container.write_aavf(
141
+ str(args.sam_file),
142
+ reference,
143
+ args.aavf,
144
+ protein_names,
145
+ min_freq=args.min_freq,
146
+ min_coverage=args.min_coverage,
147
+ sb_alpha=args.sb_alpha,
148
+ )
@@ -0,0 +1,52 @@
1
+ """Shared opt-in CLI flags for research statistics and AAVF."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from argparse import ArgumentParser
6
+ from pathlib import Path
7
+
8
+ from .constants import DEFAULT_MIN_COVERAGE, DEFAULT_MIN_FREQ, DEFAULT_SB_ALPHA
9
+
10
+
11
+ def add_research_output_args(parser: ArgumentParser) -> None:
12
+ """Attach opt-in 1.3.0 flags. Defaults leave TSV/XML unchanged."""
13
+ parser.add_argument(
14
+ "--min-baseq",
15
+ type=float,
16
+ default=None,
17
+ help=(
18
+ "If set, count a codon only when all three bases have Phred-like "
19
+ "quality ≥ this value (e.g. 20). Off by default so TSV/XML stay "
20
+ "byte-identical to 1.2.1."
21
+ ),
22
+ )
23
+ parser.add_argument(
24
+ "--stats",
25
+ type=Path,
26
+ default=None,
27
+ help="Optional stats TSV (Wilson 95%% CI, strand-bias p, flags, diversity).",
28
+ )
29
+ parser.add_argument(
30
+ "--aavf",
31
+ type=Path,
32
+ default=None,
33
+ help="Optional AAVF v1.0 path. Research use only; CAT/SRVL are omitted.",
34
+ )
35
+ parser.add_argument(
36
+ "--min-freq",
37
+ type=float,
38
+ default=DEFAULT_MIN_FREQ,
39
+ help="Frequency floor (fraction 0–1) for the lowfreq flag / AAVF FILTER (default: 0.01).",
40
+ )
41
+ parser.add_argument(
42
+ "--min-coverage",
43
+ type=int,
44
+ default=DEFAULT_MIN_COVERAGE,
45
+ help="Coverage floor for the lowcov flag / AAVF FILTER (default: 10).",
46
+ )
47
+ parser.add_argument(
48
+ "--sb-alpha",
49
+ type=float,
50
+ default=DEFAULT_SB_ALPHA,
51
+ help="Fisher exact p-value cutoff for the sb (strand-bias) flag (default: 0.05).",
52
+ )
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ DEFAULT_RATIO_UPPER = 3.16227766
4
+ DEFAULT_RATIO_LOWER = 0.316227766
5
+ DEFAULT_ENTROPY_THRESHOLD = 0.0
6
+ DEFAULT_PROTEIN = "RT"
7
+
8
+ # Opt-in research statistics (v1.3.0). Unused unless --stats / --aavf / --min-baseq is set.
9
+ PACKAGE_VERSION = "1.3.0"
10
+ WILSON_Z_95 = 1.959963984540054 # Φ^{-1}(0.975)
11
+ DEFAULT_MIN_FREQ = 0.01
12
+ DEFAULT_MIN_COVERAGE = 10
13
+ DEFAULT_SB_ALPHA = 0.05