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,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