import json import hashlib import math import mimetypes import multiprocessing import os import queue import re import secrets import threading import time import traceback from decimal import Decimal from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait from http.cookies import SimpleCookie from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path import subprocess import tempfile from urllib.parse import parse_qs, urlencode, urlparse from uuid import uuid4 import numpy import nupack from nupack import ( Complex, Complementarity, Domain, Diversity, EnergyMatch, DesignOptions, Library, Match, Model, Pattern, Similarity, SSM, SetSpec, Strand, TargetComplex, TargetStrand, TargetTube, Tube, Weights, Window, complex_analysis, complex_design, tube_analysis, tube_design, ) from nupack import config as nupack_config from account import AccountStore, OIDCAuth from split_strand_svg import render_split_strands_svg try: import redis except ImportError: redis = None ROOT = Path(__file__).resolve().parent INDEX_PATH = ROOT / "index.html" HOME_PATH = ROOT / "home.html" CLOUD_PATH = ROOT / "cloud.html" ACCOUNT_PATH = ROOT / "account.html" SETTINGS_PATH = ROOT / "settings.html" ADMIN_PATH = ROOT / "admin.html" ADMIN_LOGIN_PATH = ROOT / "admin-login.html" STATIC_PATHS = { "/static/app-shell.css": ROOT / "static" / "app-shell.css", "/static/workspace-refresh.css": ROOT / "static" / "workspace-refresh.css", "/static/portal.js": ROOT / "static" / "portal.js", } FAVICON_PATH = ROOT / "favicon.svg" GUIDE_PATH = ROOT / "design-guide.html" HOST = os.environ.get("NP_HOST", "0.0.0.0") PORT = int(os.environ.get("NP_PORT", "18765")) RNA_PLOT_CMD = os.environ.get("RNA_PLOT_CMD", "RNAplot") ENABLE_RNAPLOT = os.environ.get("ENABLE_RNAPLOT", "1") != "0" STRUCTURE_PLOT_MODE = os.environ.get("NP_STRUCTURE_PLOT_MODE", "auto").strip().lower() RUN_MODE = os.environ.get("NP_RUN_MODE", "server") REDIS_URL = os.environ.get("NP_REDIS_URL", "").strip() JOB_QUEUE_KEY = os.environ.get("NP_JOB_QUEUE_KEY", "np_replica:jobs") JOB_RUNNING_KEY = os.environ.get("NP_JOB_RUNNING_KEY", "np_replica:jobs:running") WORKER_RESOURCE_KEY = os.environ.get("NP_WORKER_RESOURCE_KEY", "np_replica:worker:resources") WORKER_CONCURRENCY = os.environ.get("NP_WORKER_CONCURRENCY", "auto").strip().lower() PER_JOB_THREAD_LIMIT = max(1, int(os.environ.get("NP_PER_JOB_THREAD_LIMIT", "1"))) NUPACK_CACHE_GB = float(os.environ.get("NP_NUPACK_CACHE_GB", "2.0")) WORKER_MEMORY_GB = float(os.environ.get("NP_WORKER_MEMORY_GB", "0")) WORKER_MEMORY_RESERVE_GB = max(0.0, float(os.environ.get("NP_WORKER_MEMORY_RESERVE_GB", "2.0"))) ESTIMATED_JOB_MEMORY_GB = max(0.1, float(os.environ.get("NP_ESTIMATED_JOB_MEMORY_GB", "8.0"))) ACCOUNT_DB_PATH = os.environ.get("NP_ACCOUNT_DB_PATH", "/data/np-replica.sqlite3") ADMIN_TOKEN = os.environ.get("NP_ADMIN_TOKEN", "").strip() ADMIN_SESSION_TTL_SECONDS = max(300, int(os.environ.get("NP_ADMIN_SESSION_TTL_SECONDS", "28800"))) ADMIN_COOKIE_NAME = os.environ.get("NP_ADMIN_COOKIE_NAME", "np_admin_session") ADMIN_COOKIE_SECURE = os.environ.get("NP_ADMIN_COOKIE_SECURE", "1") != "0" ADMIN_SESSION_PREFIX = os.environ.get("NP_ADMIN_SESSION_PREFIX", "np_replica:admin-session") UNIT_SCALE = { "M": 1.0, "mM": 1e-3, "uM": 1e-6, "nM": 1e-9, "pM": 1e-12, } IUPAC_CODES = "ACGTUWSMKRYBDHVN" IUPAC_CONSTRAINT_TOKEN = re.compile(rf"[{IUPAC_CODES}](?:\d+)?") FIXED_BASES = set("ACGTU") DNA_COMPLEMENT_BASE = str.maketrans({"A": "T", "C": "G", "G": "C", "T": "A", "U": "A"}) RNA_COMPLEMENT_BASE = str.maketrans({"A": "U", "C": "G", "G": "C", "T": "A", "U": "A"}) NUPACK_VERSION = str(getattr(nupack, "__version__", "4.1.0.1")) MATERIAL_ALIASES = {"rna": "rna06", "dna": "dna04.3", "dna04": "dna04.3"} MIXED_MATERIAL_PREFIXES = {"rna-dna06": "rd", "rna-merna06": "rm"} MATERIAL_RULES = { "rna": {"sodium": (0.05, 1.0, 1.0), "magnesium": (0.0, 0.0, 0.0)}, "rna06": {"sodium": (0.05, 1.0, 1.0), "magnesium": (0.0, 0.0, 0.0)}, "rna95": {"sodium": (1.0, 1.0, 1.0), "magnesium": (0.0, 0.0, 0.0)}, "merna06": {"sodium": (0.12, 0.12, 0.12), "magnesium": (0.0, 0.0, 0.0)}, "rna-dna06": {"sodium": (0.12, 1.0, 1.0), "magnesium": (0.0, 0.0, 0.0)}, "rna-merna06": {"sodium": (0.12, 0.12, 0.12), "magnesium": (0.0, 0.0, 0.0)}, "dna": {"sodium": (0.05, 1.1, 1.0), "magnesium": (0.0, 0.2, 0.0)}, "dna04": {"sodium": (0.05, 1.1, 1.0), "magnesium": (0.0, 0.2, 0.0)}, "dna04.1": {"sodium": (0.05, 1.1, 1.0), "magnesium": (0.0, 0.2, 0.0)}, "dna04.2": {"sodium": (0.05, 1.1, 1.0), "magnesium": (0.0, 0.2, 0.0)}, "dna04.3": {"sodium": (0.05, 1.1, 1.0), "magnesium": (0.0, 0.2, 0.0)}, } VALID_COMPUTE = {"pfunc", "pairs", "mfe", "sample", "subopt", "ensemble_size"} VALID_UTILITY_OPERATIONS = { "pfunc", "structure_energy", "structure_probability", "sample", "pairs", "mfe", "subopt", "ensemble_size", "des", "defect", "seq_distance", "struc_distance", } FIXED_TARGET_POLICIES = {"exclude_from_optimization", "include"} CANCEL_REQUESTED_STATUS = "cancel_requested" CANCELED_STATUS = "canceled" TERMINAL_JOB_STATUSES = {"success", "error", CANCELED_STATUS} JOB_STORE = {} JOB_LOCK = threading.Lock() JOB_DEDUP_RESERVATIONS = {} JOB_TTL_SECONDS = int(os.environ.get("NP_JOB_TTL_SECONDS", "3600")) JOB_HEARTBEAT_SECONDS = max(1, int(os.environ.get("NP_JOB_HEARTBEAT_SECONDS", "30"))) JOB_MAX_COUNT = int(os.environ.get("NP_JOB_MAX_COUNT", "64")) JOB_DEDUP_PREFIX = os.environ.get("NP_JOB_DEDUP_PREFIX", "np_replica:job-dedup") SHARE_STORE = {} SHARE_LOCK = threading.Lock() ADMIN_SESSION_STORE = {} ADMIN_SESSION_LOCK = threading.Lock() SHARE_MAX_COUNT = int(os.environ.get("NP_SHARE_MAX_COUNT", "100")) SHARE_MAX_BYTES = int(os.environ.get("NP_SHARE_MAX_BYTES", str(12 * 1024 * 1024))) SHARE_KEY_PREFIX = os.environ.get("NP_SHARE_KEY_PREFIX", "np_replica:shares") SHARE_INDEX_KEY = f"{SHARE_KEY_PREFIX}:index" REDIS_CLIENT = None ACCOUNT_STORE = AccountStore(ACCOUNT_DB_PATH) OIDC_AUTH = None def json_bytes(payload, status=HTTPStatus.OK): return ( status, "application/json; charset=utf-8", json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8"), ) def log_event(message): print(f"[np-replica] {time.strftime('%Y-%m-%d %H:%M:%S')} {message}", flush=True) def detected_worker_memory_gb(): if WORKER_MEMORY_GB > 0: return WORKER_MEMORY_GB try: raw = Path("/sys/fs/cgroup/memory.max").read_text().strip() if raw != "max": return int(raw) / (1024 ** 3) except (OSError, ValueError): pass try: return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / (1024 ** 3) except (OSError, ValueError): return 0.0 def detected_worker_cpu_count(): capacities = [max(1, os.cpu_count() or 1)] try: capacities.append(max(1, len(os.sched_getaffinity(0)))) except (AttributeError, OSError): pass quota_paths = ( (Path("/sys/fs/cgroup/cpu.max"), "v2"), (Path("/sys/fs/cgroup/cpu/cpu.cfs_quota_us"), "v1"), ) for path, version in quota_paths: try: raw = path.read_text().strip() if version == "v2": quota_text, period_text = raw.split()[:2] if quota_text == "max": break quota, period = int(quota_text), int(period_text) else: quota = int(raw) if quota < 0: break period = int(Path("/sys/fs/cgroup/cpu/cpu.cfs_period_us").read_text().strip()) if quota > 0 and period > 0: capacities.append(max(1, quota // period)) break except (OSError, ValueError): continue return min(capacities) def worker_resource_plan(): cpu_count = detected_worker_cpu_count() memory_gb = detected_worker_memory_gb() if WORKER_CONCURRENCY != "auto": try: concurrency = max(1, int(WORKER_CONCURRENCY)) except ValueError as exc: raise ValueError("NP_WORKER_CONCURRENCY must be a positive integer or 'auto'.") from exc source = "manual" else: cpu_capacity = max(1, cpu_count // PER_JOB_THREAD_LIMIT) usable_memory_gb = max(0.1, memory_gb - WORKER_MEMORY_RESERVE_GB) if memory_gb else 0 memory_capacity = max(1, int(usable_memory_gb // ESTIMATED_JOB_MEMORY_GB)) if usable_memory_gb else cpu_capacity concurrency = max(1, min(cpu_capacity, memory_capacity)) source = "auto" return { "concurrency": concurrency, "source": source, "cpu_count": cpu_count, "per_job_threads": PER_JOB_THREAD_LIMIT, "memory_gb": round(memory_gb, 3), "memory_reserve_gb": WORKER_MEMORY_RESERVE_GB, "estimated_job_memory_gb": ESTIMATED_JOB_MEMORY_GB, } def published_worker_resource_plan(): if redis_enabled(): try: raw = redis_client().get(WORKER_RESOURCE_KEY) if raw: return json.loads(raw) except Exception: pass return worker_resource_plan() def apply_thread_limits(): # Keep native math libraries and NUPACK's own executor from using all CPU cores inside one job. thread_limit_int = max(1, PER_JOB_THREAD_LIMIT) thread_limit = str(thread_limit_int) for key in ( "OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS", "NUMEXPR_NUM_THREADS", "VECLIB_MAXIMUM_THREADS", "GOTO_NUM_THREADS", ): os.environ[key] = thread_limit nupack_config.threads = thread_limit_int nupack_config.cache = max(0.1, NUPACK_CACHE_GB) def html_bytes(path): body = path.read_bytes() mime, _ = mimetypes.guess_type(str(path)) return HTTPStatus.OK, mime or "text/html; charset=utf-8", body def file_etag(path): stat = path.stat() return f'W/"{stat.st_mtime_ns:x}-{stat.st_size:x}"' def redis_enabled(): return bool(REDIS_URL and redis is not None) def redis_client(): global REDIS_CLIENT if not redis_enabled(): return None if REDIS_CLIENT is None: REDIS_CLIENT = redis.Redis.from_url(REDIS_URL, decode_responses=True) return REDIS_CLIENT def create_admin_session(): token = secrets.token_urlsafe(32) if redis_enabled(): redis_client().setex(f"{ADMIN_SESSION_PREFIX}:{token}", ADMIN_SESSION_TTL_SECONDS, "1") else: with ADMIN_SESSION_LOCK: ADMIN_SESSION_STORE[token] = time.time() + ADMIN_SESSION_TTL_SECONDS return token def valid_admin_session(token): if not token: return False if redis_enabled(): return bool(redis_client().get(f"{ADMIN_SESSION_PREFIX}:{token}")) now = time.time() with ADMIN_SESSION_LOCK: expired = [key for key, expires_at in ADMIN_SESSION_STORE.items() if expires_at <= now] for key in expired: ADMIN_SESSION_STORE.pop(key, None) return ADMIN_SESSION_STORE.get(token, 0) > now def delete_admin_session(token): if not token: return if redis_enabled(): redis_client().delete(f"{ADMIN_SESSION_PREFIX}:{token}") else: with ADMIN_SESSION_LOCK: ADMIN_SESSION_STORE.pop(token, None) def admin_cookie_header(token, *, clear=False): secure = "; Secure" if ADMIN_COOKIE_SECURE else "" if clear: return f"{ADMIN_COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict{secure}" return ( f"{ADMIN_COOKIE_NAME}={token}; Path=/; Max-Age={ADMIN_SESSION_TTL_SECONDS}; " f"HttpOnly; SameSite=Strict{secure}" ) OIDC_AUTH = OIDCAuth(redis_client) def queue_size(): if not redis_enabled(): with JOB_LOCK: return sum(1 for job in JOB_STORE.values() if job.get("status") in {"queued", "running"}) try: return int(redis_client().llen(JOB_QUEUE_KEY)) except Exception: return None def prune_redis_running_jobs(): if not redis_enabled(): return 0 client = redis_client() removed = 0 try: for job_id in client.smembers(JOB_RUNNING_KEY): raw = client.get(job_key(job_id)) if raw is None: client.srem(JOB_RUNNING_KEY, job_id) removed += 1 continue try: if json.loads(raw).get("status") != "running": client.srem(JOB_RUNNING_KEY, job_id) removed += 1 except Exception: client.srem(JOB_RUNNING_KEY, job_id) removed += 1 except Exception: return removed return removed def job_stats(): stats = { "queued": 0, "running": 0, "success": 0, "error": 0, } if redis_enabled(): prune_redis_running_jobs() client = redis_client() stats["queued"] = int(client.llen(JOB_QUEUE_KEY)) stats["running"] = int(client.scard(JOB_RUNNING_KEY)) return stats with JOB_LOCK: prune_jobs() for job in JOB_STORE.values(): status = job.get("status") if status in stats: stats[status] += 1 return stats def job_key(job_id): return f"np_replica:job:{job_id}" def job_dedup_key(fingerprint): return f"{JOB_DEDUP_PREFIX}:{fingerprint}" def refresh_redis_job_claim(job_id, fingerprint): return redis_client().eval( """ local current = redis.call('GET', KEYS[1]) if (not current) or current == ARGV[1] then redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2]) return 1 end return 0 """, 1, job_dedup_key(fingerprint), job_id, max(60, JOB_TTL_SECONDS), ) def delete_redis_job_claim(job_id, fingerprint): return redis_client().eval( """ if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0 """, 1, job_dedup_key(fingerprint), job_id, ) def job_payload_fingerprint(payload, user_id): encoded = json.dumps( payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ) return hashlib.sha256(f"{user_id}\0{encoded}".encode("utf-8")).hexdigest() def active_job_status(status): return status in {"queued", "running", CANCEL_REQUESTED_STATUS} def claim_active_job(payload, owner, job_id): """Return an existing active duplicate, or claim this payload for job_id.""" fingerprint = job_payload_fingerprint(payload, owner["user_id"]) if redis_enabled(): client = redis_client() key = job_dedup_key(fingerprint) while True: if client.set(key, job_id, nx=True, ex=max(60, JOB_TTL_SECONDS)): return job_id, fingerprint, False existing_id = client.get(key) if not existing_id: continue existing = get_job_data(existing_id) if existing and existing.get("user_id") == owner["user_id"] and active_job_status(existing.get("status")): return existing_id, fingerprint, True # A terminal or expired job left a stale dedup key behind. delete_redis_job_claim(existing_id, fingerprint) with JOB_LOCK: prune_jobs() for existing in JOB_STORE.values(): if ( existing.get("user_id") == owner["user_id"] and existing.get("dedup_fingerprint") == fingerprint and active_job_status(existing.get("status")) ): return existing["job_id"], fingerprint, True reserved_id = JOB_DEDUP_RESERVATIONS.get(fingerprint) if reserved_id: if reserved_id in JOB_STORE: return reserved_id, fingerprint, True JOB_DEDUP_RESERVATIONS.pop(fingerprint, None) JOB_DEDUP_RESERVATIONS[fingerprint] = job_id return job_id, fingerprint, False def release_job_claim(job_id, fingerprint): if redis_enabled(): delete_redis_job_claim(job_id, fingerprint) return with JOB_LOCK: if JOB_DEDUP_RESERVATIONS.get(fingerprint) == job_id: JOB_DEDUP_RESERVATIONS.pop(fingerprint, None) def replace_job_claim(job_id, fingerprint): if redis_enabled(): redis_client().setex( job_dedup_key(fingerprint), max(60, JOB_TTL_SECONDS), job_id, ) return with JOB_LOCK: JOB_DEDUP_RESERVATIONS[fingerprint] = job_id def share_key(share_id): return f"{SHARE_KEY_PREFIX}:item:{share_id}" def normalize_sequence(sequence, material="rna"): normalized = re.sub(r"\s+", "", str(sequence or "")) if str(material or "").strip().lower() not in MIXED_MATERIAL_PREFIXES: return normalized.upper() return normalized def material_rule(material): return MATERIAL_RULES.get(str(material or "rna").strip().lower()) def validate_material_salt(material, ion, value): rule = material_rule(material) if rule is None: return lower, upper, _default = rule[ion] label = "Sodium" if ion == "sodium" else "Magnesium" if math.isclose(lower, upper, rel_tol=0.0, abs_tol=1e-12): if not math.isclose(value, lower, rel_tol=0.0, abs_tol=1e-12): raise ValueError(f"{label} concentration for {material} must be {lower:g} M.") elif value < lower or value > upper: raise ValueError( f"{label} concentration for {material} must be between {lower:g} and {upper:g} M." ) def sequence_length(model, sequence): try: return int(model.alphabet.sequence_length(sequence)) except Exception as exc: raise ValueError(f"Invalid sequence for material {model}: {sequence}") from exc def validate_analysis_sequence(sequence, material, model, label="Sequence"): sequence = normalize_sequence(sequence, material) material_name = str(material or "rna").strip().lower() prefixes = MIXED_MATERIAL_PREFIXES.get(material_name) if prefixes: if not re.fullmatch(rf"(?:[{prefixes}][ACGTU]+)+", sequence): raise ValueError( f"{label} must use explicit lowercase material prefixes " f"({', '.join(prefixes)}) followed by A, C, G, T, or U." ) elif not re.fullmatch(r"[ACGTU]+", sequence): raise ValueError(f"{label} must contain only A, C, G, T, or U for analysis.") try: model.alphabet.sequence(sequence) except Exception as exc: raise ValueError(f"{label} is invalid for material {material_name}.") from exc return sequence def normalize_design_sequence(sequence, material="rna"): normalized = re.sub(r"\s+", "", str(sequence or "")) if str(material or "rna").strip().lower() in MIXED_MATERIAL_PREFIXES: return normalized return normalized.upper() def parse_design_constraint_tokens(sequence, material="rna"): material_name = str(material or "rna").strip().lower() seq = normalize_design_sequence(sequence, material_name) if not seq: raise ValueError("Sequence constraint cannot be empty.") mixed_prefixes = MIXED_MATERIAL_PREFIXES.get(material_name) allowed_prefixes = set((mixed_prefixes or "") + ("w" if mixed_prefixes else "")) index = 0 current_prefix = None tokens = [] while index < len(seq): if mixed_prefixes and seq[index] in allowed_prefixes: current_prefix = seq[index] index += 1 if index >= len(seq): raise ValueError("A material prefix must be followed by an IUPAC constraint.") if mixed_prefixes and current_prefix is None: expected = ", ".join(sorted(allowed_prefixes)) raise ValueError(f"Mixed-material constraints must start with a lowercase prefix: {expected}.") match = IUPAC_CONSTRAINT_TOKEN.match(seq, index) if not match: raise ValueError(f"Invalid sequence constraint near: {seq[index:]}") token = match.group(0) count = int(token[1:] or "1") if count < 1: raise ValueError("Sequence constraint repeat counts must be positive.") tokens.append((current_prefix, token[0], count)) index = match.end() return seq, tokens def is_valid_iupac_constraint(sequence, material="rna", model=None): try: seq, _tokens = parse_design_constraint_tokens(sequence, material) if model is not None: model.alphabet.domain(seq) except Exception: return False return True def expand_iupac_constraint(sequence, material="rna"): _seq, tokens = parse_design_constraint_tokens(sequence, material) output = [] previous_prefix = None for prefix, base, count in tokens: if prefix is not None and prefix != previous_prefix: output.append(prefix) output.append(base * count) previous_prefix = prefix return "".join(output) def is_mutable_iupac_constraint(sequence, material="rna"): _seq, tokens = parse_design_constraint_tokens(sequence, material) return any(prefix == "w" or base not in FIXED_BASES for prefix, base, _count in tokens) def reverse_complement_fixed(sequence, material="rna", model=None): material_name = str(material or "rna").strip().lower() if model is not None: complement = model.alphabet.to_string(model.alphabet.domain(f"~{sequence}")) if material_name in {"rna", "rna06", "rna95", "merna06"}: complement = complement.replace("T", "U") return complement table = DNA_COMPLEMENT_BASE if material_name.startswith("dna") else RNA_COMPLEMENT_BASE return sequence.upper().translate(table)[::-1] def parse_domain_tokens(text): tokens = [token.strip() for token in re.split(r"[\s,]+", str(text or "").strip()) if token.strip()] output = [] for token in tokens: complement = False domain_name = token if token.startswith("~"): complement = True domain_name = token[1:] elif token.endswith("*"): complement = True domain_name = token[:-1] output.append((domain_name, complement)) return output def get_mapping_value(mapping, key, default=None): try: return mapping[key] except Exception: return default def ensure_unit_interval_limits(lower, upper, label): if not (0 <= lower <= upper <= 1): raise ValueError(f"{label} limits must satisfy 0 <= lower <= upper <= 1.") def finite_float(value, label): try: parsed = float(value) except (TypeError, ValueError) as exc: raise ValueError(f"{label} must be a number.") from exc if not math.isfinite(parsed): raise ValueError(f"{label} must be finite.") return parsed def unit_to_molar(value, unit): if unit not in UNIT_SCALE: raise ValueError(f"Unsupported concentration unit: {unit}") return finite_float(value, "Concentration") * UNIT_SCALE[unit] def parse_model_input(model_input): material = str(model_input.get("material", "rna") or "rna").strip().lower() rule = material_rule(material) sodium_default = rule["sodium"][2] if rule else 1.0 magnesium_default = rule["magnesium"][2] if rule else 0.0 parsed = { "material": material, "ensemble": model_input.get("ensemble", "stacking"), "celsius": finite_float(model_input.get("celsius", 37.0), "Temperature"), "sodium": finite_float(model_input.get("sodium", sodium_default), "Sodium concentration"), "magnesium": finite_float(model_input.get("magnesium", magnesium_default), "Magnesium concentration"), } if parsed["celsius"] <= -273.15: raise ValueError("Temperature must be above absolute zero (-273.15 C).") if parsed["sodium"] < 0 or parsed["magnesium"] < 0: raise ValueError("Sodium and magnesium concentrations must be non-negative.") validate_material_salt(material, "sodium", parsed["sodium"]) validate_material_salt(material, "magnesium", parsed["magnesium"]) return parsed def build_model(model_input): return Model(**parse_model_input(model_input)) def build_model_summary(model_input): summary = parse_model_input(model_input) summary["resolved_material"] = MATERIAL_ALIASES.get(summary["material"], summary["material"]) summary["nupack_version"] = NUPACK_VERSION return summary def parse_compute(payload): compute = payload.get("compute") or ["pfunc", "mfe"] compute = [item for item in compute if item in VALID_COMPUTE] if not compute: raise ValueError("At least one compute option is required.") return compute def parse_options(payload): raw = payload.get("options") or {} options = { "num_sample": int(raw.get("num_sample", 20)), "energy_gap": finite_float(raw.get("energy_gap", 1.0), "energy_gap"), "sparsity_fraction": finite_float(raw.get("sparsity_fraction", 1.0), "sparsity_fraction"), "sparsity_threshold": finite_float(raw.get("sparsity_threshold", 0.0), "sparsity_threshold"), "single_mfe": bool(raw.get("single_mfe", False)), "indistinguishable_search": bool(raw.get("indistinguishable_search", False)), "max_subopt_count": int(raw.get("max_subopt_count", 100000)), "result_limit": int(raw.get("result_limit", 25)), "pairs_preview_size": int(raw.get("pairs_preview_size", 24)), } if options["num_sample"] < 0 or options["num_sample"] > 1000: raise ValueError("num_sample must be between 0 and 1000.") if options["energy_gap"] < 0: raise ValueError("energy_gap must be non-negative.") if not 0 <= options["sparsity_fraction"] <= 1: raise ValueError("sparsity_fraction must be between 0 and 1.") if not 0 <= options["sparsity_threshold"] <= 1: raise ValueError("sparsity_threshold must be between 0 and 1.") if options["max_subopt_count"] < 1 or options["max_subopt_count"] > 1000000: raise ValueError("max_subopt_count must be between 1 and 1000000.") if options["result_limit"] < 1: raise ValueError("result_limit must be at least 1.") if options["pairs_preview_size"] < 8 or options["pairs_preview_size"] > 128: raise ValueError("pairs_preview_size must be between 8 and 128.") return options def parse_design_options(payload): raw = payload.get("design") or {} options = { "trials": int(raw.get("trials", 1)), "result_limit": int(raw.get("result_limit", 25)), "off_target_max_size": int(raw.get("off_target_max_size", payload.get("tube", {}).get("max_size", 2))), "stop_condition": finite_float(raw.get("stop_condition", 0.02), "design stop_condition"), "seed": int(raw.get("seed", 0)), "wobble_mutations": bool(raw.get("wobble_mutations", False)), "max_time_seconds": int(raw.get("max_time_seconds", 0)), "fixed_target_policy": str(raw.get("fixed_target_policy", "include")).strip().lower(), } if options["trials"] < 1 or options["trials"] > 8: raise ValueError("design trials must be between 1 and 8.") if options["result_limit"] < 1: raise ValueError("design result_limit must be at least 1.") if options["off_target_max_size"] < 1 or options["off_target_max_size"] > 8: raise ValueError("design off_target_max_size must be between 1 and 8.") if not 0 < options["stop_condition"] < 1: raise ValueError("design stop_condition must be between 0 and 1.") if options["max_time_seconds"] < 0: raise ValueError("design max_time_seconds must be non-negative.") if options["fixed_target_policy"] not in FIXED_TARGET_POLICIES: raise ValueError("design fixed_target_policy must be exclude_from_optimization or include.") return options def euler_phi(value): result = value factor = 2 remaining = value while factor * factor <= remaining: if remaining % factor == 0: while remaining % factor == 0: remaining //= factor result -= result // factor factor += 1 if remaining > 1: result -= result // remaining return result def cyclic_complex_count(strand_species, max_size): """Count strand-order necklaces enumerated by SetSpec up to max_size.""" strand_species = max(0, int(strand_species)) max_size = max(0, int(max_size)) total = 0 for size in range(1, max_size + 1): rotations = sum( euler_phi(divisor) * (strand_species ** (size // divisor)) for divisor in range(1, size + 1) if size % divisor == 0 ) total += rotations // size return total def canonical_cyclic_identity(identity): """Normalize a strand order using NUPACK's rotation-equivalent complex identity.""" identity = tuple(identity) if not identity: return identity return min(identity[index:] + identity[:index] for index in range(len(identity))) def build_design_workload_summary(design_domains, target_rows, tube_rows, target_complex_map, design_options): mutable_domain_nucleotides = sum( item["length"] if "length" in item else len(expand_iupac_constraint(item["constraint"])) for item in design_domains if item.get("mutable") ) summary = { "mutable_domain_nucleotides": mutable_domain_nucleotides, "fixed_target_count": sum(1 for item in target_rows if not item.get("mutable", True)), "runtime_limit_seconds": design_options["max_time_seconds"] or "unbounded", } if not tube_rows: return summary total_upper_bound = 0 off_target_upper_bound = 0 largest_tube_upper_bound = 0 for tube in tube_rows: strand_names = set() on_target_identities = set() for entry in tube.get("on_targets", []): complex_obj = target_complex_map.get(entry["complex"]) if complex_obj is None: continue identity = canonical_cyclic_identity(strand.name for strand in complex_obj.strands) on_target_identities.add(identity) strand_names.update(identity) include_identities = set() exclude_identities = set() for field, identities in ( ("include_complexes", include_identities), ("exclude_complexes", exclude_identities), ): for line in tube.get(field, []): names = tuple(token.strip() for token in line.split("+") if token.strip()) if names: identity = canonical_cyclic_identity(names) identities.add(identity) strand_names.update(identity) max_size = tube["max_size"] automatic_count = cyclic_complex_count(len(strand_names), max_size) excluded_automatic = { identity for identity in exclude_identities if len(identity) <= max_size and identity not in on_target_identities } explicit_outside = { identity for identity in on_target_identities.union(include_identities) if len(identity) > max_size } tube_upper_bound = automatic_count - len(excluded_automatic) + len(explicit_outside) largest_tube_upper_bound = max(largest_tube_upper_bound, tube_upper_bound) total_upper_bound += tube_upper_bound off_target_upper_bound += max(0, tube_upper_bound - len(on_target_identities)) summary.update( estimated_complexes_upper_bound=total_upper_bound, estimated_off_targets_upper_bound=off_target_upper_bound, largest_tube_complexes_upper_bound=largest_tube_upper_bound, ) return summary def build_strands(strand_payload, model, material): if not strand_payload: raise ValueError("At least one strand is required.") strand_map = {} ordered = [] for row in strand_payload: name = (row.get("name") or "").strip() sequence = normalize_sequence(row.get("sequence") or "", material) if not name: raise ValueError("Every strand needs a name.") if not sequence: raise ValueError(f"Strand {name} is missing a sequence.") if name in strand_map: raise ValueError(f"Duplicate strand name: {name}") sequence = validate_analysis_sequence(sequence, material, model, f"Strand {name}") concentration = finite_float(row.get("concentration", 0), f"Strand {name} concentration") if concentration < 0: raise ValueError(f"Strand {name} concentration must be non-negative.") strand = Strand(sequence, name=name) strand_map[name] = strand ordered.append( { "name": name, "sequence": sequence, "concentration": concentration, "unit": row.get("unit", "uM"), "object": strand, "length": sequence_length(model, sequence), } ) return strand_map, ordered def build_design_domains(domain_payload, model=None, material="rna"): domain_map = {} ordered = [] for row in domain_payload: name = (row.get("name") or "").strip() sequence_constraint = normalize_design_sequence(row.get("sequence") or "", material) if not name: raise ValueError("Every design domain needs a name.") if not sequence_constraint: raise ValueError(f"Design domain {name} is missing a sequence constraint.") if name in domain_map: raise ValueError(f"Duplicate domain name: {name}") if not is_valid_iupac_constraint(sequence_constraint, material, model): raise ValueError(f"Design domain {name} contains unsupported constraint characters.") domain = Domain(sequence_constraint, name=name) domain_map[name] = domain mutable = is_mutable_iupac_constraint(sequence_constraint, material) length = int(model.alphabet.domain_length(sequence_constraint)) if model is not None else len( expand_iupac_constraint(sequence_constraint, material) ) ordered.append( { "name": name, "constraint": sequence_constraint, "object": domain, "mutable": mutable, "fixed_sequence": None if mutable else expand_iupac_constraint(sequence_constraint, material), "length": length, } ) return domain_map, ordered def parse_domain_composition(text, domain_map): tokens = [token.strip() for token in re.split(r"[\s,]+", str(text or "").strip()) if token.strip()] if not tokens: raise ValueError("Domain composition cannot be empty.") domains = [] for token in tokens: complement = False domain_name = token if token.startswith("~"): complement = True domain_name = token[1:] elif token.endswith("*"): complement = True domain_name = token[:-1] if not domain_name: raise ValueError(f"Invalid domain token: {token}") if domain_name not in domain_map: raise ValueError(f"Unknown domain name in strand composition: {domain_name}") domain = domain_map[domain_name] domains.append(~domain if complement else domain) return domains def build_design_strands(strand_payload, domain_map=None, domain_rows=None, material="rna", model=None): if not strand_payload: raise ValueError("At least one design strand is required.") domain_map = domain_map or {} domain_info = {item["name"]: item for item in (domain_rows or [])} use_domain_composition = bool(domain_map) target_strand_map = {} ordered = [] for row in strand_payload: name = (row.get("name") or "").strip() raw_definition = str(row.get("sequence") or "").strip() sequence_constraint = normalize_design_sequence(raw_definition, material) if not name: raise ValueError("Every design strand needs a name.") if not raw_definition: raise ValueError(f"Design strand {name} is missing a strand definition.") if name in target_strand_map: raise ValueError(f"Duplicate strand name: {name}") if use_domain_composition: try: strand_domains = parse_domain_composition(raw_definition, domain_map) except ValueError: if (" " in raw_definition) or ("," in raw_definition) or ("~" in raw_definition) or ("*" in raw_definition): raise if not is_valid_iupac_constraint(sequence_constraint, material, model): raise inline_domain = Domain(sequence_constraint, name=name) strand_domains = [inline_domain] target_strand = TargetStrand(strand_domains, name=name) constraint_kind = "sequence_constraint" constraint_value = sequence_constraint mutable = is_mutable_iupac_constraint(sequence_constraint, material) fixed_sequence = None if mutable else expand_iupac_constraint(sequence_constraint, material) else: inline_domain = None target_strand = TargetStrand(strand_domains, name=name) constraint_kind = "domain_composition" constraint_value = raw_definition fixed_parts = [] mutable = False for domain_name, complement in parse_domain_tokens(raw_definition): info = domain_info.get(domain_name) if info is None: mutable = True fixed_parts = [] break if info.get("mutable"): mutable = True fixed_parts = [] break part = info.get("fixed_sequence") or "" fixed_parts.append(reverse_complement_fixed(part, material, model) if complement else part) fixed_sequence = None if mutable else "".join(fixed_parts) else: if not is_valid_iupac_constraint(sequence_constraint, material, model): raise ValueError(f"Design strand {name} contains unsupported constraint characters.") inline_domain = Domain(sequence_constraint, name=name) strand_domains = [inline_domain] target_strand = TargetStrand(strand_domains, name=name) constraint_kind = "sequence_constraint" constraint_value = sequence_constraint mutable = is_mutable_iupac_constraint(sequence_constraint, material) fixed_sequence = None if mutable else expand_iupac_constraint(sequence_constraint, material) target_strand_map[name] = target_strand ordered.append( { "name": name, "definition": raw_definition, "constraint": constraint_value, "constraint_kind": constraint_kind, "object": target_strand, "domains": strand_domains, "domain": inline_domain, "mutable": mutable, "fixed_sequence": fixed_sequence, } ) return target_strand_map, ordered def resolve_scope_reference(expr, domain_map, strand_map, *, allow_global=False): text = str(expr or "").strip() if not text: if allow_global: return None raise ValueError("Constraint scope cannot be empty.") if text in strand_map: return strand_map[text] return parse_domain_composition(text, domain_map) def resolve_domain_scope(expr, domain_map): text = str(expr or "").strip() if not text: raise ValueError("Domain scope cannot be empty.") return parse_domain_composition(text, domain_map) def resolve_target_complex_scope(expr, target_complex_map): text = str(expr or "").strip() if not text: raise ValueError("Target complex scope cannot be empty.") tokens = [token.strip() for token in re.split(r"[\s,]+", text) if token.strip()] if not tokens: raise ValueError("Target complex scope cannot be empty.") missing = [token for token in tokens if token not in target_complex_map] if missing: raise ValueError(f"Unknown target complex name(s): {', '.join(missing)}") return [target_complex_map[token] for token in tokens] def parse_sequence_list(text, material="rna", model=None): if isinstance(text, list): values = text else: normalized = str(text or "").replace("|", "\n").replace(";", "\n") values = normalized.splitlines() output = [normalize_design_sequence(item, material) for item in values if item and item.strip()] if not output: raise ValueError("Constraint sequence list cannot be empty.") for seq in output: if not is_valid_iupac_constraint(seq, material, model): raise ValueError(f"Invalid sequence source: {seq!r}") return output def parse_pattern_list(text, material="rna", model=None): if isinstance(text, list): values = text else: values = re.split(r"[\n,]+", str(text or "")) output = [normalize_design_sequence(item, material) for item in values if item and item.strip()] if not output: raise ValueError("Pattern constraint requires at least one pattern.") for pattern in output: if not is_valid_iupac_constraint(pattern, material, model): raise ValueError(f"Invalid pattern: {pattern!r}") return output def parse_catalog_list(text, material="rna", model=None): raw_lines = [line.strip() for line in str(text or "").splitlines() if line.strip()] if not raw_lines: raise ValueError("Library constraint requires at least one catalog row.") catalog = [] for line in raw_lines: library = [normalize_design_sequence(item, material) for item in re.split(r"[,|]+", line) if item.strip()] if not library: raise ValueError("Library constraint contains an empty catalog row.") for seq in library: if not is_valid_iupac_constraint(seq, material, model): raise ValueError(f"Invalid library sequence: {seq!r}") catalog.append(library) return catalog def build_hard_constraints(payload, domain_map, strand_map, material="rna", model=None): constraints = [] for index, row in enumerate(payload.get("hard_constraints") or [], start=1): constraint_type = (row.get("type") or "").strip().lower() if not constraint_type: continue try: def require_text(field, label): value = str(row.get(field) or "").strip() if not value: raise ValueError(f"{label} is required.") return value if constraint_type == "match": constraints.append( Match( resolve_scope_reference(require_text("left", "Match left scope"), domain_map, strand_map), resolve_scope_reference(require_text("right", "Match right scope"), domain_map, strand_map), ) ) elif constraint_type == "complementarity": constraints.append( Complementarity( resolve_scope_reference(require_text("left", "Complementarity left scope"), domain_map, strand_map), resolve_scope_reference(require_text("right", "Complementarity right scope"), domain_map, strand_map), wobble_mutations=bool(row.get("wobble_mutations", False)), ) ) elif constraint_type == "diversity": word = int(row.get("word", 4)) types = int(row.get("types", 2)) if word < 1: raise ValueError("Diversity word must be >= 1.") if types < 1 or types > 4: raise ValueError("Diversity types must be between 1 and 4.") kwargs = { "word": word, "types": types, } scope = resolve_scope_reference(row.get("scope"), domain_map, strand_map, allow_global=True) if scope is not None: kwargs["scope"] = scope constraints.append(Diversity(**kwargs)) elif constraint_type == "similarity": lower = float(row.get("min_fraction", 0.0)) upper = float(row.get("max_fraction", 1.0)) ensure_unit_interval_limits(lower, upper, "Similarity") reference = normalize_design_sequence(row.get("reference") or "", material) if not is_valid_iupac_constraint(reference, material, model): raise ValueError("Similarity reference must be a valid IUPAC constraint.") constraints.append( Similarity( resolve_scope_reference(require_text("scope", "Similarity scope"), domain_map, strand_map), reference, limits=[lower, upper], ) ) elif constraint_type == "window": constraints.append( Window( resolve_scope_reference(require_text("scope", "Window scope"), domain_map, strand_map), sources=parse_sequence_list(row.get("sources") or "", material, model), ) ) elif constraint_type == "library": constraints.append( Library( resolve_scope_reference(require_text("scope", "Library scope"), domain_map, strand_map), catalog=parse_catalog_list(row.get("catalog") or "", material, model), ) ) elif constraint_type == "pattern": kwargs = {"patterns": parse_pattern_list(row.get("patterns") or "", material, model)} scope = resolve_scope_reference(row.get("scope"), domain_map, strand_map, allow_global=True) if scope is not None: kwargs["scope"] = scope constraints.append(Pattern(**kwargs)) else: raise ValueError(f"Unsupported hard constraint type: {constraint_type}") except Exception as exc: raise ValueError(f"Invalid hard constraint #{index}: {exc}") from exc return constraints def build_soft_constraints(payload, domain_map, strand_map, target_complex_map, material="rna", model=None): constraints = [] for index, row in enumerate(payload.get("soft_constraints") or [], start=1): constraint_type = (row.get("type") or "").strip().lower() if not constraint_type: continue try: def require_text(field, label): value = str(row.get(field) or "").strip() if not value: raise ValueError(f"{label} is required.") return value if constraint_type == "pattern": weight = float(row.get("weight", 1.0)) if weight < 0: raise ValueError("Pattern weight must be non-negative.") kwargs = { "patterns": parse_pattern_list(row.get("patterns") or "", material, model), "weight": weight, } scope = resolve_scope_reference(row.get("scope"), domain_map, strand_map, allow_global=True) if scope is not None: kwargs["scope"] = scope constraints.append(Pattern(**kwargs)) elif constraint_type == "similarity": lower = float(row.get("min_fraction", 0.0)) upper = float(row.get("max_fraction", 1.0)) ensure_unit_interval_limits(lower, upper, "Similarity") reference = normalize_design_sequence(row.get("reference") or "", material) if not is_valid_iupac_constraint(reference, material, model): raise ValueError("Similarity reference must be a valid IUPAC constraint.") weight = float(row.get("weight", 1.0)) if weight < 0: raise ValueError("Similarity weight must be non-negative.") constraints.append( Similarity( resolve_scope_reference(require_text("scope", "Similarity scope"), domain_map, strand_map), reference, limits=[lower, upper], weight=weight, ) ) elif constraint_type == "ssm": word = int(row.get("word", 4)) if word < 1: raise ValueError("SSM word must be >= 1.") weight = float(row.get("weight", 1.0)) if weight < 0: raise ValueError("SSM weight must be non-negative.") kwargs = { "word": word, "weight": weight, } scope = resolve_target_complex_scope(row.get("scope"), target_complex_map) if row.get("scope") else None if scope is not None: kwargs["scope"] = scope constraints.append(SSM(**kwargs)) elif constraint_type == "energy_match": weight = float(row.get("weight", 1.0)) if weight < 0: raise ValueError("EnergyMatch weight must be non-negative.") kwargs = { "domains": resolve_domain_scope(require_text("scope", "EnergyMatch domain scope"), domain_map), "weight": weight, } if row.get("energy_ref") not in {None, ""}: kwargs["energy_ref"] = float(row.get("energy_ref")) constraints.append(EnergyMatch(**kwargs)) else: raise ValueError(f"Unsupported soft constraint type: {constraint_type}") except Exception as exc: raise ValueError(f"Invalid soft constraint #{index}: {exc}") from exc return constraints def parse_design_complexes(payload, target_strand_map, strand_rows=None): target_rows = payload.get("design_complexes") or payload.get("design_targets") or [] if not target_rows: raise ValueError("At least one design target complex is required.") targets = [] target_complex_map = {} strand_info = {item["name"]: item for item in (strand_rows or [])} for idx, row in enumerate(target_rows, start=1): name = (row.get("name") or f"target_{idx}").strip() or f"target_{idx}" if name in target_complex_map: raise ValueError(f"Duplicate design target complex name: {name}") strand_text = (row.get("strands") or "").strip() structure = (row.get("structure") or "").strip() if not strand_text: raise ValueError(f"Design target complex {name} is missing strand membership.") if not structure: raise ValueError(f"Design target complex {name} is missing a target structure.") tokens = [token.strip() for token in strand_text.split("+") if token.strip()] if not tokens: raise ValueError(f"Design target complex {name} has an invalid strand list.") missing = [token for token in tokens if token not in target_strand_map] if missing: raise ValueError( f"Unknown strand name(s) in design target complex {name}: {', '.join(missing)}" ) bonus = float(row.get("bonus", 0) or 0) target_complex = TargetComplex( [target_strand_map[token] for token in tokens], structure, bonus=bonus, name=name, ) mutable = any(strand_info.get(token, {}).get("mutable", True) for token in tokens) target_payload = { "name": name, "strands": tokens, "structure": structure, "bonus": bonus, "object": target_complex, "mutable": mutable, "optimization_status": "included", } targets.append(target_payload) target_complex_map[name] = target_complex return targets, target_complex_map def parse_design_set_members(value, target_complex_map, target_strand_map, label): members = [] identities = set() lines = [line.strip() for line in str(value or "").splitlines() if line.strip()] for index, line in enumerate(lines, start=1): if line in target_complex_map: member = target_complex_map[line] identity = tuple(strand.name for strand in member.strands) else: names = [token.strip() for token in line.split("+") if token.strip()] missing = [name for name in names if name not in target_strand_map] if not names or missing: detail = f": {', '.join(missing)}" if missing else "" raise ValueError(f"Invalid {label} entry #{index}{detail}") member = [target_strand_map[name] for name in names] identity = tuple(names) members.append(member) identities.add(identity) return members, identities def lines_from_set_members(members): return ["+".join(strand.name for strand in member.strands) if hasattr(member, "strands") else "+".join(strand.name for strand in member) for member in members] def parse_design_tubes(payload, target_rows, target_complex_map, target_strand_map, default_max_size): tube_rows = payload.get("design_tubes") or [] if not tube_rows: legacy_targets = payload.get("design_targets") or [] if legacy_targets: on_targets = [] for row in legacy_targets: if row.get("concentration") in {None, ""}: continue on_targets.append( { "complex": row.get("name"), "concentration": row.get("concentration"), "unit": row.get("unit", "uM"), } ) if on_targets: tube_rows = [{ "name": ((payload.get("tube") or {}).get("name") or "design_tube"), "max_size": default_max_size, "on_targets": on_targets, }] if not tube_rows: raise ValueError("Tube design requires at least one target tube.") tubes = [] ordered_rows = [] tube_name_set = set() for index, row in enumerate(tube_rows, start=1): tube_name = (row.get("name") or f"tube_{index}").strip() or f"tube_{index}" if tube_name in tube_name_set: raise ValueError(f"Duplicate target tube name: {tube_name}") tube_name_set.add(tube_name) max_size = int(row.get("max_size", default_max_size)) if max_size < 1 or max_size > 8: raise ValueError(f"Target tube {tube_name} max_size must be between 1 and 8.") on_targets_input = row.get("on_targets") or [] if not on_targets_input: raise ValueError(f"Target tube {tube_name} must include at least one on-target complex.") on_targets = {} serialized_on_targets = [] seen_complex_names = set() for entry in on_targets_input: complex_name = (entry.get("complex") or "").strip() if complex_name not in target_complex_map: raise ValueError(f"Unknown target complex {complex_name} in tube {tube_name}.") if complex_name in seen_complex_names: raise ValueError(f"Duplicate on-target complex {complex_name} in tube {tube_name}.") seen_complex_names.add(complex_name) concentration = finite_float( entry.get("concentration", 0), f"Target tube {tube_name} concentration", ) unit = entry.get("unit", "uM") if concentration <= 0: raise ValueError(f"Target tube {tube_name} requires positive target concentrations.") concentration_M = unit_to_molar(concentration, unit) complex_obj = target_complex_map[complex_name] on_targets[complex_obj] = concentration_M serialized_on_targets.append( { "complex": complex_name, "concentration": concentration, "unit": unit, "target_concentration_M": concentration_M, } ) include, include_ids = parse_design_set_members( row.get("include_complexes", ""), target_complex_map, target_strand_map, f"target tube {tube_name} include list", ) exclude, exclude_ids = parse_design_set_members( row.get("exclude_complexes", ""), target_complex_map, target_strand_map, f"target tube {tube_name} exclude list", ) overlap = include_ids.intersection(exclude_ids) if overlap: display = ", ".join("+".join(names) for names in sorted(overlap)) raise ValueError(f"Target tube {tube_name} complexes cannot be both included and excluded: {display}") tube = TargetTube( on_targets=on_targets, off_targets=SetSpec(max_size=max_size, include=tuple(include), exclude=tuple(exclude)), name=tube_name, ) ordered_rows.append( { "name": tube_name, "max_size": max_size, "on_targets": serialized_on_targets, "include_complexes": lines_from_set_members(include), "exclude_complexes": lines_from_set_members(exclude), "object": tube, } ) tubes.append(tube) return ordered_rows, tubes def payload_with_allowed_design_targets(payload, allowed_target_names): allowed = set(allowed_target_names) filtered_tubes = [] for row in payload.get("design_tubes") or []: on_targets = [entry for entry in (row.get("on_targets") or []) if (entry.get("complex") or "").strip() in allowed] if on_targets: tube_row = dict(row) tube_row["on_targets"] = on_targets filtered_tubes.append(tube_row) output = dict(payload) output["design_tubes"] = filtered_tubes return output def validate_design_object_names(design_domains, design_strands, target_rows, tube_rows): name_map = {} for kind, rows in ( ("domain", design_domains), ("strand", design_strands), ("target complex", target_rows), ("target tube", tube_rows), ): for row in rows: name = (row.get("name") or "").strip() if not name: continue prev = name_map.get(name) if prev and prev != kind: raise ValueError( f"Object name {name!r} is reused across {prev} and {kind}. " "Design object names must be unique in a job." ) name_map[name] = kind def build_defect_weights(payload, design_mode, design_domains, design_strands, target_rows, tube_rows): rows = payload.get("defect_weights") or [] if not rows: return None domain_index = {item["name"]: item["object"] for item in design_domains} strand_index = {item["name"]: item["object"] for item in design_strands} complex_index = {item["name"]: item["object"] for item in target_rows} tube_index = {item["name"]: item["object"] for item in tube_rows} weight_base = [item["object"] for item in tube_rows] if design_mode == "tube" else [item["object"] for item in target_rows] weights = Weights(weight_base) for index, row in enumerate(rows, start=1): kind = (row.get("kind") or "").strip().lower() value = float(row.get("weight", 1.0)) if value < 0: raise ValueError(f"Invalid defect weight #{index}: weight must be non-negative") name = (row.get("name") or "").strip() try: if kind == "global": weights[:] = value elif kind == "domain": weights[domain_index[name]] = value elif kind == "strand": weights[:, strand_index[name]] = value elif kind == "complex": weights[:, :, complex_index[name]] = value elif kind == "tube": if design_mode != "tube": raise ValueError("tube-level defect weights are only valid in tube design mode") weights[:, :, :, tube_index[name]] = value else: raise ValueError(f"Unsupported defect weight kind: {kind}") except KeyError as exc: raise ValueError(f"Unknown entity for defect weight #{index}: {name}") from exc except Exception as exc: raise ValueError(f"Invalid defect weight #{index}: {exc}") from exc return weights def parse_complex_lines(text, strand_map, *, required=True, label="Complex mode", name_prefix="complex"): complexes = [] raw_lines = [line.strip() for line in str(text or "").splitlines() if line.strip()] if not raw_lines: if required: raise ValueError(f"{label} requires at least one complex definition.") return complexes for idx, line in enumerate(raw_lines, start=1): definition_parts = [part.strip() for part in line.split(";") if part.strip()] composition = definition_parts[0] tokens = [token.strip() for token in composition.split("+") if token.strip()] if not tokens: raise ValueError(f"Invalid complex definition on line {idx}: {line}") bonus = 0.0 for option in definition_parts[1:]: match = re.fullmatch(r"bonus\s*=\s*(.+)", option, flags=re.IGNORECASE) if not match: raise ValueError(f"Unsupported complex option on line {idx}: {option}") bonus = finite_float(match.group(1), f"Complex line {idx} bonus") missing = [token for token in tokens if token not in strand_map] if missing: raise ValueError( f"Unknown strand name(s) in complex line {idx}: {', '.join(missing)}" ) strands = [strand_map[token] for token in tokens] complexes.append(Complex(strands, name=f"{name_prefix}_{idx}", bonus=bonus)) return complexes def build_tube_set_spec(tube_cfg, strand_map, max_size): included = parse_complex_lines( tube_cfg.get("include_complexes", ""), strand_map, required=False, label="Tube include list", name_prefix="included_complex", ) excluded = parse_complex_lines( tube_cfg.get("exclude_complexes", ""), strand_map, required=False, label="Tube exclude list", name_prefix="excluded_complex", ) overlap = set(included).intersection(excluded) if overlap: names = ", ".join(sorted(stringify_complex(item) for item in overlap)) raise ValueError(f"Tube complexes cannot be both included and excluded: {names}") return ( SetSpec(max_size=max_size, include=tuple(included), exclude=tuple(excluded)), included, excluded, ) def seconds_since(started_at): return round(time.perf_counter() - started_at, 6) def build_performance_summary(timings, workload, notes, pipeline): measured = {key: round(float(value), 6) for key, value in timings.items()} dominant_stage = max(measured, key=measured.get) if measured else None return { "pipeline": pipeline, "timings_seconds": measured, "dominant_stage": dominant_stage, "workload": workload, "notes": notes, } def stringify_complex(complex_obj): return " + ".join(strand.name for strand in complex_obj.strands) def flatten_sequence(complex_obj, alphabet=None, for_plot=False): sequences = [] for strand in complex_obj.strands: raw = str(strand) if alphabet is not None and for_plot: raw = str(alphabet.sequence(raw)).upper() sequences.append(raw) return "".join(sequences) def sanitize_structure_for_rnaplot(structure): return str(structure).replace("+", "") def safe_name(name): return re.sub(r"[^A-Za-z0-9_.-]+", "_", name).strip("_") or "structure" def render_rnaplot_svg(complex_obj, structure, suffix, alphabet=None): if not ENABLE_RNAPLOT: return None seq_name = safe_name(f"{stringify_complex(complex_obj)}_{suffix}") sequence = flatten_sequence(complex_obj, alphabet=alphabet, for_plot=True) structure_text = sanitize_structure_for_rnaplot(structure) with tempfile.TemporaryDirectory(prefix="rnaplot-") as tmpdir: tmp_path = Path(tmpdir) input_path = tmp_path / f"{seq_name}.seq" output_path = tmp_path / f"{seq_name}_ss.svg" input_path.write_text( f">{seq_name}\n{sequence}\n{structure_text}\n", encoding="utf-8", newline="\n", ) process = subprocess.run( [RNA_PLOT_CMD, "-f", "svg", input_path.name], cwd=tmpdir, capture_output=True, text=True, timeout=20, ) if process.returncode != 0: raise RuntimeError(process.stderr.strip() or "RNAplot failed.") if not output_path.exists(): raise RuntimeError("RNAplot finished without producing an SVG file.") return output_path.read_text(encoding="utf-8") def render_structure_svg(complex_obj, structure, suffix, alphabet=None): structure_text = str(structure) multistrand = len(complex_obj.strands) > 1 and "+" in structure_text should_try_split = STRUCTURE_PLOT_MODE == "split" or ( STRUCTURE_PLOT_MODE != "rnaplot" and multistrand ) split_error = None if should_try_split: try: return render_split_strands_svg( [str(alphabet.sequence(str(strand))).upper() if alphabet is not None else str(strand) for strand in complex_obj.strands], structure_text, title=safe_name(f"{stringify_complex(complex_obj)}_{suffix}"), ) except Exception as exc: split_error = exc if STRUCTURE_PLOT_MODE == "split": raise RuntimeError(f"Split-strand layout failed: {exc}") from exc if STRUCTURE_PLOT_MODE == "split": if split_error is not None: raise RuntimeError(f"Split-strand layout failed: {split_error}") from split_error return None try: return render_rnaplot_svg(complex_obj, structure, suffix, alphabet=alphabet) except Exception as exc: if split_error is not None: raise RuntimeError( f"Split-strand layout failed: {split_error}; RNAplot fallback failed: {exc}" ) from exc raise def serialize_structures(complex_obj, items, plot_kind=None, alphabet=None): output = [] for index, item in enumerate(items or []): row = { "structure": str(item.structure), "energy": round(float(item.energy), 6), "stack_energy": round(float(item.stack_energy), 6), } if plot_kind and index == 0: try: row["rnaplot_svg"] = render_structure_svg( complex_obj, item.structure, plot_kind, alphabet=alphabet ) except Exception as exc: row["rnaplot_error"] = str(exc) output.append(row) return output def serialize_samples(items): return [str(item) for item in (items or [])] def serialize_pairs(pair_matrix, preview_limit=24): if pair_matrix is None: return None dense = numpy.asarray(pair_matrix.to_array(), dtype=float) preview = dense[:preview_limit, :preview_limit] return { "shape": list(dense.shape), "preview": numpy.round(preview, 6).tolist(), "preview_size": int(preview_limit), "preview_truncated": bool(dense.shape[0] > preview_limit), } def parse_structure_pairs(structure): stack = [] pairs = [] compact_index = -1 index_map = [] for char in str(structure): if char == "+": continue compact_index += 1 index_map.append(compact_index) if char == "(": stack.append(compact_index) elif char == ")": if not stack: raise ValueError("Unbalanced structure: missing opening bracket.") left = stack.pop() pairs.append((left, compact_index)) if stack: raise ValueError("Unbalanced structure: missing closing bracket.") pairs.sort() return pairs, compact_index + 1 def build_structure_probabilities(structure, pair_matrix): if pair_matrix is None: return None dense = numpy.asarray(pair_matrix.to_array(), dtype=float) mfe_pairs, structure_length = parse_structure_pairs(structure) if dense.shape[0] != dense.shape[1]: return None if structure_length != dense.shape[0]: return None pair_partner = {} for left, right in mfe_pairs: pair_partner[left] = right pair_partner[right] = left residue_probabilities = [] for index in range(structure_length): partner = pair_partner.get(index) if partner is None: probability = float(dense[index, index]) else: probability = float(dense[index, partner]) residue_probabilities.append(round(probability, 6)) pair_probabilities = [ { "i": left + 1, "j": right + 1, "probability": round(float(dense[left, right]), 6), } for left, right in mfe_pairs ] return { "length": structure_length, "residue_probabilities": residue_probabilities, "pair_probabilities": pair_probabilities, } def serialize_complex_result(complex_obj, data, pairs_preview_size=24, alphabet=None): payload = { "name": getattr(complex_obj, "name", None), "display": stringify_complex(complex_obj), "strand_names": [strand.name for strand in complex_obj.strands], "sequence": flatten_sequence(complex_obj), "strand_lengths": [ int(strand.nt(alphabet)) if alphabet is not None else len(str(strand)) for strand in complex_obj.strands ], "bonus_kcal_mol": float(getattr(complex_obj, "bonus", 0.0)), } if data.pfunc is not None: payload["pfunc"] = format(data.pfunc, "g") if isinstance(data.pfunc, Decimal) else str(data.pfunc) if data.free_energy is not None: payload["free_energy_kcal_mol"] = round(float(data.free_energy), 6) if data.ensemble_size is not None: payload["ensemble_size"] = int(data.ensemble_size) if data.mfe_stack is not None: payload["mfe_stack_kcal_mol"] = round(float(data.mfe_stack), 6) if data.mfe is not None: payload["mfe"] = serialize_structures( complex_obj, data.mfe, plot_kind="mfe", alphabet=alphabet ) if data.subopt is not None: payload["subopt"] = serialize_structures(complex_obj, data.subopt, alphabet=alphabet) if data.sample is not None: payload["sample"] = serialize_samples(data.sample) if data.pairs is not None: payload["pairs"] = serialize_pairs(data.pairs, preview_limit=pairs_preview_size) if payload.get("mfe"): payload["mfe"][0]["pair_probability_annotations"] = build_structure_probabilities( payload["mfe"][0]["structure"], data.pairs ) return payload def serialize_target_defect_rows(df, limit=None): if df is None: return [] rows = [] for record in df.to_dict("records"): row = {} for key, value in record.items(): if key in {"tube", "complex"}: continue if isinstance(value, numpy.floating): row[key] = float(value) else: row[key] = value rows.append(row) if limit is not None: rows = rows[:limit] return rows def serialize_design_result( design_result, design_mode, model, model_summary, design_options, target_rows, tube_rows, ordered_domains, ordered_strands, ): analysis_map = getattr(design_result, "to_analysis", {}) or {} designed_domains = [] designed_domain_map = getattr(design_result, "domains", {}) or {} for item in ordered_domains: designed_domain = designed_domain_map.get(item["object"]) domain_sequence = str(designed_domain) if designed_domain is not None else item.get("fixed_sequence") designed_domains.append( { "name": item["name"], "constraint": item["constraint"], "sequence": domain_sequence, "length": int(model.alphabet.domain_length(domain_sequence)) if domain_sequence is not None else None, "mutable": bool(item.get("mutable", True)), } ) designed_strands = [] designed_strand_by_name = {} for item in ordered_strands: target_strand = item["object"] analysis_strand = get_mapping_value(analysis_map, target_strand) sequence = str(analysis_strand) if analysis_strand is not None else item.get("fixed_sequence") designed_strands.append( { "name": item["name"], "constraint": item["constraint"], "constraint_kind": item.get("constraint_kind", "sequence_constraint"), "definition": item.get("definition", item["constraint"]), "sequence": sequence, "length": sequence_length(model, sequence) if sequence is not None else None, "mutable": bool(item.get("mutable", True)), } ) if sequence is not None: designed_strand_by_name[item["name"]] = sequence target_complexes = [] for target in target_rows: target_complex = target["object"] analysis_complex = get_mapping_value(analysis_map, target_complex) if analysis_complex is not None: display = stringify_complex(analysis_complex) sequence = flatten_sequence(analysis_complex) else: display = " + ".join(target["strands"]) sequence = "".join(designed_strand_by_name.get(name, "") for name in target["strands"]) target_complexes.append( { "name": target["name"], "display": display, "strand_names": list(target["strands"]), "structure": target["structure"], "bonus": target.get("bonus", 0), "sequence": sequence, "target_concentration_M": target.get("target_concentration_M"), "optimization_status": target.get("optimization_status", "included"), "mutable": bool(target.get("mutable", True)), } ) concentration_rows = [] concentration_table = getattr(getattr(design_result, "concentrations", None), "table", None) if design_mode == "tube" and concentration_table is not None: for record in concentration_table.to_dict("records"): concentration_rows.append( { "tube_name": record["tube_name"], "complex_name": record["complex_name"], "concentration_M": float(record["concentration"]), "target_concentration_M": float(record["target_concentration"]), "nucleotides": int(record["nucleotides"]), } ) concentration_rows.sort(key=lambda row: (row["tube_name"], -row["concentration_M"])) tube_results = [] if design_mode == "tube": for tube_row in tube_rows: rows = [row for row in concentration_rows if row["tube_name"] == tube_row["name"]] tube_results.append({ "name": tube_row["name"], "max_size": tube_row["max_size"], "include_complexes": tube_row.get("include_complexes", []), "exclude_complexes": tube_row.get("exclude_complexes", []), "complex_concentrations": rows[: design_options["result_limit"]], "total_complex_concentrations": len(rows), }) displayed_concentration_rows = [ concentration for tube_result in tube_results for concentration in tube_result["complex_concentrations"] ] return { "workflow": "design", "mode": design_mode, "model": model_summary, "compute": ["design"], "options": design_options, "strands": designed_strands, "complexes": target_complexes, "total_complex_count": len(target_complexes), "displayed_complex_count": len(target_complexes), "tube": tube_results[0] if tube_results else None, "tubes": tube_results, "design": { "ensemble_defect": float(design_result.ensemble_defect), "weighted_ensemble_defect": float(design_result.defects.weighted_ensemble_defect), "objective": float(design_objective(design_result)), "domains": designed_domains, "stats": { key: float(value) if isinstance(value, (int, float, numpy.floating)) else value for key, value in design_result.stats.items() }, "job_options": { "f_stop": design_options["stop_condition"], "seed": design_options["seed"], "wobble_mutations": design_options["wobble_mutations"], "max_time_seconds": design_options["max_time_seconds"], "fixed_target_policy": design_options["fixed_target_policy"], }, "optimization": { "included_targets": [row["name"] for row in target_rows if row.get("optimization_status") == "included"], "excluded_fixed_targets": [row["name"] for row in target_rows if row.get("optimization_status") == "fixed_excluded"], }, "target_tubes": [ { "name": row["name"], "max_size": row["max_size"], "on_targets": row["on_targets"], "include_complexes": row.get("include_complexes", []), "exclude_complexes": row.get("exclude_complexes", []), } for row in tube_rows ], "targets": target_complexes, "defects": { "tubes": serialize_target_defect_rows(getattr(design_result.defects, "tubes", None)), "complexes": serialize_target_defect_rows(getattr(design_result.defects, "complexes", None)), "tube_complexes": serialize_target_defect_rows(getattr(design_result.defects, "tube_complexes", None)), }, "concentrations": displayed_concentration_rows, }, } def design_objective(result): objectives = getattr(getattr(result, "defects", None), "objectives", None) if objectives is not None and "weighted" in objectives: return float(objectives["weighted"].sum()) return float(result.ensemble_defect) def serialize_utility_structure_energies(items): return [ { "structure": str(item.structure), "energy": float(item.energy), "stack_energy": float(item.stack_energy), } for item in items ] def run_utility_payload(payload, model, model_summary, progress_callback=None): utility = payload.get("utility") or {} operation = str(utility.get("operation", "pfunc")).strip().lower() if operation not in VALID_UTILITY_OPERATIONS: raise ValueError(f"Unsupported utility operation: {operation}") if progress_callback: progress_callback("validating", "Validating utility inputs", {"operation": operation}) rows = payload.get("strands") or [] material = model_summary["material"] sequences = [normalize_sequence(row.get("sequence") or "", material) for row in rows] if operation not in {"seq_distance", "struc_distance"}: if operation != "des" and (not sequences or any(not sequence for sequence in sequences)): raise ValueError(f"{operation} requires at least one sequence.") if operation == "des": if material in MIXED_MATERIAL_PREFIXES: raise ValueError("Mixed-material des utility is not enabled in this release.") if any(not sequence or not re.fullmatch(rf"[{IUPAC_CODES}]+", sequence) for sequence in sequences): raise ValueError("des received an invalid IUPAC sequence alphabet.") else: sequences = [ validate_analysis_sequence(sequence, material, model, f"Utility strand {index + 1}") for index, sequence in enumerate(sequences) ] structure = str(utility.get("structure") or "").strip() options = parse_options(payload) started = time.perf_counter() if progress_callback: progress_callback("computing", f"Running NUPACK utility: {operation}", { "operation": operation, "strand_count": len(sequences), "nucleotides": sum(sequence_length(model, sequence) for sequence in sequences), }) output = {} if operation == "pfunc": partition_function, free_energy = nupack.pfunc(sequences, model) output = {"partition_function": str(partition_function), "free_energy_kcal_per_mol": float(free_energy)} elif operation == "structure_energy": if not structure: raise ValueError("structure_energy requires a structure.") output = {"energy_kcal_per_mol": float(nupack.structure_energy( sequences, structure, model, distinguishable=bool(utility.get("distinguishable", False)) ))} elif operation == "structure_probability": if not structure: raise ValueError("structure_probability requires a structure.") output = {"probability": float(nupack.structure_probability(sequences, structure, model))} elif operation == "sample": output = {"structures": [str(item) for item in nupack.sample(sequences, options["num_sample"], model)]} elif operation == "pairs": matrix = nupack.pairs( sequences, model, sparsity_fraction=options["sparsity_fraction"], sparsity_threshold=options["sparsity_threshold"], ) output = {"pairs": serialize_pairs(matrix, preview_limit=options["pairs_preview_size"])} elif operation == "mfe": output = {"structures": serialize_utility_structure_energies(nupack.mfe( sequences, model, max_subopt_count=options["max_subopt_count"], indistinguishable_search=options["indistinguishable_search"], ))} elif operation == "subopt": output = {"structures": serialize_utility_structure_energies( nupack.subopt( sequences, options["energy_gap"], model, indistinguishable_search=options["indistinguishable_search"], max_subopt_count=options["max_subopt_count"], ) )} elif operation == "ensemble_size": output = {"ensemble_size": int(nupack.ensemble_size(sequences, model))} elif operation == "des": if not structure: raise ValueError("des requires a target structure.") output = {"sequences": [str(item) for item in nupack.des( structure, strands=sequences or None, model=model )]} elif operation == "defect": if not structure: raise ValueError("defect requires a target structure.") output = {"defect": float(nupack.defect(structure, sequences, model=model))} elif operation == "seq_distance": first = normalize_sequence(utility.get("input_a") or "", material) second = normalize_sequence(utility.get("input_b") or "", material) if not first or not second: raise ValueError("seq_distance requires two sequences.") first = validate_analysis_sequence(first, material, model, "seq_distance input A") second = validate_analysis_sequence(second, material, model, "seq_distance input B") output = {"distance": int(model.alphabet.seq_distance(first, second))} elif operation == "struc_distance": first = str(utility.get("input_a") or "").strip() second = str(utility.get("input_b") or "").strip() if not first or not second: raise ValueError("struc_distance requires two structures.") output = {"distance": int(nupack.struc_distance(first, second))} compute_seconds = seconds_since(started) if progress_callback: progress_callback("serializing", "Serializing utility result", {"operation": operation}) result = { "workflow": "utilities", "mode": "utility", "model": model_summary, "compute": [operation], "operation": operation, "options": options, "strands": [{"name": row.get("name") or f"strand_{i + 1}", "sequence": sequence} for i, (row, sequence) in enumerate(zip(rows, sequences))], "utility": output, "complexes": [], "total_complex_count": 0, "displayed_complex_count": 0, } result["performance"] = build_performance_summary( {"nupack_compute": compute_seconds}, { "strand_count": len(sequences), "input_nucleotides": sum(sequence_length(model, item) for item in sequences), }, [], f"utility_{operation}", ) return result def sort_concentrations(complex_concentrations): rows = [] for complex_obj, value in complex_concentrations.items(): rows.append( { "display": stringify_complex(complex_obj), "strand_names": [strand.name for strand in complex_obj.strands], "concentration_M": float(value), } ) rows.sort(key=lambda row: row["concentration_M"], reverse=True) return rows def run_job_payload(payload, progress_callback=None): job_started = time.perf_counter() workflow = payload.get("workflow", "analysis") mode = payload.get("mode", "tube") model_input = payload.get("model") or {} model = build_model(model_input) model_summary = build_model_summary(model_input) if workflow == "utilities": return run_utility_payload(payload, model, model_summary, progress_callback) if workflow == "design": if progress_callback: progress_callback("preparing_design", "Building NUPACK design specification", { "mode": mode, "trials": int((payload.get("design") or {}).get("trials", 1)), }) design_options = parse_design_options(payload) design_job_option_kwargs = { "f_stop": design_options["stop_condition"], "seed": design_options["seed"], "wobble_mutations": design_options["wobble_mutations"], } if design_options["max_time_seconds"] > 0: design_job_option_kwargs["max_time"] = design_options["max_time_seconds"] design_job_options = DesignOptions(**design_job_option_kwargs) design_domain_map, design_domains = build_design_domains( payload.get("design_domains") or [], model=model, material=model_summary["material"] ) target_strand_map, design_strands = build_design_strands( payload.get("strands") or [], domain_map=design_domain_map, domain_rows=design_domains, material=model_summary["material"], model=model, ) target_rows, target_complex_map = parse_design_complexes(payload, target_strand_map, strand_rows=design_strands) optimization_target_rows = target_rows optimization_target_complex_map = target_complex_map if design_options["fixed_target_policy"] == "exclude_from_optimization": optimization_target_rows = [row for row in target_rows if row.get("mutable", True)] fixed_names = {row["name"] for row in target_rows if not row.get("mutable", True)} for row in target_rows: if row["name"] in fixed_names: row["optimization_status"] = "fixed_excluded" optimization_target_complex_map = { row["name"]: row["object"] for row in optimization_target_rows } if not optimization_target_rows: raise ValueError("Design contains no mutable target complexes after fixed-target filtering.") hard_constraints = build_hard_constraints( payload, design_domain_map, target_strand_map, model_summary["material"], model ) soft_constraints = build_soft_constraints( payload, design_domain_map, target_strand_map, optimization_target_complex_map, model_summary["material"], model, ) tube_rows = [] design_tubes = [] if mode == "tube": optimization_payload = payload if design_options["fixed_target_policy"] == "exclude_from_optimization": optimization_payload = payload_with_allowed_design_targets( payload, {row["name"] for row in optimization_target_rows}, ) tube_rows, design_tubes = parse_design_tubes( optimization_payload, optimization_target_rows, optimization_target_complex_map, target_strand_map, design_options["off_target_max_size"], ) validate_design_object_names(design_domains, design_strands, target_rows, tube_rows) defect_weights = build_defect_weights( payload, mode, design_domains, design_strands, target_rows, tube_rows, ) design_workload = build_design_workload_summary( design_domains, target_rows, tube_rows, optimization_target_complex_map, design_options, ) if mode == "tube": tube_name = ((payload.get("tube") or {}).get("name") or "design_tube").strip() or "design_tube" design_job = tube_design( tubes=design_tubes, model=model, options=design_job_options, hard_constraints=hard_constraints, soft_constraints=soft_constraints, defect_weights=defect_weights, ) elif mode == "complex": design_job = complex_design( complexes=[row["object"] for row in optimization_target_rows], model=model, options=design_job_options, hard_constraints=hard_constraints, soft_constraints=soft_constraints, defect_weights=defect_weights, ) else: raise ValueError(f"Unsupported design mode: {mode}") setup_seconds = seconds_since(job_started) design_started = time.perf_counter() if progress_callback: progress_callback("optimizing", "Running NUPACK design optimization", { "mode": mode, "trials": design_options["trials"], "target_complexes": len(optimization_target_rows), "target_tubes": len(tube_rows), "f_stop": design_options["stop_condition"], **design_workload, }) results = design_job.run(trials=design_options["trials"]) design_seconds = seconds_since(design_started) best_result = min(results, key=design_objective) serialization_started = time.perf_counter() if progress_callback: progress_callback("serializing", "Serializing best design result", { "completed_trials": len(results), "best_objective": design_objective(best_result), }) serialized_result = serialize_design_result( best_result, mode, model, model_summary, design_options, target_rows, tube_rows, design_domains, design_strands, ) serialization_seconds = seconds_since(serialization_started) notes = [] if design_options["trials"] > 1: notes.append("trials repeats the complete stochastic design search; runtime grows approximately with trial count.") if design_options["stop_condition"] < 0.02: notes.append("A strict f_stop can keep the optimizer searching much longer when the target defect is hard to reach.") if mode == "tube" and design_options["off_target_max_size"] > 2: notes.append("off_target_max_size expands the off-target ensemble combinatorially.") serialized_result["performance"] = build_performance_summary( { "input_setup": setup_seconds, "nupack_design": design_seconds, "serialization": serialization_seconds, }, { "domain_count": len(design_domains), "strand_count": len(design_strands), "target_complex_count": len(target_rows), "target_tube_count": len(tube_rows), "trials": design_options["trials"], "off_target_max_size": design_options["off_target_max_size"], "hard_constraint_count": len(payload.get("hard_constraints") or []), "soft_constraint_count": len(payload.get("soft_constraints") or []), **design_workload, }, notes, "tube_design" if mode == "tube" else "complex_design", ) return serialized_result if workflow != "analysis": raise ValueError(f"Unsupported workflow: {workflow}") compute = parse_compute(payload) options = parse_options(payload) nupack_options = { key: value for key, value in options.items() if key not in {"result_limit", "pairs_preview_size"} } strand_map, strands = build_strands( payload.get("strands") or [], model, model_summary["material"] ) if mode == "tube": tube_compute = [item for item in compute if item != "pfunc"] tube_cfg = payload.get("tube") or {} max_size = int(tube_cfg.get("max_size", 2)) if max_size < 1 or max_size > 8: raise ValueError("max_size must be between 1 and 8.") strand_concentrations = { item["object"]: unit_to_molar(item["concentration"], item["unit"]) for item in strands } set_spec, included_complexes, excluded_complexes = build_tube_set_spec(tube_cfg, strand_map, max_size) tube = Tube( strands=strand_concentrations, complexes=set_spec, name=(tube_cfg.get("name") or "tube1").strip() or "tube1", ) setup_seconds = seconds_since(job_started) nupack_started = time.perf_counter() if progress_callback: progress_callback("computing", "Running NUPACK tube analysis", { "max_size": max_size, "strand_count": len(strands), "compute": tube_compute, }) result = tube_analysis([tube], model=model, compute=tube_compute, options=nupack_options) nupack_seconds = seconds_since(nupack_started) tube_result = result[tube] serialization_started = time.perf_counter() if progress_callback: progress_callback("serializing", "Serializing tube analysis result", {}) concentration_rows = sort_concentrations(tube_result.complex_concentrations) total_complex_count = len(concentration_rows) result_limit = options["result_limit"] displayed_concentration_rows = concentration_rows[:result_limit] concentration_by_display = { row["display"]: row["concentration_M"] for row in displayed_concentration_rows } complexes = [] for complex_obj, data in result.complexes.items(): display_name = stringify_complex(complex_obj) if display_name not in concentration_by_display: continue row = serialize_complex_result( complex_obj, data, pairs_preview_size=options["pairs_preview_size"], alphabet=model.alphabet, ) row["concentration_M"] = concentration_by_display[row["display"]] complexes.append(row) complexes.sort(key=lambda row: row["concentration_M"], reverse=True) serialization_seconds = seconds_since(serialization_started) notes = [] if max_size > 2: notes.append("max_size expands the combinatorial complex ensemble and is usually the main Tube cost driver.") if "pairs" in compute: notes.append("pairs computes a full complex-ensemble pair matrix for every Tube complex; sparsity reduces returned matrix density.") if "subopt" in compute: notes.append("subopt result count can grow rapidly as energy_gap increases.") if "sample" in compute: notes.append(f"sample requested {options['num_sample']} Boltzmann structures per complex.") performance = build_performance_summary( { "input_setup": setup_seconds, "nupack_compute": nupack_seconds, "serialization_and_plots": serialization_seconds, }, { "strand_count": len(strands), "input_nucleotides": sum(item["length"] for item in strands), "enumerated_complex_count": total_complex_count, "max_complex_size": max_size, }, notes, "tube_analysis", ) return { "workflow": "analysis", "mode": "tube", "model": model_summary, "compute": compute, "options": options, "strands": [ { "name": item["name"], "sequence": item["sequence"], "concentration": item["concentration"], "unit": item["unit"], "concentration_M": unit_to_molar(item["concentration"], item["unit"]), } for item in strands ], "tube": { "name": tube.name, "max_size": max_size, "include_complexes": [stringify_complex(item) for item in included_complexes], "exclude_complexes": [stringify_complex(item) for item in excluded_complexes], "fraction_bases_unpaired": ( float(tube_result.fraction_bases_unpaired) if tube_result.fraction_bases_unpaired is not None else None ), "ensemble_pair_fractions": serialize_pairs( tube_result.ensemble_pair_fractions, preview_limit=options["pairs_preview_size"], ), "complex_concentrations": displayed_concentration_rows, "total_complex_concentrations": total_complex_count, }, "complexes": complexes, "total_complex_count": total_complex_count, "displayed_complex_count": len(complexes), "performance": performance, } if mode == "complex": complexes = parse_complex_lines(payload.get("complexes_text", ""), strand_map) setup_seconds = seconds_since(job_started) nupack_started = time.perf_counter() if progress_callback: progress_callback("computing", "Running NUPACK complex analysis", { "complex_count": len(complexes), "compute": compute, }) result = complex_analysis(complexes, model=model, compute=compute, options=nupack_options) nupack_seconds = seconds_since(nupack_started) serialization_started = time.perf_counter() if progress_callback: progress_callback("serializing", "Serializing complex analysis result", {}) rows = [ serialize_complex_result( complex_obj, data, pairs_preview_size=options["pairs_preview_size"], alphabet=model.alphabet, ) for complex_obj, data in result.complexes.items() ] rows.sort(key=lambda row: row["display"]) total_complex_count = len(rows) serialization_seconds = seconds_since(serialization_started) notes = [] if "subopt" in compute: notes.append("subopt result count can grow rapidly as energy_gap increases.") if "sample" in compute: notes.append(f"sample requested {options['num_sample']} Boltzmann structures per complex.") performance = build_performance_summary( { "input_setup": setup_seconds, "nupack_compute": nupack_seconds, "serialization_and_plots": serialization_seconds, }, { "strand_count": len(strands), "input_nucleotides": sum(item["length"] for item in strands), "complex_count": total_complex_count, "largest_complex_nucleotides": max((item.nt(model.alphabet) for item in complexes), default=0), }, notes, "complex_analysis", ) rows = rows[: options["result_limit"]] return { "workflow": "analysis", "mode": "complex", "model": model_summary, "compute": compute, "options": options, "strands": [ { "name": item["name"], "sequence": item["sequence"], } for item in strands ], "complexes": rows, "total_complex_count": total_complex_count, "performance": performance, "displayed_complex_count": len(rows), } raise ValueError(f"Unsupported analysis mode: {mode}") def prune_jobs(now=None): now = now or time.time() expired_ids = [] for job_id, job in JOB_STORE.items(): updated_at = job.get("updated_at", job.get("created_at", now)) if now - updated_at > JOB_TTL_SECONDS: expired_ids.append(job_id) for job_id in expired_ids: expired = JOB_STORE.pop(job_id, None) fingerprint = (expired or {}).get("dedup_fingerprint") if fingerprint and JOB_DEDUP_RESERVATIONS.get(fingerprint) == job_id: JOB_DEDUP_RESERVATIONS.pop(fingerprint, None) if len(JOB_STORE) > JOB_MAX_COUNT: keep_ids = sorted( JOB_STORE, key=lambda job_id: JOB_STORE[job_id].get("created_at", 0), reverse=True, )[:JOB_MAX_COUNT] keep_set = set(keep_ids) for job_id in list(JOB_STORE): if job_id not in keep_set: removed = JOB_STORE.pop(job_id, None) fingerprint = (removed or {}).get("dedup_fingerprint") if fingerprint and JOB_DEDUP_RESERVATIONS.get(fingerprint) == job_id: JOB_DEDUP_RESERVATIONS.pop(fingerprint, None) def set_job_data(job): if redis_enabled(): client = redis_client() key = job_key(job["job_id"]) encoded = json.dumps(job, ensure_ascii=False) if job.get("status") in TERMINAL_JOB_STATUSES: client.setex(key, JOB_TTL_SECONDS, encoded) if job.get("dedup_fingerprint"): delete_redis_job_claim(job["job_id"], job["dedup_fingerprint"]) else: # Active jobs may legitimately outlive the result-retention TTL. client.set(key, encoded) if job.get("dedup_fingerprint"): refresh_redis_job_claim(job["job_id"], job["dedup_fingerprint"]) return with JOB_LOCK: prune_jobs(job.get("updated_at")) JOB_STORE[job["job_id"]] = dict(job) def get_job_data(job_id, include_payload=False): if redis_enabled(): raw = redis_client().get(job_key(job_id)) if raw is None: return None job = json.loads(raw) job.pop("dedup_fingerprint", None) if not include_payload: job.pop("payload", None) return job with JOB_LOCK: prune_jobs() job = JOB_STORE.get(job_id) if job is None: return None output = dict(job) output.pop("dedup_fingerprint", None) if not include_payload: output.pop("payload", None) return output def should_keep_cancel_state(current, updates): current_status = current.get("status") if current else None next_status = updates.get("status") if current_status in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}: return next_status not in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS} return False def update_job_data(job_id, **updates): if redis_enabled(): client = redis_client() key = job_key(job_id) while True: pipe = client.pipeline() try: pipe.watch(key) raw = pipe.get(key) current = json.loads(raw) if raw is not None else {"job_id": job_id, "created_at": time.time()} if should_keep_cancel_state(current, updates): pipe.unwatch() return current current.update(updates) current["updated_at"] = time.time() pipe.multi() encoded = json.dumps(current, ensure_ascii=False) if current.get("status") in TERMINAL_JOB_STATUSES: pipe.setex(key, JOB_TTL_SECONDS, encoded) else: pipe.set(key, encoded) pipe.execute() if current.get("dedup_fingerprint"): if current.get("status") in TERMINAL_JOB_STATUSES: delete_redis_job_claim(job_id, current["dedup_fingerprint"]) else: refresh_redis_job_claim(job_id, current["dedup_fingerprint"]) return current except redis.WatchError: continue finally: pipe.reset() with JOB_LOCK: current = JOB_STORE.get(job_id) if current is None: current = {"job_id": job_id, "created_at": time.time()} if should_keep_cancel_state(current, updates): return dict(current) current.update(updates) current["updated_at"] = time.time() prune_jobs(current["updated_at"]) JOB_STORE[job_id] = current if current.get("status") in TERMINAL_JOB_STATUSES and current.get("dedup_fingerprint"): if JOB_DEDUP_RESERVATIONS.get(current["dedup_fingerprint"]) == job_id: JOB_DEDUP_RESERVATIONS.pop(current["dedup_fingerprint"], None) return dict(current) def create_job(payload, owner, force_duplicate=False): requested_job_id = uuid4().hex job_id, dedup_fingerprint, reused = claim_active_job(payload, owner, requested_job_id) if reused: log_event(f"deduplicated job_id={job_id} user_id={owner['user_id']}") if not force_duplicate: return job_id, True job_id = requested_job_id replace_job_claim(job_id, dedup_fingerprint) now = time.time() job = { "job_id": job_id, "user_id": owner["user_id"], "dedup_fingerprint": dedup_fingerprint, "status": "queued", "error": None, "result": None, "created_at": now, "updated_at": now, "payload": payload, "progress": { "stage": "queued", "message": "Waiting for a worker", "details": {}, "updated_at": now, }, } try: ACCOUNT_STORE.create_job(job_id, owner, payload, status="queued", created_at=now) set_job_data(job) except Exception: release_job_claim(job_id, dedup_fingerprint) raise log_event(f"accepted job_id={job_id} user_id={owner['user_id']} mode={payload.get('mode', 'tube')}") if redis_enabled(): redis_client().lpush(JOB_QUEUE_KEY, job_id) else: thread = threading.Thread(target=_run_job, args=(job_id, payload), daemon=True) thread.start() return job_id, False def _job_process_entry(payload, result_queue): apply_thread_limits() def report_progress(stage, message, details=None): result_queue.put({ "kind": "progress", "progress": { "stage": stage, "message": message, "details": details or {}, "updated_at": time.time(), }, }) try: result_queue.put({ "kind": "terminal", "status": "success", "result": run_job_payload(payload, progress_callback=report_progress), }) except BaseException as exc: result_queue.put( { "kind": "terminal", "status": "error", "error": { "message": str(exc), "traceback": traceback.format_exc(), }, } ) def terminate_process(process): if not process.is_alive(): return if hasattr(process, "kill"): process.kill() else: process.terminate() process.join(timeout=2) if process.is_alive(): process.terminate() process.join(timeout=2) def _run_job(job_id, payload): started_at = time.time() last_heartbeat_at = started_at current = get_job_data(job_id) if current and current.get("status") in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}: elapsed = round(time.time() - started_at, 3) update_job_data(job_id, status=CANCELED_STATUS, payload=None, elapsed_seconds=elapsed) ACCOUNT_STORE.update_job(job_id, CANCELED_STATUS, error={"message": "Job canceled by user."}, elapsed_seconds=elapsed) return log_event(f"running job_id={job_id}") update_job_data(job_id, status="running") ACCOUNT_STORE.update_job(job_id, "running") if redis_enabled(): redis_client().sadd(JOB_RUNNING_KEY, job_id) ctx = multiprocessing.get_context("spawn") result_queue = ctx.Queue(maxsize=32) process = ctx.Process(target=_job_process_entry, args=(payload, result_queue), daemon=True) message = None try: process.start() while process.is_alive(): current = get_job_data(job_id) if current and current.get("status") in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}: terminate_process(process) elapsed = round(time.time() - started_at, 3) update_job_data( job_id, status=CANCELED_STATUS, error={"message": "Job canceled by user."}, result=None, payload=None, elapsed_seconds=elapsed, ) ACCOUNT_STORE.update_job( job_id, CANCELED_STATUS, error={"message": "Job canceled by user."}, elapsed_seconds=elapsed ) log_event(f"canceled job_id={job_id} elapsed={elapsed}s") return now = time.time() if now - last_heartbeat_at >= JOB_HEARTBEAT_SECONDS: update_job_data(job_id, heartbeat_at=now) last_heartbeat_at = now try: incoming = result_queue.get_nowait() if incoming.get("kind") == "progress": update_job_data( job_id, progress=incoming["progress"], elapsed_seconds=round(time.time() - started_at, 3), ) continue message = incoming break except queue.Empty: time.sleep(0.25) process.join(timeout=2) if message is None: while True: try: incoming = result_queue.get_nowait() if incoming.get("kind") == "progress": update_job_data( job_id, progress=incoming["progress"], elapsed_seconds=round(time.time() - started_at, 3), ) else: message = incoming except queue.Empty: break current = get_job_data(job_id) if current and current.get("status") in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}: elapsed = round(time.time() - started_at, 3) update_job_data( job_id, status=CANCELED_STATUS, error={"message": "Job canceled by user."}, result=None, payload=None, elapsed_seconds=elapsed, ) ACCOUNT_STORE.update_job( job_id, CANCELED_STATUS, error={"message": "Job canceled by user."}, elapsed_seconds=elapsed ) log_event(f"canceled job_id={job_id} elapsed={elapsed}s") return elapsed = round(time.time() - started_at, 3) if message and message.get("status") == "success": update_job_data( job_id, status="success", result=message["result"], payload=None, elapsed_seconds=elapsed, progress={ "stage": "complete", "message": "Calculation complete", "details": {}, "updated_at": time.time(), }, ) ACCOUNT_STORE.update_job(job_id, "success", result=message["result"], elapsed_seconds=elapsed) log_event(f"success job_id={job_id} elapsed={elapsed}s") elif message and message.get("status") == "error": error = message.get("error") or {"message": "Job failed."} update_job_data( job_id, status="error", error=error, payload=None, elapsed_seconds=elapsed, progress={ "stage": "error", "message": error.get("message", "Job failed."), "details": {}, "updated_at": time.time(), }, ) ACCOUNT_STORE.update_job(job_id, "error", error=error, elapsed_seconds=elapsed) log_event(f"error job_id={job_id} elapsed={elapsed}s message={message.get('error', {}).get('message')}") else: error = {"message": f"Job process exited with code {process.exitcode}."} update_job_data( job_id, status="error", error=error, payload=None, elapsed_seconds=elapsed, ) ACCOUNT_STORE.update_job(job_id, "error", error=error, elapsed_seconds=elapsed) log_event(f"error job_id={job_id} elapsed={elapsed}s exitcode={process.exitcode}") finally: terminate_process(process) result_queue.close() result_queue.join_thread() if redis_enabled(): redis_client().srem(JOB_RUNNING_KEY, job_id) def cancel_job(job_id): current = get_job_data(job_id, include_payload=True) if current is None: return None status = current.get("status") if status in TERMINAL_JOB_STATUSES: output = dict(current) output.pop("payload", None) return output if redis_enabled() and status == "queued": redis_client().lrem(JOB_QUEUE_KEY, 0, job_id) if status == "queued": updated = update_job_data( job_id, status=CANCELED_STATUS, error={"message": "Job canceled by user."}, result=None, payload=None, ) ACCOUNT_STORE.update_job(job_id, CANCELED_STATUS, error={"message": "Job canceled by user."}) else: updated = update_job_data( job_id, status=CANCEL_REQUESTED_STATUS, error={"message": "Cancellation requested."}, ) ACCOUNT_STORE.update_job(job_id, CANCEL_REQUESTED_STATUS, error={"message": "Cancellation requested."}) updated.pop("payload", None) return updated def get_job(job_id): job = get_job_data(job_id, include_payload=False) if job and job.get("status") in {"queued", "running", CANCEL_REQUESTED_STATUS}: job["elapsed_seconds"] = round(time.time() - job.get("created_at", time.time()), 3) return job def prune_shares(now=None): if SHARE_MAX_COUNT < 1: return if redis_enabled(): client = redis_client() extra = client.zcard(SHARE_INDEX_KEY) - SHARE_MAX_COUNT if extra <= 0: return stale_ids = client.zrange(SHARE_INDEX_KEY, 0, extra - 1) if stale_ids: client.delete(*(share_key(share_id) for share_id in stale_ids)) client.zrem(SHARE_INDEX_KEY, *stale_ids) return keep_ids = sorted( SHARE_STORE, key=lambda share_id: SHARE_STORE[share_id].get("created_at", 0), reverse=True, )[:SHARE_MAX_COUNT] keep_set = set(keep_ids) for share_id in list(SHARE_STORE): if share_id not in keep_set: SHARE_STORE.pop(share_id, None) def create_share(record): payload = record.get("payload") result = record.get("result") if not isinstance(payload, dict): raise ValueError("Share payload must contain an input payload object.") if not isinstance(result, dict): raise ValueError("Share payload must contain a result object.") now = time.time() share_id = uuid4().hex[:16] item = { "id": share_id, "created_at": now, "created_at_iso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)), "payload": payload, "result": result, "result_summary": record.get("result_summary") or {}, } if redis_enabled(): client = redis_client() client.set(share_key(share_id), json.dumps(item, ensure_ascii=False)) client.zadd(SHARE_INDEX_KEY, {share_id: now}) prune_shares(now) return item with SHARE_LOCK: SHARE_STORE[share_id] = item prune_shares(now) return dict(item) def get_share(share_id): share_id = str(share_id or "").strip() if not re.fullmatch(r"[0-9a-fA-F]{8,64}", share_id): return None if redis_enabled(): raw = redis_client().get(share_key(share_id)) return json.loads(raw) if raw else None with SHARE_LOCK: item = SHARE_STORE.get(share_id) return dict(item) if item else None def public_share_payload(share_id, *, record_access=True): metadata = ACCOUNT_STORE.share_metadata(share_id) if metadata is None: return get_share(share_id) live = get_job_data(metadata["job_id"], include_payload=True) if live is not None: if record_access: ACCOUNT_STORE.record_share_access(share_id) payload = live.get("payload") if payload is None: account_job = ACCOUNT_STORE.get_job(metadata["user_id"], metadata["job_id"], include_content=True) payload = account_job.get("payload") if account_job else None result = live.get("result") error = live.get("error") return { "id": share_id, "share_id": share_id, "job_id": metadata["job_id"], "created_at": metadata["created_at"], "status": live.get("status"), "updated_at": live.get("updated_at"), "elapsed_seconds": live.get("elapsed_seconds"), "payload": payload, "result": result, "error": error, "result_summary": build_history_summary(payload, live), } return ACCOUNT_STORE.resolve_share(share_id, record_access=record_access) def build_history_summary(payload, fallback=None): payload = payload or {} fallback = fallback or {} model = payload.get("model") or {} workflow = payload.get("workflow") or fallback.get("workflow") or "analysis" mode = payload.get("mode") or fallback.get("mode") or "tube" strands = payload.get("strands") or [] if workflow == "design": complexes = payload.get("design_complexes") or payload.get("design_targets") or [] tube_sizes = [int(row.get("max_size", 0) or 0) for row in (payload.get("design_tubes") or [])] max_size = max(tube_sizes, default=int((payload.get("design") or {}).get("off_target_max_size", 0) or 0)) else: complexes = [line for line in str(payload.get("complexes_text") or "").splitlines() if line.strip()] max_size = int((payload.get("tube") or {}).get("max_size", 0) or 0) return { "workflow": workflow, "mode": mode, "material": model.get("material", fallback.get("material", "rna")), "celsius": float(model.get("celsius", fallback.get("celsius", 37)) or 37), "sodium": float(model.get("sodium", fallback.get("sodium", 0)) or 0), "magnesium": float(model.get("magnesium", fallback.get("magnesium", 0)) or 0), "max_size": max_size, "strand_count": len(strands), "complex_count": len(complexes), "compute": list( ([((payload.get("utility") or {}).get("operation") or "pfunc")] if workflow == "utilities" else payload.get("compute")) or fallback.get("compute") or ([] if workflow != "design" else ["design"]) ), "trials": int((payload.get("design") or {}).get("trials", fallback.get("trials", 0)) or 0), "stop_condition": float((payload.get("design") or {}).get("stop_condition", fallback.get("stop_condition", 0)) or 0), "max_time_seconds": int((payload.get("design") or {}).get("max_time_seconds", fallback.get("max_time_seconds", 0)) or 0), } def recover_interrupted_jobs(): client = redis_client() recovered = 0 canceled = 0 for job_id in client.smembers(JOB_RUNNING_KEY): raw = client.get(job_key(job_id)) if raw is None: client.srem(JOB_RUNNING_KEY, job_id) continue job = json.loads(raw) status = job.get("status") if status == "running": client.lrem(JOB_QUEUE_KEY, 0, job_id) update_job_data( job_id, status="queued", recovered_at=time.time(), recovery_count=int(job.get("recovery_count", 0)) + 1, ) client.lpush(JOB_QUEUE_KEY, job_id) ACCOUNT_STORE.update_job(job_id, "queued") recovered += 1 elif status == CANCEL_REQUESTED_STATUS: update_job_data( job_id, status=CANCELED_STATUS, error={"message": "Job canceled while the worker was restarting."}, result=None, payload=None, ) ACCOUNT_STORE.update_job( job_id, CANCELED_STATUS, error={"message": "Job canceled while the worker was restarting."}, ) canceled += 1 client.srem(JOB_RUNNING_KEY, job_id) if recovered or canceled: log_event(f"recovered jobs queued={recovered} canceled={canceled}") def run_worker_loop(): if not redis_enabled(): raise RuntimeError("Worker mode requires NP_REDIS_URL and the redis package.") client = redis_client() recover_interrupted_jobs() resource_plan = worker_resource_plan() resource_plan["published_at"] = time.time() worker_count = resource_plan["concurrency"] client.setex(WORKER_RESOURCE_KEY, 90, json.dumps(resource_plan)) log_event( f"Starting worker loop on Redis queue {JOB_QUEUE_KEY} " f"(concurrency={worker_count}, source={resource_plan['source']}, " f"per_job_threads={PER_JOB_THREAD_LIMIT}, memory_gb={resource_plan['memory_gb']}, " f"estimated_job_memory_gb={ESTIMATED_JOB_MEMORY_GB})" ) with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="np-job") as executor: inflight = set() resource_published_at = time.time() while True: if time.time() - resource_published_at >= 30: resource_plan["published_at"] = time.time() client.setex(WORKER_RESOURCE_KEY, 90, json.dumps(resource_plan)) resource_published_at = time.time() finished = {future for future in inflight if future.done()} if finished: inflight.difference_update(finished) for future in finished: future.result() if len(inflight) >= worker_count: done, _ = wait(inflight, return_when=FIRST_COMPLETED, timeout=1) inflight.difference_update(done) for future in done: future.result() continue item = client.brpop(JOB_QUEUE_KEY, timeout=2) if not item: continue _, job_id = item raw = client.get(job_key(job_id)) if raw is None: continue job = json.loads(raw) if job.get("status") in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}: continue payload = job.get("payload") if payload is None: continue inflight.add(executor.submit(_run_job, job_id, payload)) EXAMPLE_PAYLOAD = { "workflow": "analysis", "mode": "tube", "model": { "material": "rna", "ensemble": "stacking", "celsius": 37, "sodium": 1.0, "magnesium": 0.0, }, "compute": ["pfunc", "mfe", "pairs"], "options": { "num_sample": 20, "energy_gap": 1.0, "sparsity_fraction": 1.0, "sparsity_threshold": 0.0, "single_mfe": False, "result_limit": 25, "pairs_preview_size": 24, }, "strands": [ {"name": "A", "sequence": "AGUCUAGGAU", "concentration": 1.0, "unit": "uM"}, {"name": "B", "sequence": "UUAACCCACG", "concentration": 2.0, "unit": "uM"}, ], "tube": {"name": "tube1", "max_size": 2, "include_complexes": "", "exclude_complexes": ""}, "complexes_text": "A\nB\nA+B", "design": { "trials": 1, "result_limit": 25, "off_target_max_size": 2, "stop_condition": 0.02, "seed": 0, "wobble_mutations": False, "max_time_seconds": 0, "fixed_target_policy": "include", }, "design_domains": [ {"name": "a", "sequence": "N10"}, {"name": "b", "sequence": "N10"}, ], "design_complexes": [ { "name": "AB_target", "strands": "A+B", "structure": "(10+)10", } ], "design_tubes": [ { "name": "Tube 1", "max_size": 2, "on_targets": [ {"complex": "AB_target", "concentration": 1.0, "unit": "uM"}, ], } ], "hard_constraints": [], "soft_constraints": [], "defect_weights": [], } # Use A=a and B=~a so the full-duplex target "(10+)10" is base-pair consistent. DESIGN_TUBE_EXAMPLE_PAYLOAD = { "workflow": "design", "mode": "tube", "model": { "material": "rna", "ensemble": "stacking", "celsius": 37, "sodium": 1.0, "magnesium": 0.0, }, "compute": ["pfunc", "mfe", "pairs"], "options": { "num_sample": 20, "energy_gap": 1.0, "sparsity_fraction": 1.0, "sparsity_threshold": 0.0, "single_mfe": False, "result_limit": 25, "pairs_preview_size": 24, }, "strands": [ {"name": "A", "sequence": "a", "concentration": 1.0, "unit": "uM"}, {"name": "B", "sequence": "~a", "concentration": 1.0, "unit": "uM"}, ], "tube": {"name": "Design Tube", "max_size": 2}, "complexes_text": "A+B", "design": { "trials": 1, "result_limit": 25, "off_target_max_size": 2, "stop_condition": 0.05, "seed": 1, "wobble_mutations": False, "max_time_seconds": 0, "fixed_target_policy": "include", }, "design_domains": [ {"name": "a", "sequence": "N10"}, ], "design_complexes": [ { "name": "AB_target", "strands": "A+B", "structure": "(10+)10", } ], "design_tubes": [ { "name": "Design Tube", "max_size": 2, "on_targets": [ {"complex": "AB_target", "concentration": 1.0, "unit": "uM"}, ], } ], "hard_constraints": [], "soft_constraints": [], "defect_weights": [], } DESIGN_COMPLEX_EXAMPLE_PAYLOAD = { **DESIGN_TUBE_EXAMPLE_PAYLOAD, "mode": "complex", "tube": {"name": "Design Tube", "max_size": 1}, "design": { **DESIGN_TUBE_EXAMPLE_PAYLOAD["design"], "off_target_max_size": 1, }, "design_tubes": [], } UTILITIES_EXAMPLE_PAYLOAD = { **EXAMPLE_PAYLOAD, "workflow": "utilities", "mode": "utility", "strands": [ {"name": "A", "sequence": "GCGCUUCGCG", "concentration": 0, "unit": "uM"}, ], "utility": { "operation": "mfe", "structure": "..........", "input_a": "", "input_b": "", "distinguishable": False, }, } def get_example_payload(query): params = parse_qs(query) workflow = (params.get("workflow") or ["analysis"])[0] mode = (params.get("mode") or ["tube"])[0] if workflow == "utilities": return UTILITIES_EXAMPLE_PAYLOAD if workflow == "design": if mode == "complex": return DESIGN_COMPLEX_EXAMPLE_PAYLOAD return DESIGN_TUBE_EXAMPLE_PAYLOAD return EXAMPLE_PAYLOAD class AppHandler(BaseHTTPRequestHandler): server_version = "NPReplica/0.1" def _session(self): if not hasattr(self, "_cached_session"): self._cached_session = OIDC_AUTH.current_session(self.headers) if self._cached_session: ACCOUNT_STORE.upsert_user(self._cached_session["user"]) return self._cached_session def _require_user(self): session = self._session() if session is None: self._respond(*json_bytes({"error": "Authentication required", "login_url": "/auth/login"}, status=HTTPStatus.UNAUTHORIZED)) return None return session["user"] def _read_json(self, max_bytes=16 * 1024 * 1024): length = int(self.headers.get("Content-Length", "0")) if length < 0 or length > max_bytes: raise ValueError(f"Request payload is too large. Limit is {max_bytes} bytes.") return json.loads(self.rfile.read(length).decode("utf-8")) def _redirect(self, location, *, cookie=None): headers = {"Location": location} if cookie: headers["Set-Cookie"] = cookie self._respond(HTTPStatus.FOUND, "text/plain; charset=utf-8", b"Redirecting", extra_headers=headers) def _require_page_user(self, next_path): if self._session() is not None: return True self._redirect(f"/auth/login?{urlencode({'next': next_path})}") return False def _admin_session_token(self): cookie = SimpleCookie() try: cookie.load(self.headers.get("Cookie", "")) except Exception: return None morsel = cookie.get(ADMIN_COOKIE_NAME) return morsel.value if morsel else None def _require_admin(self): token = self._admin_session_token() if valid_admin_session(token): return token self._respond( *json_bytes({"error": "Admin authentication required"}, status=HTTPStatus.UNAUTHORIZED) ) return None def _owned_job(self, user, job_id, include_content=True): account_metadata = ACCOUNT_STORE.get_job( user["user_id"], job_id, include_content=False, ) if account_metadata is None: return None live = get_job(job_id) if live is not None: live.pop("user_id", None) return live return ACCOUNT_STORE.get_job(user["user_id"], job_id, include_content=include_content) def do_GET(self): parsed = urlparse(self.path) if parsed.path == "/admin": if valid_admin_session(self._admin_session_token()): self._redirect("/admin/panel") else: self._respond_file(ADMIN_LOGIN_PATH, cache_control="private, no-store") return if parsed.path == "/admin/panel": if not valid_admin_session(self._admin_session_token()): self._redirect("/admin") return self._respond_file(ADMIN_PATH, cache_control="private, no-store") return if parsed.path == "/api/admin/jobs": if self._require_admin() is None: return params = {key: values[0] for key, values in parse_qs(parsed.query).items() if values} self._respond(*json_bytes({ "status": "success", **ACCOUNT_STORE.list_all_jobs(params), "live": job_stats(), "worker": published_worker_resource_plan(), })) return if parsed.path == "/api/admin/settings": if self._require_admin() is None: return retention_seconds = ACCOUNT_STORE.trash_retention_seconds() self._respond(*json_bytes({ "status": "success", "trash_retention_seconds": retention_seconds, "trash_retention_days": retention_seconds / 86400, })) return if parsed.path == "/auth/login": next_path = (parse_qs(parsed.query).get("next") or ["/"])[0] try: self._redirect(OIDC_AUTH.begin_login(next_path)) except Exception as exc: self._respond(*json_bytes({"error": f"Unable to start login: {exc}"}, status=HTTPStatus.BAD_GATEWAY)) return if parsed.path == "/auth/callback": try: session_id, user, next_path = OIDC_AUTH.complete_login(parse_qs(parsed.query)) ACCOUNT_STORE.upsert_user(user) self._redirect(next_path, cookie=OIDC_AUTH.cookie_header(session_id)) except Exception as exc: log_event(f"OIDC callback failed: {exc}") self._respond(*json_bytes({"error": f"Login failed: {exc}"}, status=HTTPStatus.BAD_REQUEST)) return if parsed.path == "/auth/logout": session = self._session() try: location = OIDC_AUTH.logout_url(session) except Exception: location = "/" OIDC_AUTH.delete_session(session) self._redirect(location, cookie=OIDC_AUTH.clear_cookie_header()) return share_page = re.fullmatch(r"/share/([A-Za-z0-9_-]{8,128})", parsed.path) if share_page: self._respond_file(INDEX_PATH, cache_control="public, max-age=60, stale-while-revalidate=86400") return legacy_share = (parse_qs(parsed.query).get("share") or [""])[0] if parsed.path == "/" and re.fullmatch(r"[A-Za-z0-9_-]{8,128}", legacy_share): self._respond_file(INDEX_PATH, cache_control="public, max-age=60, stale-while-revalidate=86400") return if parsed.path == "/": if not self._require_page_user(self.path): return self._respond_file(HOME_PATH, cache_control="private, no-cache") return page_paths = { "/workspace": INDEX_PATH, "/cloud": CLOUD_PATH, "/account": ACCOUNT_PATH, "/settings": SETTINGS_PATH, } if parsed.path in page_paths: if not self._require_page_user(self.path): return self._respond_file(page_paths[parsed.path], cache_control="private, no-cache") return if parsed.path in STATIC_PATHS: self._respond_file(STATIC_PATHS[parsed.path], cache_control="public, max-age=3600") return if parsed.path in {"/favicon.svg", "/favicon.ico"}: self._respond_file(FAVICON_PATH, cache_control="public, max-age=86400") return if parsed.path == "/design-guide.html": if not self._require_page_user(self.path): return self._respond_file(GUIDE_PATH, cache_control="public, max-age=3600") return if parsed.path == "/health": stats = job_stats() resource_plan = published_worker_resource_plan() self._respond( *json_bytes( { "status": "ok", "job_backend": "redis" if redis_enabled() else "memory", "run_mode": RUN_MODE, "queue_depth": queue_size(), "jobs_running": stats["running"], "jobs_queued": stats["queued"], "job_ttl_seconds": JOB_TTL_SECONDS, "worker_concurrency": resource_plan["concurrency"], "worker_concurrency_source": resource_plan["source"], "worker_resource_plan": resource_plan, "per_job_thread_limit": PER_JOB_THREAD_LIMIT, "nupack_threads": int(getattr(nupack_config, "threads", PER_JOB_THREAD_LIMIT)), "nupack_cache_gb": float(getattr(nupack_config, "cache", NUPACK_CACHE_GB)), } ) ) return if parsed.path == "/api/me": user = self._require_user() if user is None: return self._respond(*json_bytes({"status": "success", "user": user, "usage": ACCOUNT_STORE.usage(user["user_id"])})) return if parsed.path == "/api/history": user = self._require_user() if user is None: return params = {key: values[0] for key, values in parse_qs(parsed.query).items() if values} self._respond(*json_bytes({"status": "success", **ACCOUNT_STORE.list_jobs(user["user_id"], params)})) return if parsed.path.startswith("/api/history/"): user = self._require_user() if user is None: return job_id = parsed.path.rsplit("/", 1)[-1] job = ACCOUNT_STORE.get_job(user["user_id"], job_id, include_content=True) if job is None: self._respond(*json_bytes({"error": "History record not found"}, status=HTTPStatus.NOT_FOUND)) return self._respond(*json_bytes({"status": "success", "item": job})) return if parsed.path == "/api/account/shares": user = self._require_user() if user is None: return self._respond(*json_bytes({"status": "success", "items": ACCOUNT_STORE.list_shares(user["user_id"])})) return if parsed.path == "/api/example": user = self._require_user() if user is None: return self._respond(*json_bytes(get_example_payload(parsed.query))) return if parsed.path.startswith("/api/jobs/"): user = self._require_user() if user is None: return job_id = parsed.path.rsplit("/", 1)[-1] job = self._owned_job(user, job_id) if job is None: self._respond(*json_bytes({"error": "Job not found"}, status=HTTPStatus.NOT_FOUND)) return self._respond(*json_bytes(job)) return if parsed.path.startswith("/api/shares/"): share_id = parsed.path.rsplit("/", 1)[-1] share = public_share_payload(share_id, record_access=(parse_qs(parsed.query).get("poll") or ["0"])[0] != "1") if share is None: self._respond(*json_bytes({"error": "Share not found"}, status=HTTPStatus.NOT_FOUND)) return self._respond(*json_bytes({"status": "success", "share": share})) return self._respond(*json_bytes({"error": "Not found"}, status=HTTPStatus.NOT_FOUND)) def do_POST(self): parsed = urlparse(self.path) try: if parsed.path == "/api/admin/login": body = self._read_json(max_bytes=4096) password = str(body.get("password") or "") if not ADMIN_TOKEN: self._respond(*json_bytes( {"error": "Admin password is not configured"}, status=HTTPStatus.SERVICE_UNAVAILABLE, )) return if not secrets.compare_digest(password, ADMIN_TOKEN): self._respond(*json_bytes( {"error": "Admin password is incorrect"}, status=HTTPStatus.UNAUTHORIZED, )) return token = create_admin_session() self._respond( *json_bytes({"status": "success", "url": "/admin/panel"}), extra_headers={"Set-Cookie": admin_cookie_header(token)}, ) return if parsed.path == "/api/admin/logout": delete_admin_session(self._admin_session_token()) self._respond( *json_bytes({"status": "success"}), extra_headers={"Set-Cookie": admin_cookie_header("", clear=True)}, ) return if parsed.path == "/api/admin/settings": if self._require_admin() is None: return body = self._read_json(max_bytes=4096) seconds = ACCOUNT_STORE.set_trash_retention_days(body.get("trash_retention_days")) self._respond(*json_bytes({ "status": "success", "trash_retention_seconds": seconds, "trash_retention_days": seconds / 86400, })) return admin_action = re.fullmatch( r"/api/admin/jobs/([A-Za-z0-9_-]{8,128})/(cancel|trash|restore|delete)", parsed.path, ) if admin_action: if self._require_admin() is None: return job_id, action = admin_action.groups() if action == "cancel": result = cancel_job(job_id) found = result is not None elif action == "trash": found = ACCOUNT_STORE.admin_trash_job(job_id) result = {"job_id": job_id, "deleted": found} elif action == "restore": found = ACCOUNT_STORE.restore_job(job_id) result = {"job_id": job_id, "restored": found} else: found = ACCOUNT_STORE.permanently_delete_job(job_id) result = {"job_id": job_id, "permanently_deleted": found} if not found: self._respond(*json_bytes({"error": "Job not found"}, status=HTTPStatus.NOT_FOUND)) return self._respond(*json_bytes({"status": "success", "job": result})) return except Exception as exc: self._respond(*json_bytes({"status": "error", "error": str(exc)}, status=HTTPStatus.BAD_REQUEST)) return user = self._require_user() if user is None: return try: if parsed.path.startswith("/api/jobs/") and parsed.path.endswith("/cancel"): job_id = parsed.path.split("/")[-2] if ACCOUNT_STORE.owner_id(job_id) != user["user_id"]: self._respond(*json_bytes({"error": "Job not found"}, status=HTTPStatus.NOT_FOUND)) return job = cancel_job(job_id) if job is None: self._respond(*json_bytes({"error": "Job not found"}, status=HTTPStatus.NOT_FOUND)) return job.pop("user_id", None) self._respond(*json_bytes({"status": "success", "job": job})) return if parsed.path.startswith("/api/jobs/") and parsed.path.endswith("/shares"): job_id = parsed.path.split("/")[-2] body = self._read_json() expires_in = body.get("expires_in") if expires_in not in {None, "", 0}: expires_in = int(expires_in) if expires_in < 60: raise ValueError("Share duration must be at least 60 seconds.") else: expires_in = None share = ACCOUNT_STORE.create_share(user["user_id"], job_id, expires_in) self._respond(*json_bytes({"status": "success", "share": share, "url": f"/share/{share['share_id']}"}, status=HTTPStatus.CREATED)) return if parsed.path == "/api/jobs": payload = self._read_json() force_duplicate = (parse_qs(parsed.query).get("force") or ["0"])[0] == "1" job_id, deduplicated = create_job(payload, user, force_duplicate=force_duplicate) if deduplicated: duplicate = self._owned_job(user, job_id, include_content=False) or {"job_id": job_id} self._respond(*json_bytes( { "status": "duplicate", "message": "An identical job is already active for this user.", "duplicate": { key: duplicate.get(key) for key in ("job_id", "status", "created_at", "updated_at", "progress") }, }, status=HTTPStatus.CONFLICT, )) return self._respond(*json_bytes( {"status": "accepted", "job_id": job_id, "forced_duplicate": force_duplicate}, status=HTTPStatus.ACCEPTED, )) return if parsed.path == "/api/history/import": body = self._read_json(max_bytes=64 * 1024 * 1024) entries = body.get("history") if isinstance(body, dict) else None if not isinstance(entries, list): raise ValueError("History import requires a history array.") imported = ACCOUNT_STORE.import_history(user, entries) self._respond(*json_bytes({"status": "success", "imported": imported})) return if parsed.path == "/api/shares": body = self._read_json() job_id = str(body.get("job_id") or "") if not job_id: raise ValueError("Cloud shares require a job_id.") share = ACCOUNT_STORE.create_share(user["user_id"], job_id, body.get("expires_in")) self._respond(*json_bytes({"status": "success", "share_id": share["share_id"], "url": f"/share/{share['share_id']}"}, status=HTTPStatus.CREATED)) return if parsed.path == "/api/analyze": payload = self._read_json() job_id = uuid4().hex started = time.time() ACCOUNT_STORE.create_job(job_id, user, payload, status="running", created_at=started) try: result = run_job_payload(payload) except Exception as exc: elapsed = round(time.time() - started, 3) ACCOUNT_STORE.update_job(job_id, "error", error={"message": str(exc), "traceback": traceback.format_exc()}, elapsed_seconds=elapsed) raise elapsed = round(time.time() - started, 3) ACCOUNT_STORE.update_job(job_id, "success", result=result, elapsed_seconds=elapsed) self._respond(*json_bytes({"status": "success", "job_id": job_id, "result": result})) return self._respond(*json_bytes({"error": "Not found"}, status=HTTPStatus.NOT_FOUND)) except Exception as exc: self._respond( *json_bytes( { "status": "error", "error": str(exc), "traceback": traceback.format_exc(), }, status=HTTPStatus.BAD_REQUEST, ) ) def do_PATCH(self): parsed = urlparse(self.path) user = self._require_user() if user is None: return try: if parsed.path.startswith("/api/account/shares/"): share_id = parsed.path.rsplit("/", 1)[-1] body = self._read_json() expires_in = body["expires_in"] if "expires_in" in body else "unchanged" share = ACCOUNT_STORE.update_share( user["user_id"], share_id, active=body.get("active"), expires_in=expires_in ) if share is None: self._respond(*json_bytes({"error": "Share not found"}, status=HTTPStatus.NOT_FOUND)) return self._respond(*json_bytes({"status": "success", "share": share})) return self._respond(*json_bytes({"error": "Not found"}, status=HTTPStatus.NOT_FOUND)) except Exception as exc: self._respond(*json_bytes({"status": "error", "error": str(exc)}, status=HTTPStatus.BAD_REQUEST)) def do_DELETE(self): parsed = urlparse(self.path) user = self._require_user() if user is None: return try: if parsed.path.startswith("/api/history/"): job_id = parsed.path.rsplit("/", 1)[-1] if not ACCOUNT_STORE.delete_job(user["user_id"], job_id): self._respond(*json_bytes({"error": "History record not found"}, status=HTTPStatus.NOT_FOUND)) return self._respond(*json_bytes({"status": "success"})) return if parsed.path.startswith("/api/account/shares/"): share_id = parsed.path.rsplit("/", 1)[-1] share = ACCOUNT_STORE.update_share(user["user_id"], share_id, active=False) if share is None: self._respond(*json_bytes({"error": "Share not found"}, status=HTTPStatus.NOT_FOUND)) return self._respond(*json_bytes({"status": "success", "share": share})) return self._respond(*json_bytes({"error": "Not found"}, status=HTTPStatus.NOT_FOUND)) except Exception as exc: self._respond(*json_bytes({"status": "error", "error": str(exc)}, status=HTTPStatus.BAD_REQUEST)) def log_message(self, format_, *args): print(f"{self.address_string()} - {format_ % args}") def _respond_file(self, path, cache_control): etag = file_etag(path) if self.headers.get("If-None-Match") == etag: self.send_response(HTTPStatus.NOT_MODIFIED) self.send_header("ETag", etag) self.send_header("Cache-Control", cache_control) self.end_headers() return status, content_type, body = html_bytes(path) self._respond(status, content_type, body, cache_control=cache_control, extra_headers={"ETag": etag}) def _respond(self, status, content_type, body, cache_control=None, extra_headers=None): self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", cache_control or "no-store, max-age=0") if cache_control is None: self.send_header("Pragma", "no-cache") self.send_header("Expires", "0") for key, value in (extra_headers or {}).items(): self.send_header(key, value) self.end_headers() self.wfile.write(body) def main(): apply_thread_limits() ACCOUNT_STORE.initialize() if RUN_MODE == "worker": run_worker_loop() return server = ThreadingHTTPServer((HOST, PORT), AppHandler) log_event( f"Serving NP replica on http://{HOST}:{PORT} " f"(jobs via {'redis' if redis_enabled() else 'memory'}, " f"per_job_threads={max(1, PER_JOB_THREAD_LIMIT)})" ) server.serve_forever() if __name__ == "__main__": main()