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.
@@ -0,0 +1,115 @@
1
+ """
2
+ CodonAdaptPy Genetic-Code Models
3
+
4
+ This module provides immutable genetic-code objects and codon-family lookup
5
+ tables. NCBI genetic code 1 is bundled so the core package has no mandatory
6
+ dependencies; other NCBI tables are loaded through Biopython when installed.
7
+
8
+ Features:
9
+ - DNA-normalized codon translation
10
+ - Synonymous-codon family construction
11
+ - Start- and stop-codon classification
12
+ - Optional access to all NCBI genetic codes through Biopython
13
+
14
+ Classes:
15
+ - GeneticCode: Immutable translation table used throughout the package.
16
+
17
+ :Created: July 20, 2026
18
+ :Updated: July 20, 2026
19
+ :Author: Naveen Duhan
20
+ :Version: 1.0.0
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+ from functools import cached_property
27
+
28
+ from .exceptions import ReferenceDataError
29
+
30
+ _BASES = "TCAG"
31
+ _AMINO_ACIDS = "FFLLSSSSYY**CC*WLLLLPPPPHHQQRRRRIIIMTTTTNNKKSSRRVVVVAAAADDEEGGGG"
32
+ STANDARD_FORWARD_TABLE = {
33
+ a + b + c: amino_acid
34
+ for (a, b, c), amino_acid in zip(
35
+ ((a, b, c) for a in _BASES for b in _BASES for c in _BASES),
36
+ _AMINO_ACIDS,
37
+ strict=True,
38
+ )
39
+ }
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class GeneticCode:
44
+ """Represent a DNA codon translation table.
45
+
46
+ Parameters
47
+ ----------
48
+ table_id:
49
+ NCBI genetic-code identifier.
50
+ name:
51
+ Human-readable genetic-code name.
52
+ forward_table:
53
+ Mapping from each unambiguous DNA codon to a one-letter amino acid or
54
+ ``"*"`` for termination.
55
+ start_codons:
56
+ Codons accepted as translation starts by this genetic code.
57
+ stop_codons:
58
+ Codons interpreted as termination signals.
59
+ """
60
+
61
+ table_id: int
62
+ name: str
63
+ forward_table: dict[str, str]
64
+ start_codons: frozenset[str]
65
+ stop_codons: frozenset[str]
66
+
67
+ @classmethod
68
+ def from_ncbi(cls, table_id: int = 1) -> GeneticCode:
69
+ """Create a genetic code from a numeric NCBI table identifier.
70
+
71
+ Standard code 1 is always available. Other tables require the
72
+ ``CodonAdaptPy[io]`` extra because their definitions are obtained from
73
+ Biopython's maintained NCBI codon-table registry.
74
+ """
75
+
76
+ if table_id == 1:
77
+ stops = frozenset(codon for codon, aa in STANDARD_FORWARD_TABLE.items() if aa == "*")
78
+ return cls(
79
+ table_id=1,
80
+ name="Standard",
81
+ forward_table=dict(STANDARD_FORWARD_TABLE),
82
+ start_codons=frozenset({"ATG", "TTG", "CTG"}),
83
+ stop_codons=stops,
84
+ )
85
+ try:
86
+ from Bio.Data import CodonTable
87
+ except ImportError as exc:
88
+ raise ReferenceDataError(f"Genetic code {table_id} requires Biopython; install CodonAdaptPy[io].") from exc
89
+ try:
90
+ source = CodonTable.unambiguous_dna_by_id[table_id]
91
+ except KeyError as exc:
92
+ raise ReferenceDataError(f"Unsupported NCBI genetic code: {table_id}") from exc
93
+ table = dict(source.forward_table)
94
+ table.update({codon: "*" for codon in source.stop_codons})
95
+ return cls(
96
+ table_id=table_id,
97
+ name=source.names[0],
98
+ forward_table=table,
99
+ start_codons=frozenset(source.start_codons),
100
+ stop_codons=frozenset(source.stop_codons),
101
+ )
102
+
103
+ @cached_property
104
+ def synonymous_codons(self) -> dict[str, tuple[str, ...]]:
105
+ """Return sorted synonymous codon families keyed by amino acid."""
106
+
107
+ families: dict[str, list[str]] = {}
108
+ for codon, amino_acid in self.forward_table.items():
109
+ families.setdefault(amino_acid, []).append(codon)
110
+ return {amino_acid: tuple(sorted(codons)) for amino_acid, codons in families.items()}
111
+
112
+ def translate(self, codon: str) -> str | None:
113
+ """Translate one DNA or RNA codon, returning ``None`` if ambiguous."""
114
+
115
+ return self.forward_table.get(codon.upper().replace("U", "T"))
@@ -0,0 +1,28 @@
1
+ """
2
+ CodonAdaptPy Metric Implementations
3
+
4
+ This package groups independent, testable calculation classes for adaptation,
5
+ codon usage, nucleotide composition, oligonucleotide representation, codon
6
+ pairs, host comparison, and evolutionary diagnostics.
7
+
8
+ :Created: July 20, 2026
9
+ :Updated: July 20, 2026
10
+ :Author: Naveen Duhan
11
+ :Version: 1.0.0
12
+ """
13
+
14
+ from .adaptation import AdaptationMetrics
15
+ from .composition import CompositionMetrics
16
+ from .evolutionary import EvolutionaryMetrics
17
+ from .host import HostComparator
18
+ from .pairs import PairMetrics
19
+ from .usage import CodonUsageMetrics
20
+
21
+ __all__ = [
22
+ "AdaptationMetrics",
23
+ "CodonUsageMetrics",
24
+ "CompositionMetrics",
25
+ "EvolutionaryMetrics",
26
+ "HostComparator",
27
+ "PairMetrics",
28
+ ]
@@ -0,0 +1,133 @@
1
+ """
2
+ CodonAdaptPy Adaptation Metrics
3
+
4
+ This module calculates CAI, expected/simulated CAI, confidence intervals,
5
+ sliding-window profiles, RCDI, and tRNA adaptation index in logarithmic space.
6
+ Zero-frequency reference codons receive an explicit configurable floor rather
7
+ than triggering numerical underflow or silently disappearing.
8
+
9
+ Classes:
10
+ - AdaptationMetrics: Reference-aware adaptation calculations.
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
+ import math
21
+ import random
22
+ from collections import Counter
23
+ from collections.abc import Iterable, Mapping
24
+
25
+ from ..exceptions import AnalysisError, ReferenceDataError
26
+ from ..genetic_code import GeneticCode
27
+
28
+
29
+ class AdaptationMetrics:
30
+ """Calculate reference-dependent codon adaptation statistics."""
31
+
32
+ def __init__(self, genetic_code: GeneticCode | None = None, *, zero_floor: float = 0.01) -> None:
33
+ """Initialize metrics for a genetic code and positive zero-count floor."""
34
+
35
+ if not 0 < zero_floor <= 1:
36
+ raise ValueError("zero_floor must be in (0, 1].")
37
+ self.genetic_code = genetic_code or GeneticCode.from_ncbi(1)
38
+ self.zero_floor = zero_floor
39
+
40
+ def weights_from_counts(self, counts: Mapping[str, int | float]) -> dict[str, float]:
41
+ """Derive relative adaptiveness weights within synonymous families."""
42
+
43
+ weights: dict[str, float] = {}
44
+ for amino_acid, family in self.genetic_code.synonymous_codons.items():
45
+ if amino_acid == "*":
46
+ continue
47
+ maximum = max(float(counts.get(codon, 0.0)) for codon in family)
48
+ if maximum <= 0:
49
+ weights.update({codon: self.zero_floor for codon in family})
50
+ else:
51
+ weights.update(
52
+ {codon: max(float(counts.get(codon, 0.0)) / maximum, self.zero_floor) for codon in family}
53
+ )
54
+ return weights
55
+
56
+ def cai(self, codons: Iterable[str], weights: Mapping[str, float]) -> float:
57
+ """Calculate classical CAI as a geometric mean in logarithmic space.
58
+
59
+ Methionine and tryptophan are included with weight 1 when present in a
60
+ complete table. Stop and ambiguous codons are excluded. Missing sense
61
+ codons are excluded, while explicitly non-positive weights are errors.
62
+ """
63
+
64
+ values: list[float] = []
65
+ for raw_codon in codons:
66
+ codon = raw_codon.upper().replace("U", "T")
67
+ if codon in self.genetic_code.stop_codons or set(codon) - set("ACGT"):
68
+ continue
69
+ if codon not in weights:
70
+ continue
71
+ weight = float(weights[codon])
72
+ if weight <= 0:
73
+ raise ReferenceDataError(f"Codon {codon} has a non-positive reference weight.")
74
+ values.append(math.log(weight))
75
+ if not values:
76
+ raise AnalysisError("No eligible sense codons were available for CAI calculation.")
77
+ return math.exp(sum(values) / len(values))
78
+
79
+ def sliding_cai(
80
+ self, codons: list[str], weights: Mapping[str, float], *, window: int = 30, step: int = 1
81
+ ) -> list[dict[str, float]]:
82
+ """Return CAI values for overlapping codon windows."""
83
+
84
+ if window < 1 or step < 1:
85
+ raise ValueError("window and step must be positive integers.")
86
+ if len(codons) < window:
87
+ return [{"start_codon": 1.0, "end_codon": float(len(codons)), "cai": self.cai(codons, weights)}]
88
+ return [
89
+ {
90
+ "start_codon": float(start + 1),
91
+ "end_codon": float(start + window),
92
+ "cai": self.cai(codons[start : start + window], weights),
93
+ }
94
+ for start in range(0, len(codons) - window + 1, step)
95
+ ]
96
+
97
+ def simulated_cai(
98
+ self, codons: list[str], weights: Mapping[str, float], *, simulations: int = 1000, seed: int = 1
99
+ ) -> dict[str, float | list[float]]:
100
+ """Simulate synonymous sequences preserving amino-acid composition."""
101
+
102
+ if simulations < 1:
103
+ raise ValueError("simulations must be positive.")
104
+ rng = random.Random(seed)
105
+ translated = [self.genetic_code.translate(codon) for codon in codons]
106
+ amino_acids = [aa for aa in translated if aa is not None and aa != "*"]
107
+ values = [
108
+ self.cai([rng.choice(self.genetic_code.synonymous_codons[aa]) for aa in amino_acids], weights)
109
+ for _ in range(simulations)
110
+ ]
111
+ ordered = sorted(values)
112
+ low = ordered[max(0, int(0.025 * simulations) - 1)]
113
+ high = ordered[min(simulations - 1, int(0.975 * simulations))]
114
+ return {"mean": sum(values) / len(values), "ci_low": low, "ci_high": high, "values": values}
115
+
116
+ def rcdi(self, codons: Iterable[str], reference_frequencies: Mapping[str, float]) -> float:
117
+ """Calculate Relative Codon Deoptimization Index; 1 indicates a close match."""
118
+
119
+ counts = Counter(codon for codon in codons if self.genetic_code.translate(codon) not in {None, "*"})
120
+ total = sum(counts.values())
121
+ if not total:
122
+ raise AnalysisError("RCDI requires at least one unambiguous sense codon.")
123
+ score = 0.0
124
+ for codon, count in counts.items():
125
+ observed = count / total
126
+ expected = float(reference_frequencies.get(codon, 0.0))
127
+ score += observed / max(expected, self.zero_floor / 64.0)
128
+ return score / len(counts)
129
+
130
+ def tai(self, codons: Iterable[str], trna_weights: Mapping[str, float]) -> float:
131
+ """Calculate tRNA Adaptation Index from user-supplied codon weights."""
132
+
133
+ return self.cai(codons, trna_weights)
@@ -0,0 +1,173 @@
1
+ """
2
+ CodonAdaptPy Composition and Oligonucleotide Metrics
3
+
4
+ This module calculates whole-sequence and codon-position nucleotide metrics,
5
+ GC content at synonymous third positions (GC3s), overlapping di-/trinucleotide
6
+ frequencies, and observed/expected representation ratios for all 16
7
+ dinucleotides. Pooled calculations preserve CDS boundaries.
8
+
9
+ Classes:
10
+ - CompositionMetrics: Dependency-free nucleotide composition calculations.
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
22
+
23
+ from ..genetic_code import GeneticCode
24
+
25
+ DINUCLEOTIDES = tuple(left + right for left in "ACGT" for right in "ACGT")
26
+
27
+
28
+ class CompositionMetrics:
29
+ """Calculate sequence composition and short-word statistics."""
30
+
31
+ @staticmethod
32
+ def _clean(sequence: str) -> str:
33
+ """Normalize RNA to DNA and retain unambiguous nucleotides only."""
34
+
35
+ return "".join(base for base in sequence.upper().replace("U", "T") if base in "ACGT")
36
+
37
+ def gc_metrics(
38
+ self,
39
+ sequence: str,
40
+ *,
41
+ frame: int = 0,
42
+ genetic_code: GeneticCode | None = None,
43
+ ) -> dict[str, float]:
44
+ """Return whole, positional, and synonymous-third composition.
45
+
46
+ ``GC3s`` uses third positions of unambiguous sense codons belonging to
47
+ amino acids with more than one synonymous codon. Methionine,
48
+ tryptophan, stops, and ambiguous codons are therefore excluded.
49
+ """
50
+
51
+ clean = self._clean(sequence)
52
+ framed = clean[frame:]
53
+ positions = [framed[index::3] for index in range(3)]
54
+ code = genetic_code or GeneticCode.from_ncbi(1)
55
+ codons = [framed[index : index + 3] for index in range(0, len(framed) - 2, 3)]
56
+ synonymous_thirds = "".join(
57
+ codon[2]
58
+ for codon in codons
59
+ if (amino_acid := code.translate(codon)) not in {None, "*"} and len(code.synonymous_codons[amino_acid]) > 1
60
+ )
61
+
62
+ def fraction(text: str, accepted: set[str]) -> float:
63
+ return sum(base in accepted for base in text) / len(text) if text else 0.0
64
+
65
+ third = positions[2]
66
+ return {
67
+ "a": fraction(clean, {"A"}),
68
+ "t": fraction(clean, {"T"}),
69
+ "g": fraction(clean, {"G"}),
70
+ "c": fraction(clean, {"C"}),
71
+ "at": fraction(clean, {"A", "T"}),
72
+ "gc": fraction(clean, {"G", "C"}),
73
+ "at1": fraction(positions[0], {"A", "T"}),
74
+ "at2": fraction(positions[1], {"A", "T"}),
75
+ "at3": fraction(third, {"A", "T"}),
76
+ "gc1": fraction(positions[0], {"G", "C"}),
77
+ "gc2": fraction(positions[1], {"G", "C"}),
78
+ "gc3": fraction(third, {"G", "C"}),
79
+ "gc3s": fraction(synonymous_thirds, {"G", "C"}),
80
+ "a3": fraction(third, {"A"}),
81
+ "t3": fraction(third, {"T"}),
82
+ "g3": fraction(third, {"G"}),
83
+ "c3": fraction(third, {"C"}),
84
+ }
85
+
86
+ def oligonucleotide_frequencies(self, sequence: str, size: int) -> dict[str, float]:
87
+ """Return overlapping frequencies for observed words of requested size."""
88
+
89
+ return self.oligonucleotide_frequencies_many([sequence], size)
90
+
91
+ def oligonucleotide_frequencies_many(self, sequences: Iterable[str], size: int) -> dict[str, float]:
92
+ """Return pooled word frequencies without crossing sequence boundaries."""
93
+
94
+ if size < 1:
95
+ raise ValueError("Oligonucleotide size must be positive.")
96
+ counts: Counter[str] = Counter()
97
+ for sequence in sequences:
98
+ clean = self._clean(sequence)
99
+ counts.update(clean[index : index + size] for index in range(max(0, len(clean) - size + 1)))
100
+ total = sum(counts.values())
101
+ return {word: count / total for word, count in sorted(counts.items())} if total else {}
102
+
103
+ def representation(self, sequence: str, dinucleotide: str) -> float:
104
+ """Calculate a dinucleotide observed/expected representation ratio."""
105
+
106
+ clean = self._clean(sequence)
107
+ pair = dinucleotide.upper().replace("U", "T")
108
+ if len(pair) != 2 or set(pair) - set("ACGT"):
109
+ raise ValueError("dinucleotide must contain exactly two A/C/G/T bases.")
110
+ if len(clean) < 2:
111
+ return 0.0
112
+ observed = sum(clean[index : index + 2] == pair for index in range(len(clean) - 1)) / (len(clean) - 1)
113
+ first = clean.count(pair[0]) / len(clean)
114
+ second = clean.count(pair[1]) / len(clean)
115
+ return observed / (first * second) if first and second else 0.0
116
+
117
+ def dinucleotide_odds(self, sequence: str) -> dict[str, float | None]:
118
+ """Return observed/expected ratios for all 16 DNA dinucleotides."""
119
+
120
+ clean = self._clean(sequence)
121
+ return self.dinucleotide_odds_many([clean])
122
+
123
+ def dinucleotide_odds_many(self, sequences: Iterable[str]) -> dict[str, float | None]:
124
+ """Return boundary-safe pooled O/E ratios across multiple CDSs.
125
+
126
+ Adjacent bases from different CDSs are never counted as a biological
127
+ dinucleotide. Mononucleotide and within-CDS pair counts are pooled
128
+ before observed and expected frequencies are calculated.
129
+ """
130
+
131
+ mono_counts: Counter[str] = Counter()
132
+ pair_counts: Counter[str] = Counter()
133
+ nucleotide_total = 0
134
+ pair_total = 0
135
+ for sequence in sequences:
136
+ clean = self._clean(sequence)
137
+ mono_counts.update(clean)
138
+ pairs = [clean[index : index + 2] for index in range(max(0, len(clean) - 1))]
139
+ pair_counts.update(pairs)
140
+ nucleotide_total += len(clean)
141
+ pair_total += len(pairs)
142
+ if nucleotide_total == 0 or pair_total == 0:
143
+ return {pair: None for pair in DINUCLEOTIDES}
144
+ result: dict[str, float | None] = {}
145
+ for pair in DINUCLEOTIDES:
146
+ observed = pair_counts[pair] / pair_total
147
+ expected = (mono_counts[pair[0]] / nucleotide_total) * (mono_counts[pair[1]] / nucleotide_total)
148
+ result[pair] = observed / expected if expected else None
149
+ return result
150
+
151
+ @staticmethod
152
+ def classify_dinucleotide_odds(
153
+ odds: dict[str, float | None],
154
+ *,
155
+ overrepresented: float = 1.25,
156
+ underrepresented: float = 0.78,
157
+ ) -> dict[str, str]:
158
+ """Classify O/E ratios using configurable literature thresholds."""
159
+
160
+ if underrepresented >= overrepresented:
161
+ raise ValueError("underrepresented threshold must be below overrepresented threshold.")
162
+ return {
163
+ pair: (
164
+ "undefined"
165
+ if value is None
166
+ else "overrepresented"
167
+ if value > overrepresented
168
+ else "underrepresented"
169
+ if value < underrepresented
170
+ else "neutral"
171
+ )
172
+ for pair, value in odds.items()
173
+ }
@@ -0,0 +1,60 @@
1
+ """
2
+ CodonAdaptPy Evolutionary Codon-Usage Diagnostics
3
+
4
+ This module supplies data for ENC-GC3, neutrality, and Parity Rule 2 analyses.
5
+ The methods return numeric summaries suitable for plotting and group analysis
6
+ without coupling the scientific calculations to a plotting backend.
7
+
8
+ Classes:
9
+ - EvolutionaryMetrics: Mutation/selection-oriented codon diagnostics.
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
+ from collections.abc import Sequence
20
+
21
+
22
+ class EvolutionaryMetrics:
23
+ """Calculate expected ENC and compositional evolutionary summaries."""
24
+
25
+ @staticmethod
26
+ def expected_enc(gc3: float) -> float:
27
+ """Return Wright's mutation-only expected ENC for a GC3 fraction."""
28
+
29
+ gc3 = max(0.0, min(1.0, gc3))
30
+ denominator = gc3**2 + (1 - gc3) ** 2
31
+ return 2 + gc3 + 29 / denominator
32
+
33
+ @staticmethod
34
+ def neutrality(gc1: Sequence[float], gc2: Sequence[float], gc3: Sequence[float]) -> dict[str, float]:
35
+ """Fit GC12 against GC3 and report slope, intercept, and R-squared."""
36
+
37
+ if not (len(gc1) == len(gc2) == len(gc3)) or not gc3:
38
+ raise ValueError("GC1, GC2, and GC3 must have equal non-zero lengths.")
39
+ y = [(a + b) / 2 for a, b in zip(gc1, gc2, strict=True)]
40
+ x_mean, y_mean = sum(gc3) / len(gc3), sum(y) / len(y)
41
+ denominator = sum((value - x_mean) ** 2 for value in gc3)
42
+ slope = (
43
+ sum((x - x_mean) * (target - y_mean) for x, target in zip(gc3, y, strict=True)) / denominator
44
+ if denominator
45
+ else 0.0
46
+ )
47
+ intercept = y_mean - slope * x_mean
48
+ fitted = [intercept + slope * x for x in gc3]
49
+ total = sum((target - y_mean) ** 2 for target in y)
50
+ residual = sum((target - estimate) ** 2 for target, estimate in zip(y, fitted, strict=True))
51
+ return {"slope": slope, "intercept": intercept, "r_squared": 1 - residual / total if total else 0.0}
52
+
53
+ @staticmethod
54
+ def parity_rule_2(a3: float, t3: float, g3: float, c3: float) -> dict[str, float]:
55
+ """Return PR2 coordinates A3/(A3+T3) and G3/(G3+C3)."""
56
+
57
+ return {
58
+ "at_bias": a3 / (a3 + t3) if a3 + t3 else 0.5,
59
+ "gc_bias": g3 / (g3 + c3) if g3 + c3 else 0.5,
60
+ }
@@ -0,0 +1,145 @@
1
+ """
2
+ CodonAdaptPy Multi-Host Comparison Metrics
3
+
4
+ This module compares sequence codon usage with one or more host reference
5
+ profiles using RSCU similarity, cosine similarity, Euclidean distance,
6
+ Jensen-Shannon divergence, rank/linear correlation, dinucleotide compatibility,
7
+ RCDI, CAI, and a transparent bounded composite score.
8
+
9
+ Classes:
10
+ - HostComparator: Compare query codons with named reference profiles.
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
+ import math
21
+ from collections.abc import Mapping, Sequence
22
+
23
+ from ..genetic_code import GeneticCode
24
+ from .adaptation import AdaptationMetrics
25
+ from .usage import CodonUsageMetrics
26
+
27
+
28
+ def _pearson(left: Sequence[float], right: Sequence[float]) -> float:
29
+ """Calculate Pearson correlation without requiring SciPy."""
30
+
31
+ if len(left) != len(right) or not left:
32
+ return 0.0
33
+ left_mean, right_mean = sum(left) / len(left), sum(right) / len(right)
34
+ numerator = sum((a - left_mean) * (b - right_mean) for a, b in zip(left, right, strict=True))
35
+ denominator = math.sqrt(sum((a - left_mean) ** 2 for a in left) * sum((b - right_mean) ** 2 for b in right))
36
+ return numerator / denominator if denominator else 0.0
37
+
38
+
39
+ def _ranks(values: Sequence[float]) -> list[float]:
40
+ """Assign average ranks, including tied values."""
41
+
42
+ ordered = sorted(range(len(values)), key=lambda index: values[index])
43
+ ranks = [0.0] * len(values)
44
+ start = 0
45
+ while start < len(ordered):
46
+ end = start + 1
47
+ while end < len(ordered) and values[ordered[end]] == values[ordered[start]]:
48
+ end += 1
49
+ rank = (start + end - 1) / 2 + 1
50
+ for index in ordered[start:end]:
51
+ ranks[index] = rank
52
+ start = end
53
+ return ranks
54
+
55
+
56
+ class HostComparator:
57
+ """Compare sequence usage against codon-count or frequency references."""
58
+
59
+ def __init__(self, genetic_code: GeneticCode | None = None) -> None:
60
+ """Initialize shared codon-usage and adaptation calculators."""
61
+
62
+ self.genetic_code = genetic_code or GeneticCode.from_ncbi(1)
63
+ self.usage = CodonUsageMetrics(self.genetic_code)
64
+ self.adaptation = AdaptationMetrics(self.genetic_code)
65
+
66
+ def compare(self, codons: list[str], reference: Mapping[str, float]) -> dict[str, float]:
67
+ """Calculate a complete host-comparison result for one reference."""
68
+
69
+ keys = sorted(codon for codon, aa in self.genetic_code.forward_table.items() if aa != "*")
70
+ query_frequency = self.usage.frequencies(codons)
71
+ reference_total = sum(max(0.0, float(reference.get(key, 0.0))) for key in keys)
72
+ ref_frequency = {
73
+ key: max(0.0, float(reference.get(key, 0.0))) / reference_total if reference_total else 0.0 for key in keys
74
+ }
75
+ query = [query_frequency.get(key, 0.0) for key in keys]
76
+ target = [ref_frequency[key] for key in keys]
77
+ dot = sum(a * b for a, b in zip(query, target, strict=True))
78
+ norm = math.sqrt(sum(a * a for a in query) * sum(b * b for b in target))
79
+ cosine = dot / norm if norm else 0.0
80
+ euclidean = math.sqrt(sum((a - b) ** 2 for a, b in zip(query, target, strict=True)))
81
+ midpoint = [(a + b) / 2 for a, b in zip(query, target, strict=True)]
82
+
83
+ def kl(values: Sequence[float], mean: Sequence[float]) -> float:
84
+ return sum(
85
+ value * math.log(value / average, 2)
86
+ for value, average in zip(values, mean, strict=True)
87
+ if value > 0 and average > 0
88
+ )
89
+
90
+ jsd = (kl(query, midpoint) + kl(target, midpoint)) / 2
91
+ weights = self.adaptation.weights_from_counts(reference)
92
+ query_rscu = self.usage.rscu(codons)
93
+ reference_rscu = self.usage.rscu_from_counts(reference)
94
+ rscu_distance = math.sqrt(sum((query_rscu.get(key, 0.0) - reference_rscu.get(key, 0.0)) ** 2 for key in keys))
95
+ sid_similarity, sid_distance = self.sid(query_rscu, reference_rscu)
96
+ pearson = _pearson(query, target)
97
+ spearman = _pearson(_ranks(query), _ranks(target))
98
+ cai = self.adaptation.cai(codons, weights)
99
+ rcdi = self.adaptation.rcdi(codons, ref_frequency)
100
+ composite = max(0.0, min(1.0, (cai + cosine + (pearson + 1) / 2 + (1 - min(1.0, jsd))) / 4))
101
+ return {
102
+ "cai": cai,
103
+ "rcdi": rcdi,
104
+ "rscu_distance": rscu_distance,
105
+ "sid_similarity": sid_similarity,
106
+ "sid_distance": sid_distance,
107
+ "cosine_similarity": cosine,
108
+ "euclidean_distance": euclidean,
109
+ "jensen_shannon_divergence": jsd,
110
+ "pearson_correlation": pearson,
111
+ "spearman_correlation": spearman,
112
+ "composite_adaptation_score": composite,
113
+ }
114
+
115
+ def sid(
116
+ self,
117
+ query_rscu: Mapping[str, float],
118
+ reference_rscu: Mapping[str, float],
119
+ ) -> tuple[float, float]:
120
+ """Calculate the exact 59-codon RSCU similarity and distance.
121
+
122
+ The calculation excludes stop codons and the single-codon methionine
123
+ and tryptophan families. It returns cosine similarity ``R`` followed
124
+ by the published distance ``D(A,B) = (1 - R) / 2``.
125
+ """
126
+
127
+ codons = sorted(
128
+ codon
129
+ for amino_acid, family in self.genetic_code.synonymous_codons.items()
130
+ if amino_acid != "*" and len(family) > 1
131
+ for codon in family
132
+ )
133
+ query = [float(query_rscu.get(codon, 0.0)) for codon in codons]
134
+ target = [float(reference_rscu.get(codon, 0.0)) for codon in codons]
135
+ numerator = sum(left * right for left, right in zip(query, target, strict=True))
136
+ denominator = math.sqrt(sum(value * value for value in query) * sum(value * value for value in target))
137
+ similarity = numerator / denominator if denominator else 0.0
138
+ return similarity, (1 - similarity) / 2
139
+
140
+ def compare_many(
141
+ self, codons: list[str], references: Mapping[str, Mapping[str, float]]
142
+ ) -> dict[str, dict[str, float]]:
143
+ """Compare one query sequence with every named host profile."""
144
+
145
+ return {name: self.compare(codons, reference) for name, reference in references.items()}