diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4629c2..6b42630 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,8 +39,8 @@ jobs: - 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. + # Explicit/automatic human splice screening uses MaxEntScan when it is + # installed; exercise that optional backend as well as dependency-free runs. - 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()" diff --git a/README.md b/README.md index c79142d..6e27b86 100644 --- a/README.md +++ b/README.md @@ -23,15 +23,17 @@
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 +sequence that resolves mandatory requirements before balancing GC content, +repeats, hairpins, homopolymers, candidate splice signals and host codon usage. +Avoided motifs are mandatory by default; other criteria are soft preferences +unless explicitly required. It is a pure-Python package with no runtime dependencies, a command line, a Python API and a local web interface. ## Highlights -- **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. +- **Requirements before preferences.** Mandatory violations take precedence over the weighted score. Every result is independently checked and marked valid or unresolved; an unresolved result is not proof that no solution exists. +- **Incremental scoring.** A codon swap re-scores nearby terms and updates repeat/hairpin indexes. Each edit still copies the DNA string; search cost is not strictly independent of gene length. - **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. @@ -54,24 +56,26 @@ codonopt optimize --add-stop --avoid golden_gate,EcoRI --seed 5 --splice-backend The sequence goes to stdout as FASTA (or `--out FILE`), the summary to stderr: ``` ->protein codonopt table=human score=1.17 +>protein codonopt table=human score=1.08 valid=true ATGAAATGGGTGACATTCATCAGCTTACTGCTGCTGTTCTCTTCCGCTTACTCCCGGGGC -GTGTTTAGACGGGACACTCATAAATCTGAGATCGCTCACAGGTTTAAGGACCTGGGCGAG -GAGAACTTCAAGGCCTTGGTTTTAATCGCCTTCGCACAGTATCTGCAGCAGTGCCCTTTC +GTGTTTAGACGGGACACACACAAATCTGAGATCGCTCACAGGTTTAAGGACCTGGGCGAG +GAGAACTTCAAGGCCTTGGTTTTAATCGCCTTTGCACAGTATCTGCAGCAGTGCCCTTTC TGA -Objective 54.44 -> 1.17 (improved by 53.28) +Requirements valid +Objective 53.57 -> 1.08 (improved by 52.49) Property before after ------------------------------------------------ GC fraction 0.481 0.503 -Codon adaptation index 0.774 0.837 +Codon adaptation index 0.774 0.842 Forbidden motif hits 1 0 -Splice donor sites 2 0 +Splice donor sites 1 0 Splice acceptor sites 1 0 ``` `--json FILE` writes the same numbers, the per-term score and the sequence. +Reports also include validity and the effective settings. ## Web interface @@ -84,6 +88,10 @@ table and what to keep out, and press **Optimize**. You get: - 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. +If mandatory requirements remain, the result is labelled **Unresolved candidate**. +Downloading it does not certify it as a valid design. Splice screening is off +by default; select it explicitly when relevant to the expression host. + 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. @@ -92,13 +100,13 @@ files. Add `--no-window --no-browser` to just serve it. | Criterion | Why | Default | | --- | --- | --- | -| Overall GC | Extreme GC complicates synthesis and PCR | target 0.50 | +| Overall GC | Extreme GC complicates synthesis and PCR | target 0.50; length-scaled soft cost | | Local GC | Stretches matter, not just the average | 30 bp windows within 0.30–0.70 | | Direct repeats | Mis-assembly in synthesis, recombination in vivo | no repeated 12-mers | | Hairpins | Stall synthesis and reverse transcription | no 10 bp inverted repeat, 3–60 nt loop | | Homopolymers | Slippage in synthesis and sequencing | at most 6 identical bases | -| Forbidden motifs | Cloning sites, poly(A) signals, anything you name | none | -| Cryptic splice sites | Unwanted splicing of a transgene | MaxEntScan or a consensus screen | +| Forbidden motifs | Cloning sites, poly(A) signals, anything you name | none supplied; mandatory when supplied | +| Candidate splice signals | Screen for potential unwanted splicing | off; opt into MaxEntScan or a consensus screen | | Codon usage | Rare codons slow translation | host codon adaptation index | ## Command line @@ -116,10 +124,23 @@ codonopt motifs # known motifs and g | `--avoid SPEC` | Exclude motifs: a group (`golden_gate`, `common_cloning`, `regulatory`), an enzyme (`EcoRI`), an IUPAC pattern (`GGWCC`) or `NAME=PATTERN`. Both strands are checked. | | `--table NAME` / `--table-file F` | Host codon usage: `human`, `ecoli`, `uniform`, or your own JSON/CSV. | | `--weight NAME=VALUE` | Re-balance a term: `gc_global=200` holds the GC target harder, `usage=0` ignores codon preference. | -| `--splice-backend` | `auto` (MaxEntScan if installed, else a consensus screen), `maxent`, `motif`, `none`. | +| `--splice-backend` | Default `none`. `auto` screens only the bundled human host, using MaxEntScan if installed, otherwise a consensus screen. `maxent` and `motif` are explicit overrides for other hosts; neither is automatically validated for them. | +| `--splice-strand` | Default `forward`, the expressed transcript. Select `reverse` or `both` only when appropriate. Restriction motifs still use both DNA strands. | +| `--require NAME` | Make `gc_window`, `repeat`, `hairpin`, `homopolymer` or `splice` mandatory. Repeat the option for several criteria. Required splice screening needs an explicit backend. | +| `--soft-motifs` | Make motif avoidance a preference instead of a requirement. | +| `--left-flank SEQ` / `--right-flank SEQ` | Score immutable construct context, including features across CDS junctions. Output FASTA remains the editable CDS. | +| `--protect-codon INDEX` | Preserve a zero-based codon of supplied starting DNA, including across restarts. Repeat for several positions. | +| `--exclude-codon CODON` | Forbid a codon explicitly; a zero frequency in a usage table is not an exclusion. | | `--seed N` | Reproducible runs. | | `--restarts N`, `--sweeps N`, `--anneal N` | More search effort. | | `--seconds N` | Wall-clock cap (default 120). | +| `--repair-candidates N` / `--repair-max-codons N` | Bound coordinated repair per restart (defaults 2000 complete candidates and up to 3 codons; maximum 4). | + +The CLI returns **0** when configured requirements are met, **2** when they +remain unresolved, and **1** for invalid input. It still emits the best +candidate and report on exit 2, including in quiet mode. A valid result can +have a higher soft score than its input because validity takes precedence. +Cancellation and time limits are recorded separately from validity. `codonopt optimize --help` lists the rest. MaxEntScan is optional and not on PyPI: `pip install "git+https://github.com/kepbod/maxentpy.git"`. @@ -127,16 +148,43 @@ PyPI: `pip install "git+https://github.com/kepbod/maxentpy.git"`. ## Python API ```python -from codonopt import DesignConfig, OptimizerSettings, get_table, optimize, resolve_motifs +from codonopt import DesignConfig, OptimizerSettings, get_table, optimize, optimize_dna, resolve_motifs config = DesignConfig(table=get_table("human"), motifs=tuple(resolve_motifs(["golden_gate"]))) result = optimize("MKWVTFISLLLLFSSAYS", config, OptimizerSettings(seed=1)) result.sequence, result.score, result.breakdown.terms +result.valid, result.unresolved_constraints, result.stop_reason ``` `measure(sequence, config)` returns what `report` prints; `optimize_dna()` improves an existing sequence. +For an existing construct with fixed context and mandatory local GC: + +```python +config = DesignConfig( + motifs=tuple(resolve_motifs(["golden_gate"])), + required=("motif", "gc_window"), + gc_step=1, # every base-offset window, when the synthesis policy needs it + left_flank="ACGT", right_flank="TGCA", + protected_codons=(0,), +) +result = optimize_dna("ATGGGTCTCAAA", config, OptimizerSettings(seed=5)) +``` + +All context-dependent criteria scan the **complete supplied construct**, +including fixed-only regions. A violation confined to a fixed flank remains +unresolved. Codon usage and overall GC target apply to the CDS. Diagnostic +positions and GC profiles use construct coordinates; `cds_start` records the +offset. Flanks need not have lengths divisible by three. + +JSON reports preserve original input/first seed, winning restart seed, full +effective configuration, actual codon-table data and hash, generated or supplied +seed, search settings, software/backend versions, validity and stop reason. +Custom scorer implementations and uninstalled backend sources additionally +need their source revision recorded externally. A seed and deterministic +proposal budgets replay a run; wall-clock truncation can differ by machine. + ## How it works Each penalty term is tied to a position (a GC window, a homopolymer, a motif @@ -145,12 +193,21 @@ 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. -The search sweeps every codon greedily until nothing improves, optionally -followed by simulated annealing and several restarts. +The search compares candidates by `(mandatory violation count, soft score)`. +It sweeps codons greedily, uses bounded coordinated repair around unresolved +regions, and optionally follows with simulated annealing and several restarts. +Annealing cannot worsen mandatory violation count. Intermediate edits in a +multi-codon repair are temporary; the complete candidate decides acceptance. `SequenceState.audit()` recomputes a score from scratch; the tests check the incremental path against it and against brute-force implementations in `codonopt.metrics`. +Overall GC cost is `weight * abs(GC bases - target * CDS bases) / 300`. +This retains the old scale at 100 codons while making each GC-base change +carry the same tradeoff at different gene lengths. Local GC, repeats and the +other occurrence-based costs remain sums. The default target is a design +preference, not a universal expression optimum. + ## Development The Python package needs nothing but Python. The interface is written in @@ -191,6 +248,16 @@ benchmark are in [`legacy/README.md`](legacy/README.md). 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. +- Bounded coordinated repair can still leave feasible requirements unresolved. + The tool does not claim mathematical infeasibility from a search failure. +- A valid result meets the **configured checks**, not an unprovided synthesis + vendor policy or a guarantee of improved expression. CAI and exact inverted + repeats are proxies, not validated predictions of expression or RNA folding. +- The time limit covers search checks. Initial scoring and final independent + validation/reporting must still complete and can add time beyond that budget. +- Version 0.3 changes motif validity, splice defaults/strand policy and GC cost + scaling. Previous seeds can produce different designs. Before/after DNA + reports now always use the original DNA, even when a later restart wins. ## License diff --git a/benchmarks/compare_legacy.py b/benchmarks/compare_legacy.py index 59c30e7..1a73ee9 100644 --- a/benchmarks/compare_legacy.py +++ b/benchmarks/compare_legacy.py @@ -66,6 +66,8 @@ def main() -> int: help="skip the end-to-end comparison, which is the slow part", ) args = parser.parse_args() + # The legacy functions use the module-level generator, not the local RNG. + random.seed(args.seed) splice_legacy = load_legacy(LEGACY_SPLICE) hairpin_legacy = load_legacy(LEGACY_HAIRPIN) diff --git a/pyproject.toml b/pyproject.toml index d4d409a..63a2421 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codonopt" -version = "0.2.0" +version = "0.3.0" description = "Codon-optimize an open reading frame for synthesis success and expression" readme = "README.md" requires-python = ">=3.10" diff --git a/src/codonopt/__init__.py b/src/codonopt/__init__.py index 801a6a4..41e582e 100644 --- a/src/codonopt/__init__.py +++ b/src/codonopt/__init__.py @@ -2,9 +2,8 @@ A coding sequence can be written in many ways without changing the protein it encodes, and some of those ways are much easier to synthesise and express than -others. This package searches that space under one weighted objective covering -GC content, direct repeats, hairpins, homopolymer runs, forbidden motifs, -cryptic splice sites and host codon preference. +others. This package resolves configured mandatory requirements before +improving soft sequence-quality and host-codon-preference objectives. Typical use:: @@ -23,9 +22,10 @@ from __future__ import annotations -__version__ = "0.2.0" +__version__ = "0.3.0" from .config import DesignConfig, OptimizerSettings, Weights +from .constraints import constraint_violations from .index import KmerIndex from .metrics import ( count_hairpin_pairs, @@ -65,6 +65,7 @@ "SequenceState", "Weights", "available_tables", + "constraint_violations", "count_hairpin_pairs", "find_hairpins", "find_splice_sites", diff --git a/src/codonopt/cli.py b/src/codonopt/cli.py index 5519d0b..9152721 100644 --- a/src/codonopt/cli.py +++ b/src/codonopt/cli.py @@ -195,9 +195,9 @@ def _add_constraint_arguments( ) group.add_argument( "--splice-backend", - default="auto", + default="none", choices=("auto", "maxent", "motif", "none"), - help="cryptic splice site scoring (default: auto)", + help="candidate splice signal screening (default: none; auto is human-only)", ) group.add_argument( "--donor-cutoff", @@ -211,6 +211,17 @@ def _add_constraint_arguments( metavar="F", help="highest acceptable acceptor score (default: the backend's)", ) + group.add_argument("--splice-strand", choices=("forward", "reverse", "both"), default="forward") + group.add_argument("--require", action="append", default=[], + choices=("motif", "gc_window", "repeat", "hairpin", "homopolymer", "splice"), + help="make a criterion mandatory; repeat to require several") + group.add_argument("--soft-motifs", action="store_true", help="treat avoided motifs as preferences instead of requirements") + group.add_argument("--left-flank", default="", help="immutable upstream DNA used when scoring the construct") + group.add_argument("--right-flank", default="", help="immutable downstream DNA used when scoring the construct") + group.add_argument("--protect-codon", type=int, action="append", default=[], metavar="INDEX", + help="protect a zero-based codon index in starting DNA; repeat as needed") + group.add_argument("--exclude-codon", action="append", default=[], metavar="CODON", + help="forbid a codon independently of its frequency") if weights: group.add_argument( "--weight", @@ -250,6 +261,8 @@ def _add_search_arguments(parser: argparse.ArgumentParser) -> None: group.add_argument( "--seed-mode", default="weighted", choices=("weighted", "best", "random") ) + group.add_argument("--repair-candidates", type=int, default=2000) + group.add_argument("--repair-max-codons", type=int, default=3, choices=(2, 3, 4)) class _UsageError(Exception): @@ -261,7 +274,7 @@ def _build_config(args: argparse.Namespace) -> DesignConfig: load_table(args.table_file) if args.table_file else get_table(args.table) ) try: - scorer = get_scorer(args.splice_backend) + scorer = get_scorer(args.splice_backend, host=table.name) except RuntimeError as exc: raise _UsageError(str(exc)) from None @@ -286,6 +299,12 @@ def _build_config(args: argparse.Namespace) -> DesignConfig: donor_cutoff=args.donor_cutoff, acceptor_cutoff=args.acceptor_cutoff, weights=_build_weights(getattr(args, "weight", [])), + required=tuple(args.require) + (() if args.soft_motifs else ("motif",)), + splice_strand=args.splice_strand, + left_flank=args.left_flank, + right_flank=args.right_flank, + protected_codons=tuple(args.protect_codon), + excluded_codons=tuple(args.exclude_codon), ) return config @@ -352,7 +371,11 @@ def _run_optimize(args: argparse.Namespace) -> int: protein = validate_protein(raw) if args.add_stop and not protein.endswith("*"): protein += "*" - start = None # the starting sequence no longer matches the protein + if start is not None: + options = [c for c in config.table.codons("*") if c not in config.excluded_codons] + if not options: + raise _UsageError("all stop codons are excluded") + start += max(options, key=config.table.frequency) log = (lambda message: None) if args.quiet else _stderr settings = OptimizerSettings( @@ -361,10 +384,12 @@ def _run_optimize(args: argparse.Namespace) -> int: anneal_steps=args.anneal, start_temperature=args.temp_start, end_temperature=args.temp_end, - max_seconds=None if args.seconds <= 0 else args.seconds, + max_seconds=None if args.seconds == 0 else args.seconds, seed=args.seed, seed_mode=args.seed_mode, progress=log, + repair_candidates=args.repair_candidates, + repair_max_codons=args.repair_max_codons, ) if config.splice_scorer is not None and not args.quiet: log(f"splice backend: {config.splice_scorer.name}") @@ -374,7 +399,7 @@ def _run_optimize(args: argparse.Namespace) -> int: record = Record( name=args.name or name, description=(args.name or name) - + f" codonopt table={config.table.name} score={result.score:.2f}", + + f" codonopt table={config.table.name} score={result.score:.2f} valid={str(result.valid).lower()}", sequence=result.sequence, ) text = format_fasta(record) @@ -385,12 +410,15 @@ def _run_optimize(args: argparse.Namespace) -> int: sys.stdout.write(text) if args.json: Path(args.json).write_text( - json.dumps(report_dict(result, config), indent=2) + "\n" + json.dumps(report_dict(result, config), indent=2, allow_nan=False) + "\n" ) log(f"wrote {args.json}") if not args.quiet: log("") log(format_report(result, config)) + if not result.valid: + _stderr("codonopt: unresolved mandatory constraints; output is a candidate, not a valid design") + return 2 return 0 diff --git a/src/codonopt/config.py b/src/codonopt/config.py index 4bca2e8..eed0fdf 100644 --- a/src/codonopt/config.py +++ b/src/codonopt/config.py @@ -3,8 +3,10 @@ from __future__ import annotations from dataclasses import dataclass, field, replace +import math -from .motifs import Motif +from .motifs import Motif, canonicalize_motifs +from .sequence import CODON_TO_AA, validate_dna from .splice import SpliceScorer from .tables import CodonUsage, HUMAN @@ -16,12 +18,12 @@ class Weights: """Relative importance of each penalty term. All terms are penalties: lower is better, and zero switches a term off. - The defaults were chosen so that a single forbidden motif outweighs any - amount of ordinary GC or codon-usage drift, while repeats and hairpins - trade off against each other roughly one for one. + Mandatory constraints are configured separately and take precedence over + this score. Finite weights alone cannot guarantee a requirement. Attributes: - gc_global: Multiplies ``|GC fraction - target|``. + gc_global: Multiplies absolute CDS GC base-count deviation divided by + 300 (the previous fraction-based scale for a 100-codon CDS). gc_window: Multiplies the summed shortfall/excess of each scored window relative to the acceptable GC range, as a fraction. repeat: Multiplies the number of redundant k-mer occurrences. @@ -47,8 +49,8 @@ class Weights: def __post_init__(self) -> None: for name, value in vars(self).items(): - if value < 0: - raise ValueError(f"weight {name} must not be negative: {value}") + if not math.isfinite(value) or value < 0: + raise ValueError(f"weight {name} must be finite and non-negative: {value}") @dataclass(frozen=True) @@ -90,6 +92,13 @@ class DesignConfig: donor_cutoff: float | None = None acceptor_cutoff: float | None = None weights: Weights = field(default_factory=Weights) + # Names of mandatory criteria. Other criteria remain soft preferences. + required: tuple[str, ...] = ("motif",) + splice_strand: str = "forward" + left_flank: str = "" + right_flank: str = "" + protected_codons: tuple[int, ...] = () + excluded_codons: tuple[str, ...] = () def __post_init__(self) -> None: if not 0.0 <= self.gc_target <= 1.0: @@ -103,10 +112,43 @@ def __post_init__(self) -> None: "with low <= high (use 0.3 rather than 30)" ) for name in ("gc_window", "gc_step", "repeat_k", "hairpin_stem", "max_homopolymer"): - if getattr(self, name) < 1: - raise ValueError(f"{name} must be at least 1") + value = getattr(self, name) + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ValueError(f"{name} must be a positive integer") + for name in ("hairpin_min_loop", "hairpin_max_loop"): + if isinstance(getattr(self, name), bool) or not isinstance(getattr(self, name), int): + raise ValueError(f"{name} must be an integer") if self.hairpin_min_loop < 0 or self.hairpin_max_loop < self.hairpin_min_loop: raise ValueError("require 0 <= hairpin_min_loop <= hairpin_max_loop") + for name in ("donor_cutoff", "acceptor_cutoff"): + value = getattr(self, name) + if value is not None and not math.isfinite(value): + raise ValueError(f"{name} must be finite") + known = {"motif", "gc_window", "repeat", "hairpin", "homopolymer", "splice"} + if set(self.required) - known: + raise ValueError(f"unknown required criteria: {sorted(set(self.required) - known)}") + object.__setattr__(self, "required", tuple(sorted(set(self.required)))) + if "splice" in self.required and self.splice_scorer is None: + raise ValueError("required splice screening needs an explicit splice scorer") + if not isinstance(self.splice_strand, str) or self.splice_strand not in {"forward", "reverse", "both"}: + raise ValueError("splice_strand must be forward, reverse or both") + for name in ("left_flank", "right_flank"): + value = getattr(self, name) + if not isinstance(value, str): + raise ValueError(f"{name} must be a DNA string") + object.__setattr__(self, name, validate_dna(value) if value else "") + if any(isinstance(i, bool) or not isinstance(i, int) or i < 0 for i in self.protected_codons): + raise ValueError("protected_codons must contain non-negative integer indices") + object.__setattr__(self, "protected_codons", tuple(sorted(set(self.protected_codons)))) + excluded = tuple(sorted(set(c.strip().upper().replace("U", "T") for c in self.excluded_codons))) + if any(c not in CODON_TO_AA for c in excluded): + raise ValueError("excluded_codons must contain standard genetic-code codons") + object.__setattr__(self, "excluded_codons", excluded) + object.__setattr__(self, "motifs", canonicalize_motifs(self.motifs)) + + @property + def splice_strands(self) -> tuple[str, ...]: + return ("forward", "reverse") if self.splice_strand == "both" else (self.splice_strand,) @property def effective_donor_cutoff(self) -> float: @@ -152,6 +194,9 @@ class OptimizerSettings: 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. + repair_candidates: Maximum complete coordinated candidates per restart. + Zero disables repair; unresolved requirements are still reported. + repair_max_codons: Maximum codons per coordinated repair, 2 through 4. """ restarts: int = 1 @@ -164,8 +209,14 @@ class OptimizerSettings: seed_mode: str = "weighted" progress: object | None = None should_stop: object | None = None + repair_candidates: int = 2000 + repair_max_codons: int = 3 def __post_init__(self) -> None: + for name in ("restarts", "max_sweeps", "anneal_steps", "repair_candidates", "repair_max_codons"): + value = getattr(self, name) + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{name} must be an integer") if self.restarts < 1: raise ValueError("restarts must be at least 1") if self.max_sweeps < 0 or self.anneal_steps < 0: @@ -175,7 +226,11 @@ def __post_init__(self) -> None: f"unknown seed_mode {self.seed_mode!r}; " "expected weighted, best or random" ) - if self.start_temperature <= 0 or self.end_temperature <= 0: - raise ValueError("temperatures must be positive") - if self.max_seconds is not None and self.max_seconds <= 0: - raise ValueError("max_seconds must be positive when set") + if self.repair_candidates < 0 or not 2 <= self.repair_max_codons <= 4: + raise ValueError("repair_candidates must be non-negative and repair_max_codons between 2 and 4") + if any(not math.isfinite(t) or t <= 0 for t in (self.start_temperature, self.end_temperature)): + raise ValueError("temperatures must be finite and positive") + if self.max_seconds is not None and (not math.isfinite(self.max_seconds) or self.max_seconds <= 0): + raise ValueError("max_seconds must be finite and positive when set") + if self.seed is not None and (isinstance(self.seed, bool) or not isinstance(self.seed, int)): + raise ValueError("seed must be an integer") diff --git a/src/codonopt/constraints.py b/src/codonopt/constraints.py new file mode 100644 index 0000000..d14429e --- /dev/null +++ b/src/codonopt/constraints.py @@ -0,0 +1,87 @@ +"""Independent validation of required criteria, in construct coordinates.""" + +from __future__ import annotations + +from itertools import islice + +from .config import DesignConfig +from .metrics import gc_window_report, homopolymer_runs, find_hairpins +from .sequence import reverse_complement, split_codons, validate_dna +from .splice import find_splice_sites + + +def constraint_violations(sequence: str, config: DesignConfig) -> list[dict]: + """Validate a CDS and return unresolved criteria with bounded region examples. + + Counts are complete. Region examples are capped at 64 per criterion; absence + of a violation here means the configured requirement was checked in full. + Fixed flanks are included, so a failure confined to them remains unresolved. + """ + sequence = validate_dna(sequence, in_frame=True) + context = config.left_flank + sequence + config.right_flank + required = set(config.required) + result: list[dict] = [] + + def add(name: str, count: int, regions) -> None: + if count: + examples = list(islice(regions, 64)) + result.append({"criterion": name, "count": count, "regions": examples}) + + if "motif" in required: + regions = [ + {"start": p, "end": p + m.length, "name": m.name} + for m in config.motifs for p in m.find_all(context) + ] + add("motif", len(regions), regions) + if "gc_window" in required: + gc = gc_window_report(context, window=config.gc_window, step=config.gc_step, + target_range=config.gc_range) + add("gc_window", gc.violations, + ({"start": p, "end": min(p + config.gc_window, len(context))} + for p in gc.worst_positions)) + if "homopolymer" in required: + runs = homopolymer_runs(context, config.max_homopolymer + 1) + add("homopolymer", sum(r.length - config.max_homopolymer for r in runs), + ({"start": r.start, "end": r.start + r.length} for r in runs)) + if "repeat" in required: + seen: dict[str, int] = {} + count = 0 + regions = [] + for p in range(len(context) - config.repeat_k + 1): + kmer = context[p:p + config.repeat_k] + if kmer in seen: + count += 1 + if len(regions) < 64: + regions.extend(({"start": seen[kmer], "end": seen[kmer] + config.repeat_k}, + {"start": p, "end": p + config.repeat_k})) + else: + seen[kmer] = p + add("repeat", count, regions) + if "hairpin" in required: + # Count the exact k-mer pairs used by the objective, independently. + k = config.hairpin_stem + count = 0 + for p in range(len(context) - k + 1): + partner = reverse_complement(context[p:p + k]) + for q in range(p + k + config.hairpin_min_loop, + min(p + k + config.hairpin_max_loop + 1, len(context) - k + 1)): + count += context[q:q + k] == partner + examples = find_hairpins(context, stem=k, min_loop=config.hairpin_min_loop, + max_loop=config.hairpin_max_loop, limit=32) + add("hairpin", count, + ({"start": h.stem_start, "end": h.partner_start + h.stem_length} + for h in examples)) + if "splice" in required: + sites = find_splice_sites(context, config.splice_scorer, + donor_cutoff=config.effective_donor_cutoff, + acceptor_cutoff=config.effective_acceptor_cutoff, + strand=config.splice_strand) + add("splice", len(sites), + ({"start": s.position, "end": s.position + (9 if s.site_type == "donor" else 23), + "type": s.site_type, "strand": s.strand} for s in sites)) + if config.excluded_codons: + start = len(config.left_flank) + regions = [{"start": start + 3*i, "end": start + 3*i + 3, "codon": c} + for i, c in enumerate(split_codons(sequence)) if c in config.excluded_codons] + add("excluded_codons", len(regions), regions) + return result diff --git a/src/codonopt/metrics.py b/src/codonopt/metrics.py index e9682cb..f788003 100644 --- a/src/codonopt/metrics.py +++ b/src/codonopt/metrics.py @@ -10,6 +10,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from bisect import bisect_left, bisect_right from .motifs import Motif from .sequence import gc_count, gc_prefix, reverse_complement @@ -322,7 +323,7 @@ def count_hairpin_pairs( continue low = i + stem + min_loop high = i + stem + max_loop - pairs += sum(1 for j in candidates if low <= j <= high) + pairs += bisect_right(candidates, high) - bisect_left(candidates, low) return pairs @@ -343,6 +344,8 @@ def find_hairpins( length for a fixed loop range. """ _check_hairpin_params(stem, min_loop, max_loop) + if limit < 1: + raise ValueError("limit must be positive") index: dict[str, list[int]] = {} for i in range(len(sequence) - stem + 1): index.setdefault(sequence[i : i + stem], []).append(i) @@ -351,7 +354,10 @@ def find_hairpins( hairpins: list[Hairpin] = [] for i in range(len(sequence) - stem + 1): partner = reverse_complement(sequence[i : i + stem]) - for j in index.get(partner, ()): # j is the 3' arm start + candidates = index.get(partner, ()) + first = bisect_left(candidates, i + stem + min_loop) + last = bisect_right(candidates, i + stem + max_loop) + for j in candidates[first:last]: # j is the 3' arm start loop = j - (i + stem) if not (min_loop <= loop <= max_loop): continue @@ -384,9 +390,9 @@ def find_hairpins( stem=sequence[left : left + length], ) ) - if len(hairpins) >= limit: + if len(hairpins) > limit: hairpins.sort(key=lambda h: (-h.stem_length, h.stem_start)) - return hairpins + del hairpins[limit:] hairpins.sort(key=lambda h: (-h.stem_length, h.stem_start)) return hairpins diff --git a/src/codonopt/motifs.py b/src/codonopt/motifs.py index 4930672..024a4eb 100644 --- a/src/codonopt/motifs.py +++ b/src/codonopt/motifs.py @@ -134,6 +134,19 @@ def __repr__(self) -> str: # pragma: no cover - debugging aid return f"Motif({self.name!r}, {self.pattern!r})" +def canonicalize_motifs(motifs) -> tuple[Motif, ...]: + """Deduplicate both-strand patterns and reject ambiguous report names.""" + patterns: dict[str, Motif] = {} + names: dict[str, str] = {} + for motif in motifs: + key = min(motif.pattern, _revcomp_iupac(motif.pattern)) + if motif.name in names and names[motif.name] != key: + raise ValueError(f"motif name {motif.name!r} refers to different patterns") + names[motif.name] = key + patterns.setdefault(key, motif) + return tuple(patterns.values()) + + _IUPAC_COMPLEMENT = str.maketrans( "ACGTRYSWKMBDHVN", "TGCAYRSWMKVHDBN" ) @@ -177,6 +190,8 @@ def resolve_motifs(specs: list[str] | tuple[str, ...] | None) -> list[Motif]: def add(name: str, pattern: str) -> None: motif = Motif(name, pattern) key = min(motif.pattern, _revcomp_iupac(motif.pattern)) + if any(m.name == name and k != key for k, m in resolved.items()): + raise ValueError(f"motif name {name!r} refers to different patterns") resolved.setdefault(key, motif) for raw in specs or (): diff --git a/src/codonopt/optimize.py b/src/codonopt/optimize.py index c484cc7..1599fed 100644 --- a/src/codonopt/optimize.py +++ b/src/codonopt/optimize.py @@ -7,10 +7,11 @@ overall result. This module replaces the four passes with one weighted objective (:mod:`codonopt.state`) and a local search over synonymous codons. -Two phases run per restart: +Three phases run per restart: * greedy sweeps, which visit every codon in random order and keep the best synonymous replacement, repeating until a whole sweep changes nothing; +* bounded coordinated repair around unresolved mandatory requirements; * optional simulated annealing, which accepts occasional uphill moves so the search can leave a local minimum. @@ -23,10 +24,12 @@ import math import random import time -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields +from itertools import product from typing import Callable from .config import DesignConfig, OptimizerSettings +from .constraints import constraint_violations from .sequence import ( AA_TO_CODONS, translate, @@ -50,9 +53,8 @@ class OptimizationResult: protein: The protein it encodes, which equals the input protein. score: Final objective value. breakdown: Final per-term objective values. - initial_sequence: The starting sequence of the restart that produced - this result, before local search ran on it. With several restarts - this is the winning restart's seed, not the best seed. + initial_sequence: Original DNA input, or the first seed for a protein + design. This baseline is preserved even if a later restart wins. initial_score: Its objective value. initial_breakdown: Its per-term objective values. sweeps: Greedy sweeps run, summed over restarts. @@ -79,6 +81,13 @@ class OptimizationResult: hit_time_limit: bool = False cancelled: bool = False notes: list[str] = field(default_factory=list) + valid: bool = True + unresolved_constraints: list[dict] = field(default_factory=list) + stop_reason: str = "completed" + effective_seed: int | None = None + search_settings: dict = field(default_factory=dict) + winning_seed_sequence: str = "" + repair_evaluated: int = 0 @property def improvement(self) -> float: @@ -91,6 +100,8 @@ def seed_sequence( table: CodonUsage, rng: random.Random, mode: str = "weighted", + *, + excluded_codons: tuple[str, ...] = (), ) -> str: """Build a starting coding sequence for ``protein``. @@ -105,6 +116,23 @@ def seed_sequence( Raises: ValueError: On an unknown ``mode``. """ + if excluded_codons: + sequence = [] + for aa in protein: + options = [c for c in AA_TO_CODONS[aa] if c not in excluded_codons] + if not options: + raise ValueError(f"all codons for {aa} are excluded") + if mode == "weighted": + frequencies = [table.frequency(c) for c in options] + codon = rng.choices(options, weights=frequencies if sum(frequencies) else None, k=1)[0] + elif mode == "best": + codon = max(options, key=table.frequency) + elif mode == "random": + codon = rng.choice(options) + else: + raise ValueError(f"unknown seed mode {mode!r}") + sequence.append(codon) + return "".join(sequence) if mode == "weighted": return "".join(table.sample(aa, rng) for aa in protein) if mode == "best": @@ -145,34 +173,67 @@ def optimize( if translate(start) != protein: raise ValueError("start sequence does not encode the given protein") - rng = random.Random(settings.seed) + if config.protected_codons and start is None: + raise ValueError("protected_codons require a starting DNA sequence") + if any(i >= len(protein) for i in config.protected_codons): + raise ValueError("protected codon index is outside the coding sequence") + for i, aa in enumerate(protein): + if i not in config.protected_codons and not set(AA_TO_CODONS[aa]) - set(config.excluded_codons): + raise ValueError(f"all codons for {aa} at position {i} are excluded") + + effective_seed = settings.seed if settings.seed is not None else random.SystemRandom().getrandbits(31) + rng = random.Random(effective_seed) started = time.perf_counter() deadline = ( started + settings.max_seconds if settings.max_seconds is not None else None ) should_stop: Callable[[], bool] | None = settings.should_stop # type: ignore[assignment] + stop_reason = "completed" def expired() -> bool: """True once the time budget is spent or the caller asked to stop.""" + nonlocal stop_reason + if stop_reason != "completed": + return True if should_stop is not None and should_stop(): + stop_reason = "cancelled" return True - return deadline is not None and time.perf_counter() >= deadline + if deadline is not None and time.perf_counter() >= deadline: + stop_reason = "time_limit" + return True + return False best: SequenceState | None = None - best_initial: tuple[str, float] | None = None + winning_seed = "" + baseline: SequenceState | None = None sweeps = evaluated = accepted = restarts_run = 0 hit_limit = False + repair_evaluated = 0 for restart in range(settings.restarts): if best is not None and expired(): hit_limit = True break - seed = ( - start - if restart == 0 and start is not None - else seed_sequence(protein, config.table, rng, settings.seed_mode) - ) + if restart == 0 and start is not None: + seed = start + elif config.protected_codons: + seed = "".join( + start[3*i:3*i+3] if i in config.protected_codons else + seed_sequence(aa, config.table, rng, settings.seed_mode, + excluded_codons=config.excluded_codons) + for i, aa in enumerate(protein) + ) + else: + seed = seed_sequence(protein, config.table, rng, settings.seed_mode, + excluded_codons=config.excluded_codons) + if config.protected_codons: + codons = [seed[i:i+3] for i in range(0, len(seed), 3)] + for i in config.protected_codons: + codons[i] = start[3*i:3*i+3] + seed = "".join(codons) state = SequenceState(seed, config) + if baseline is None: + baseline = SequenceState(seed, config) initial = (state.sequence, state.score) restarts_run += 1 report( @@ -188,6 +249,20 @@ def expired() -> bool: accepted += local_accepted hit_limit = hit_limit or stopped + if state.violations and not stopped and settings.repair_candidates: + count, kept, stopped = _repair(state, settings, expired) + repair_evaluated += count + evaluated += count + accepted += kept + hit_limit = hit_limit or stopped + report(f"repair: {count} candidates, {state.violations} mandatory violations remain") + if kept and not stopped: + extra_sweeps, extra_evaluated, extra_accepted, stopped = _greedy(state, rng, settings, expired) + sweeps += extra_sweeps + evaluated += extra_evaluated + accepted += extra_accepted + hit_limit = hit_limit or stopped + if settings.anneal_steps and not stopped: annealed, anneal_evaluated, anneal_accepted, stopped = _anneal( state, rng, settings, expired @@ -202,24 +277,31 @@ def expired() -> bool: f"restart {restart + 1}/{settings.restarts}: " f"final score {state.score:.2f}" ) - if best is None or state.score < best.score: + if best is None or _better(state.rank, best.rank): best = state - best_initial = initial + winning_seed = seed - assert best is not None and best_initial is not None # restarts >= 1 + assert best is not None and baseline is not None # restarts >= 1 if translate(best.sequence) != protein: # pragma: no cover - safety net raise AssertionError("optimizer changed the encoded protein") - cancelled = bool(hit_limit and should_stop is not None and should_stop()) - initial_state = SequenceState(best_initial[0], config) + # Validate independently rather than treating the incremental counter as + # proof of feasibility. Never label heuristic failure as impossibility. + unresolved = constraint_violations(best.sequence, config) + if bool(unresolved) != bool(best.violations): + raise AssertionError("incremental constraint count disagrees with independent validation") + cancelled = stop_reason == "cancelled" + search_settings = {f.name: getattr(settings, f.name) for f in fields(settings) + if f.name not in {"progress", "should_stop"}} + search_settings["seed"] = effective_seed return OptimizationResult( sequence=best.sequence, protein=protein, score=best.score, breakdown=best.breakdown(), - initial_sequence=best_initial[0], - initial_score=best_initial[1], - initial_breakdown=initial_state.breakdown(), + initial_sequence=baseline.sequence, + initial_score=baseline.score, + initial_breakdown=baseline.breakdown(), sweeps=sweeps, moves_evaluated=evaluated, moves_accepted=accepted, @@ -227,6 +309,14 @@ def expired() -> bool: elapsed=time.perf_counter() - started, hit_time_limit=hit_limit and not cancelled, cancelled=cancelled, + valid=not unresolved, + unresolved_constraints=unresolved, + stop_reason=stop_reason, + effective_seed=effective_seed, + search_settings=search_settings, + winning_seed_sequence=winning_seed, + repair_evaluated=repair_evaluated, + notes=(["Mandatory constraints remain unresolved; this is not proof of impossibility."] if unresolved else []), ) @@ -249,6 +339,8 @@ def _greedy( expired: Callable[[], bool], ) -> tuple[int, int, int, bool]: """Run greedy sweeps until nothing improves. Returns counters.""" + if expired(): + return 0, 0, 0, True order = list(range(state.codon_count)) sweeps = evaluated = accepted = 0 for _ in range(settings.max_sweeps): @@ -257,20 +349,20 @@ def _greedy( improved = 0 for counter, position in enumerate(order): if not counter % 64 and expired(): - return sweeps, evaluated, accepted, True + return sweeps, evaluated, accepted + improved, True options = state.alternatives(position) - if len(options) == 1: + if options == (state.codon(position),): continue current = state.codon(position) - best_codon, best_score = current, state.score + best_codon, best_rank = current, state.rank for option in options: if option == current: continue state.set_codon(position, option) evaluated += 1 - score = state.score - if score < best_score - _EPSILON: - best_codon, best_score = option, score + rank = state.rank + if _better(rank, best_rank): + best_codon, best_rank = option, rank state.set_codon(position, best_codon) if best_codon != current: improved += 1 @@ -280,6 +372,65 @@ def _greedy( return sweeps, evaluated, accepted, False +def _better(candidate: tuple[int, float], incumbent: tuple[int, float]) -> bool: + return candidate[0] < incumbent[0] or (candidate[0] == incumbent[0] and candidate[1] < incumbent[1] - _EPSILON) + + +def _repair(state: SequenceState, settings: OptimizerSettings, + expired: Callable[[], bool]) -> tuple[int, int, bool]: + """Bounded coordinated repair around unresolved regions. + + Intermediate edits may violate constraints; only the complete candidate + is compared with the incumbent. The search budget is per restart and is + shared across passes and regions. + """ + evaluated = accepted = 0 + while state.violations and evaluated < settings.repair_candidates: + changed = False + failures = constraint_violations(state.sequence, state.config) + for failure in failures: + for region in failure["regions"]: + offset = len(state.config.left_flank) + lo = max(0, (region["start"] - offset) // 3) + hi = min(state.codon_count, (region["end"] - offset + 2) // 3) + if lo >= hi: # wholly fixed context cannot be edited + continue + mutable = [p for p in range(max(0, lo - 1), min(state.codon_count, hi + 1)) + if len(state.alternatives(p)) > 1] + # Long failures are addressed with overlapping local blocks; + # do not enumerate combinations across an entire long run. + for width in range(2, settings.repair_max_codons + 1): + for block_start in range(max(1, len(mutable) - width + 1)): + positions = mutable[block_start:block_start + width] + if len(positions) != width: + continue + original = tuple(state.codon(p) for p in positions) + best_codons, best_rank = original, state.rank + options = [state.alternatives(p) for p in positions] + for candidate in product(*options): + if candidate == original: + continue + if evaluated >= settings.repair_candidates or expired(): + for p, c in zip(positions, best_codons): + state.set_codon(p, c) + return evaluated, accepted + int(best_codons != original), expired() + for p, c in zip(positions, candidate): + state.set_codon(p, c) + evaluated += 1 + if _better(state.rank, best_rank): + best_codons, best_rank = candidate, state.rank + for p, c in zip(positions, best_codons): + state.set_codon(p, c) + if best_codons != original: + accepted += 1 + changed = True + if state.violations == 0: + return evaluated, accepted, False + if not changed: + break + return evaluated, accepted, False + + def _anneal( state: SequenceState, rng: random.Random, @@ -300,15 +451,15 @@ def _anneal( if not positions or steps <= 0: return None, 0, 0, False - best_sequence, best_score = state.sequence, state.score - cooling = (settings.end_temperature / settings.start_temperature) ** ( - 1.0 / max(1, steps - 1) - ) - temperature = settings.start_temperature + best_sequence, best_rank = state.sequence, state.rank evaluated = accepted = 0 stopped = False for step in range(steps): + # Schedule is tied to iterations, including draws of the current codon. + fraction = step / max(1, steps - 1) + temperature = math.exp((1-fraction)*math.log(settings.start_temperature) + + fraction*math.log(settings.end_temperature)) if not step % 256 and expired(): stopped = True break @@ -318,17 +469,19 @@ def _anneal( option = rng.choice(options) if option == current: continue - previous_score = state.score + previous_rank = state.rank state.set_codon(position, option) evaluated += 1 - delta = state.score - previous_score - if delta <= 0 or rng.random() < math.exp(-delta / temperature): + delta = state.score - previous_rank[1] + if state.violations < previous_rank[0] or ( + state.violations == previous_rank[0] and + (delta <= 0 or rng.random() < math.exp(-delta / temperature)) + ): accepted += 1 - if state.score < best_score - _EPSILON: - best_sequence, best_score = state.sequence, state.score + if _better(state.rank, best_rank): + best_sequence, best_rank = state.sequence, state.rank else: state.set_codon(position, current) - temperature *= cooling if state.sequence != best_sequence: state = SequenceState(best_sequence, state.config) diff --git a/src/codonopt/report.py b/src/codonopt/report.py index 68e64ef..073b6d9 100644 --- a/src/codonopt/report.py +++ b/src/codonopt/report.py @@ -2,6 +2,12 @@ from __future__ import annotations +from dataclasses import asdict, fields +import hashlib +import json +import platform +from importlib.metadata import version, PackageNotFoundError + from .config import DesignConfig from .metrics import ( count_hairpin_pairs, @@ -13,7 +19,7 @@ repeat_report, ) from .optimize import OptimizationResult -from .sequence import gc_fraction +from .sequence import gc_fraction, validate_dna from .splice import find_splice_sites __all__ = ["measure", "report_dict", "format_report"] @@ -25,26 +31,30 @@ def measure(sequence: str, config: DesignConfig) -> dict: Returns a JSON-serialisable dictionary; the same keys appear for the starting and the optimized sequence so the two can be compared directly. """ + sequence = validate_dna(sequence, in_frame=True) + context = config.left_flank + sequence + config.right_flank gc = gc_window_report( - sequence, + context, window=config.gc_window, step=config.gc_step, target_range=config.gc_range, ) - repeats = repeat_report(sequence, config.repeat_k) + repeats = repeat_report(context, config.repeat_k) hairpins = find_hairpins( - sequence, + context, stem=config.hairpin_stem, min_loop=config.hairpin_min_loop, max_loop=config.hairpin_max_loop, limit=10, ) - all_runs = homopolymer_runs(sequence, 1) + all_runs = homopolymer_runs(context, 1) runs = [run for run in all_runs if run.length > config.max_homopolymer] - hits = motif_hits(sequence, list(config.motifs)) + hits = motif_hits(context, list(config.motifs)) summary = { "length": len(sequence), + "context_length": len(context), + "cds_start": len(config.left_flank), "gc_fraction": round(gc_fraction(sequence), 4), "gc_windows_scored": gc.windows, "gc_windows_below_range": gc.low, @@ -57,7 +67,7 @@ def measure(sequence: str, config: DesignConfig) -> dict: "distinct_repeated_kmers": repeats.distinct_repeated, "repeat_covered_fraction": round(repeats.covered_fraction, 4), "hairpin_pairs": count_hairpin_pairs( - sequence, + context, stem=config.hairpin_stem, min_loop=config.hairpin_min_loop, max_loop=config.hairpin_max_loop, @@ -65,7 +75,7 @@ def measure(sequence: str, config: DesignConfig) -> dict: "longest_hairpin_stem": hairpins[0].stem_length if hairpins else 0, "homopolymer_runs_over_limit": len(runs), "homopolymer_excess_bases": homopolymer_excess( - sequence, config.max_homopolymer + context, config.max_homopolymer ), "longest_homopolymer": max((run.length for run in all_runs), default=0), "forbidden_motif_hits": sum(len(positions) for positions in hits.values()), @@ -74,12 +84,14 @@ def measure(sequence: str, config: DesignConfig) -> dict: if config.splice_scorer is not None: sites = find_splice_sites( - sequence, + context, config.splice_scorer, donor_cutoff=config.effective_donor_cutoff, acceptor_cutoff=config.effective_acceptor_cutoff, + strand=config.splice_strand, ) summary["splice_backend"] = config.splice_scorer.name + summary["splice_strand"] = config.splice_strand summary["splice_donor_sites"] = sum( 1 for site in sites if site.site_type == "donor" ) @@ -94,8 +106,45 @@ def measure(sequence: str, config: DesignConfig) -> dict: def report_dict(result: OptimizationResult, config: DesignConfig) -> dict: """Return a complete, JSON-serialisable record of a run.""" + from . import __version__ + + configuration = {f.name: getattr(config, f.name) for f in fields(config) + if f.name not in {"table", "motifs", "splice_scorer", "weights"}} + configuration["weights"] = asdict(config.weights) + configuration["motifs"] = [{"name": m.name, "pattern": m.pattern} for m in config.motifs] + table = {"name": config.table.name, "description": config.table.description, + "frequencies": dict(config.table.frequencies), "weights": dict(config.table.weights)} + table["sha256"] = hashlib.sha256(json.dumps(table, sort_keys=True, allow_nan=False).encode()).hexdigest() + configuration["table"] = table + scorer = config.splice_scorer + try: + backend_version = ( + version("maxentpy") if scorer and scorer.name == "maxent" + else __version__ if scorer and type(scorer).__module__ == "codonopt.splice" + else None + ) + except PackageNotFoundError: + backend_version = "uninstalled source; record source revision separately" + configuration["splice_scorer"] = { + "name": scorer.name if scorer else "none", + "class": f"{type(scorer).__module__}.{type(scorer).__qualname__}" if scorer else None, + "version": backend_version, + "donor_cutoff": config.effective_donor_cutoff, + "acceptor_cutoff": config.effective_acceptor_cutoff, + } return { + "schema_version": 1, + "software": {"codonopt": __version__, "python": platform.python_version()}, + "configuration": configuration, + "valid": result.valid, + "unresolved_constraints": result.unresolved_constraints, + "stop_reason": result.stop_reason, + "cancelled": result.cancelled, + "notes": result.notes, + "initial_sequence": result.initial_sequence, + "winning_seed_sequence": result.winning_seed_sequence, "protein_length": len(result.protein), + "protein": result.protein, "codon_usage_table": config.table.name, "score": { "initial": round(result.initial_score, 4), @@ -110,16 +159,21 @@ def report_dict(result: OptimizationResult, config: DesignConfig) -> dict: }, }, "search": { + "settings": result.search_settings, + "seed": result.effective_seed, "restarts_run": result.restarts_run, "greedy_sweeps": result.sweeps, "moves_evaluated": result.moves_evaluated, "moves_accepted": result.moves_accepted, "seconds": round(result.elapsed, 3), "hit_time_limit": result.hit_time_limit, + "cancelled": result.cancelled, + "repair_evaluated": result.repair_evaluated, }, "initial": measure(result.initial_sequence, config), "final": measure(result.sequence, config), "sequence": result.sequence, + "context_sequence": config.left_flank + result.sequence + config.right_flank, } @@ -145,6 +199,7 @@ def format_report(result: OptimizationResult, config: DesignConfig) -> str: after = measure(result.sequence, config) lines = [ + "Requirements " + ("valid" if result.valid else "UNRESOLVED (not a valid design)"), f"Protein length {len(result.protein)} residues", f"Coding sequence {after['length']} bases", f"Codon usage table {config.table.name}", @@ -154,6 +209,7 @@ def format_report(result: OptimizationResult, config: DesignConfig) -> str: f"{result.sweeps} sweep(s), {result.moves_evaluated} moves evaluated, " f"{result.elapsed:.1f}s" + (" [time limit reached]" if result.hit_time_limit else ""), + f"Stop reason {result.stop_reason}", "", f"{'Property':<24}{'before':>12}{'after':>12}", "-" * 48, @@ -170,4 +226,7 @@ def format_report(result: OptimizationResult, config: DesignConfig) -> str: present = after.get("forbidden_motifs_present") if present: lines += ["", "Remaining forbidden motifs: " + ", ".join(present)] + if result.unresolved_constraints: + lines += ["", "Unresolved requirements: " + ", ".join( + f"{v['criterion']} ({v['count']})" for v in result.unresolved_constraints)] return "\n".join(lines) diff --git a/src/codonopt/server.py b/src/codonopt/server.py index 0dbdb8f..5560710 100644 --- a/src/codonopt/server.py +++ b/src/codonopt/server.py @@ -282,19 +282,33 @@ def build_request(payload: object) -> JobSpec: {"v": gc_range[1]}, "v", 0.70, low=0, high=1, label="GC range high" ) - backend = payload.get("splice_backend", "auto") + table = _build_table(payload) + backend = payload.get("splice_backend", "none") if backend not in {"auto", "maxent", "motif", "none"}: raise RequestError("splice backend must be auto, maxent, motif or none") try: - scorer = get_scorer(backend) + scorer = get_scorer(backend, host=table.name) except RuntimeError as exc: raise RequestError(str(exc).splitlines()[0]) from None def whole(key: str, default: int, low: int, high: int) -> int: return _number(payload, key, default, low=low, high=high, integer=True) + def items(key: str, default: list, kind: type) -> tuple: + value = payload.get(key, default) + if not isinstance(value, list) or any(type(x) is not kind for x in value): + raise RequestError(f"{key} must be a list of {kind.__name__} values") + return tuple(value) + + left_flank = payload.get("left_flank", "") + right_flank = payload.get("right_flank", "") + if not isinstance(left_flank, str) or not isinstance(right_flank, str): + raise RequestError("flanks must be DNA strings") + if len(left_flank) + len(sequence) + len(right_flank) > 3 * MAX_RESIDUES: + raise RequestError("the complete construct exceeds the interface length limit") + config = DesignConfig( - table=_build_table(payload), + table=table, gc_target=_number(payload, "gc_target", 0.50, low=0, high=1), gc_range=(gc_low, gc_high), gc_window=whole("gc_window", 30, 6, 300), @@ -311,6 +325,12 @@ def whole(key: str, default: int, low: int, high: int) -> int: payload, "acceptor_cutoff", None, low=-100, high=100 ), weights=_build_weights(payload.get("weights")), + required=items("required", ["motif"], str), + protected_codons=items("protected_codons", [], int), + excluded_codons=items("excluded_codons", [], str), + splice_strand=payload.get("splice_strand", "forward"), + left_flank=left_flank, + right_flank=right_flank, ) effort = payload.get("effort", "standard") @@ -446,13 +466,15 @@ def result_payload(result: OptimizationResult, spec: JobSpec) -> dict: config = spec.config payload = report_dict(result, config) profile_before = gc_profile( - result.initial_sequence, window=config.gc_window, step=config.gc_step + config.left_flank + result.initial_sequence + config.right_flank, + window=config.gc_window, step=config.gc_step ) profile_after = gc_profile( - result.sequence, window=config.gc_window, step=config.gc_step + config.left_flank + result.sequence + config.right_flank, + window=config.gc_window, step=config.gc_step ) lengths = {motif.name: motif.length for motif in config.motifs} - hits = motif_hits(result.sequence, list(config.motifs)) + hits = motif_hits(config.left_flank + result.sequence + config.right_flank, list(config.motifs)) payload.update( name=spec.name, kind=spec.kind, @@ -464,6 +486,7 @@ def result_payload(result: OptimizationResult, spec: JobSpec) -> dict: "step": config.gc_step, "range": list(config.gc_range), "target": config.gc_target, + "coordinates": "construct", "starts": [start for start, _ in profile_after], "initial": [round(value, 4) for _, value in profile_before], "final": [round(value, 4) for _, value in profile_after], @@ -477,7 +500,7 @@ def result_payload(result: OptimizationResult, spec: JobSpec) -> dict: name=spec.name, description=( f"{spec.name} codonopt table={config.table.name} " - f"score={result.score:.2f}" + f"score={result.score:.2f} valid={str(result.valid).lower()}" ), sequence=result.sequence, ) diff --git a/src/codonopt/splice.py b/src/codonopt/splice.py index dd41a78..11d201e 100644 --- a/src/codonopt/splice.py +++ b/src/codonopt/splice.py @@ -169,7 +169,7 @@ def maxent_available() -> bool: return True -def get_scorer(backend: str) -> SpliceScorer | None: +def get_scorer(backend: str, *, host: str | None = None) -> SpliceScorer | None: """Return a scorer for ``backend``. Args: @@ -191,6 +191,10 @@ def get_scorer(backend: str) -> SpliceScorer | None: if key == "motif": return MotifScorer() if key == "auto": + # Automatic screening is only supported for the bundled human host. + # Other hosts can opt into a backend explicitly, with its limitations. + if host is not None and host.strip().lower() != "human": + return None return MaxEntScorer() if maxent_available() else MotifScorer() raise ValueError( f"unknown splice backend {backend!r}; expected maxent, motif, none or auto" diff --git a/src/codonopt/state.py b/src/codonopt/state.py index 7d885b7..13be5ad 100644 --- a/src/codonopt/state.py +++ b/src/codonopt/state.py @@ -7,9 +7,8 @@ the edit need recomputing. Repeats and hairpins, which are not local, are carried by :class:`codonopt.index.KmerIndex`. -The result is that a candidate codon swap costs time proportional to the window -sizes involved rather than to the length of the sequence, which is what makes a -whole-sequence search practical. :func:`SequenceState.audit` recomputes the +Penalty recomputation is local, though each edit still copies the immutable +sequence string. :func:`SequenceState.audit` recomputes the score from scratch and is used by the tests to confirm the incremental path agrees with a full recomputation. """ @@ -21,7 +20,7 @@ from bisect import bisect_left, bisect_right from dataclasses import dataclass -from .config import DesignConfig +from .config import DesignConfig, Weights from .index import KmerIndex from .metrics import window_starts from .motifs import Motif @@ -38,8 +37,6 @@ __all__ = ["SequenceState", "ScoreBreakdown"] -_MIN_WEIGHT = 1e-9 - @dataclass(frozen=True) class ScoreBreakdown: @@ -81,15 +78,28 @@ class SequenceState: "_pattern_motifs", "_motif_lengths", "_splice_terms", + "_coding_start", + "_coding_length", + "_required_weights", + "_required_total", ) def __init__(self, sequence: str, config: DesignConfig) -> None: - self._sequence = validate_dna(sequence, in_frame=True) + sequence = validate_dna(sequence, in_frame=True) + self._coding_start = len(config.left_flank) + self._coding_length = len(sequence) + self._sequence = config.left_flank + sequence + config.right_flank self.config = config - self.protein = translate(self._sequence) + self.protein = translate(sequence) + if any(i >= len(self.protein) for i in config.protected_codons): + raise ValueError("protected codon index is outside the coding sequence") weights = config.weights + self._required_weights = Weights(**{ + name: float(name in config.required) + for name in vars(weights) + }) - self._gc = gc_count(self._sequence) + self._gc = gc_count(sequence) self._window_starts = window_starts( len(self._sequence), config.gc_window, config.gc_step ) @@ -106,13 +116,13 @@ def __init__(self, sequence: str, config: DesignConfig) -> None: (ACCEPTOR_WINDOW, config.splice_scorer.score_acceptor, config.effective_acceptor_cutoff), ) - if config.splice_scorer is not None and weights.splice > _MIN_WEIGHT + if config.splice_scorer is not None else () ) self._repeat_index = ( KmerIndex(self._sequence, config.repeat_k, track_hairpins=False) - if weights.repeat > _MIN_WEIGHT + if weights.repeat > 0 or "repeat" in config.required else None ) self._hairpin_index = ( @@ -123,36 +133,62 @@ def __init__(self, sequence: str, config: DesignConfig) -> None: max_loop=config.hairpin_max_loop, track_hairpins=True, ) - if weights.hairpin > _MIN_WEIGHT + if weights.hairpin > 0 or "hairpin" in config.required else None ) self._local_total = self._local(0, len(self._sequence)) + self._required_total = self._local(0, len(self._sequence), required=True) # -- read-only views --------------------------------------------------- @property def sequence(self) -> str: """The current coding sequence.""" + return self._sequence[self._coding_start : self._coding_start + self._coding_length] + + @property + def context_sequence(self) -> str: + """The CDS together with immutable construct flanks.""" return self._sequence @property def codon_count(self) -> int: """Number of codons in the sequence.""" - return len(self._sequence) // 3 + return self._coding_length // 3 @property def score(self) -> float: """The weighted objective; lower is better.""" return self._local_total + self._global_terms() + @property + def violations(self) -> int: + """Number of mandatory constraint violations, maintained incrementally.""" + total = self._required_total + if "repeat" in self.config.required: + total += self._repeat_index.redundant + if "hairpin" in self.config.required: + total += self._hairpin_index.hairpin_pairs + return int(total) + + @property + def rank(self) -> tuple[int, float]: + """Feasibility takes precedence over the soft objective.""" + return self.violations, self.score + def codon(self, codon_index: int) -> str: """Return the codon at ``codon_index``.""" - start = codon_index * 3 + if not 0 <= codon_index < self.codon_count: + raise IndexError("codon index is outside the coding sequence") + start = self._coding_start + codon_index * 3 return self._sequence[start : start + 3] def alternatives(self, codon_index: int) -> tuple[str, ...]: """Return every synonymous codon for the residue at ``codon_index``.""" - return AA_TO_CODONS[CODON_TO_AA[self.codon(codon_index)]] + current = self.codon(codon_index) + if codon_index in self.config.protected_codons: + return (current,) + return tuple(c for c in AA_TO_CODONS[CODON_TO_AA[current]] if c not in self.config.excluded_codons) # -- mutation ---------------------------------------------------------- @@ -166,11 +202,15 @@ def set_codon(self, codon_index: int, codon: str) -> str: Raises: ValueError: If ``codon`` does not encode the same residue. """ - lo = codon_index * 3 + previous = self.codon(codon_index) + lo = self._coding_start + codon_index * 3 hi = lo + 3 - previous = self._sequence[lo:hi] if codon == previous: return previous + if codon_index in self.config.protected_codons: + raise ValueError(f"codon {codon_index} is protected") + if codon not in CODON_TO_AA: + raise ValueError(f"not a standard genetic-code codon: {codon!r}") if CODON_TO_AA[codon] != CODON_TO_AA[previous]: raise ValueError( f"{codon} encodes {CODON_TO_AA[codon]} but position " @@ -178,6 +218,7 @@ def set_codon(self, codon_index: int, codon: str) -> str: ) before = self._local(lo, hi) + required_before = self._local(lo, hi, required=True) self._sequence = self._sequence[:lo] + codon + self._sequence[hi:] self._gc += gc_count(codon) - gc_count(previous) if self._repeat_index is not None: @@ -185,6 +226,7 @@ def set_codon(self, codon_index: int, codon: str) -> str: if self._hairpin_index is not None: self._hairpin_index.replace_range(self._sequence, lo, hi) self._local_total += self._local(lo, hi) - before + self._required_total += self._local(lo, hi, required=True) - required_before return previous # -- scoring ----------------------------------------------------------- @@ -192,24 +234,23 @@ def set_codon(self, codon_index: int, codon: str) -> str: def _global_terms(self) -> float: weights = self.config.weights total = 0.0 - if weights.gc_global > _MIN_WEIGHT: - fraction = self._gc / len(self._sequence) - total += weights.gc_global * abs(fraction - self.config.gc_target) + if weights.gc_global > 0: + total += weights.gc_global * abs(self._gc - self.config.gc_target * self._coding_length) / 300 if self._repeat_index is not None: total += weights.repeat * self._repeat_index.redundant if self._hairpin_index is not None: total += weights.hairpin * self._hairpin_index.hairpin_pairs return total - def _local(self, lo: int, hi: int) -> float: + def _local(self, lo: int, hi: int, *, required: bool = False) -> float: """Weighted penalty of every term anchored near bases ``[lo, hi)``.""" sequence = self._sequence length = len(sequence) config = self.config - weights = config.weights + weights = self._required_weights if required else config.weights total = 0.0 - if weights.gc_window > _MIN_WEIGHT and self._window_starts: + if weights.gc_window > 0 and self._window_starts: window = config.gc_window low_bound, high_bound = config.gc_range starts = self._window_starts @@ -222,18 +263,18 @@ def _local(self, lo: int, hi: int) -> float: + sequence.count("C", position, end) ) / (end - position) if fraction < low_bound: - total += weights.gc_window * (low_bound - fraction) + total += 1 if required else weights.gc_window * (low_bound - fraction) elif fraction > high_bound: - total += weights.gc_window * (fraction - high_bound) + total += 1 if required else weights.gc_window * (fraction - high_bound) - if weights.homopolymer > _MIN_WEIGHT: + if weights.homopolymer > 0: run = config.max_homopolymer for i in range(max(run, lo), min(length, hi + run)): segment = sequence[i - run : i + 1] if segment == segment[0] * (run + 1): total += weights.homopolymer - if weights.motif > _MIN_WEIGHT: + if weights.motif > 0: for size in self._motif_lengths: literals = self._literals_by_length[size] first = max(0, lo - size + 1) @@ -249,22 +290,26 @@ def _local(self, lo: int, hi: int) -> float: total += weights.motif for window, score_window, cutoff in self._splice_terms: + if weights.splice == 0: + continue first = max(0, lo - window + 1) last = min(length - window + 1, hi) weight = weights.splice for position in range(first, last): forward = sequence[position : position + window] - score = score_window(forward) - if score > cutoff: - total += weight * (score - cutoff) - score = score_window(reverse_complement(forward)) - if score > cutoff: - total += weight * (score - cutoff) - - if weights.usage > _MIN_WEIGHT: + for strand in config.splice_strands: + candidate = reverse_complement(forward) if strand == "reverse" else forward + score = score_window(candidate) + if score > cutoff: + total += 1 if required else weight * (score - cutoff) + + if weights.usage > 0 or (required and config.excluded_codons): cost = self._usage_cost - for index in range(lo // 3, (hi + 2) // 3): - total += weights.usage * cost[sequence[index * 3 : index * 3 + 3]] + first = max(0, (lo - self._coding_start) // 3) + last = min(self.codon_count, (hi - self._coding_start + 2) // 3) + for index in range(first, last): + codon = self.codon(index) + total += int(codon in config.excluded_codons) if required else weights.usage * cost[codon] return total @@ -276,7 +321,7 @@ def audit(self) -> float: Used by the tests to confirm the incremental bookkeeping has not drifted from a full recomputation. """ - return SequenceState(self._sequence, self.config).score + return SequenceState(self.sequence, self.config).score def breakdown(self) -> ScoreBreakdown: """Return the objective split into its individual terms.""" @@ -286,9 +331,9 @@ def breakdown(self) -> ScoreBreakdown: raw: dict[str, float] = {} terms: dict[str, float] = {} - fraction = self._gc / len(sequence) + fraction = self._gc / self._coding_length raw["gc_global"] = fraction - terms["gc_global"] = weights.gc_global * abs(fraction - config.gc_target) + terms["gc_global"] = weights.gc_global * abs(self._gc - config.gc_target * self._coding_length) / 300 window_excess = 0.0 low_bound, high_bound = config.gc_range @@ -339,14 +384,15 @@ def breakdown(self) -> ScoreBreakdown: for window, score_window, cutoff in self._splice_terms: for position in range(len(sequence) - window + 1): forward = sequence[position : position + window] - for candidate in (forward, reverse_complement(forward)): + for strand in config.splice_strands: + candidate = reverse_complement(forward) if strand == "reverse" else forward score = score_window(candidate) if score > cutoff: splice_excess += score - cutoff raw["splice"] = splice_excess terms["splice"] = weights.splice * splice_excess - usage = sum(self._usage_cost[codon] for codon in split_codons(sequence)) + usage = sum(self._usage_cost[codon] for codon in split_codons(self.sequence)) raw["usage"] = usage terms["usage"] = weights.usage * usage diff --git a/src/codonopt/tables.py b/src/codonopt/tables.py index 3a2b717..c9e62c5 100644 --- a/src/codonopt/tables.py +++ b/src/codonopt/tables.py @@ -102,6 +102,18 @@ class CodonUsage: weights: dict[str, float] description: str = "" + def __post_init__(self) -> None: + for name, values in (("frequencies", self.frequencies), ("weights", self.weights)): + if set(values) != set(CODON_TO_AA): + raise ValueError(f"{name} must cover every standard genetic-code codon") + if any(not math.isfinite(v) or not 0 <= v <= 1 for v in values.values()): + raise ValueError(f"{name} must contain finite values between 0 and 1") + for codons in AA_TO_CODONS.values(): + if not math.isclose(sum(self.frequencies[c] for c in codons), 1.0, abs_tol=1e-12): + raise ValueError("codon frequencies must be normalized within each family") + if not math.isclose(max(self.weights[c] for c in codons), 1.0, abs_tol=1e-12): + raise ValueError("each family must have a relative adaptiveness of 1") + @classmethod def from_frequencies( cls, @@ -114,9 +126,11 @@ def from_frequencies( Values are normalised within each amino acid family, so either raw codon counts or already-normalised frequencies may be supplied. Codons - missing from ``frequencies`` default to 0 and are never chosen, unless + missing from ``frequencies`` default to 0 and are never sampled, unless every codon in a family is missing, in which case the family falls back to a uniform distribution. + Zero frequencies are absent observations, not forbidden codons; search + may use them. Use DesignConfig.excluded_codons for actual exclusions. Raises: ValueError: If a key is not a codon of the standard genetic code or @@ -127,8 +141,8 @@ def from_frequencies( codon = codon.strip().upper().replace("U", "T") if codon not in CODON_TO_AA: raise ValueError(f"not a codon of the standard genetic code: {codon!r}") - if value < 0: - raise ValueError(f"negative frequency for codon {codon}: {value}") + if not math.isfinite(value) or value < 0: + raise ValueError(f"negative or non-finite frequency for codon {codon}: {value}") raw[codon] = float(value) normalised: dict[str, float] = {} @@ -136,6 +150,8 @@ def from_frequencies( for aa, codons in AA_TO_CODONS.items(): values = [raw.get(codon, 0.0) for codon in codons] total = sum(values) + if not math.isfinite(total): + raise ValueError(f"frequency total overflows for amino acid {aa}") if total <= 0: values = [1.0] * len(codons) total = float(len(codons)) diff --git a/src/codonopt/web/app.js b/src/codonopt/web/app.js index b87c020..b44d9e0 100644 --- a/src/codonopt/web/app.js +++ b/src/codonopt/web/app.js @@ -1,5 +1,5 @@ -"use strict";(()=>{var P=class extends Error{constructor(n,o){super(n);this.status=o;this.name="ApiError"}},Me="X-Codonopt-Token",I=class{constructor(e){this.token=e}meta(){return this.request("GET","/api/meta")}start(e){return this.request("POST","/api/jobs",e)}poll(e){return this.request("GET",`/api/jobs/${encodeURIComponent(e)}`)}cancel(e){return this.request("POST",`/api/jobs/${encodeURIComponent(e)}/cancel`)}async request(e,n,o){let a;try{a=await fetch(n,{method:e,headers:{[Me]:this.token,...o===void 0?{}:{"Content-Type":"application/json"}},body:o===void 0?void 0:JSON.stringify(o),cache:"no-store"})}catch{throw new P("Cannot reach the codonopt server. Was the window or terminal that started it closed?",0)}let i=null;try{i=await a.json()}catch{}if(!a.ok){let r=i&&typeof i=="object"&&"error"in i?String(i.error):`The server answered ${a.status}.`;throw new P(r,a.status)}if(i===null||typeof i!="object")throw new P(`The server answered ${a.status} but sent no readable data.`,a.status);return i}};var Le=new Set("ACDEFGHIKLMNPQRSTVWY*"),re=new Set("ACGTU");function ce(t,e,n,o=!1){let a=t.trim();if(!a)return{state:"empty",message:"Nothing pasted yet.",kind:null,length:0};let i=a;if(a.startsWith(">")){let b=a.split(/\r?\n/),d=b.filter(w=>w.startsWith(">")).length;if(d>1)return q(`${d} FASTA records found. Paste one sequence at a time.`);i=b.slice(1).join("")}let r=i.replace(/\s+/g,"").toUpperCase();if(!r)return q("The FASTA header has no sequence under it.");let h=r.length>=9&&[...r].every(b=>re.has(b));if((e==="auto"?h?"dna":"protein":e)==="dna"){let b=le([...r].filter(w=>!re.has(w)));if(b.length)return q(`Not DNA: ${b.join(", ")}`);if(r.length%3)return q(`DNA \xB7 ${r.length} bases is not a whole number of codons (${r.length%3} extra).`);let d=r.length/3;return d>n?q(`DNA \xB7 ${d.toLocaleString()} codons; this page takes up to ${n.toLocaleString()}.`):ie("dna",r.length,`DNA \xB7 ${r.length.toLocaleString()} bases, ${d} codons`)}let m=le([...r].filter(b=>!Le.has(b)));if(m.length)return q(`Not a protein letter: ${m.join(", ")}`);let p=r.indexOf("*");if(p!==-1&&p!==r.length-1)return q(`Protein \xB7 stop symbol (*) at position ${p+1}, before the end.`);let L=r.length+(o&&!r.endsWith("*")?1:0);if(L>n)return q(`Protein \xB7 ${L.toLocaleString()} residues${L>r.length?" with the stop codon":""}; this page takes up to ${n.toLocaleString()}.`);let f=r.length===1?"residue":"residues";return ie("protein",r.length,`Protein \xB7 ${r.length.toLocaleString()} ${f}`)}function ie(t,e,n){return{state:"ok",message:n,kind:t,length:e}}function q(t){return{state:"error",message:t,kind:null,length:0}}function le(t){return[...new Set(t)].sort()}function V(t,e){for(let n of e)n==null||n===!1||t.appendChild(typeof n=="object"?n:document.createTextNode(String(n)))}function s(t,e,...n){let o=document.createElement(t);if(e){let{attrs:a,on:i,text:r,...h}=e;for(let[u,m]of Object.entries(h))m===void 0||m===!1||(u==="class"?o.className=String(m):typeof m=="boolean"?o.setAttribute(u,""):o.setAttribute(u,String(m)));for(let[u,m]of Object.entries(a??{}))o.setAttribute(u,m);if(r!==void 0&&(o.textContent=r),i)for(let[u,m]of Object.entries(i))o.addEventListener(u,m)}return V(o,n),o}var _e="http://www.w3.org/2000/svg";function E(t,e,...n){let o=document.createElementNS(_e,t);for(let[a,i]of Object.entries(e??{}))o.setAttribute(a,String(i));return V(o,n),o}function c(t){let e=document.getElementById(t);if(!e)throw new Error(`Missing #${t} in the page`);return e}function k(t,...e){t.replaceChildren(),V(t,e)}var x=(t,e=2)=>Number.isFinite(t)?t.toFixed(e):"\u2013",T=t=>Math.round(t).toLocaleString("en-US");var Se={gc_global:"Overall GC",gc_window:"Local GC",repeat:"Repeats",hairpin:"Hairpins",homopolymer:"Single-base runs",motif:"Forbidden sites",splice:"Splice sites",usage:"Codon usage"},ke={golden_gate:"Golden Gate",common_cloning:"Common cloning sites",regulatory:"Poly(A) and TATA signals"},$e={auto:"Automatic",maxent:"MaxEntScan",motif:"Consensus screen",none:"Off"},M=t=>c(t);function de(t){c("version").textContent=`v${t.version}`;let e=c("table");k(e,...t.tables.map(n=>s("option",{value:n.name,text:n.name})),s("option",{value:"custom",text:"Custom table\u2026"})),k(c("avoid-groups"),...Object.entries(t.motif_groups).filter(([,n])=>n.length).map(([n,o])=>s("label",{class:"chip-check",title:o.join(", ")},s("input",{type:"checkbox",value:n,attrs:{name:"avoid"}}),s("span",{text:ke[n]??n})))),k(c("splice"),...t.splice_backends.map(n=>s("option",{value:n,text:$e[n]??n}))),k(c("weights"),...Object.keys(t.weights).map(n=>s("label",{class:"field"},s("span",{text:Se[n]??n}),s("input",{type:"number",id:`weight-${n}`,attrs:{min:"0",step:"any",inputmode:"decimal","data-weight":n}})))),X(t)}function X(t){let e=t.defaults;c("table").value=t.default_table,c("table-text").value="",Y();for(let n of document.querySelectorAll('#avoid-groups input[type="checkbox"]'))n.checked=!1;c("avoid-extra").value="",M("gc-target").value=String(e.gc_target),M("gc-low").value=String(e.gc_range[0]),M("gc-high").value=String(e.gc_range[1]),M("max-homopolymer").value=String(e.max_homopolymer),c("splice").value="auto",c("effort-standard").checked=!0,M("seconds").value=String(e.seconds),M("seed").value="",M("gc-window").value=String(e.gc_window),M("gc-step").value=String(e.gc_step),M("repeat-k").value=String(e.repeat_k),M("hairpin-stem").value=String(e.hairpin_stem),M("hairpin-min-loop").value=String(e.hairpin_min_loop),M("hairpin-max-loop").value=String(e.hairpin_max_loop);for(let[n,o]of Object.entries(t.weights))M(`weight-${n}`).value=String(o);Q(t),Z(t)}function Y(){let t=c("table").value==="custom";c("custom-table").hidden=!t}function ue(){return c("table").value!=="custom"||c("table-text").value.trim()?null:"Paste or open a codon usage table, or pick a built-in host instead of \u201CCustom table\u2026\u201D."}function B(t){let e=c("table").value;c("table-hint").textContent=e==="custom"?"":t.tables.find(n=>n.name===e)?.description??""}function Q(t){let e=pe(),n=t.effort[e];if(!n)return;let o=[`${n.max_sweeps} greedy passes`];n.restarts>1&&o.push(`${n.restarts} independent restarts`),n.anneal_steps&&o.push(`${n.anneal_steps.toLocaleString()} annealing steps each`),c("effort-hint").textContent=`${o.join(", ")}.`}function Z(t){let e=c("splice").value,n=c("splice-hint");e==="maxent"&&!t.maxent_available?n.textContent="MaxEntScan is not installed, so this will report an error. Pick Automatic or Consensus screen.":e==="auto"?n.textContent=t.maxent_available?"Uses MaxEntScan.":"MaxEntScan is not installed, so a simpler consensus screen is used.":e==="motif"?n.textContent="A fast consensus screen. It flags likely sites rather than modelling splicing.":e==="none"?n.textContent="Splice sites are ignored. Right for sequences that will not be spliced, and faster.":n.textContent=""}function pe(){return document.querySelector('input[name="effort"]:checked')?.value??"standard"}function ee(){return document.querySelector('input[name="kind"]:checked')?.value??"auto"}function S(t){let e=M(t).value.trim();if(e==="")return;let n=Number(e);return Number.isFinite(n)?n:e}function he(){let t=[...document.querySelectorAll("#avoid-groups input:checked")].map(a=>a.value);t.push(...c("avoid-extra").value.split(/[,\n]/).map(a=>a.trim()).filter(Boolean));let e={};for(let a of document.querySelectorAll("#weights input")){let i=a.value.trim();i!==""&&(e[a.dataset.weight??""]=Number.isFinite(Number(i))?Number(i):i)}let n=c("table").value,o=ee();return{sequence:c("sequence").value,kind:o,add_stop:c("add-stop").checked,table:n==="custom"?void 0:n,table_text:n==="custom"?c("table-text").value:void 0,avoid:t,gc_target:S("gc-target"),gc_range:[S("gc-low"),S("gc-high")],max_homopolymer:S("max-homopolymer"),splice_backend:c("splice").value,effort:pe(),seconds:S("seconds"),seed:S("seed"),gc_window:S("gc-window"),gc_step:S("gc-step"),repeat_k:S("repeat-k"),hairpin_stem:S("hairpin-stem"),hairpin_min_loop:S("hairpin-min-loop"),hairpin_max_loop:S("hairpin-max-loop"),weights:e}}var O=720,ne=236,v={left:46,right:14,top:12,bottom:34},G=O-v.left-v.right,D=ne-v.top-v.bottom,te=[50,100,200,250,500,1e3,2e3,2500,5e3,1e4,2e4,5e4];function Ce(t){return te.find(e=>t/e<=8)??te[te.length-1]}function me(t,e){let n=Math.max(e,1),o=d=>v.left+d/n*G,a=d=>v.top+(1-d)*D,i=d=>Math.min(d+t.window/2,n),r=d=>d.map((w,_)=>`${_?"L":"M"}${o(i(t.starts[_])).toFixed(1)} ${a(w).toFixed(1)}`).join(""),h=E("svg",{viewBox:`0 0 ${O} ${ne}`,role:"img",class:"chart","aria-label":`GC content in ${t.window} base windows along the sequence, before and after optimisation, against the allowed range of ${x(t.range[0]*100,0)} to ${x(t.range[1]*100,0)} percent.`});h.append(E("rect",{class:"band",x:v.left,y:a(t.range[1]),width:G,height:Math.max(0,a(t.range[0])-a(t.range[1]))}));for(let d of[0,.25,.5,.75,1])h.append(E("line",{class:"grid",x1:v.left,x2:O-v.right,y1:a(d),y2:a(d)}),E("text",{class:"tick",x:v.left-8,y:a(d)+4,"text-anchor":"end"},`${d*100}%`));let u=Ce(n);for(let d=0;d<=n;d+=u)h.append(E("line",{class:"grid faint",x1:o(d),x2:o(d),y1:v.top,y2:v.top+D}),E("text",{class:"tick",x:o(d),y:ne-12,"text-anchor":"middle"},T(d)));h.append(E("line",{class:"target",x1:v.left,x2:O-v.right,y1:a(t.target),y2:a(t.target)})),t.starts.length>1?h.append(E("path",{class:"line before",d:r(t.initial)}),E("path",{class:"line after",d:r(t.final)})):t.starts.length===1&&h.append(E("circle",{class:"dot before",cx:o(i(t.starts[0])),cy:a(t.initial[0]),r:4}),E("circle",{class:"dot after",cx:o(i(t.starts[0])),cy:a(t.final[0]),r:4}));let m=E("line",{class:"cursor",x1:0,x2:0,y1:v.top,y2:v.top+D});m.setAttribute("visibility","hidden");let p=E("rect",{class:"overlay",x:v.left,y:v.top,width:G,height:D});h.append(m,p);let L="Hover the chart to read a window.",f=s("p",{class:"readout",text:L});p.addEventListener("pointermove",d=>{let w=h.getBoundingClientRect(),A=((d.clientX-w.left)/w.width*O-v.left)/G*n,y=0;for(let N=1;N