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