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.
- {codonyat-1.2.0/codonyat.egg-info → codonyat-1.2.1}/PKG-INFO +6 -5
- {codonyat-1.2.0 → codonyat-1.2.1}/README.md +5 -4
- {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/app.py +6 -1
- {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/cli.py +28 -8
- codonyat-1.2.1/aa_caller/container.py +392 -0
- {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/reference.py +75 -0
- {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/runner.py +34 -11
- {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/validators.py +13 -9
- {codonyat-1.2.0 → codonyat-1.2.1/codonyat.egg-info}/PKG-INFO +6 -5
- {codonyat-1.2.0 → codonyat-1.2.1}/codonyat.egg-info/SOURCES.txt +1 -0
- {codonyat-1.2.0 → codonyat-1.2.1}/pyproject.toml +1 -1
- codonyat-1.2.1/tests/test_protein_selection.py +259 -0
- codonyat-1.2.0/aa_caller/container.py +0 -263
- {codonyat-1.2.0 → codonyat-1.2.1}/LICENSE +0 -0
- {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/__init__.py +0 -0
- {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/__main__.py +0 -0
- {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/constants.py +0 -0
- {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/genetic_code.py +0 -0
- {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/models.py +0 -0
- {codonyat-1.2.0 → codonyat-1.2.1}/aa_caller/sam.py +0 -0
- {codonyat-1.2.0 → codonyat-1.2.1}/codonyat.egg-info/dependency_links.txt +0 -0
- {codonyat-1.2.0 → codonyat-1.2.1}/codonyat.egg-info/entry_points.txt +0 -0
- {codonyat-1.2.0 → codonyat-1.2.1}/codonyat.egg-info/requires.txt +0 -0
- {codonyat-1.2.0 → codonyat-1.2.1}/codonyat.egg-info/top_level.txt +0 -0
- {codonyat-1.2.0 → codonyat-1.2.1}/setup.cfg +0 -0
- {codonyat-1.2.0 → codonyat-1.2.1}/tests/test_app.py +0 -0
- {codonyat-1.2.0 → codonyat-1.2.1}/tests/test_integration.py +0 -0
- {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.
|
|
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` |
|
|
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` |
|
|
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
|
|
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=
|
|
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
|
-
|
|
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",
|
|
95
|
-
container.
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|