Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

# A new push makes the previous run pointless.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
python:
name: Python ${{ matrix.python }}
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
python: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
- run: pip install -e ".[dev]"
- run: pytest -q

splice:
name: Python 3.12 with MaxEntScan
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
# The default splice backend prefers MaxEntScan when it is installed, so
# without this job that path is never exercised in CI.
- run: pip install -e ".[dev]" "git+https://github.com/kepbod/maxentpy.git"
- name: Confirm MaxEntScan really is in use
run: python -c "from codonopt.splice import maxent_available; assert maxent_available()"
- run: pytest -q

web:
name: Interface (type-check, build, end-to-end)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
cache-dependency-path: web/package-lock.json
- run: pip install -e .
- run: npm ci
working-directory: web
- name: Type-check and verify the committed build is current
run: npm run check
working-directory: web
- run: npx playwright install --with-deps chromium
working-directory: web
- name: End-to-end tests against the Python server
run: npm test
working-directory: web
env:
CI: "true"
# Traces and screenshots, so a failure here can be read rather than guessed at.
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: |
web/playwright-report
web/test-results
retention-days: 7
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ dist/
.pytest_cache/
.venv/
venv/

# web interface tooling
web/node_modules/
web/test-results/
web/playwright-report/
107 changes: 87 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,52 @@
# computational_codon_optimization
<h1 align="center">codonopt</h1>

<p align="center">
<strong>Codon optimization for gene synthesis and expression.</strong><br>
Rewrite the DNA of an open reading frame so it is easier to make and express,<br>
without changing the protein it encodes.
</p>

<p align="center">
<a href="https://github.com/thomdehoog/computational_codon_optimization/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/thomdehoog/computational_codon_optimization/actions/workflows/ci.yml/badge.svg"></a>
<img alt="Python 3.10+" src="https://img.shields.io/badge/python-3.10%2B-3776ab?logo=python&logoColor=white">
<img alt="Runtime dependencies: none" src="https://img.shields.io/badge/runtime%20dependencies-none-2ea44f">
<img alt="Interface: TypeScript" src="https://img.shields.io/badge/interface-TypeScript-3178c6?logo=typescript&logoColor=white">
<img alt="Tests: pytest and Playwright" src="https://img.shields.io/badge/tests-pytest%20%2B%20Playwright-0a9edc?logo=pytest&logoColor=white">
<a href="LICENSE"><img alt="License: GPL v3" src="https://img.shields.io/badge/license-GPLv3-blue"></a>
</p>

<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/interface-dark.png">
<img alt="The codonopt interface after optimizing a 120-residue protein: 16 issues down to 0, with the changed codons highlighted" src="docs/interface-light.png" width="920">
</picture>
</p>

Give it a protein, or an existing coding sequence, and it returns a synonymous
sequence that balances GC content, repeats, hairpins, homopolymers, forbidden
motifs, cryptic splice sites and host codon usage in a single score. It is a
pure-Python package with no runtime dependencies, a command line, a Python API
and a local web interface.

Rewrites the DNA of an open reading frame so it is easier to synthesise and
express, without changing the protein it encodes.
## Highlights

Give it a protein (or an existing coding sequence) and it returns a synonymous
sequence that balances GC content, repeats, hairpins, homopolymers, forbidden
motifs, cryptic splice sites and host codon usage in a single score.
- **One objective, not four passes.** Every concern is a weighted penalty in one score, so fixing one problem cannot silently undo another.
- **Fast.** Scoring is incremental: a codon swap re-scores only what sits near it. An 800-residue protein takes 19 s, against 234 s for the scripts this replaces.
- **The protein cannot change.** The only move is swapping a codon for a synonymous one, so correctness holds by construction, not by checking afterwards.
- **Three ways in.** `codonopt` on the command line, a small Python API, and a browser or desktop interface.
- **Checked against brute force.** The tests compare the incremental score with a from-scratch recomputation and with simple reference implementations.

## Quickstart

```bash
pip install -e .

codonopt gui # the web interface (opens in your browser)
```

Or from the command line:

```bash
codonopt optimize --add-stop --avoid golden_gate,EcoRI --seed 5 --splice-backend motif \
--protein MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEENFKALVLIAFAQYLQQCPF
```
Expand All @@ -38,6 +73,21 @@ Splice acceptor sites 1 0

`--json FILE` writes the same numbers, the per-term score and the sequence.

## Web interface

`codonopt gui` starts a local server and opens the interface in a native window
if [pywebview](https://pywebview.flowrl.com/) is installed (`pip install -e ".[gui]"`),
otherwise in your browser. Paste or open a protein or DNA sequence, pick a host
table and what to keep out, and press **Optimize**. You get:

- the sequence with every changed codon highlighted, and the protein above it;
- a before/after table, a GC plot along the sequence and the score breakdown;
- copy and download for the DNA, FASTA and a report; runs can be cancelled and keep the best result so far.

It is local only: the server binds to loopback, checks Host and Origin, requires
a per-run token, sends a strict content security policy and never touches your
files. Add `--no-window --no-browser` to just serve it.

## What it optimizes

| Criterion | Why | Default |
Expand All @@ -51,10 +101,7 @@ Splice acceptor sites 1 0
| Cryptic splice sites | Unwanted splicing of a transgene | MaxEntScan or a consensus screen |
| Codon usage | Rare codons slow translation | host codon adaptation index |

The protein cannot change: the only move the optimizer has is swapping a codon
for a synonymous one.

## Commands
## Command line

```bash
codonopt optimize --protein-fasta gene.faa --out gene.fna # design from a protein
Expand All @@ -77,7 +124,7 @@ codonopt motifs # known motifs and g
`codonopt optimize --help` lists the rest. MaxEntScan is optional and not on
PyPI: `pip install "git+https://github.com/kepbod/maxentpy.git"`.

## Python
## Python API

```python
from codonopt import DesignConfig, OptimizerSettings, get_table, optimize, resolve_motifs
Expand All @@ -92,8 +139,8 @@ improves an existing sequence.

## How it works

Each penalty term is tied to a position — a GC window, a homopolymer, a motif
match, a splice window, a codon — so a codon swap only re-scores what sits near
Each penalty term is tied to a position (a GC window, a homopolymer, a motif
match, a splice window, a codon), so a codon swap only re-scores what sits near
it. Repeats and hairpins are not local, so an incrementally updated k-mer index
carries them. A candidate swap costs time proportional to the window sizes, not
the sequence length.
Expand All @@ -104,6 +151,29 @@ followed by simulated annealing and several restarts.
incremental path against it and against brute-force implementations in
`codonopt.metrics`.

## Development

The Python package needs nothing but Python. The interface is written in
TypeScript in [`web/`](web/) and compiled with esbuild to plain HTML, CSS and
JavaScript in `src/codonopt/web/`. That output is committed, so running the
interface never needs Node; Node is only for working on it.

```bash
pip install -e ".[dev]" && pytest # Python side

cd web && npm install
npm run build # regenerate src/codonopt/web
npm run check # type-check, and fail if the committed build is stale
npm test # Playwright end-to-end tests against the real Python server
```

```
src/codonopt/ optimizer, scoring, CLI and the local server (stdlib only)
web/ TypeScript and CSS sources, build script, Playwright tests
benchmarks/ reproducible performance comparison
legacy/ the two original scripts this replaces, kept for reference
```

## History

This replaces two work-in-progress scripts, kept in [`legacy/`](legacy/). They
Expand All @@ -114,17 +184,14 @@ takes 19 s instead of 234 s and ends with a better codon adaptation index
(0.90 vs 0.72) and no forbidden motifs (vs four). Details and a reproducible
benchmark are in [`legacy/README.md`](legacy/README.md).

## Development

```bash
pip install -e ".[dev]"
pytest
```

## Limitations

- Hairpins are inverted repeats, not free-energy folds.
- The bundled `human` and `ecoli` tables are approximate; measure your own host
for production work. The consensus splice screen is a filter, not a model.
- The search is local. A given `--seed` is reproducible, but another seed may do
slightly better; use `--restarts` or `--anneal` when it counts.

## License

[GPL-3.0](LICENSE) © Thom de Hoog
Binary file added docs/interface-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/interface-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ dependencies = []
# MaxEntScan splice site scoring. Not on PyPI, so it is installed from source:
# pip install "git+https://github.com/kepbod/maxentpy.git"
splice = ["maxentpy"]
# Optional native window for `codonopt gui`; without it the browser is used.
gui = ["pywebview"]
dev = ["pytest>=7"]

[project.scripts]
Expand All @@ -30,6 +32,11 @@ codonopt = "codonopt.cli:main"
[tool.setuptools.packages.find]
where = ["src"]

# The pre-built web interface ships inside the package, so using it needs no
# Node toolchain.
[tool.setuptools.package-data]
codonopt = ["web/*"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
7 changes: 7 additions & 0 deletions src/codonopt/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Allow ``python -m codonopt``."""

import sys

from .cli import main

sys.exit(main())
35 changes: 35 additions & 0 deletions src/codonopt/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

codonopt optimize --protein-fasta gene.faa --table human --out gene.fna
codonopt report --dna-fasta gene.fna
codonopt gui
codonopt tables
codonopt motifs
"""
Expand Down Expand Up @@ -71,6 +72,29 @@ def build_parser() -> argparse.ArgumentParser:
)
report_parser.set_defaults(handler=_run_report)

gui_parser = subparsers.add_parser(
"gui",
help="open the graphical interface (a native window with pywebview, "
"otherwise your browser)",
)
gui_parser.add_argument(
"--port", type=int, default=0, metavar="N", help="port to listen on (default: any free one)"
)
gui_parser.add_argument(
"--no-window",
action="store_true",
help="serve the interface without opening a native window",
)
gui_parser.add_argument(
"--no-browser",
action="store_true",
help="do not open the default browser (use with --no-window)",
)
gui_parser.add_argument(
"--verbose", action="store_true", help="log each request to stderr"
)
gui_parser.set_defaults(handler=_run_gui)

tables_parser = subparsers.add_parser("tables", help="list codon usage tables")
tables_parser.set_defaults(handler=_run_tables)

Expand Down Expand Up @@ -388,6 +412,17 @@ def _run_report(args: argparse.Namespace) -> int:
return 0


def _run_gui(args: argparse.Namespace) -> int:
from .server import run_gui

return run_gui(
args.port,
window=not args.no_window,
open_browser=not args.no_browser,
verbose=args.verbose,
)


def _run_tables(args: argparse.Namespace) -> int:
for name in available_tables():
table = get_table(name)
Expand Down
4 changes: 4 additions & 0 deletions src/codonopt/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ class OptimizerSettings:
codons by host frequency, ``"best"`` takes the most frequent codon
everywhere, ``"random"`` samples uniformly.
progress: Called with a short status line after each phase.
should_stop: Called regularly during the search; once it returns True
the search stops cleanly and returns the best sequence found so
far, which is how a caller cancels a long run.
"""

restarts: int = 1
Expand All @@ -160,6 +163,7 @@ class OptimizerSettings:
seed: int | None = None
seed_mode: str = "weighted"
progress: object | None = None
should_stop: object | None = None

def __post_init__(self) -> None:
if self.restarts < 1:
Expand Down
19 changes: 19 additions & 0 deletions src/codonopt/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"HomopolymerRun",
"RepeatReport",
"Hairpin",
"gc_profile",
"gc_window_report",
"homopolymer_runs",
"homopolymer_excess",
Expand Down Expand Up @@ -133,6 +134,24 @@ def gc_window_report(
)


def gc_profile(
sequence: str, *, window: int = 30, step: int = 3
) -> list[tuple[int, float]]:
"""Return ``(start, GC fraction)`` for every scored window.

The same windows :func:`gc_window_report` scores, so a plot of this profile
shows exactly what the penalty sees.
"""
if not sequence:
return []
prefix = gc_prefix(sequence)
profile = []
for start in window_starts(len(sequence), window, step):
end = min(start + window, len(sequence))
profile.append((start, (prefix[end] - prefix[start]) / (end - start)))
return profile


def _check_fraction_range(target_range: tuple[float, float]) -> tuple[float, float]:
low, high = target_range
if not (0.0 <= low <= high <= 1.0):
Expand Down
Loading
Loading