CodonAdaptPy 1.0.0__py3-none-any.whl
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.
- codonadaptpy/__init__.py +67 -0
- codonadaptpy/__main__.py +17 -0
- codonadaptpy/__version__.py +21 -0
- codonadaptpy/aggregation.py +94 -0
- codonadaptpy/analyzer.py +270 -0
- codonadaptpy/cli.py +409 -0
- codonadaptpy/comparative.py +538 -0
- codonadaptpy/exceptions.py +39 -0
- codonadaptpy/genetic_code.py +115 -0
- codonadaptpy/metrics/__init__.py +28 -0
- codonadaptpy/metrics/adaptation.py +133 -0
- codonadaptpy/metrics/composition.py +173 -0
- codonadaptpy/metrics/evolutionary.py +60 -0
- codonadaptpy/metrics/host.py +145 -0
- codonadaptpy/metrics/pairs.py +107 -0
- codonadaptpy/metrics/usage.py +155 -0
- codonadaptpy/models.py +128 -0
- codonadaptpy/optimizer.py +194 -0
- codonadaptpy/parsers.py +220 -0
- codonadaptpy/phylo_visualization.py +334 -0
- codonadaptpy/phylogenetics.py +602 -0
- codonadaptpy/py.typed +1 -0
- codonadaptpy/references.py +169 -0
- codonadaptpy/reporting.py +174 -0
- codonadaptpy/validation.py +169 -0
- codonadaptpy/visualization.py +1073 -0
- codonadaptpy-1.0.0.dist-info/METADATA +346 -0
- codonadaptpy-1.0.0.dist-info/RECORD +32 -0
- codonadaptpy-1.0.0.dist-info/WHEEL +5 -0
- codonadaptpy-1.0.0.dist-info/entry_points.txt +2 -0
- codonadaptpy-1.0.0.dist-info/licenses/LICENSE +14 -0
- codonadaptpy-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodonAdaptPy Codon-Pair Metrics
|
|
3
|
+
|
|
4
|
+
This module calculates observed codon-pair frequencies, expected pair usage
|
|
5
|
+
under independent codon sampling, log-odds codon-pair scores, and a sequence
|
|
6
|
+
codon-pair bias summary. Ambiguous and stop-containing pairs are excluded.
|
|
7
|
+
|
|
8
|
+
Classes:
|
|
9
|
+
- PairMetrics: Codon-pair counting and scoring utilities.
|
|
10
|
+
|
|
11
|
+
:Created: July 20, 2026
|
|
12
|
+
:Updated: July 20, 2026
|
|
13
|
+
:Author: Naveen Duhan
|
|
14
|
+
:Version: 1.0.0
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
from collections import Counter
|
|
21
|
+
from collections.abc import Iterable, Mapping
|
|
22
|
+
|
|
23
|
+
from ..genetic_code import GeneticCode
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PairMetrics:
|
|
27
|
+
"""Calculate codon-pair frequencies, scores, and compatibility."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, genetic_code: GeneticCode | None = None) -> None:
|
|
30
|
+
"""Initialize pair filtering for a selected genetic code."""
|
|
31
|
+
|
|
32
|
+
self.genetic_code = genetic_code or GeneticCode.from_ncbi(1)
|
|
33
|
+
|
|
34
|
+
def frequencies(self, codons: Iterable[str]) -> dict[str, float]:
|
|
35
|
+
"""Return adjacent sense-codon pair frequencies keyed ``CODON-CODON``."""
|
|
36
|
+
|
|
37
|
+
return self.frequencies_many([codons])
|
|
38
|
+
|
|
39
|
+
def frequencies_many(self, codon_segments: Iterable[Iterable[str]]) -> dict[str, float]:
|
|
40
|
+
"""Return pooled pair frequencies without creating cross-CDS pairs."""
|
|
41
|
+
|
|
42
|
+
counts: Counter[str] = Counter()
|
|
43
|
+
for codons in codon_segments:
|
|
44
|
+
values = [codon for codon in codons if self.genetic_code.translate(codon) not in {None, "*"}]
|
|
45
|
+
counts.update(f"{left}-{right}" for left, right in zip(values, values[1:], strict=False))
|
|
46
|
+
total = sum(counts.values())
|
|
47
|
+
return {pair: count / total for pair, count in sorted(counts.items())} if total else {}
|
|
48
|
+
|
|
49
|
+
def scores(self, codons: Iterable[str], *, pseudocount: float = 1e-9) -> dict[str, float]:
|
|
50
|
+
"""Return log observed/expected scores for codon pairs in one sequence."""
|
|
51
|
+
|
|
52
|
+
return self.scores_many([codons], pseudocount=pseudocount)
|
|
53
|
+
|
|
54
|
+
def scores_many(
|
|
55
|
+
self,
|
|
56
|
+
codon_segments: Iterable[Iterable[str]],
|
|
57
|
+
*,
|
|
58
|
+
pseudocount: float = 1e-9,
|
|
59
|
+
) -> dict[str, float]:
|
|
60
|
+
"""Return pooled pair log-odds while respecting CDS boundaries."""
|
|
61
|
+
|
|
62
|
+
segments = [
|
|
63
|
+
[codon for codon in segment if self.genetic_code.translate(codon) not in {None, "*"}]
|
|
64
|
+
for segment in codon_segments
|
|
65
|
+
]
|
|
66
|
+
values = [codon for segment in segments for codon in segment]
|
|
67
|
+
singles = Counter(values)
|
|
68
|
+
pairs = self.frequencies_many(segments)
|
|
69
|
+
total = len(values)
|
|
70
|
+
return (
|
|
71
|
+
{
|
|
72
|
+
pair: math.log(
|
|
73
|
+
(observed + pseudocount) / ((singles[left] / total) * (singles[right] / total) + pseudocount)
|
|
74
|
+
)
|
|
75
|
+
for pair, observed in pairs.items()
|
|
76
|
+
for left, right in [pair.split("-")]
|
|
77
|
+
}
|
|
78
|
+
if total
|
|
79
|
+
else {}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def bias(self, codons: Iterable[str], reference_scores: Mapping[str, float] | None = None) -> float:
|
|
83
|
+
"""Return mean codon-pair score using sequence or reference pair scores."""
|
|
84
|
+
|
|
85
|
+
values = list(codons)
|
|
86
|
+
scores = dict(reference_scores) if reference_scores is not None else self.scores(values)
|
|
87
|
+
pairs = [
|
|
88
|
+
f"{left}-{right}" for left, right in zip(values, values[1:], strict=False) if f"{left}-{right}" in scores
|
|
89
|
+
]
|
|
90
|
+
return sum(scores[pair] for pair in pairs) / len(pairs) if pairs else 0.0
|
|
91
|
+
|
|
92
|
+
def bias_many(
|
|
93
|
+
self,
|
|
94
|
+
codon_segments: Iterable[Iterable[str]],
|
|
95
|
+
reference_scores: Mapping[str, float] | None = None,
|
|
96
|
+
) -> float:
|
|
97
|
+
"""Return mean pair score across CDSs without boundary artifacts."""
|
|
98
|
+
|
|
99
|
+
segments = [list(segment) for segment in codon_segments]
|
|
100
|
+
scores = dict(reference_scores) if reference_scores is not None else self.scores_many(segments)
|
|
101
|
+
pairs = [
|
|
102
|
+
f"{left}-{right}"
|
|
103
|
+
for segment in segments
|
|
104
|
+
for left, right in zip(segment, segment[1:], strict=False)
|
|
105
|
+
if f"{left}-{right}" in scores
|
|
106
|
+
]
|
|
107
|
+
return sum(scores[pair] for pair in pairs) / len(pairs) if pairs else 0.0
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodonAdaptPy Codon-Usage Metrics
|
|
3
|
+
|
|
4
|
+
This module calculates codon counts, RSCU, ENC, FOP, CBI, intrinsic codon
|
|
5
|
+
deviation, amino-acid composition, and reference frequencies. Implementations
|
|
6
|
+
operate on codon iterables and explicitly exclude termination and ambiguous
|
|
7
|
+
codons unless a metric documents otherwise.
|
|
8
|
+
|
|
9
|
+
Classes:
|
|
10
|
+
- CodonUsageMetrics: Genetic-code-aware codon-bias statistics.
|
|
11
|
+
|
|
12
|
+
:Created: July 20, 2026
|
|
13
|
+
:Updated: July 20, 2026
|
|
14
|
+
:Author: Naveen Duhan
|
|
15
|
+
:Version: 1.0.0
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections import Counter
|
|
21
|
+
from collections.abc import Iterable, Mapping
|
|
22
|
+
|
|
23
|
+
from ..genetic_code import GeneticCode
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CodonUsageMetrics:
|
|
27
|
+
"""Calculate intrinsic and reference-aware codon-usage statistics."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, genetic_code: GeneticCode | None = None) -> None:
|
|
30
|
+
"""Initialize calculations for a selected genetic code."""
|
|
31
|
+
|
|
32
|
+
self.genetic_code = genetic_code or GeneticCode.from_ncbi(1)
|
|
33
|
+
|
|
34
|
+
def counts(self, codons: Iterable[str], *, include_stops: bool = False) -> dict[str, int]:
|
|
35
|
+
"""Count unambiguous recognized codons and return a complete 64-codon map."""
|
|
36
|
+
|
|
37
|
+
observed = Counter(codon.upper().replace("U", "T") for codon in codons)
|
|
38
|
+
return {
|
|
39
|
+
codon: observed.get(codon, 0)
|
|
40
|
+
for codon, amino_acid in sorted(self.genetic_code.forward_table.items())
|
|
41
|
+
if include_stops or amino_acid != "*"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def frequencies(self, codons: Iterable[str]) -> dict[str, float]:
|
|
45
|
+
"""Return global sense-codon relative frequencies summing to one."""
|
|
46
|
+
|
|
47
|
+
counts = self.counts(codons)
|
|
48
|
+
total = sum(counts.values())
|
|
49
|
+
return {codon: count / total if total else 0.0 for codon, count in counts.items()}
|
|
50
|
+
|
|
51
|
+
def rscu(self, codons: Iterable[str]) -> dict[str, float]:
|
|
52
|
+
"""Calculate Relative Synonymous Codon Usage for every sense codon."""
|
|
53
|
+
|
|
54
|
+
return self.rscu_from_counts(self.counts(codons))
|
|
55
|
+
|
|
56
|
+
def rscu_from_counts(self, counts: Mapping[str, int | float]) -> dict[str, float]:
|
|
57
|
+
"""Calculate RSCU directly from an existing codon-count mapping."""
|
|
58
|
+
|
|
59
|
+
result: dict[str, float] = {}
|
|
60
|
+
for amino_acid, family in self.genetic_code.synonymous_codons.items():
|
|
61
|
+
if amino_acid == "*":
|
|
62
|
+
continue
|
|
63
|
+
total = sum(float(counts.get(codon, 0.0)) for codon in family)
|
|
64
|
+
result.update(
|
|
65
|
+
{codon: (float(counts.get(codon, 0.0)) * len(family) / total if total else 0.0) for codon in family}
|
|
66
|
+
)
|
|
67
|
+
return dict(sorted(result.items()))
|
|
68
|
+
|
|
69
|
+
def amino_acid_composition(self, codons: Iterable[str]) -> dict[str, float]:
|
|
70
|
+
"""Return fractional one-letter amino-acid composition."""
|
|
71
|
+
|
|
72
|
+
amino_acids = [self.genetic_code.translate(codon) for codon in codons]
|
|
73
|
+
counts = Counter(aa for aa in amino_acids if aa and aa != "*")
|
|
74
|
+
total = sum(counts.values())
|
|
75
|
+
return {
|
|
76
|
+
aa: counts.get(aa, 0) / total if total else 0.0
|
|
77
|
+
for aa in sorted(self.genetic_code.synonymous_codons)
|
|
78
|
+
if aa != "*"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def enc(self, codons: Iterable[str]) -> float:
|
|
82
|
+
"""Estimate effective number of codons using homozygosity by family.
|
|
83
|
+
|
|
84
|
+
The estimator follows Wright's family-degeneracy formulation with an
|
|
85
|
+
unbiased within-family homozygosity correction. Missing degeneracy
|
|
86
|
+
classes contribute their neutral expectation, keeping ENC within the
|
|
87
|
+
conventional 20 (maximal bias) to 61 (minimal bias) range.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
counts = self.counts(codons)
|
|
91
|
+
family_f: dict[int, list[float]] = {2: [], 3: [], 4: [], 6: []}
|
|
92
|
+
for amino_acid, family in self.genetic_code.synonymous_codons.items():
|
|
93
|
+
if amino_acid == "*" or len(family) == 1:
|
|
94
|
+
continue
|
|
95
|
+
values = [counts[codon] for codon in family]
|
|
96
|
+
n = sum(values)
|
|
97
|
+
if n > 1:
|
|
98
|
+
f_value = (sum(value * value for value in values) - n) / (n * (n - 1))
|
|
99
|
+
family_f.setdefault(len(family), []).append(max(f_value, 1 / len(family)))
|
|
100
|
+
means = {size: (sum(values) / len(values) if values else 1 / size) for size, values in family_f.items()}
|
|
101
|
+
value = 2 + 9 / means[2] + 1 / means[3] + 5 / means[4] + 3 / means[6]
|
|
102
|
+
return min(61.0, max(20.0, value))
|
|
103
|
+
|
|
104
|
+
def optimal_codons(self, reference_counts: Mapping[str, int | float]) -> set[str]:
|
|
105
|
+
"""Select the most frequent codon(s) in each synonymous family."""
|
|
106
|
+
|
|
107
|
+
optimal: set[str] = set()
|
|
108
|
+
for amino_acid, family in self.genetic_code.synonymous_codons.items():
|
|
109
|
+
if amino_acid == "*" or len(family) == 1:
|
|
110
|
+
continue
|
|
111
|
+
maximum = max(float(reference_counts.get(codon, 0.0)) for codon in family)
|
|
112
|
+
optimal.update(codon for codon in family if float(reference_counts.get(codon, 0.0)) == maximum)
|
|
113
|
+
return optimal
|
|
114
|
+
|
|
115
|
+
def fop(self, codons: Iterable[str], optimal_codons: set[str]) -> float:
|
|
116
|
+
"""Calculate frequency of optimal codons among synonymous sense codons."""
|
|
117
|
+
|
|
118
|
+
eligible = [
|
|
119
|
+
codon
|
|
120
|
+
for codon in codons
|
|
121
|
+
if len(self.genetic_code.synonymous_codons.get(self.genetic_code.translate(codon) or "", ())) > 1
|
|
122
|
+
]
|
|
123
|
+
return sum(codon in optimal_codons for codon in eligible) / len(eligible) if eligible else 0.0
|
|
124
|
+
|
|
125
|
+
def cbi(self, codons: Iterable[str], optimal_codons: set[str]) -> float:
|
|
126
|
+
"""Calculate Codon Bias Index relative to a selected optimal-codon set."""
|
|
127
|
+
|
|
128
|
+
codon_list = list(codons)
|
|
129
|
+
counts = self.counts(codon_list)
|
|
130
|
+
observed = sum(counts.get(codon, 0) for codon in optimal_codons)
|
|
131
|
+
expected = 0.0
|
|
132
|
+
maximum = 0
|
|
133
|
+
for amino_acid, family in self.genetic_code.synonymous_codons.items():
|
|
134
|
+
if amino_acid == "*" or len(family) == 1:
|
|
135
|
+
continue
|
|
136
|
+
family_total = sum(counts[codon] for codon in family)
|
|
137
|
+
family_optimal = len(set(family) & optimal_codons)
|
|
138
|
+
expected += family_total * family_optimal / len(family)
|
|
139
|
+
maximum += family_total
|
|
140
|
+
denominator = maximum - expected
|
|
141
|
+
return (observed - expected) / denominator if denominator else 0.0
|
|
142
|
+
|
|
143
|
+
def icdi(self, codons: Iterable[str]) -> float:
|
|
144
|
+
"""Calculate intrinsic codon deviation from equal synonymous usage."""
|
|
145
|
+
|
|
146
|
+
counts = self.counts(codons)
|
|
147
|
+
deviations: list[float] = []
|
|
148
|
+
for amino_acid, family in self.genetic_code.synonymous_codons.items():
|
|
149
|
+
if amino_acid == "*" or len(family) == 1:
|
|
150
|
+
continue
|
|
151
|
+
total = sum(counts[codon] for codon in family)
|
|
152
|
+
if total:
|
|
153
|
+
equal = 1 / len(family)
|
|
154
|
+
deviations.append(sum(abs(counts[codon] / total - equal) for codon in family) / (2 * (1 - equal)))
|
|
155
|
+
return sum(deviations) / len(deviations) if deviations else 0.0
|
codonadaptpy/models.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodonAdaptPy Data Models
|
|
3
|
+
|
|
4
|
+
This module contains dependency-free dataclasses shared by parsing,
|
|
5
|
+
validation, analysis, optimization, reporting, and command-line workflows.
|
|
6
|
+
Models expose ``to_dict`` methods so results can be serialized without a
|
|
7
|
+
framework-specific schema library.
|
|
8
|
+
|
|
9
|
+
Classes:
|
|
10
|
+
- SequenceRecord: One coding sequence and its metadata.
|
|
11
|
+
- ValidationConfig: User-selected validation policy.
|
|
12
|
+
- ValidationIssue: One structured validation observation.
|
|
13
|
+
- ValidationReport: Validation outcome and normalized sequence.
|
|
14
|
+
- AnalysisResult: Complete metrics for a single sequence.
|
|
15
|
+
|
|
16
|
+
:Created: July 20, 2026
|
|
17
|
+
:Updated: July 20, 2026
|
|
18
|
+
:Author: Naveen Duhan
|
|
19
|
+
:Version: 1.0.0
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import asdict, dataclass, field
|
|
25
|
+
from typing import Any, Literal
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class SequenceRecord:
|
|
30
|
+
"""Store a named nucleotide sequence and arbitrary sample metadata."""
|
|
31
|
+
|
|
32
|
+
identifier: str
|
|
33
|
+
sequence: str
|
|
34
|
+
description: str = ""
|
|
35
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, Any]:
|
|
38
|
+
"""Return a JSON-compatible representation of the record."""
|
|
39
|
+
|
|
40
|
+
return asdict(self)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(slots=True)
|
|
44
|
+
class ValidationConfig:
|
|
45
|
+
"""Control normalization and coding-sequence quality checks.
|
|
46
|
+
|
|
47
|
+
Reading frames use zero-based offsets ``0``, ``1``, or ``2``. Ambiguous
|
|
48
|
+
codons may be rejected, skipped by downstream analyses, or retained with
|
|
49
|
+
warnings. Partial sequences relax start, stop, and divisible-by-three
|
|
50
|
+
requirements while preserving all other diagnostics.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
reading_frame: Literal[0, 1, 2] = 0
|
|
54
|
+
genetic_code: int = 1
|
|
55
|
+
require_start: bool = True
|
|
56
|
+
require_stop: bool = True
|
|
57
|
+
ambiguous_policy: Literal["reject", "skip", "allow"] = "reject"
|
|
58
|
+
allow_partial: bool = False
|
|
59
|
+
convert_rna: bool = True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(slots=True)
|
|
63
|
+
class ValidationIssue:
|
|
64
|
+
"""Describe one validation warning or error with optional sequence locus."""
|
|
65
|
+
|
|
66
|
+
code: str
|
|
67
|
+
message: str
|
|
68
|
+
severity: Literal["warning", "error"]
|
|
69
|
+
position: int | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(slots=True)
|
|
73
|
+
class ValidationReport:
|
|
74
|
+
"""Collect normalized sequence data and structured validation findings."""
|
|
75
|
+
|
|
76
|
+
identifier: str
|
|
77
|
+
normalized_sequence: str
|
|
78
|
+
issues: list[ValidationIssue] = field(default_factory=list)
|
|
79
|
+
genetic_code: int = 1
|
|
80
|
+
reading_frame: int = 0
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def is_valid(self) -> bool:
|
|
84
|
+
"""Return ``True`` when the report contains no error-level issues."""
|
|
85
|
+
|
|
86
|
+
return not any(issue.severity == "error" for issue in self.issues)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def errors(self) -> list[ValidationIssue]:
|
|
90
|
+
"""Return only error-level findings."""
|
|
91
|
+
|
|
92
|
+
return [issue for issue in self.issues if issue.severity == "error"]
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def warnings(self) -> list[ValidationIssue]:
|
|
96
|
+
"""Return only warning-level findings."""
|
|
97
|
+
|
|
98
|
+
return [issue for issue in self.issues if issue.severity == "warning"]
|
|
99
|
+
|
|
100
|
+
def to_dict(self) -> dict[str, Any]:
|
|
101
|
+
"""Return a JSON-compatible representation of the report."""
|
|
102
|
+
|
|
103
|
+
return asdict(self) | {"is_valid": self.is_valid}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(slots=True)
|
|
107
|
+
class AnalysisResult:
|
|
108
|
+
"""Store validation, scalar metrics, profiles, and count tables."""
|
|
109
|
+
|
|
110
|
+
identifier: str
|
|
111
|
+
validation: ValidationReport
|
|
112
|
+
metrics: dict[str, Any]
|
|
113
|
+
codon_counts: dict[str, int]
|
|
114
|
+
rscu: dict[str, float]
|
|
115
|
+
amino_acid_composition: dict[str, float]
|
|
116
|
+
dinucleotide_frequencies: dict[str, float]
|
|
117
|
+
dinucleotide_odds: dict[str, float | None]
|
|
118
|
+
dinucleotide_bias: dict[str, str]
|
|
119
|
+
trinucleotide_frequencies: dict[str, float]
|
|
120
|
+
codon_pair_scores: dict[str, float] = field(default_factory=dict)
|
|
121
|
+
sliding_windows: dict[str, list[dict[str, float]]] = field(default_factory=dict)
|
|
122
|
+
host_comparisons: dict[str, dict[str, float]] = field(default_factory=dict)
|
|
123
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
124
|
+
|
|
125
|
+
def to_dict(self) -> dict[str, Any]:
|
|
126
|
+
"""Return all nested result fields in JSON-compatible form."""
|
|
127
|
+
|
|
128
|
+
return asdict(self)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodonAdaptPy Constrained Sequence Optimization
|
|
3
|
+
|
|
4
|
+
This module designs multiple synonymous coding sequences while preserving the
|
|
5
|
+
translated amino-acid sequence. Candidate ranking can increase or decrease
|
|
6
|
+
reference CAI, approach a target GC fraction, control CpG/UpA, avoid or require
|
|
7
|
+
motifs and restriction sites, limit homopolymers, and account for codon-pair
|
|
8
|
+
scores. Results expose each trade-off rather than hiding it in one sequence.
|
|
9
|
+
|
|
10
|
+
Classes:
|
|
11
|
+
- OptimizationConfig: Objectives, constraints, and reproducibility options.
|
|
12
|
+
- OptimizationCandidate: One designed sequence and its diagnostics.
|
|
13
|
+
- OptimizationResult: Ranked candidates and source metadata.
|
|
14
|
+
- CodonOptimizer: Reproducible stochastic constrained optimizer.
|
|
15
|
+
|
|
16
|
+
:Created: July 20, 2026
|
|
17
|
+
:Updated: July 20, 2026
|
|
18
|
+
:Author: Naveen Duhan
|
|
19
|
+
:Version: 1.0.0
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import random
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import asdict, dataclass, field
|
|
27
|
+
from typing import Any, Literal
|
|
28
|
+
|
|
29
|
+
from .exceptions import AnalysisError
|
|
30
|
+
from .genetic_code import GeneticCode
|
|
31
|
+
from .metrics.adaptation import AdaptationMetrics
|
|
32
|
+
from .metrics.composition import CompositionMetrics
|
|
33
|
+
from .metrics.pairs import PairMetrics
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class OptimizationConfig:
|
|
38
|
+
"""Configure candidate search objectives and hard/soft constraints."""
|
|
39
|
+
|
|
40
|
+
direction: Literal["optimize", "deoptimize", "match"] = "optimize"
|
|
41
|
+
candidates: int = 5
|
|
42
|
+
iterations: int = 5000
|
|
43
|
+
seed: int = 1
|
|
44
|
+
target_gc: float | None = None
|
|
45
|
+
gc_tolerance: float = 0.05
|
|
46
|
+
avoid_motifs: tuple[str, ...] = ()
|
|
47
|
+
preserve_motifs: tuple[str, ...] = ()
|
|
48
|
+
remove_restriction_sites: tuple[str, ...] = ()
|
|
49
|
+
add_restriction_sites: tuple[str, ...] = ()
|
|
50
|
+
max_homopolymer: int = 6
|
|
51
|
+
target_cpg: float | None = None
|
|
52
|
+
target_upa: float | None = None
|
|
53
|
+
cai_weight: float = 1.0
|
|
54
|
+
gc_weight: float = 1.0
|
|
55
|
+
motif_penalty: float = 10.0
|
|
56
|
+
pair_weight: float = 0.0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(slots=True)
|
|
60
|
+
class OptimizationCandidate:
|
|
61
|
+
"""Store one synonymous candidate and transparent objective diagnostics."""
|
|
62
|
+
|
|
63
|
+
rank: int
|
|
64
|
+
sequence: str
|
|
65
|
+
score: float
|
|
66
|
+
cai: float
|
|
67
|
+
gc: float
|
|
68
|
+
cpg_representation: float
|
|
69
|
+
upa_representation: float
|
|
70
|
+
constraint_violations: list[str] = field(default_factory=list)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(slots=True)
|
|
74
|
+
class OptimizationResult:
|
|
75
|
+
"""Collect source sequence, configuration, and ranked candidate designs."""
|
|
76
|
+
|
|
77
|
+
source_sequence: str
|
|
78
|
+
amino_acid_sequence: str
|
|
79
|
+
candidates: list[OptimizationCandidate]
|
|
80
|
+
config: OptimizationConfig
|
|
81
|
+
genetic_code: int
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> dict[str, Any]:
|
|
84
|
+
"""Return nested optimization output in JSON-compatible form."""
|
|
85
|
+
|
|
86
|
+
return asdict(self)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class CodonOptimizer:
|
|
90
|
+
"""Generate and rank synonymous sequences using seeded stochastic search."""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self, reference_counts: dict[str, float], *, genetic_code: int = 1, pair_scores: dict[str, float] | None = None
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Initialize optimizer with a target reference profile."""
|
|
96
|
+
|
|
97
|
+
self.code = GeneticCode.from_ncbi(genetic_code)
|
|
98
|
+
self.adaptation = AdaptationMetrics(self.code)
|
|
99
|
+
self.composition = CompositionMetrics()
|
|
100
|
+
self.pairs = PairMetrics(self.code)
|
|
101
|
+
self.weights = self.adaptation.weights_from_counts(reference_counts)
|
|
102
|
+
self.reference_counts = reference_counts
|
|
103
|
+
self.pair_scores = pair_scores or {}
|
|
104
|
+
|
|
105
|
+
def optimize(self, sequence: str, config: OptimizationConfig | None = None) -> OptimizationResult:
|
|
106
|
+
"""Generate several ranked synonymous candidate coding sequences."""
|
|
107
|
+
|
|
108
|
+
config = config or OptimizationConfig()
|
|
109
|
+
normalized = "".join(sequence.upper().replace("U", "T").split())
|
|
110
|
+
if len(normalized) % 3:
|
|
111
|
+
raise AnalysisError("Optimization requires a sequence divisible by three.")
|
|
112
|
+
codons = [normalized[index : index + 3] for index in range(0, len(normalized), 3)]
|
|
113
|
+
amino_acids = [self.code.translate(codon) for codon in codons]
|
|
114
|
+
if any(amino_acid is None for amino_acid in amino_acids):
|
|
115
|
+
raise AnalysisError("Optimization does not accept ambiguous or invalid codons.")
|
|
116
|
+
terminal_stop = bool(amino_acids and amino_acids[-1] == "*")
|
|
117
|
+
if "*" in amino_acids[:-1]:
|
|
118
|
+
raise AnalysisError("Optimization does not accept internal stop codons.")
|
|
119
|
+
design_amino_acids = [aa for aa in amino_acids if aa is not None and aa != "*"]
|
|
120
|
+
stop = codons[-1] if terminal_stop else ""
|
|
121
|
+
rng = random.Random(config.seed)
|
|
122
|
+
designs: dict[str, tuple[float, dict[str, float], list[str]]] = {}
|
|
123
|
+
initial = "".join(codons[:-1] if terminal_stop else codons) + stop
|
|
124
|
+
for _ in range(max(config.iterations, config.candidates)):
|
|
125
|
+
candidate_codons = [self._choose_codon(aa, config.direction, rng) for aa in design_amino_acids]
|
|
126
|
+
candidate = "".join(candidate_codons) + stop
|
|
127
|
+
score, diagnostics, violations = self._score(candidate, candidate_codons, config)
|
|
128
|
+
previous = designs.get(candidate)
|
|
129
|
+
if previous is None or score > previous[0]:
|
|
130
|
+
designs[candidate] = (score, diagnostics, violations)
|
|
131
|
+
if initial not in designs:
|
|
132
|
+
initial_codons = codons[:-1] if terminal_stop else codons
|
|
133
|
+
designs[initial] = self._score(initial, initial_codons, config)
|
|
134
|
+
ordered = sorted(designs.items(), key=lambda item: item[1][0], reverse=True)[: config.candidates]
|
|
135
|
+
candidates = [
|
|
136
|
+
OptimizationCandidate(
|
|
137
|
+
rank,
|
|
138
|
+
candidate,
|
|
139
|
+
score,
|
|
140
|
+
diagnostics["cai"],
|
|
141
|
+
diagnostics["gc"],
|
|
142
|
+
diagnostics["cpg"],
|
|
143
|
+
diagnostics["upa"],
|
|
144
|
+
violations,
|
|
145
|
+
)
|
|
146
|
+
for rank, (candidate, (score, diagnostics, violations)) in enumerate(ordered, start=1)
|
|
147
|
+
]
|
|
148
|
+
return OptimizationResult(normalized, "".join(design_amino_acids), candidates, config, self.code.table_id)
|
|
149
|
+
|
|
150
|
+
def _choose_codon(self, amino_acid: str, direction: str, rng: random.Random) -> str:
|
|
151
|
+
"""Sample a synonymous codon according to the requested direction."""
|
|
152
|
+
|
|
153
|
+
family = self.code.synonymous_codons[amino_acid]
|
|
154
|
+
weights = [self.weights[codon] for codon in family]
|
|
155
|
+
if direction == "deoptimize":
|
|
156
|
+
weights = [1 / max(weight, 1e-9) for weight in weights]
|
|
157
|
+
elif direction == "match":
|
|
158
|
+
weights = [max(float(self.reference_counts.get(codon, 0.0)), 1e-9) for codon in family]
|
|
159
|
+
return rng.choices(family, weights=weights, k=1)[0]
|
|
160
|
+
|
|
161
|
+
def _score(
|
|
162
|
+
self, sequence: str, codons: list[str], config: OptimizationConfig
|
|
163
|
+
) -> tuple[float, dict[str, float], list[str]]:
|
|
164
|
+
"""Evaluate objectives and report every constraint violation."""
|
|
165
|
+
|
|
166
|
+
cai = self.adaptation.cai(codons, self.weights)
|
|
167
|
+
composition = self.composition.gc_metrics(sequence)
|
|
168
|
+
cpg = self.composition.representation(sequence, "CG")
|
|
169
|
+
upa = self.composition.representation(sequence, "TA")
|
|
170
|
+
cai_term = -cai if config.direction == "deoptimize" else cai
|
|
171
|
+
score = config.cai_weight * cai_term
|
|
172
|
+
if config.target_gc is not None:
|
|
173
|
+
score -= config.gc_weight * abs(composition["gc"] - config.target_gc)
|
|
174
|
+
if config.target_cpg is not None:
|
|
175
|
+
score -= abs(cpg - config.target_cpg)
|
|
176
|
+
if config.target_upa is not None:
|
|
177
|
+
score -= abs(upa - config.target_upa)
|
|
178
|
+
if self.pair_scores and config.pair_weight:
|
|
179
|
+
score += config.pair_weight * self.pairs.bias(codons, self.pair_scores)
|
|
180
|
+
violations: list[str] = []
|
|
181
|
+
forbidden = tuple(
|
|
182
|
+
item.upper().replace("U", "T") for item in config.avoid_motifs + config.remove_restriction_sites
|
|
183
|
+
)
|
|
184
|
+
required = tuple(
|
|
185
|
+
item.upper().replace("U", "T") for item in config.preserve_motifs + config.add_restriction_sites
|
|
186
|
+
)
|
|
187
|
+
violations.extend(f"forbidden motif present: {motif}" for motif in forbidden if motif and motif in sequence)
|
|
188
|
+
violations.extend(f"required motif absent: {motif}" for motif in required if motif and motif not in sequence)
|
|
189
|
+
if config.target_gc is not None and abs(composition["gc"] - config.target_gc) > config.gc_tolerance:
|
|
190
|
+
violations.append("GC content outside tolerance")
|
|
191
|
+
if config.max_homopolymer > 0 and re.search(rf"([ACGT])\1{{{config.max_homopolymer},}}", sequence):
|
|
192
|
+
violations.append(f"homopolymer longer than {config.max_homopolymer}")
|
|
193
|
+
score -= config.motif_penalty * len(violations)
|
|
194
|
+
return score, {"cai": cai, "gc": composition["gc"], "cpg": cpg, "upa": upa}, violations
|