File size: 12,637 Bytes
1bf37a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
"""Why a construct expresses badly β€” the causes that are visible in the DNA.

WHAT IS HONEST HERE, AND WHAT IS NOT
------------------------------------
#24 is rated High because a design that does not express is a wasted month,
and nothing in the product currently looks at it. But an expression LEVEL β€”
"expect 40 mg/L" β€” is not something this engine can honestly produce. Yield
depends on the host strain, the promoter, plasmid copy number, induction
temperature, media, growth phase, and whether the protein is toxic to the
cell. None of that is in a sequence, and no model trained on it is available
here. So there is no predicted yield in this module and there is no score.

What IS in the sequence is a specific, enumerable set of causes that are known
to break translation, and those are computed exactly:

* **Rare codons**, from the engine's real usage tables in `codon.py` β€” the same
  tables the library encoder uses, so a codon this module calls rare and a
  codon the DE pipeline installs are the same opinion, not two.
* **Rare-codon RUNS**, which matter far more than the overall count. A
  scattered rare codon is absorbed; three in a row is where a ribosome stalls.
* **The 5' ramp**, because the first ~50 codons carry the initiation region and
  a rare codon there costs more than the same codon at residue 400.
* **Reading-frame integrity**, because a construct with an internal stop
  expresses a truncated product and every other number would describe a
  protein that is never made.

THE RARE-CODON THRESHOLD IS DERIVED, NOT CHOSEN
------------------------------------------------
Relative adaptiveness w = f(codon) / f(the most-used synonym), which is the
Sharp & Li definition. At w < 0.15 the E. coli table selects AGA, AGG, ATA and
CTA β€” precisely the set that Rosetta / CodonPlus strains exist to supply
(argU, ileY, leuW). The cutoff was checked against the tables rather than
picked, and the codons it selects are reported so the call can be audited.

The same cutoff selects three arginine codons in yeast and NOTHING in human β€”
which is a real result, not a failure. Mammalian codon usage is much flatter
and the rare-codon story is genuinely weaker there. Reporting that is more
useful than manufacturing a warning to look thorough.

HOSTS THIS HAS NO TABLE FOR
----------------------------
`codon.py` carries E. coli, S. cerevisiae and human. For anything else this
refuses and says so. That is not a capability gate on the organism β€” it is a
missing data file, and silently scoring a plant construct against the E. coli
table would be far worse than declining. Audit #11 (Kazusa tables) is the fix.
"""
from __future__ import annotations

import re
from typing import Any, Dict, List, Optional

from dee.core import codon as _codon
from dee.core import edits as _edits

# Relative adaptiveness below which a codon is called rare. Derived: see module
# docstring. The selected codons travel with every result so this is auditable.
RARE_BELOW = 0.15
# Consecutive rare codons at or above this length is a stall risk rather than
# noise. A lone rare codon is absorbed by the tRNA pool; a run depletes it.
RUN_LENGTH = 3
# The initiation region, where a rare codon costs disproportionately.
RAMP_CODONS = 50
# Below this there is not enough coding sequence for the statistics to mean
# anything.
MIN_CODONS = 30
STOPS = {"TAA", "TAG", "TGA"}


def _clean_dna(dna: str) -> str:
    return re.sub(r"[^ACGTUacgtu]", "", dna or "").upper().replace("U", "T")


def _adaptiveness(host: str) -> Dict[str, Any]:
    """codon -> (amino acid, relative adaptiveness), from the engine's tables."""
    table = _codon._resolve_table(host)
    w: Dict[str, float] = {}
    aa_of: Dict[str, str] = {}
    for aa, usage in table.items():
        top = max(usage.values()) or 1.0
        for cod, freq in usage.items():
            w[cod] = freq / top
            aa_of[cod] = aa
    return {"w": w, "aa": aa_of}


def _runs(flags: List[bool], codons: List[str]) -> List[Dict[str, Any]]:
    out: List[Dict[str, Any]] = []
    start = None
    for i, bad in enumerate(flags + [False]):
        if bad and start is None:
            start = i
        elif not bad and start is not None:
            if i - start >= RUN_LENGTH:
                out.append({"start_codon": start + 1, "end_codon": i,
                            "length": i - start,
                            "codons": codons[start:i]})
            start = None
    return out


def assess(dna: str, host: str) -> Dict[str, Any]:
    """Sequence-visible reasons a CDS may express poorly in `host`."""
    seq = _clean_dna(dna)
    if not host or not str(host).strip():
        return {"ok": False, "error": "Which host? Expression is host-specific "
                                      "β€” the same CDS behaves differently in "
                                      "E. coli and in human cells."}
    try:
        adapt = _adaptiveness(str(host))
    except ValueError as exc:
        return {
            "ok": False, "kind": "no_codon_table",
            "error": str(exc),
            "next": ("This is a missing data file, not an unsupported "
                     "organism. Scoring the construct against a different "
                     "host's table would produce confident nonsense, so it "
                     "declines instead. Everything else about the sequence "
                     "(check_synthesis, GC, reading frame) still works."),
        }

    if len(seq) < MIN_CODONS * 3:
        return {"ok": False, "kind": "too_short",
                "error": f"Need at least {MIN_CODONS} codons; got "
                         f"{len(seq) // 3}.",
                "next": "Paste the full coding sequence."}

    frame_ok = len(seq) % 3 == 0
    codons = [seq[i:i + 3] for i in range(0, len(seq) - len(seq) % 3, 3)]
    # The terminal stop is expected; internal ones are the finding.
    terminal_stop = bool(codons) and codons[-1] in STOPS
    body = codons[:-1] if terminal_stop else codons
    internal_stops = [i + 1 for i, c in enumerate(body) if c in STOPS]

    w_map, aa_map = adapt["w"], adapt["aa"]
    sense = [c for c in body if c not in STOPS]
    unknown = [c for c in sense if c not in w_map]
    scored = [c for c in sense if c in w_map]
    # CAI excludes the single-codon families (Met, Trp): with one option there
    # is no choice to adapt, and including them only pulls every score toward 1.
    cai_pool = [w_map[c] for c in scored if aa_map[c] not in ("M", "W")]
    cai = None
    if cai_pool:
        # Geometric mean, in log space β€” the direct product underflows on a
        # long CDS and silently returns 0.0.
        import math
        cai = round(math.exp(sum(math.log(max(x, 1e-6)) for x in cai_pool)
                             / len(cai_pool)), 3)

    rare_flags = [c in w_map and w_map[c] < RARE_BELOW for c in body]
    rare = [{"codon_number": i + 1, "codon": c, "amino_acid": aa_map.get(c),
             "adaptiveness": round(w_map[c], 3)}
            for i, c in enumerate(body)
            if c in w_map and w_map[c] < RARE_BELOW]
    runs = _runs(rare_flags, body)
    ramp = [r for r in rare if r["codon_number"] <= RAMP_CODONS]

    gc = round(100.0 * sum(c in "GC" for c in seq) / len(seq), 1)
    ramp_seq = seq[:RAMP_CODONS * 3]
    gc_ramp = round(100.0 * sum(c in "GC" for c in ramp_seq) / len(ramp_seq), 1)

    rare_set = sorted({c for c, x in w_map.items() if x < RARE_BELOW
                       and c not in STOPS})

    flags: List[Dict[str, str]] = []
    if not frame_ok:
        flags.append({"factor": "not a whole number of codons",
                      "observed": f"{len(seq)} nt",
                      "why": ("The sequence is not in frame as given. Every "
                              "codon-level number below is computed on the "
                              "frame starting at base 1, which may not be the "
                              "intended one.")})
    if internal_stops:
        flags.append({"factor": "internal stop codon",
                      "observed": f"codon {internal_stops[0]}"
                                  + (f" (+{len(internal_stops) - 1} more)"
                                     if len(internal_stops) > 1 else ""),
                      "why": ("Translation terminates here. The product is a "
                              "truncated fragment, so nothing downstream "
                              "describes the intended protein.")})
    if not terminal_stop:
        flags.append({"factor": "no terminal stop codon",
                      "observed": f"ends {codons[-1] if codons else '?'}",
                      "why": ("Without a stop the ribosome reads into the "
                              "vector, adding an unintended C-terminal tail.")})
    for run in runs:
        flags.append({"factor": f"run of {run['length']} consecutive rare codons",
                      "observed": (f"codons {run['start_codon']}-"
                                   f"{run['end_codon']}: "
                                   + " ".join(run["codons"])),
                      "why": ("Consecutive rare codons deplete the local "
                              "charged-tRNA pool and stall the ribosome, which "
                              "causes truncation and frameshifting. This is "
                              "the single most actionable finding here β€” "
                              "recoding just this run is usually enough.")})
    if ramp:
        flags.append({"factor": f"{len(ramp)} rare codon(s) in the first "
                                f"{RAMP_CODONS} codons",
                      "observed": ", ".join(f"{r['codon']}@{r['codon_number']}"
                                            for r in ramp[:6]),
                      "why": ("The 5' region sets the rate of translation "
                              "initiation, so a rare codon here costs more "
                              "than the same codon further in.")})

    return {
        "ok": True,
        "host": host,
        "codons": len(body),
        "codon_adaptation_index": cai,
        "gc_percent": gc,
        "gc_percent_5prime": gc_ramp,
        "rare_codons": rare,
        "rare_codon_count": len(rare),
        "rare_codon_runs": runs,
        "rare_in_ramp": ramp,
        "internal_stops": internal_stops,
        "has_terminal_stop": terminal_stop,
        "in_frame": frame_ok,
        "is_clean_cds": _edits.is_coding(seq),
        "unrecognised_codons": len(unknown),
        "flags": flags,
        "summary": _summary(host, len(body), cai, rare, runs, flags),
        "rare_codons_in_this_host": rare_set,
        "method": (f"Relative adaptiveness w = f(codon) / f(most-used synonym) "
                   f"(Sharp & Li), from the engine's own {host} usage table β€” "
                   f"the same table the library encoder writes with. Rare means "
                   f"w < {RARE_BELOW}, which in this host selects: "
                   f"{' '.join(rare_set) if rare_set else 'nothing'}. CAI is "
                   f"the geometric mean of w over sense codons, excluding Met "
                   f"and Trp, which have no synonym to choose between."),
        # The line this module exists to be able to say.
        "no_yield": ("This does NOT predict an expression level. Yield depends "
                     "on strain, promoter, copy number, induction temperature, "
                     "media and protein toxicity β€” none of which is in a "
                     "sequence, and no model trained on them is available "
                     "here. These are the sequence-visible causes of failure, "
                     "not a forecast."),
        "not_covered": ("Also not checked here: internal Shine-Dalgarno-like "
                        "pausing sites, mRNA secondary structure over the RBS "
                        "(audit #38/#39, which need an RNA folding engine), "
                        "and protein-level causes β€” for those run "
                        "assess_solubility on the translated product."),
    }


def _summary(host: str, n: int, cai: Optional[float], rare: List[Dict[str, Any]],
             runs: List[Dict[str, Any]], flags: List[Dict[str, str]]) -> str:
    bits = [f"{n} codons in {host}"]
    if cai is not None:
        bits.append(f"CAI {cai}")
    bits.append(f"{len(rare)} rare codon(s)")
    if runs:
        bits.append(f"{len(runs)} rare run(s) β€” codons "
                    + ", ".join(f"{r['start_codon']}-{r['end_codon']}"
                                for r in runs[:3]))
    if not flags:
        bits.append("nothing flagged")
    return " Β· ".join(bits)