diff --git a/.env.vercel b/.env.vercel new file mode 100644 index 0000000..2b705e4 --- /dev/null +++ b/.env.vercel @@ -0,0 +1,4 @@ +CATPRED_DEFAULT_BACKEND=modal +CATPRED_MODAL_ENDPOINT=https://kaalabhairava2026--catpred-modal-api-predict.modal.run +CATPRED_MODAL_TOKEN= +CATPRED_MODAL_FALLBACK_TO_LOCAL=0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1467ea9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + validate: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install minimal runtime dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Compile Python sources + run: | + git ls-files '*.py' | xargs -r python -m py_compile + + - name: Smoke test API entrypoints + run: | + python - <<'PY' + from catpred.web.app import create_app + from api.index import app as vercel_app + + api_app = create_app() + assert api_app.title == "CatPred API" + assert vercel_app is not None + print("API smoke checks passed.") + PY diff --git a/.github/workflows/deploy-modal.yml b/.github/workflows/deploy-modal.yml new file mode 100644 index 0000000..af3ba68 --- /dev/null +++ b/.github/workflows/deploy-modal.yml @@ -0,0 +1,49 @@ +name: Deploy Modal + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - ".github/workflows/deploy-modal.yml" + - "modal_app.py" + - "predict.py" + - "scripts/create_pdbrecords.py" + - "catpred/**" + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: read + concurrency: + group: modal-production-deploy + cancel-in-progress: true + env: + MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} + MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} + steps: + - name: Skip when Modal credentials are not configured + if: ${{ env.MODAL_TOKEN_ID == '' || env.MODAL_TOKEN_SECRET == '' }} + run: echo "Skipping Modal deploy because MODAL_TOKEN_ID / MODAL_TOKEN_SECRET are not configured." + + - name: Checkout + if: ${{ env.MODAL_TOKEN_ID != '' && env.MODAL_TOKEN_SECRET != '' }} + uses: actions/checkout@v4 + + - name: Set up Python + if: ${{ env.MODAL_TOKEN_ID != '' && env.MODAL_TOKEN_SECRET != '' }} + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install Modal CLI + if: ${{ env.MODAL_TOKEN_ID != '' && env.MODAL_TOKEN_SECRET != '' }} + run: | + python -m pip install --upgrade pip + pip install "modal>=0.73" + + - name: Deploy modal_app.py + if: ${{ env.MODAL_TOKEN_ID != '' && env.MODAL_TOKEN_SECRET != '' }} + run: modal deploy modal_app.py diff --git a/.gitignore b/.gitignore index b155533..4edc983 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,16 @@ catpred/data/__pycache__/esm_utils.cpython-312.pyc catpred/data/__pycache__/data.cpython-312.pyc catpred/data/__pycache__/cache_utils.cpython-312.pyc catpred/data/__pycache__/__init__.cpython-312.pyc + +# Generic Python/OS artifacts +__pycache__/ +*.pyo +*.pyd +.DS_Store +*.egg-info/ +.venv/ +.ipynb_checkpoints/ + +# Local validation artifacts +.e2e-assets/ +.e2e-tests/ diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..e3a5762 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,8 @@ +.venv/ +.e2e-assets/ +.e2e-tests/ +results/ +output/ +checkpoints/ +external/ +*.ipynb diff --git a/README.md b/README.md index 1933ecd..1b9094a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # CatPred: A Comprehensive Framework for Deep Learning In Vitro Enzyme Kinetic Parameters -[![DOI](https://img.shields.io/badge/DOI-10.1101/2024.03.10.584340-blue)](https://www.nature.com/articles/s41467-025-57215-9) +[![Web App](https://img.shields.io/badge/Web_App-www.catpred.com-059669)](https://www.catpred.com) +[![DOI](https://img.shields.io/badge/DOI-10.1038/s41467--025--57215--9-blue)](https://www.nature.com/articles/s41467-025-57215-9) [![Colab](https://img.shields.io/badge/GoogleColab-tiny.cc/catpred-red)](https://tiny.cc/catpred) [![License](https://img.shields.io/badge/License-MIT-green)](LICENSE) @@ -8,28 +9,42 @@ ## ๐Ÿšจ Announcements ๐Ÿ“ข +- โœ… **14th Mar 2026** - Web app live at [www.catpred.com](https://www.catpred.com) โ€” predict kcat, Km, and Ki directly in your browser! - โœ… **28th Feb 2025** - Published in [_Nature Communications_](https://www.nature.com/articles/s41467-025-57215-9) - โœ… **27th Dec 2024** - Updated repository with scripts to reproduce results from the manuscript. -- ๐Ÿšง **TODO** - - Add prediction codes for models using 3D-structural features. - - Add instructions to install CatPred using a Docker image. --- ## ๐Ÿ“š Table of Contents +- [Web App](#web-app) - [Google Colab Interface](#colab-interface) - [Local Installation](#local-installation) - [System Requirements](#requirements) - [Installation](#installing) - [Prediction](#predict) + - [Web API (Optional)](#web-api-optional) + - [Vercel Deployment (Optional)](#vercel-deployment-optional) - [Reproducibility](#reproduce) + - [Fine-Tuning On Custom Data](#-fine-tuning-on-custom-data) + - [Docker](#-docker) - [Acknowledgements](#acknw) - [License](#license) - [Citations](#citations) --- +## ๐ŸŒ Web App + +CatPred is live at **[www.catpred.com](https://www.catpred.com)** โ€” no installation needed. + +- **Two prediction modes:** Substrate kinetics (kcat/Km) and Inhibition (Ki) +- **Multi-substrate input** with primary substrate marker +- **CSV import/export** for batch workflows +- Powered by a [Modal](https://modal.com) serverless backend + +--- + ## ๐ŸŒ Google Colab Interface For ease of use without any hardware requirements, a Google Colab interface is available here: [tiny.cc/catpred](http://tiny.cc/catpred). @@ -65,19 +80,208 @@ Then proceed to either option below to complete the installation. If installing ```bash mkdir catpred_pipeline catpred_pipeline/results cd catpred_pipeline -wget https://catpred.s3.us-east-1.amazonaws.com/capsule_data_update.tar.gz +wget -c --tries=5 --timeout=30 https://catpred.s3.us-east-1.amazonaws.com/capsule_data_update.tar.gz || \ +wget -c --tries=5 --timeout=30 https://catpred.s3.amazonaws.com/capsule_data_update.tar.gz tar -xzf capsule_data_update.tar.gz -git clone https://github.com/maranasgroup/catpred.git +git clone https://github.com/maranasgroup/CatPred.git cd catpred conda env create -f environment.yml conda activate catpred pip install -e . -```` +``` + +`stride` is Linux-only and optional for the default demos. If needed for your workflow, install it separately on Linux: + +```bash +conda install -c kimlab stride +``` + +### ๐Ÿณ Docker + +A `Dockerfile` is included for containerized usage (PyTorch 2.4, CUDA 12.4, Python 3.12.4 via Mambaforge). + +```bash +docker build -t catpred . +docker run --gpus all -it catpred +``` ### ๐Ÿ”ฎ Prediction The Jupyter Notebook `batch_demo.ipynb` and the Python script `demo_run.py` show the usage of pre-trained models for prediction. +Input CSV requirements for `demo_run.py` and batch prediction: +- Required columns: `SMILES`, `sequence`, `pdbpath`. +- `pdbpath` must be unique per unique sequence. Reusing the same `pdbpath` for different sequences can produce incorrect cached embeddings. +- Reusing the same `pdbpath` for repeated measurements of the same sequence is supported. + +The helper script used to build protein records is: + +```bash +python ./scripts/create_pdbrecords.py --data_file --out_file +``` + +CatPred currently expects one sequence per row. Multi-protein complexes (e.g., heteromers/homodimers) are not explicitly modeled as separate chains in the default prediction workflow. + +For released benchmark datasets, the number of entries with 3D structure can be smaller than the total sequence/substrate pairs; 3D-derived artifacts are available only for the subset with valid structure mapping. + +### ๐ŸŒ Web API (Optional) + +CatPred also provides an optional FastAPI service for prediction workflows. The Vue 3 frontend lives in `catpred/web/frontend/` and is served by the API at `/`. + +Install web dependencies: + +```bash +pip install -e ".[web]" +``` + +Run the API (serves the built frontend at `/`): + +```bash +catpred_web --host 0.0.0.0 --port 8000 +``` + +To develop the frontend: + +```bash +cd catpred/web/frontend +npm install +npm run dev # Vite dev server with HMR +npm run build # Production build (vue-tsc + vite) +``` + +Endpoints: +- `GET /health` โ€” liveness check. +- `GET /ready` โ€” backend configuration/readiness. +- `POST /predict` โ€” run inference. + +By default, the API is hardened for service use: +- `input_file` requests are disabled (use `input_rows` instead). +- request-time overrides of `repo_root` / `python_executable` are disabled. +- `results_dir` is constrained under `CATPRED_API_RESULTS_ROOT`. +- for local backend (and modal requests with fallback enabled), `checkpoint_dir` must resolve under `CATPRED_API_CHECKPOINT_ROOT`. + +Minimal `POST /predict` example for local inference using `input_rows` (human glucokinase + D-glucose): + +```bash +curl -X POST http://127.0.0.1:8000/predict \ + -H "Content-Type: application/json" \ + -d '{ + "parameter": "kcat", + "checkpoint_dir": "kcat", + "input_rows": [ + { + "SMILES": "C(C1C(C(C(C(O1)O)O)O)O)O", + "sequence": "MLDDRARMEAAKKEKVEQILAEFQLQEEDLKKVMRRMQKEMDRGLRLETHEEASVKMLPTYVRSTPEGSEVGDFLSLDLGGTNFRVMLVKVGEGEEGQWSVKTKHQMYSIPEDAMTGTAEMLFDYISECISDFLDKHQMKHKKLPLGFTFSFPVRHEDIDKGILLNWTKGFKASGAEGNNVVGLLRDAIKRRGDFEMDVVAMVNDTVATMISCYYEDHQCEVGMIVGTGCNACYMEEMQNVELVEGDEGRMCVNTEWGAFGDSGELDEFLLEYDRLVDESSANPGQQLYEKLIGGKYMGELVRLVLLRLVDENLLFHGEASEQLRTRGAFETRFVSQVESDTGDRKQIYNILSTLGLRPSTTDCDIVRRACESVSTRAAHMCSAGLAGVINRMRESRSEDVMRITVGVDGSVYKLHPSFKERFHASVRRLTPSCEITFIESEEGSGRGAALVSAVACKKACMLGQ", + "pdbpath": "GCK_HUMAN" + } + ], + "results_dir": "batch1", + "backend": "local" + }' +``` + +You can keep local inference as default and optionally enable Modal as another backend: + +```bash +export CATPRED_DEFAULT_BACKEND=local +export CATPRED_MODAL_ENDPOINT="https://" +export CATPRED_MODAL_TOKEN="" +export CATPRED_MODAL_FALLBACK_TO_LOCAL=1 +``` + +Use `"backend": "modal"` in `/predict` requests to route through Modal. If fallback is enabled (env var above or request field `fallback_to_local`), failed modal requests can automatically reroute to local inference. +For local backend requests, place local checkpoints under `CATPRED_API_CHECKPOINT_ROOT` and pass a path relative to that root (for example, `"checkpoint_dir": "kcat"`). + +Optional API environment variables: + +```bash +# Root directories used by API path constraints +export CATPRED_API_INPUT_ROOT="/absolute/path/for/input-csvs" +export CATPRED_API_RESULTS_ROOT="/absolute/path/for/results" +export CATPRED_API_CHECKPOINT_ROOT="/absolute/path/for/checkpoints" + +# Enable only for trusted local workflows (not recommended for public deployments) +export CATPRED_API_ALLOW_INPUT_FILE=1 +export CATPRED_API_ALLOW_UNSAFE_OVERRIDES=1 + +# Request limits +export CATPRED_API_MAX_INPUT_ROWS=1000 +export CATPRED_API_MAX_INPUT_FILE_BYTES=5000000 +``` + +Deserialization hardening controls: + +```bash +# Trusted roots used by secure loaders (colon-separated list on Unix) +export CATPRED_TRUSTED_DESERIALIZATION_ROOTS="/srv/catpred:/srv/catpred-data" + +# Backward-compatible default is enabled (1). Set to 0 to block unsafe pickle-based loading. +# Use 0 only after validating your artifacts are safe-load compatible. +export CATPRED_ALLOW_UNSAFE_DESERIALIZATION=1 +``` + +### โ–ฒ Vercel Deployment (Optional) + +This repository includes a Vercel-ready ASGI entrypoint at `api/index.py` and a `vercel.json` route config. + +1. Push this repository to GitHub. +2. In Vercel, create a new project from that repo. +3. Set Environment Variables in Vercel Project Settings: + +```bash +# Use remote inference backend in serverless deployments +CATPRED_DEFAULT_BACKEND=modal +CATPRED_MODAL_ENDPOINT=https:// +CATPRED_MODAL_TOKEN= +CATPRED_MODAL_FALLBACK_TO_LOCAL=0 +``` + +Notes: +- Serverless filesystems are ephemeral/read-only except `/tmp`; this app auto-uses `/tmp/catpred` on Vercel. +- Local checkpoint-based inference is not recommended on Vercel serverless due runtime/dependency limits. +- If `CATPRED_MODAL_ENDPOINT` is not configured, the UI still loads but prediction requests will be limited by backend readiness. + +#### Deploy a Modal endpoint for Vercel + +This repo includes `modal_app.py`, a Modal `POST` endpoint compatible with CatPred's `/predict` modal backend contract. + +1. Install and authenticate Modal CLI: + +```bash +pip install modal +modal setup +``` + +2. Create/upload checkpoints into a Modal Volume (one-time): + +```bash +modal volume create catpred-checkpoints +modal volume put catpred-checkpoints ./checkpoints/kcat kcat +modal volume put catpred-checkpoints ./checkpoints/km km +modal volume put catpred-checkpoints ./checkpoints/ki ki +``` + +3. (Recommended) create a secret token for endpoint auth: + +```bash +modal secret create catpred-modal-auth CATPRED_MODAL_AUTH_TOKEN="" +``` + +4. Deploy: + +```bash +modal deploy modal_app.py +``` + +After deploy, copy the printed endpoint URL (for function `predict`) and set Vercel variables: + +```bash +CATPRED_DEFAULT_BACKEND=modal +CATPRED_MODAL_ENDPOINT=https:// +CATPRED_MODAL_TOKEN= +CATPRED_MODAL_FALLBACK_TO_LOCAL=0 +``` + ### ๐Ÿ”„ Reproducing Publication Results We provide three separate ways for reproducing the results of the publication. @@ -138,15 +342,14 @@ This source code is licensed under the MIT license found in the `LICENSE` file i If you find the models useful in your research, we ask that you cite the relevant paper: ```bibtex -@article {Boorla2024.03.10.584340, - author = {Veda Sheersh Boorla and Costas D. Maranas}, - title = {CatPred: A comprehensive framework for deep learning in vitro enzyme kinetic parameters kcat, Km and Ki}, - elocation-id = {2024.03.10.584340}, - year = {2024}, - doi = {10.1101/2024.03.10.584340}, - publisher = {Cold Spring Harbor Laboratory}, - URL = {https://www.biorxiv.org/content/early/2024/03/26/2024.03.10.584340}, - eprint = {https://www.biorxiv.org/content/early/2024/03/26/2024.03.10.584340.full.pdf}, - journal = {bioRxiv} +@article{Boorla2025CatPred, + author = {Boorla, Veda Sheersh and Maranas, Costas D.}, + title = {CatPred: a comprehensive framework for deep learning in vitro enzyme kinetic parameters}, + journal = {Nature Communications}, + year = {2025}, + volume = {16}, + number = {2072}, + doi = {10.1038/s41467-025-57215-9}, + URL = {https://www.nature.com/articles/s41467-025-57215-9} } ``` diff --git a/api/index.py b/api/index.py new file mode 100644 index 0000000..962060d --- /dev/null +++ b/api/index.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from pathlib import Path +import os +import sys + + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +if os.environ.get("VERCEL"): + os.environ.setdefault("CATPRED_API_RUNTIME_ROOT", "/tmp/catpred") + os.environ.setdefault("CATPRED_MODAL_FALLBACK_TO_LOCAL", "0") + if os.environ.get("CATPRED_MODAL_ENDPOINT"): + os.environ.setdefault("CATPRED_DEFAULT_BACKEND", "modal") + +from catpred.web.app import app + + +__all__ = ["app"] diff --git a/catpred/__init__.py b/catpred/__init__.py index e27ba71..75b676b 100644 --- a/catpred/__init__.py +++ b/catpred/__init__.py @@ -1,13 +1,30 @@ -import catpred.data -import catpred.features -import catpred.models -import catpred.train -import catpred.uncertainty - -import catpred.args -import catpred.constants -import catpred.nn_utils -import catpred.utils -import catpred.rdkit +from __future__ import annotations + +import importlib __version__ = "0.0.1" + +_LAZY_SUBMODULES = { + "args", + "constants", + "data", + "features", + "inference", + "models", + "nn_utils", + "rdkit", + "security", + "train", + "uncertainty", + "utils", +} + +__all__ = sorted(_LAZY_SUBMODULES) + ["__version__"] + + +def __getattr__(name: str): + if name in _LAZY_SUBMODULES: + module = importlib.import_module(f"catpred.{name}") + globals()[name] = module + return module + raise AttributeError(f"module 'catpred' has no attribute '{name}'") diff --git a/catpred/args.py b/catpred/args.py index d52b198..99cc1b3 100644 --- a/catpred/args.py +++ b/catpred/args.py @@ -1,7 +1,6 @@ import json import os from tempfile import TemporaryDirectory -import pickle from typing import List, Optional from typing_extensions import Literal from packaging import version @@ -15,6 +14,7 @@ import catpred.data.utils from catpred.data import set_cache_mol, empty_cache from catpred.features import get_available_features_generators +from catpred.security import load_index_artifact Metric = Literal['auc', 'prc-auc', 'rmse', 'mae', 'mse', 'r2', 'accuracy', 'cross_entropy', 'binary_cross_entropy', 'sid', 'wasserstein', 'f1', 'mcc', 'bounded_rmse', 'bounded_mae', 'bounded_mse'] @@ -815,8 +815,10 @@ def process_args(self) -> None: raise ValueError('When using crossval or index_predetermined split type, must provide crossval_index_file.') if self.split_type in ['crossval', 'index_predetermined']: - with open(self.crossval_index_file, 'rb') as rf: - self._crossval_index_sets = pickle.load(rf) + self._crossval_index_sets = load_index_artifact( + self.crossval_index_file, + purpose="cross-validation index file", + ) self.num_folds = len(self.crossval_index_sets) self.seed = 0 diff --git a/catpred/data/cache_utils.py b/catpred/data/cache_utils.py index 7ee9ede..a72812d 100644 --- a/catpred/data/cache_utils.py +++ b/catpred/data/cache_utils.py @@ -4,6 +4,7 @@ import hashlib from functools import wraps from pathlib import Path +from catpred.security import load_torch_artifact def exists(val): return val is not None @@ -92,7 +93,11 @@ def inner(t, *args, __cache_key = None, **kwargs): if entry_path.exists(): log(f'cache hit: fetching {t} from {str(entry_path)}') - return torch.load(str(entry_path)) + return load_torch_artifact( + str(entry_path), + purpose="esm cache entry", + roots=[CACHE_PATH], + ) out = fn(t, *args, **kwargs) diff --git a/catpred/data/esm_utils.py b/catpred/data/esm_utils.py index f45bd65..ff527e9 100644 --- a/catpred/data/esm_utils.py +++ b/catpred/data/esm_utils.py @@ -1,12 +1,9 @@ import torch import os -import re -from pathlib import Path from functools import partial import esm from torch.nn.utils.rnn import pad_sequence -from .cache_utils import cache_fn, run_once, md5_hash_fn -# import esm.inverse_folding as esm_if +from .cache_utils import cache_fn, run_once def exists(val): return val is not None @@ -20,7 +17,12 @@ def to_device(t, *, device): def cast_tuple(t): return (t,) if not isinstance(t, tuple) else t -PROTEIN_EMBED_USE_CPU = os.getenv('PROTEIN_EMBED_USE_CPU', None) is not None +def _env_flag(name: str, default: str = "0") -> bool: + raw = os.getenv(name, default) + return str(raw).strip().lower() in {"1", "true", "yes", "on"} + + +PROTEIN_EMBED_USE_CPU = _env_flag("PROTEIN_EMBED_USE_CPU", "0") or not torch.cuda.is_available() if PROTEIN_EMBED_USE_CPU: print('calculating protein embed only on cpu') @@ -32,9 +34,6 @@ def cast_tuple(t): 'tokenizer': None } -# general helper functions -import ipdb - def calc_protein_representations_with_subunits(proteins, get_repr_fn, *, device): representations = [] for subunits in proteins: @@ -102,8 +101,6 @@ def calc_protein_representations_with_subunits(proteins, get_repr_fn, *, device) } AA_STR_TO_INT_MAP = {v:k for k,v in INT_TO_AA_STR_MAP.items()} -import ipdb - def tensor_to_aa_str(t): str_seqs = [] #ipdb.set_trace() @@ -116,6 +113,7 @@ def tensor_to_aa_str(t): def init_esm(): model, alphabet = esm.pretrained.esm2_t33_650M_UR50D() batch_converter = alphabet.get_batch_converter() + model.eval() if not PROTEIN_EMBED_USE_CPU: model = model.cuda() @@ -124,6 +122,8 @@ def init_esm(): @run_once('init_esm_if') def init_esm_if(): + import esm.inverse_folding as esm_if + model, alphabet = esm.pretrained.esm_if1_gvp4_t16_142M_UR50() batch_converter = esm_if.util.CoordBatchConverter(alphabet, 2048) @@ -145,7 +145,7 @@ def get_single_esm_repr(protein_str): batch_tokens = batch_tokens[:, :ESM_MAX_LENGTH] if not PROTEIN_EMBED_USE_CPU: - batch_tokens = batch_tokens.cuda() + batch_tokens = batch_tokens.to(next(model.parameters()).device) with torch.no_grad(): results = model(batch_tokens, repr_layers=[33]) @@ -157,21 +157,34 @@ def get_single_esm_repr(protein_str): def get_esm_repr(proteins, name, device): if isinstance(proteins, torch.Tensor): proteins = tensor_to_aa_str(proteins) - - get_protein_repr_fn = cache_fn(get_single_esm_repr, path = 'esm/proteins', name = name) - return calc_protein_representations_with_subunits([proteins], get_protein_repr_fn, device = device) + # Cache by sequence content to avoid collisions when different proteins + # are accidentally given the same pdb/name identifier. + _ = name + get_protein_repr_fn = cache_fn(get_single_esm_repr, path='esm/proteins') + + return calc_protein_representations_with_subunits([proteins], get_protein_repr_fn, device=device) + +def get_coords(pdbpath: str, chain_id: str = "A"): + try: + import esm.inverse_folding as esm_if + except ImportError as exc: + raise ImportError( + "ESM inverse folding is not installed. Install optional esm inverse-folding dependencies " + "to use get_coords()." + ) from exc -def get_coords(pdbpath): - #init_esm_if() - #model, batch_converter = GLOBAL_VARIABLES['esmif_model'] - addpath = '/home/ubuntu/CatPred-DB/CatPred-DB/' - coords = esm_if.util.load_coords(addpath+pdbpath, 'A') - return coords + if not os.path.exists(pdbpath): + raise FileNotFoundError(f'PDB file not found: "{pdbpath}"') + + return esm_if.util.load_coords(pdbpath, chain_id) def get_esm_tokens(protein_str, device): if isinstance(protein_str, torch.Tensor): - proteins = tensor_to_aa_str(proteins) + protein_str = tensor_to_aa_str(protein_str) + if len(protein_str) != 1: + raise ValueError("get_esm_tokens expects a single protein sequence.") + protein_str = protein_str[0] init_esm() model, batch_converter = GLOBAL_VARIABLES['model'] @@ -184,8 +197,8 @@ def get_esm_tokens(protein_str, device): batch_tokens = batch_tokens[:, :ESM_MAX_LENGTH] - if device!='cpu': - batch_tokens = batch_tokens.cuda() + if device != 'cpu': + batch_tokens = batch_tokens.to(device) return batch_tokens @@ -201,7 +214,8 @@ def get_esm_tokens(protein_str, device): def get_protein_embedder(name): allowed_protein_embedders = list(PROTEIN_REPR_CONFIG.keys()) - assert name in allowed_protein_embedders, f"must be one of {', '.join(allowed_protein_embedders)}" + if name not in allowed_protein_embedders: + raise ValueError(f"Unsupported protein embedder '{name}'. Must be one of {', '.join(allowed_protein_embedders)}") config = PROTEIN_REPR_CONFIG[name] return config diff --git a/catpred/data/utils.py b/catpred/data/utils.py index a2baaee..02f6d62 100644 --- a/catpred/data/utils.py +++ b/catpred/data/utils.py @@ -1,11 +1,12 @@ +from __future__ import annotations + from collections import OrderedDict, defaultdict import sys import csv import ctypes from logging import Logger -import pickle from random import Random -from typing import List, Set, Tuple, Union +from typing import List, Set, Tuple, Union, TYPE_CHECKING import os import json import torch @@ -15,18 +16,47 @@ import numpy as np import pandas as pd from tqdm import tqdm -import ipdb from .esm_utils import get_protein_embedder, get_coords from .data import MoleculeDatapoint, MoleculeDataset, make_mols from .scaffold import log_scaffold_stats, scaffold_split -from catpred.args import PredictArgs, TrainArgs from catpred.features import load_features, load_valid_atom_or_bond_features, is_mol from catpred.rdkit import make_mol +from catpred.security import load_index_artifact, load_pickle_artifact + +if TYPE_CHECKING: + from catpred.args import PredictArgs, TrainArgs # Increase maximum size of field in the csv processing for the current architecture csv.field_size_limit(int(ctypes.c_ulong(-1).value // 2)) + +def _load_protein_records(protein_records_path: str): + """ + Load protein records JSON from gzip, plain JSON, or accidentally double-gzipped files. + """ + try: + with gzip.open(protein_records_path, "rt", encoding="utf-8") as handle: + return json.load(handle) + except Exception: + try: + with gzip.open(protein_records_path, "rb") as handle: + payload = handle.read() + if payload[:2] == b"\x1f\x8b": + payload = gzip.decompress(payload) + return json.loads(payload.decode("utf-8")) + except Exception: + pass + + try: + with open(protein_records_path, "rt", encoding="utf-8") as handle: + return json.load(handle) + except Exception as plain_err: + raise ValueError( + f'Failed to load protein records from "{protein_records_path}". ' + "Expected a JSON mapping in .json or .json.gz format." + ) from plain_err + def get_header(path: str) -> List[str]: """ Returns the header of a data CSV file. @@ -400,8 +430,7 @@ def get_data(path: str, if protein_records_path is None: protein_records = None else: - with gzip.open(protein_records_path, "rt", encoding="utf-8") as f: - protein_records = json.load(f) + protein_records = _load_protein_records(protein_records_path) if args is not None: # Prefer explicit function arguments but default to args if not provided @@ -498,16 +527,42 @@ def get_data(path: str, if args.smoke_test: if smoke_test_counter>100: break smiles = [row[c] for c in smiles_columns] - pdbpath = row['pdbpath'] - pdbname = pdbpath.split("/")[-1] - protein_record = protein_records[pdbname] - if not protein_records is None: - sequence_features, _ = sequence_feat_getter(protein_record['seq'], - name = pdbname, - device = 'cpu') - protein_record['esm2_feats'] = sequence_features[0] #batch dim - else: - protein_record = None + protein_record = None + + if protein_records is not None: + if 'pdbpath' not in row: + raise ValueError( + f'Missing required "pdbpath" column in {path} at row {i + 2}.' + ) + + pdbpath = (row['pdbpath'] or '').strip() + if pdbpath == '': + raise ValueError( + f'Empty pdbpath found in {path} at row {i + 2}.' + ) + + pdbname = os.path.basename(pdbpath) + protein_record = protein_records.get(pdbname) or protein_records.get(pdbpath) + if protein_record is None: + raise KeyError( + f'No protein record found for pdbpath "{pdbpath}" (basename "{pdbname}") ' + f'in row {i + 2}. Ensure create_pdbrecords.py was run on the same input file.' + ) + + row_sequence = row.get('sequence') + if row_sequence is not None and protein_record.get('seq') != row_sequence: + raise ValueError( + f'Sequence mismatch for pdbpath "{pdbpath}" in row {i + 2}. ' + 'This usually means multiple sequences share the same pdbpath identifier.' + ) + + if 'esm2_feats' not in protein_record: + sequence_features, _ = sequence_feat_getter( + protein_record['seq'], + name=pdbname, + device='cpu' + ) + protein_record['esm2_feats'] = sequence_features[0] # batch dim targets, atom_targets, bond_targets = [], [], [] for column in target_columns: @@ -740,8 +795,12 @@ def split_data(data: MoleculeDataset, for split in range(3): split_indices = [] for index in index_set[split]: - with open(os.path.join(args.crossval_index_dir, f'{index}.pkl'), 'rb') as rf: - split_indices.extend(pickle.load(rf)) + split_indices.extend( + load_index_artifact( + os.path.join(args.crossval_index_dir, f"{index}.pkl"), + purpose="cross-validation fold index file", + ) + ) data_split.append([data[i] for i in split_indices]) train, val, test = tuple(data_split) return MoleculeDataset(train), MoleculeDataset(val), MoleculeDataset(test) @@ -790,12 +849,10 @@ def split_data(data: MoleculeDataset, if test_fold_index is None: raise ValueError('arg "test_fold_index" can not be None!') - try: - with open(folds_file, 'rb') as f: - all_fold_indices = pickle.load(f) - except UnicodeDecodeError: - with open(folds_file, 'rb') as f: - all_fold_indices = pickle.load(f, encoding='latin1') # in case we're loading indices from python2 + all_fold_indices = load_pickle_artifact( + folds_file, + purpose="predetermined folds file", + ) log_scaffold_stats(data, all_fold_indices, logger=logger) diff --git a/catpred/features/__init__.py b/catpred/features/__init__.py index 596e02f..94220bf 100644 --- a/catpred/features/__init__.py +++ b/catpred/features/__init__.py @@ -4,7 +4,8 @@ from .featurization import atom_features, bond_features, BatchMolGraph, get_atom_fdim, get_bond_fdim, mol2graph, \ MolGraph, onek_encoding_unk, set_extra_atom_fdim, set_extra_bond_fdim, set_reaction, set_explicit_h, \ - set_adding_hs, set_keeping_atom_map, is_reaction, is_explicit_h, is_adding_hs, is_keeping_atom_map, is_mol, reset_featurization_parameters + set_adding_hs, set_keeping_atom_map, is_reaction, is_explicit_h, is_adding_hs, is_keeping_atom_map, is_mol, \ + reset_featurization_parameters, featurization_session from .utils import load_features, save_features, load_valid_atom_or_bond_features __all__ = [ @@ -37,5 +38,6 @@ 'load_features', 'save_features', 'load_valid_atom_or_bond_features', - 'reset_featurization_parameters' + 'reset_featurization_parameters', + 'featurization_session' ] diff --git a/catpred/features/features_generators.py b/catpred/features/features_generators.py index 40226d2..26ec91f 100644 --- a/catpred/features/features_generators.py +++ b/catpred/features/features_generators.py @@ -85,7 +85,6 @@ def morgan_binary_features_generator(mol: Molecule, # return features -import ipdb @register_features_generator('morgan_diff_fp') def morgan_difference_features_generator(rxn: Reaction) -> np.ndarray: """ diff --git a/catpred/features/featurization.py b/catpred/features/featurization.py index 7a9b5d2..b272475 100644 --- a/catpred/features/featurization.py +++ b/catpred/features/featurization.py @@ -1,6 +1,9 @@ from typing import List, Tuple, Union from itertools import zip_longest import logging +from contextlib import contextmanager +from copy import deepcopy +import threading from rdkit import Chem import torch @@ -50,6 +53,31 @@ def __init__(self) -> None: # Create a global parameter object for reference throughout this module PARAMS = Featurization_parameters() +_FEATURIZATION_LOCK = threading.RLock() + + +def _clone_featurization_parameters(params: Featurization_parameters) -> Featurization_parameters: + cloned = Featurization_parameters() + cloned.__dict__.update(deepcopy(params.__dict__)) + return cloned + + +@contextmanager +def featurization_session(): + """ + Protects global featurization state for one train/predict transaction. + + This is an interim safety mechanism for multi-request serving environments + where concurrent requests can otherwise mutate shared featurization globals. + """ + global PARAMS + + with _FEATURIZATION_LOCK: + snapshot = _clone_featurization_parameters(PARAMS) + try: + yield + finally: + PARAMS = snapshot def reset_featurization_parameters(logger: logging.Logger = None) -> None: diff --git a/catpred/features/utils.py b/catpred/features/utils.py index 617af86..c0e24dd 100644 --- a/catpred/features/utils.py +++ b/catpred/features/utils.py @@ -1,11 +1,11 @@ import csv import os -import pickle from typing import List import numpy as np import pandas as pd from rdkit.Chem import PandasTools +from catpred.security import load_pickle_artifact def save_features(path: str, features: List[np.ndarray]) -> None: @@ -49,8 +49,8 @@ def load_features(path: str) -> np.ndarray: next(reader) # skip header features = np.array([[float(value) for value in row] for row in reader]) elif extension in ['.pkl', '.pckl', '.pickle']: - with open(path, 'rb') as f: - features = np.array([np.squeeze(np.array(feat.todense())) for feat in pickle.load(f)]) + payload = load_pickle_artifact(path, purpose="feature matrix pickle") + features = np.array([np.squeeze(np.array(feat.todense())) for feat in payload]) else: raise ValueError(f'Features path extension {extension} not supported.') diff --git a/catpred/inference/__init__.py b/catpred/inference/__init__.py new file mode 100644 index 0000000..103d8ed --- /dev/null +++ b/catpred/inference/__init__.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from .backends import ( + BackendPredictionResult, + BackendRouterSettings, + InferenceBackend, + InferenceBackendError, + InferenceBackendRouter, + LocalInferenceBackend, + ModalHTTPInferenceBackend, +) +from .types import PreparedInputPaths, PredictionRequest + +__all__ = [ + "BackendPredictionResult", + "BackendRouterSettings", + "InferenceBackend", + "InferenceBackendError", + "InferenceBackendRouter", + "LocalInferenceBackend", + "ModalHTTPInferenceBackend", + "PredictionRequest", + "PreparedInputPaths", + "prepare_prediction_inputs", + "run_raw_prediction", + "postprocess_predictions", + "run_prediction_pipeline", +] + + +def __getattr__(name: str): + if name in { + "prepare_prediction_inputs", + "run_raw_prediction", + "postprocess_predictions", + "run_prediction_pipeline", + }: + from . import service + + value = getattr(service, name) + globals()[name] = value + return value + raise AttributeError(f"module 'catpred.inference' has no attribute '{name}'") diff --git a/catpred/inference/backends.py b/catpred/inference/backends.py new file mode 100644 index 0000000..916dfe9 --- /dev/null +++ b/catpred/inference/backends.py @@ -0,0 +1,325 @@ +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from pathlib import Path +import csv +import json +import os +from typing import Any +from urllib import error, request as urllib_request + +import pandas as pd + +from .types import PredictionRequest + + +class InferenceBackendError(RuntimeError): + """Raised when a backend cannot satisfy an inference request.""" + + +@dataclass(frozen=True) +class BackendPredictionResult: + backend_name: str + output_file: str + metadata: dict[str, Any] = field(default_factory=dict) + + +class InferenceBackend: + name = "base" + + def readiness(self) -> dict[str, Any]: + raise NotImplementedError + + def predict(self, request_obj: PredictionRequest, results_dir: str) -> BackendPredictionResult: + raise NotImplementedError + + +class LocalInferenceBackend(InferenceBackend): + name = "local" + + def __init__(self, repo_root: str | None = None) -> None: + self._repo_root = repo_root + + def readiness(self) -> dict[str, Any]: + root = Path(self._repo_root) if self._repo_root else Path.cwd() + root = root.resolve() + required = [ + root / "predict.py", + root / "scripts" / "create_pdbrecords.py", + ] + missing = [str(path) for path in required if not path.exists()] + return { + "configured": True, + "ready": len(missing) == 0, + "missing_files": missing, + "repo_root": str(root), + } + + def predict(self, request_obj: PredictionRequest, results_dir: str) -> BackendPredictionResult: + from .service import run_prediction_pipeline + + effective_request = request_obj + if not request_obj.repo_root and self._repo_root: + effective_request = replace(request_obj, repo_root=self._repo_root) + + output_file = run_prediction_pipeline(effective_request, results_dir=results_dir) + return BackendPredictionResult(backend_name=self.name, output_file=output_file) + + +class ModalHTTPInferenceBackend(InferenceBackend): + name = "modal" + + def __init__( + self, + endpoint: str | None, + token: str | None = None, + timeout_seconds: int = 900, + repo_root: str | None = None, + ) -> None: + self._endpoint = endpoint + self._token = token + self._timeout_seconds = timeout_seconds + self._repo_root = repo_root + + def readiness(self) -> dict[str, Any]: + configured = bool(self._endpoint) + return { + "configured": configured, + "ready": configured, + "endpoint": self._endpoint, + "timeout_seconds": self._timeout_seconds, + } + + def _resolve_input_file(self, request_obj: PredictionRequest) -> Path: + input_path = Path(request_obj.input_file) + if not input_path.is_absolute(): + root = Path(request_obj.repo_root or self._repo_root or Path.cwd()).resolve() + input_path = (root / input_path).resolve() + if not input_path.exists(): + raise FileNotFoundError(f'Input CSV not found for modal backend: "{input_path}"') + return input_path + + def _resolve_results_dir(self, results_dir: str, request_obj: PredictionRequest) -> Path: + out_dir = Path(results_dir) + if not out_dir.is_absolute(): + root = Path(request_obj.repo_root or self._repo_root or Path.cwd()).resolve() + out_dir = (root / out_dir).resolve() + out_dir.mkdir(parents=True, exist_ok=True) + return out_dir + + @staticmethod + def _load_rows(csv_path: Path) -> list[dict[str, Any]]: + with csv_path.open(newline="", encoding="utf-8") as handle: + return list(csv.DictReader(handle)) + + def _post_json(self, payload: dict[str, Any]) -> dict[str, Any]: + if not self._endpoint: + raise InferenceBackendError( + "Modal backend is not configured. Set CATPRED_MODAL_ENDPOINT." + ) + + encoded_payload = json.dumps(payload).encode("utf-8") + headers = {"Content-Type": "application/json"} + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + + req = urllib_request.Request( + url=self._endpoint, + method="POST", + data=encoded_payload, + headers=headers, + ) + try: + with urllib_request.urlopen(req, timeout=self._timeout_seconds) as resp: + raw = resp.read() + except error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise InferenceBackendError( + f"Modal backend request failed with HTTP {exc.code}: {body}" + ) from exc + except error.URLError as exc: + raise InferenceBackendError( + f"Modal backend request failed: {exc.reason}" + ) from exc + + try: + decoded = json.loads(raw.decode("utf-8")) + except json.JSONDecodeError as exc: + raise InferenceBackendError("Modal backend returned non-JSON output.") from exc + + if not isinstance(decoded, dict): + raise InferenceBackendError( + "Modal backend response must be a JSON object." + ) + return decoded + + def _materialize_output( + self, + response: dict[str, Any], + input_path: Path, + results_dir: str, + request_obj: PredictionRequest, + ) -> BackendPredictionResult: + output_file = response.get("output_file") + if isinstance(output_file, str) and output_file: + resolved = Path(output_file).resolve() + if resolved.exists(): + return BackendPredictionResult( + backend_name=self.name, + output_file=str(resolved), + metadata={"endpoint": self._endpoint, "mode": "output_file"}, + ) + + output_rows = response.get("output_rows") + if isinstance(output_rows, list): + out_dir = self._resolve_results_dir(results_dir, request_obj) + out_name = response.get("output_filename") + if not isinstance(out_name, str) or not out_name: + out_name = f"{input_path.stem}_modal_output.csv" + if not out_name.endswith(".csv"): + out_name = f"{out_name}.csv" + + final_output = out_dir / out_name + pd.DataFrame(output_rows).to_csv(final_output, index=False) + return BackendPredictionResult( + backend_name=self.name, + output_file=str(final_output), + metadata={"endpoint": self._endpoint, "mode": "output_rows"}, + ) + + output_csv_text = response.get("output_csv_text") + if isinstance(output_csv_text, str) and output_csv_text: + out_dir = self._resolve_results_dir(results_dir, request_obj) + out_name = response.get("output_filename") + if not isinstance(out_name, str) or not out_name: + out_name = f"{input_path.stem}_modal_output.csv" + if not out_name.endswith(".csv"): + out_name = f"{out_name}.csv" + final_output = out_dir / out_name + final_output.write_text(output_csv_text, encoding="utf-8") + return BackendPredictionResult( + backend_name=self.name, + output_file=str(final_output), + metadata={"endpoint": self._endpoint, "mode": "output_csv_text"}, + ) + + raise InferenceBackendError( + "Modal backend response must include one of: output_file, output_rows, output_csv_text." + ) + + def predict(self, request_obj: PredictionRequest, results_dir: str) -> BackendPredictionResult: + input_path = self._resolve_input_file(request_obj) + payload = { + "parameter": request_obj.parameter, + "checkpoint_dir": request_obj.checkpoint_dir, + "use_gpu": request_obj.use_gpu, + "input_rows": self._load_rows(input_path), + "input_filename": input_path.name, + } + response = self._post_json(payload) + return self._materialize_output( + response=response, + input_path=input_path, + results_dir=results_dir, + request_obj=request_obj, + ) + + +def _env_flag(name: str, default: bool = False) -> bool: + value = os.environ.get(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "y", "on"} + + +@dataclass(frozen=True) +class BackendRouterSettings: + default_backend: str = "local" + modal_endpoint: str | None = None + modal_token: str | None = None + modal_timeout_seconds: int = 900 + repo_root: str | None = None + + @classmethod + def from_env(cls) -> "BackendRouterSettings": + timeout = int(os.environ.get("CATPRED_MODAL_TIMEOUT_SECONDS", "900")) + return cls( + default_backend=os.environ.get("CATPRED_DEFAULT_BACKEND", "local").lower(), + modal_endpoint=os.environ.get("CATPRED_MODAL_ENDPOINT"), + modal_token=os.environ.get("CATPRED_MODAL_TOKEN"), + modal_timeout_seconds=timeout, + repo_root=os.environ.get("CATPRED_REPO_ROOT"), + ) + + +class InferenceBackendRouter: + def __init__(self, settings: BackendRouterSettings | None = None) -> None: + self.settings = settings or BackendRouterSettings.from_env() + self._backends: dict[str, InferenceBackend] = { + "local": LocalInferenceBackend(repo_root=self.settings.repo_root), + "modal": ModalHTTPInferenceBackend( + endpoint=self.settings.modal_endpoint, + token=self.settings.modal_token, + timeout_seconds=self.settings.modal_timeout_seconds, + repo_root=self.settings.repo_root, + ), + } + if self.settings.default_backend not in self._backends: + raise ValueError( + f"Unsupported CATPRED_DEFAULT_BACKEND '{self.settings.default_backend}'. " + "Use one of: local, modal." + ) + + def available_backends(self) -> list[str]: + return sorted(self._backends.keys()) + + def resolve_backend(self, backend_name: str | None = None) -> InferenceBackend: + selected = (backend_name or self.settings.default_backend).lower() + if selected not in self._backends: + raise ValueError( + f"Unsupported backend '{selected}'. Use one of: {', '.join(self.available_backends())}." + ) + + backend = self._backends[selected] + state = backend.readiness() + if not state.get("configured", False): + raise InferenceBackendError( + f"Backend '{selected}' is not configured. Readiness: {state}" + ) + return backend + + def predict( + self, + request_obj: PredictionRequest, + results_dir: str, + backend_name: str | None = None, + fallback_to_local: bool = False, + ) -> BackendPredictionResult: + selected_name = (backend_name or self.settings.default_backend).lower() + backend: InferenceBackend | None = None + try: + backend = self.resolve_backend(selected_name) + return backend.predict(request_obj=request_obj, results_dir=results_dir) + except Exception as exc: + if fallback_to_local and selected_name != "local": + local_backend = self._backends["local"] + local_result = local_backend.predict(request_obj=request_obj, results_dir=results_dir) + metadata = dict(local_result.metadata) + metadata["fallback_from"] = selected_name + metadata["fallback_reason"] = str(exc) + return BackendPredictionResult( + backend_name=local_result.backend_name, + output_file=local_result.output_file, + metadata=metadata, + ) + raise + + def readiness(self) -> dict[str, Any]: + backends = {name: backend.readiness() for name, backend in self._backends.items()} + default_state = backends[self.settings.default_backend] + return { + "default_backend": self.settings.default_backend, + "ready": bool(default_state.get("ready", False)), + "backends": backends, + "fallback_to_local_enabled": _env_flag("CATPRED_MODAL_FALLBACK_TO_LOCAL", default=False), + } diff --git a/catpred/inference/service.py b/catpred/inference/service.py new file mode 100644 index 0000000..ae8a875 --- /dev/null +++ b/catpred/inference/service.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +from pathlib import Path +import os +import subprocess +from typing import Tuple + +import numpy as np +import pandas as pd +from rdkit import Chem + +from .types import PreparedInputPaths, PredictionRequest + +_VALID_PARAMETERS = {"kcat", "km", "ki"} +_TARGET_COLUMNS = { + "kcat": ("log10kcat_max", "s^(-1)"), + "km": ("log10km_mean", "mM"), + "ki": ("log10ki_mean", "mM"), +} +_VALID_AAS = set("ACDEFGHIKLMNPQRSTVWY") + + +def _validate_parameter(parameter: str) -> str: + parameter = parameter.lower() + if parameter not in _VALID_PARAMETERS: + raise ValueError(f"Unsupported parameter '{parameter}'. Must be one of: kcat, km, ki.") + return parameter + + +def _resolve_repo_root(repo_root: str | None) -> Path: + root = Path(repo_root) if repo_root else Path.cwd() + root = root.resolve() + if not root.exists(): + raise FileNotFoundError(f'Repository root does not exist: "{root}"') + return root + + +def _resolve_input_path(input_file: str, repo_root: Path) -> Path: + path = Path(input_file) + if not path.is_absolute(): + path = (repo_root / path).resolve() + if not path.exists(): + raise FileNotFoundError(f'Input CSV not found: "{path}"') + return path + + +def _validate_and_prepare_dataframe(parameter: str, df: pd.DataFrame, input_csv: Path) -> pd.DataFrame: + required_columns = {"SMILES", "sequence", "pdbpath"} + missing = required_columns.difference(df.columns) + if missing: + raise ValueError( + f'Missing required column(s) in "{input_csv}": {", ".join(sorted(missing))}.' + ) + + conflicting_pdbpaths = ( + df.groupby("pdbpath")["sequence"] + .nunique(dropna=False) + .loc[lambda value: value > 1] + ) + if len(conflicting_pdbpaths) > 0: + preview = ", ".join(conflicting_pdbpaths.index.astype(str).tolist()[:5]) + raise ValueError( + "Found pdbpath values mapped to multiple sequences. " + f"Each unique sequence must have a unique pdbpath. Examples: {preview}" + ) + + canonical_smiles = [] + for i, raw_smiles in enumerate(df["SMILES"]): + mol = Chem.MolFromSmiles(raw_smiles) + if mol is None: + raise ValueError(f'Invalid SMILES input in row {i + 2}: "{raw_smiles}"') + smiles = Chem.MolToSmiles(mol) + if parameter == "kcat" and "." in smiles: + smiles = ".".join(sorted(smiles.split("."))) + canonical_smiles.append(smiles) + + for i, sequence in enumerate(df["sequence"]): + if not isinstance(sequence, str) or not set(sequence).issubset(_VALID_AAS): + raise ValueError(f'Invalid enzyme sequence in row {i + 2}: "{sequence}"') + + prepared = df.copy() + prepared["SMILES"] = canonical_smiles + return prepared + + +def prepare_prediction_inputs(parameter: str, input_file: str, repo_root: str | None = None) -> PreparedInputPaths: + parameter = _validate_parameter(parameter) + root = _resolve_repo_root(repo_root) + input_csv = _resolve_input_path(input_file, root) + + df = pd.read_csv(input_csv) + prepared_df = _validate_and_prepare_dataframe(parameter, df, input_csv) + + input_base = input_csv.with_suffix("") + prepared_input_csv = Path(f"{input_base}_input.csv") + prepared_df.to_csv(prepared_input_csv, index=False) + + test_prefix = prepared_input_csv.with_suffix("") + records_file = Path(f"{test_prefix}.json.gz") + output_csv = Path(f"{test_prefix}_output.csv") + + return PreparedInputPaths( + input_csv=str(prepared_input_csv), + records_file=str(records_file), + output_csv=str(output_csv), + ) + + +def _build_prediction_commands( + python_executable: str, + repo_root: Path, + paths: PreparedInputPaths, + checkpoint_dir: str, +) -> Tuple[list[str], list[str]]: + create_records_cmd = [ + python_executable, + str(repo_root / "scripts" / "create_pdbrecords.py"), + "--data_file", + paths.input_csv, + "--out_file", + paths.records_file, + ] + predict_cmd = [ + python_executable, + str(repo_root / "predict.py"), + "--test_path", + paths.input_csv, + "--preds_path", + paths.output_csv, + "--checkpoint_dir", + checkpoint_dir, + "--uncertainty_method", + "mve", + "--smiles_column", + "SMILES", + "--individual_ensemble_predictions", + "--protein_records_path", + paths.records_file, + ] + return create_records_cmd, predict_cmd + + +def run_raw_prediction(request: PredictionRequest, paths: PreparedInputPaths) -> None: + root = _resolve_repo_root(request.repo_root) + create_records_cmd, predict_cmd = _build_prediction_commands( + python_executable=request.python_executable, + repo_root=root, + paths=paths, + checkpoint_dir=request.checkpoint_dir, + ) + + env = os.environ.copy() + env["PROTEIN_EMBED_USE_CPU"] = "0" if request.use_gpu else "1" + + subprocess.run(create_records_cmd, cwd=str(root), env=env, check=True) + subprocess.run(predict_cmd, cwd=str(root), env=env, check=True) + + if not os.path.exists(paths.output_csv): + raise FileNotFoundError(f'Prediction output file was not generated: "{paths.output_csv}"') + + +def postprocess_predictions(parameter: str, output_csv: str) -> pd.DataFrame: + parameter = _validate_parameter(parameter) + target_col, unit = _TARGET_COLUMNS[parameter] + unc_col = f"{target_col}_mve_uncal_var" + + df = pd.read_csv(output_csv) + missing_cols = [col for col in [target_col, unc_col] if col not in df.columns] + if missing_cols: + raise ValueError( + f'Prediction output is missing required column(s): {", ".join(missing_cols)}' + ) + + pred_col, pred_logcol, pred_sd_tot, pred_sd_alea, pred_sd_epi = [], [], [], [], [] + + for _, row in df.iterrows(): + model_cols = [col for col in row.index if col.startswith(target_col) and "model_" in col] + + unc = row[unc_col] + prediction_log = row[target_col] + prediction_linear = np.power(10, prediction_log) + + if model_cols: + model_outs = np.array([row[col] for col in model_cols]) + epi_unc_var = np.var(model_outs) + else: + epi_unc_var = 0.0 + + alea_unc_var = max(unc - epi_unc_var, 0.0) + epi_unc = np.sqrt(epi_unc_var) + alea_unc = np.sqrt(alea_unc_var) + total_unc = np.sqrt(max(unc, 0.0)) + + pred_col.append(prediction_linear) + pred_logcol.append(prediction_log) + pred_sd_tot.append(total_unc) + pred_sd_alea.append(alea_unc) + pred_sd_epi.append(epi_unc) + + df[f"Prediction_({unit})"] = pred_col + df["Prediction_log10"] = pred_logcol + df["SD_total"] = pred_sd_tot + df["SD_aleatoric"] = pred_sd_alea + df["SD_epistemic"] = pred_sd_epi + return df + + +def run_prediction_pipeline(request: PredictionRequest, results_dir: str = "../results") -> str: + parameter = _validate_parameter(request.parameter) + paths = prepare_prediction_inputs(parameter, request.input_file, request.repo_root) + run_raw_prediction(request, paths) + + output_final = postprocess_predictions(parameter, paths.output_csv) + + results_path = Path(results_dir) + if not results_path.is_absolute(): + results_path = (_resolve_repo_root(request.repo_root) / results_path).resolve() + results_path.mkdir(parents=True, exist_ok=True) + + out_name = Path(paths.output_csv).name + final_output = results_path / out_name + output_final.to_csv(final_output, index=False) + return str(final_output) diff --git a/catpred/inference/types.py b/catpred/inference/types.py new file mode 100644 index 0000000..51c4857 --- /dev/null +++ b/catpred/inference/types.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PredictionRequest: + parameter: str + input_file: str + checkpoint_dir: str + use_gpu: bool = False + repo_root: str | None = None + python_executable: str = "python" + + +@dataclass(frozen=True) +class PreparedInputPaths: + input_csv: str + records_file: str + output_csv: str diff --git a/catpred/models/model.py b/catpred/models/model.py index 58dabe6..916cd81 100644 --- a/catpred/models/model.py +++ b/catpred/models/model.py @@ -1,34 +1,23 @@ from typing import List, Union, Tuple -from rotary_embedding_torch import RotaryEmbedding +import os import numpy as np from rdkit import Chem import torch import torch.nn as nn +from rotary_embedding_torch import RotaryEmbedding +from torch.nn.utils.rnn import pad_sequence + from .mpn import MPN from .ffn import build_ffn, MultiReadout from catpred.args import TrainArgs from catpred.features import BatchMolGraph from catpred.nn_utils import initialize_weights -from torch.nn.utils.rnn import pad_sequence - -from collections import OrderedDict -import ipdb -import os +from catpred.security import load_torch_artifact -import torch -import torch.nn as nn - -import ipdb -import torch -from torch import nn -from torch import einsum def exists(val): return val is not None - -def default(val, d): - return val if exists(val) else d class AttentivePooling(nn.Module): def __init__(self, input_size=1280, hidden_size=1280): @@ -131,12 +120,18 @@ def create_protein_model(self, args: TrainArgs) -> None: self.seq_embedder = nn.Embedding(21, args.seq_embed_dim, padding_idx=20) #last index is for padding if self.args.add_pretrained_egnn_feats: - self.pretrained_egnn_feats_dict = torch.load(self.args.pretrained_egnn_feats_path) + self.pretrained_egnn_feats_dict = load_torch_artifact( + self.args.pretrained_egnn_feats_path, + purpose="pretrained EGNN features", + ) x = list(self.pretrained_egnn_feats_dict.values()) self.pretrained_egnn_feats_avg = torch.stack(x).mean(dim=0) - # For rotary positional embeddings - self.rotary_embedder = RotaryEmbedding(dim=args.seq_embed_dim//4) + # Rotary embeddings require an even feature dimension. + rotary_dim = max(2, args.seq_embed_dim // 4) + if rotary_dim % 2 != 0: + rotary_dim -= 1 + self.rotary_embedder = RotaryEmbedding(dim=rotary_dim) # For self-attention self.multihead_attn = nn.MultiheadAttention(args.seq_embed_dim, @@ -230,7 +225,10 @@ def create_ffn(self, args: TrainArgs) -> None: first_linear_dim_now += 1280 if args.add_pretrained_egnn_feats: first_linear_dim_now+=128 - assert(os.path.exists(args.pretrained_egnn_feats_path)) + if not os.path.exists(args.pretrained_egnn_feats_path): + raise FileNotFoundError( + f'Pretrained EGNN features file not found: "{args.pretrained_egnn_feats_path}"' + ) self.readout = build_ffn( first_linear_dim=first_linear_dim_now, @@ -417,8 +415,10 @@ def seq_to_tensor(seq): esm_feature_arr = [each['esm2_feats'] for each in protein_records] esm_feature_arr = pad_sequence(esm_feature_arr, batch_first=True).to(self.device) - if seq_arr.shape[1]!=esm_feature_arr.shape[1]: - seq_arr = seq_arr[:,:esm_feature_arr.shape[1]:] + if seq_arr.shape[1] != esm_feature_arr.shape[1]: + common_len = min(seq_arr.shape[1], esm_feature_arr.shape[1]) + seq_arr = seq_arr[:, :common_len] + esm_feature_arr = esm_feature_arr[:, :common_len] # project sequence to embed dim seq_outs = self.seq_embedder(seq_arr) @@ -522,4 +522,4 @@ def seq_to_tensor(seq): else: output = nn.functional.softplus(output) + 1 - return output \ No newline at end of file + return output diff --git a/catpred/security/__init__.py b/catpred/security/__init__.py new file mode 100644 index 0000000..8c5f371 --- /dev/null +++ b/catpred/security/__init__.py @@ -0,0 +1,17 @@ +from .deserialization import ( + DeserializationSecurityError, + ensure_trusted_path, + load_index_artifact, + load_pickle_artifact, + load_torch_artifact, + unsafe_deserialization_enabled, +) + +__all__ = [ + "DeserializationSecurityError", + "ensure_trusted_path", + "load_index_artifact", + "load_pickle_artifact", + "load_torch_artifact", + "unsafe_deserialization_enabled", +] diff --git a/catpred/security/deserialization.py b/catpred/security/deserialization.py new file mode 100644 index 0000000..a3fc8f9 --- /dev/null +++ b/catpred/security/deserialization.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import gzip +import json +import os +import pickle +from pathlib import Path +from typing import Any, Iterable + + +class DeserializationSecurityError(RuntimeError): + """Raised when deserialization is blocked by policy.""" + + +_ALLOW_UNSAFE_ENV = "CATPRED_ALLOW_UNSAFE_DESERIALIZATION" +_TRUSTED_ROOTS_ENV = "CATPRED_TRUSTED_DESERIALIZATION_ROOTS" + + +def _env_flag(name: str, default: bool = False) -> bool: + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "y", "on"} + + +def _dedupe_paths(paths: list[Path]) -> list[Path]: + unique: list[Path] = [] + seen = set() + for path in paths: + resolved = path.resolve() + key = str(resolved) + if key not in seen: + unique.append(resolved) + seen.add(key) + return unique + + +def _default_trusted_roots() -> list[Path]: + raw = os.environ.get(_TRUSTED_ROOTS_ENV) + if raw: + candidates = [Path(item) for item in raw.split(os.pathsep) if item.strip()] + else: + cwd = Path.cwd().resolve() + candidates = [cwd, cwd.parent] + return _dedupe_paths(candidates) + + +def trusted_roots(extra_roots: Iterable[str | Path] | None = None) -> list[Path]: + roots = _default_trusted_roots() + if extra_roots: + roots.extend(Path(path) for path in extra_roots) + return _dedupe_paths(roots) + + +def is_trusted_path(path: str | Path, roots: Iterable[str | Path] | None = None) -> bool: + resolved = Path(path).resolve() + candidate_roots = trusted_roots(roots) + for root in candidate_roots: + try: + resolved.relative_to(root) + return True + except ValueError: + continue + return False + + +def ensure_trusted_path( + path: str | Path, + *, + purpose: str, + roots: Iterable[str | Path] | None = None, +) -> Path: + resolved = Path(path).resolve() + candidate_roots = trusted_roots(roots) + if is_trusted_path(resolved, candidate_roots): + return resolved + roots_display = ", ".join(str(item) for item in candidate_roots) + raise DeserializationSecurityError( + f'Refusing to load untrusted {purpose} from "{resolved}". ' + f"Allowed roots: {roots_display}. " + f"Use {_TRUSTED_ROOTS_ENV} to expand trusted roots." + ) + + +def unsafe_deserialization_enabled(default: bool = True) -> bool: + return _env_flag(_ALLOW_UNSAFE_ENV, default=default) + + +def load_pickle_artifact( + path: str | Path, + *, + purpose: str, + roots: Iterable[str | Path] | None = None, + allow_unsafe: bool | None = None, + encoding: str | None = None, +) -> Any: + resolved = ensure_trusted_path(path, purpose=purpose, roots=roots) + unsafe = unsafe_deserialization_enabled() if allow_unsafe is None else allow_unsafe + if not unsafe: + raise DeserializationSecurityError( + f"Pickle deserialization is disabled for {purpose}. " + f"Set {_ALLOW_UNSAFE_ENV}=1 only for trusted artifacts." + ) + + with resolved.open("rb") as handle: + if encoding is None: + try: + return pickle.load(handle) + except UnicodeDecodeError: + handle.seek(0) + return pickle.load(handle, encoding="latin1") + return pickle.load(handle, encoding=encoding) + + +def load_index_artifact( + path: str | Path, + *, + purpose: str, + roots: Iterable[str | Path] | None = None, + allow_unsafe: bool | None = None, +) -> Any: + resolved = ensure_trusted_path(path, purpose=purpose, roots=roots) + suffixes = tuple(s.lower() for s in resolved.suffixes) + if suffixes and suffixes[-1] == ".json": + with resolved.open("rt", encoding="utf-8") as handle: + return json.load(handle) + if suffixes[-2:] == (".json", ".gz"): + with gzip.open(resolved, "rt", encoding="utf-8") as handle: + return json.load(handle) + return load_pickle_artifact( + resolved, + purpose=purpose, + roots=roots, + allow_unsafe=allow_unsafe, + ) + + +def load_torch_artifact( + path: str | Path, + *, + purpose: str, + map_location=None, + roots: Iterable[str | Path] | None = None, + allow_unsafe: bool | None = None, +) -> Any: + resolved = ensure_trusted_path(path, purpose=purpose, roots=roots) + unsafe = unsafe_deserialization_enabled() if allow_unsafe is None else allow_unsafe + + import torch + + if unsafe: + try: + return torch.load(str(resolved), map_location=map_location, weights_only=False) + except TypeError: + return torch.load(str(resolved), map_location=map_location) + + try: + return torch.load(str(resolved), map_location=map_location, weights_only=True) + except TypeError as exc: + raise DeserializationSecurityError( + "Safe torch deserialization requires a torch version that supports weights_only loading. " + f"Set {_ALLOW_UNSAFE_ENV}=1 for trusted legacy checkpoints." + ) from exc + except Exception as exc: + raise DeserializationSecurityError( + f"Safe torch deserialization rejected {purpose}. " + f"If this checkpoint is trusted, set {_ALLOW_UNSAFE_ENV}=1." + ) from exc diff --git a/catpred/train/__init__.py b/catpred/train/__init__.py index d8c18d9..4f66fe7 100644 --- a/catpred/train/__init__.py +++ b/catpred/train/__init__.py @@ -1,47 +1,54 @@ -from .metrics import get_metric_func, prc_auc, bce, rmse, bounded_mse, bounded_mae, \ - bounded_rmse, accuracy, f1_metric, mcc_metric, sid_metric, wasserstein_metric -from .loss_functions import get_loss_func, bounded_mse_loss, \ - mcc_class_loss, mcc_multiclass_loss, sid_loss, wasserstein_loss -from .cross_validate import catpred_train, cross_validate, TRAIN_LOGGER_NAME -from .evaluate import evaluate, evaluate_predictions -from .make_predictions import catpred_predict, make_predictions, load_model, set_features, load_data, predict_and_save -from .molecule_fingerprint import catpred_fingerprint, model_fingerprint -from .predict import predict -from .run_training import run_training -from .train import train +from __future__ import annotations -__all__ = [ - 'catpred_train', - 'cross_validate', - 'TRAIN_LOGGER_NAME', - 'evaluate', - 'evaluate_predictions', - 'catpred_predict', - 'catpred_fingerprint', - 'make_predictions', - 'load_model', - 'set_features', - 'load_data', - 'predict_and_save', - 'predict', - 'run_training', - 'train', - 'get_metric_func', - 'prc_auc', - 'bce', - 'rmse', - 'bounded_mse', - 'bounded_mae', - 'bounded_rmse', - 'accuracy', - 'f1_metric', - 'mcc_metric', - 'sid_metric', - 'wasserstein_metric', - 'get_loss_func', - 'bounded_mse_loss', - 'mcc_class_loss', - 'mcc_multiclass_loss', - 'sid_loss', - 'wasserstein_loss' -] +import importlib + +_LAZY_ATTRS = { + "get_metric_func": ("metrics", "get_metric_func"), + "prc_auc": ("metrics", "prc_auc"), + "bce": ("metrics", "bce"), + "rmse": ("metrics", "rmse"), + "bounded_mse": ("metrics", "bounded_mse"), + "bounded_mae": ("metrics", "bounded_mae"), + "bounded_rmse": ("metrics", "bounded_rmse"), + "accuracy": ("metrics", "accuracy"), + "f1_metric": ("metrics", "f1_metric"), + "mcc_metric": ("metrics", "mcc_metric"), + "sid_metric": ("metrics", "sid_metric"), + "wasserstein_metric": ("metrics", "wasserstein_metric"), + "get_loss_func": ("loss_functions", "get_loss_func"), + "bounded_mse_loss": ("loss_functions", "bounded_mse_loss"), + "mcc_class_loss": ("loss_functions", "mcc_class_loss"), + "mcc_multiclass_loss": ("loss_functions", "mcc_multiclass_loss"), + "sid_loss": ("loss_functions", "sid_loss"), + "wasserstein_loss": ("loss_functions", "wasserstein_loss"), + "catpred_train": ("cross_validate", "catpred_train"), + "cross_validate": ("cross_validate", "cross_validate"), + "TRAIN_LOGGER_NAME": ("cross_validate", "TRAIN_LOGGER_NAME"), + "evaluate": ("evaluate", "evaluate"), + "evaluate_predictions": ("evaluate", "evaluate_predictions"), + "catpred_predict": ("make_predictions", "catpred_predict"), + "make_predictions": ("make_predictions", "make_predictions"), + "load_model": ("make_predictions", "load_model"), + "set_features": ("make_predictions", "set_features"), + "load_data": ("make_predictions", "load_data"), + "predict_and_save": ("make_predictions", "predict_and_save"), + "catpred_fingerprint": ("molecule_fingerprint", "catpred_fingerprint"), + "model_fingerprint": ("molecule_fingerprint", "model_fingerprint"), + "predict": ("predict", "predict"), + "run_training": ("run_training", "run_training"), + "train": ("train", "train"), +} + +__all__ = sorted(_LAZY_ATTRS.keys()) + + +def __getattr__(name: str): + target = _LAZY_ATTRS.get(name) + if target is None: + raise AttributeError(f"module 'catpred.train' has no attribute '{name}'") + + module_name, attr_name = target + module = importlib.import_module(f".{module_name}", __name__) + value = getattr(module, attr_name) + globals()[name] = value + return value diff --git a/catpred/train/make_predictions.py b/catpred/train/make_predictions.py index 9e285ad..419507c 100644 --- a/catpred/train/make_predictions.py +++ b/catpred/train/make_predictions.py @@ -7,7 +7,7 @@ from catpred.args import PredictArgs, TrainArgs from catpred.data import get_data, get_data_from_smiles, MoleculeDataLoader, MoleculeDataset, StandardScaler, AtomBondScaler from catpred.utils import load_args, load_checkpoint, load_scalers, makedirs, timeit, update_prediction_args -from catpred.features import set_extra_atom_fdim, set_extra_bond_fdim, set_reaction, set_explicit_h, set_adding_hs, set_keeping_atom_map, reset_featurization_parameters +from catpred.features import set_extra_atom_fdim, set_extra_bond_fdim, set_reaction, set_explicit_h, set_adding_hs, set_keeping_atom_map, reset_featurization_parameters, featurization_session from catpred.models import MoleculeModel from catpred.uncertainty import UncertaintyCalibrator, build_uncertainty_calibrator, UncertaintyEstimator, build_uncertainty_evaluator from catpred.multitask_utils import reshape_values @@ -240,8 +240,14 @@ def predict_and_save( # Save results if save_results: print(f"Saving predictions to {args.preds_path}") - assert len(test_data) == len(preds) - assert len(test_data) == len(unc) + if len(test_data) != len(preds): + raise RuntimeError( + f"Prediction count mismatch: expected {len(test_data)} rows, got {len(preds)} predictions." + ) + if len(test_data) != len(unc): + raise RuntimeError( + f"Uncertainty count mismatch: expected {len(test_data)} rows, got {len(unc)} rows." + ) makedirs(args.preds_path, isfile=True) @@ -401,110 +407,108 @@ def make_predictions( num_models = len(args.checkpoint_paths) - set_features(args, train_args) + with featurization_session(): + set_features(args, train_args) - # Note: to get the invalid SMILES for your data, use the get_invalid_smiles_from_file or get_invalid_smiles_from_list functions from data/utils.py - full_data, test_data, test_data_loader, full_to_valid_indices = load_data( - args, smiles - ) - - if args.uncertainty_method is None and (args.calibration_method is not None or args.evaluation_methods is not None): - if args.dataset_type in ['classification', 'multiclass']: - args.uncertainty_method = 'classification' - else: - raise ValueError('Cannot calibrate or evaluate uncertainty without selection of an uncertainty method.') + # Note: to get the invalid SMILES for your data, use get_invalid_smiles_from_file/get_invalid_smiles_from_list. + full_data, test_data, test_data_loader, full_to_valid_indices = load_data( + args, smiles + ) + if args.uncertainty_method is None and (args.calibration_method is not None or args.evaluation_methods is not None): + if args.dataset_type in ['classification', 'multiclass']: + args.uncertainty_method = 'classification' + else: + raise ValueError('Cannot calibrate or evaluate uncertainty without selection of an uncertainty method.') + + if calibrator is None and args.calibration_path is not None: + + calibration_data = get_data( + protein_records_path=args.protein_records_path, + path=args.calibration_path, + smiles_columns=args.smiles_columns, + target_columns=task_names, + args=args, + features_path=args.calibration_features_path, + features_generator=args.features_generator, + phase_features_path=args.calibration_phase_features_path, + atom_descriptors_path=args.calibration_atom_descriptors_path, + bond_descriptors_path=args.calibration_bond_descriptors_path, + max_data_size=args.max_data_size, + loss_function=args.loss_function, + ) - if calibrator is None and args.calibration_path is not None: + calibration_data_loader = MoleculeDataLoader( + dataset=calibration_data, + batch_size=args.batch_size, + num_workers=args.num_workers, + ) - calibration_data = get_data( - protein_records_path=args.protein_records_path, - path=args.calibration_path, - smiles_columns=args.smiles_columns, - target_columns=task_names, - args=args, - features_path=args.calibration_features_path, - features_generator=args.features_generator, - phase_features_path=args.calibration_phase_features_path, - atom_descriptors_path=args.calibration_atom_descriptors_path, - bond_descriptors_path=args.calibration_bond_descriptors_path, - max_data_size=args.max_data_size, - loss_function=args.loss_function, - ) + if isinstance(models, list) and isinstance(scalers, list): + calibration_models = models + calibration_scalers = scalers + else: + calibration_model_objects = load_model(args, generator=True) + calibration_models = calibration_model_objects[2] + calibration_scalers = calibration_model_objects[3] - calibration_data_loader = MoleculeDataLoader( - dataset=calibration_data, - batch_size=args.batch_size, - num_workers=args.num_workers, - ) + calibrator = build_uncertainty_calibrator( + calibration_method=args.calibration_method, + uncertainty_method=args.uncertainty_method, + interval_percentile=args.calibration_interval_percentile, + regression_calibrator_metric=args.regression_calibrator_metric, + calibration_data=calibration_data, + calibration_data_loader=calibration_data_loader, + models=calibration_models, + scalers=calibration_scalers, + num_models=num_models, + dataset_type=args.dataset_type, + loss_function=args.loss_function, + uncertainty_dropout_p=args.uncertainty_dropout_p, + dropout_sampling_size=args.dropout_sampling_size, + spectra_phase_mask=getattr(train_args, "spectra_phase_mask", None), + ) - if isinstance(models, List) and isinstance(scalers, List): - calibration_models = models - calibration_scalers = scalers + # Edge case if empty list of smiles is provided + if len(test_data) == 0: + preds = [None] * len(full_data) + unc = [None] * len(full_data) else: - calibration_model_objects = load_model(args, generator=True) - calibration_models = calibration_model_objects[2] - calibration_scalers = calibration_model_objects[3] - - calibrator = build_uncertainty_calibrator( - calibration_method=args.calibration_method, - uncertainty_method=args.uncertainty_method, - interval_percentile=args.calibration_interval_percentile, - regression_calibrator_metric=args.regression_calibrator_metric, - calibration_data=calibration_data, - calibration_data_loader=calibration_data_loader, - models=calibration_models, - scalers=calibration_scalers, - num_models=num_models, - dataset_type=args.dataset_type, - loss_function=args.loss_function, - uncertainty_dropout_p=args.uncertainty_dropout_p, - dropout_sampling_size=args.dropout_sampling_size, - spectra_phase_mask=getattr(train_args, "spectra_phase_mask", None), - ) - - # Edge case if empty list of smiles is provided - if len(test_data) == 0: - preds = [None] * len(full_data) - unc = [None] * len(full_data) - else: - preds, unc = predict_and_save( - args=args, - train_args=train_args, - test_data=test_data, - task_names=task_names, - num_tasks=num_tasks, - test_data_loader=test_data_loader, - full_data=full_data, - full_to_valid_indices=full_to_valid_indices, - models=models, - scalers=scalers, - num_models=num_models, - calibrator=calibrator, - return_invalid_smiles=return_invalid_smiles, - ) + preds, unc = predict_and_save( + args=args, + train_args=train_args, + test_data=test_data, + task_names=task_names, + num_tasks=num_tasks, + test_data_loader=test_data_loader, + full_data=full_data, + full_to_valid_indices=full_to_valid_indices, + models=models, + scalers=scalers, + num_models=num_models, + calibrator=calibrator, + return_invalid_smiles=return_invalid_smiles, + ) - if return_index_dict: - preds_dict = {} - unc_dict = {} - for i in range(len(full_data)): - if return_invalid_smiles: - preds_dict[i] = preds[i] - unc_dict[i] = unc[i] - else: - valid_index = full_to_valid_indices.get(i, None) - if valid_index is not None: - preds_dict[i] = preds[valid_index] - unc_dict[i] = unc[valid_index] - if return_uncertainty: - return preds_dict, unc_dict - else: + if return_index_dict: + preds_dict = {} + unc_dict = {} + for i in range(len(full_data)): + if return_invalid_smiles: + preds_dict[i] = preds[i] + unc_dict[i] = unc[i] + else: + valid_index = full_to_valid_indices.get(i, None) + if valid_index is not None: + preds_dict[i] = preds[valid_index] + unc_dict[i] = unc[valid_index] + if return_uncertainty: + return preds_dict, unc_dict return preds_dict - else: + if return_uncertainty: return preds, unc - else: - return preds + return preds def catpred_predict() -> None: diff --git a/catpred/train/run_training.py b/catpred/train/run_training.py index 1e52394..aa0b762 100644 --- a/catpred/train/run_training.py +++ b/catpred/train/run_training.py @@ -237,7 +237,7 @@ def run_training(args: TrainArgs, makedirs(save_dir) try: writer = SummaryWriter(log_dir=save_dir) - except: + except TypeError: writer = SummaryWriter(logdir=save_dir) # Load/build model diff --git a/catpred/utils.py b/catpred/utils.py index 7aaa9cf..102aeda 100644 --- a/catpred/utils.py +++ b/catpred/utils.py @@ -23,6 +23,7 @@ from catpred.models import MoleculeModel from catpred.nn_utils import NoamLR from catpred.models.ffn import MultiReadout +from catpred.security import load_torch_artifact def makedirs(path: str, isfile: bool = False) -> None: @@ -110,7 +111,11 @@ def load_checkpoint( debug = info = print # Load model and args - state = torch.load(path, map_location=lambda storage, loc: storage) + state = load_torch_artifact( + path, + purpose="model checkpoint", + map_location=lambda storage, loc: storage, + ) args = TrainArgs() args.from_dict(vars(state["args"]), skip_unsettable=True) if not pretrained_egnn_feats_path is None: @@ -122,7 +127,7 @@ def load_checkpoint( # Build model model = MoleculeModel(args) - print(model) + debug(model) model_state_dict = model.state_dict() # Skip missing parameters and parameters of mismatched size @@ -160,9 +165,6 @@ def load_checkpoint( model = model.to(args.device) return model - -import ipdb - def overwrite_state_dict( loaded_param_name: str, model_param_name: str, @@ -221,7 +223,11 @@ def load_frzn_model( """ debug = logger.debug if logger is not None else print - loaded_mpnn_model = torch.load(path, map_location=lambda storage, loc: storage) + loaded_mpnn_model = load_torch_artifact( + path, + purpose="frozen model checkpoint", + map_location=lambda storage, loc: storage, + ) loaded_state_dict = loaded_mpnn_model["state_dict"] loaded_args = loaded_mpnn_model["args"] @@ -443,7 +449,11 @@ def load_scalers( :return: A tuple with the data :class:`~catpred.data.scaler.StandardScaler` and features :class:`~catpred.data.scaler.StandardScaler`. """ - state = torch.load(path, map_location=lambda storage, loc: storage) + state = load_torch_artifact( + path, + purpose="scaler checkpoint", + map_location=lambda storage, loc: storage, + ) if state["data_scaler"] is not None: scaler = StandardScaler(state["data_scaler"]["means"], state["data_scaler"]["stds"]) @@ -498,7 +508,13 @@ def load_args(path: str) -> TrainArgs: """ args = TrainArgs() args.from_dict( - vars(torch.load(path, map_location=lambda storage, loc: storage)["args"]), + vars( + load_torch_artifact( + path, + purpose="args checkpoint", + map_location=lambda storage, loc: storage, + )["args"] + ), skip_unsettable=True, ) diff --git a/catpred/web/__init__.py b/catpred/web/__init__.py new file mode 100644 index 0000000..e4dd907 --- /dev/null +++ b/catpred/web/__init__.py @@ -0,0 +1,3 @@ +"""Web API interface for CatPred inference.""" + +__all__ = ["app", "run"] diff --git a/catpred/web/app.py b/catpred/web/app.py new file mode 100644 index 0000000..a45de62 --- /dev/null +++ b/catpred/web/app.py @@ -0,0 +1,415 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import os +import subprocess +import sys +import tempfile +from typing import Any, Optional + +import pandas as pd + +try: + from fastapi import FastAPI, HTTPException + from fastapi.responses import FileResponse + from fastapi.staticfiles import StaticFiles + from pydantic import BaseModel, Field, root_validator +except ImportError as exc: # pragma: no cover - import guard for optional dependency + raise ImportError( + "catpred.web requires optional dependencies. Install with `pip install .[web]`." + ) from exc + +from catpred.inference import ( + InferenceBackendError, + InferenceBackendRouter, + PredictionRequest, +) + + +def _env_flag(name: str, default: bool = False) -> bool: + value = os.environ.get(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "y", "on"} + + +_SUPPORTED_PARAMETERS = ("kcat", "km", "ki") + + +def _contains_model_checkpoints(path: Path) -> bool: + if not path.exists() or not path.is_dir(): + return False + return any(path.rglob("model.pt")) + + +def _discover_default_checkpoint_root(repo_root: Path) -> Path: + default_root = (repo_root / "checkpoints").resolve() + production_root = (repo_root / ".e2e-assets" / "pretrained" / "production").resolve() + candidates = [default_root, production_root] + + best_root: Optional[Path] = None + best_score = -1 + for candidate in candidates: + if not candidate.exists() or not candidate.is_dir(): + continue + score = sum(int((candidate / parameter).is_dir()) for parameter in _SUPPORTED_PARAMETERS) + if score > best_score: + best_score = score + best_root = candidate + + if best_root and best_score > 0: + return best_root + + for candidate in candidates: + if _contains_model_checkpoints(candidate): + return candidate + + return default_root + + +def _discover_available_checkpoints(checkpoint_root: Path) -> dict[str, str]: + available: dict[str, str] = {} + for parameter in _SUPPORTED_PARAMETERS: + param_dir = (checkpoint_root / parameter).resolve() + if param_dir.is_dir() and _contains_model_checkpoints(param_dir): + available[parameter] = parameter + return available + + +@dataclass(frozen=True) +class APISettings: + repo_root: str + python_executable: str + input_root: str + results_root: str + temp_root: str + checkpoint_root: str + allow_input_file: bool = False + allow_unsafe_request_overrides: bool = False + max_input_rows: int = 1000 + max_input_file_bytes: int = 5_000_000 + preview_rows: int = 5 + + @classmethod + def from_env(cls) -> "APISettings": + env_repo_root = os.environ.get("CATPRED_REPO_ROOT") + repo_root = str(Path(env_repo_root).resolve()) if env_repo_root else str(Path.cwd().resolve()) + repo_root_path = Path(repo_root).resolve() + env_runtime_root = os.environ.get("CATPRED_API_RUNTIME_ROOT") + if env_runtime_root: + default_runtime_root = Path(env_runtime_root).resolve() + elif os.environ.get("VERCEL"): + default_runtime_root = Path("/tmp/catpred").resolve() + else: + default_runtime_root = repo_root_path + input_root = os.environ.get("CATPRED_API_INPUT_ROOT") + results_root = os.environ.get("CATPRED_API_RESULTS_ROOT") + temp_root = os.environ.get("CATPRED_API_TEMP_ROOT") + checkpoint_root = os.environ.get("CATPRED_API_CHECKPOINT_ROOT") + + return cls( + repo_root=repo_root, + python_executable=os.environ.get("CATPRED_PYTHON_EXECUTABLE", sys.executable or "python"), + input_root=( + str(Path(input_root).resolve()) + if input_root + else str((default_runtime_root / "inputs").resolve()) + ), + results_root=( + str(Path(results_root).resolve()) + if results_root + else str((default_runtime_root / "results").resolve()) + ), + temp_root=( + str(Path(temp_root).resolve()) + if temp_root + else str((default_runtime_root / "tmp").resolve()) + ), + checkpoint_root=( + str(Path(checkpoint_root).resolve()) + if checkpoint_root + else str(_discover_default_checkpoint_root(repo_root_path)) + ), + allow_input_file=_env_flag("CATPRED_API_ALLOW_INPUT_FILE", default=False), + allow_unsafe_request_overrides=_env_flag("CATPRED_API_ALLOW_UNSAFE_OVERRIDES", default=False), + max_input_rows=max(int(os.environ.get("CATPRED_API_MAX_INPUT_ROWS", "1000")), 1), + max_input_file_bytes=max(int(os.environ.get("CATPRED_API_MAX_INPUT_FILE_BYTES", "5000000")), 1024), + preview_rows=max(int(os.environ.get("CATPRED_API_PREVIEW_ROWS", "5")), 1), + ) + + +class PredictRequest(BaseModel): + parameter: str = Field(..., description="One of: kcat, km, ki") + checkpoint_dir: str = Field(..., description="Path to the checkpoint directory") + input_file: Optional[str] = Field(default=None, description="Path to input CSV") + input_rows: Optional[list[dict[str, Any]]] = Field( + default=None, + description="Optional in-request CSV rows. Use this for remote clients.", + ) + use_gpu: bool = False + backend: Optional[str] = Field(default=None, description="Override backend (local|modal)") + fallback_to_local: Optional[bool] = Field(default=None, description="Fallback if modal fails") + results_dir: str = Field( + default="results", + description="Results subdirectory under CATPRED_API_RESULTS_ROOT.", + ) + repo_root: Optional[str] = Field( + default=None, + description="Unsafe override. Disabled by default; only for trusted local workflows.", + ) + python_executable: Optional[str] = Field( + default=None, + description="Unsafe override. Disabled by default; only for trusted local workflows.", + ) + + @root_validator + def _validate_input_source(cls, values: dict[str, Any]) -> dict[str, Any]: + has_file = bool(values.get("input_file")) + has_rows = bool(values.get("input_rows")) + if has_file == has_rows: + raise ValueError("Provide exactly one of `input_file` or `input_rows`.") + return values + + +class PredictResponse(BaseModel): + backend: str + output_file: str + row_count: int + preview_rows: list[dict[str, Any]] + metadata: dict[str, Any] = Field(default_factory=dict) + + +def _is_subpath(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False + + +def _resolve_repo_root(repo_root: Optional[str], settings: APISettings) -> Path: + if repo_root is not None: + if not settings.allow_unsafe_request_overrides: + raise ValueError( + "Request field `repo_root` is disabled. " + "Set CATPRED_API_ALLOW_UNSAFE_OVERRIDES=1 for trusted local use." + ) + return Path(repo_root).resolve() + return Path(settings.repo_root).resolve() + + +def _resolve_python_executable(python_executable: Optional[str], settings: APISettings) -> str: + if python_executable is not None: + if not settings.allow_unsafe_request_overrides: + raise ValueError( + "Request field `python_executable` is disabled. " + "Set CATPRED_API_ALLOW_UNSAFE_OVERRIDES=1 for trusted local use." + ) + return python_executable + return settings.python_executable + + +def _resolve_and_validate_path_under_root(raw_path: str, root: Path, purpose: str) -> Path: + candidate = Path(raw_path) + resolved = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve() + if not _is_subpath(resolved, root): + raise ValueError(f"{purpose} must stay under configured root: {root}") + return resolved + + +def _resolve_results_dir(raw_results_dir: str, settings: APISettings) -> str: + results_root = Path(settings.results_root).resolve() + results_root.mkdir(parents=True, exist_ok=True) + resolved = _resolve_and_validate_path_under_root( + raw_path=raw_results_dir, + root=results_root, + purpose="results_dir", + ) + resolved.mkdir(parents=True, exist_ok=True) + return str(resolved) + + +def _resolve_checkpoint_dir(raw_checkpoint_dir: str, settings: APISettings) -> str: + checkpoint_root = Path(settings.checkpoint_root).resolve() + resolved = _resolve_and_validate_path_under_root( + raw_path=raw_checkpoint_dir, + root=checkpoint_root, + purpose="checkpoint_dir", + ) + if not resolved.exists(): + raise FileNotFoundError(f'Checkpoint directory not found: "{resolved}"') + if not resolved.is_dir(): + raise ValueError(f'checkpoint_dir must be a directory: "{resolved}"') + return str(resolved) + + +def _resolve_input_file_path(input_file: str, settings: APISettings) -> Path: + if not settings.allow_input_file: + raise ValueError( + "Request field `input_file` is disabled. Submit `input_rows` instead, " + "or set CATPRED_API_ALLOW_INPUT_FILE=1 for trusted local use." + ) + + input_root = Path(settings.input_root).resolve() + input_root.mkdir(parents=True, exist_ok=True) + resolved = _resolve_and_validate_path_under_root( + raw_path=input_file, + root=input_root, + purpose="input_file", + ) + + if not resolved.exists(): + raise FileNotFoundError(f'Input CSV not found: "{resolved}"') + if not resolved.is_file(): + raise ValueError(f'Input CSV path is not a file: "{resolved}"') + if resolved.stat().st_size > settings.max_input_file_bytes: + raise ValueError( + f'Input file exceeds CATPRED_API_MAX_INPUT_FILE_BYTES ({settings.max_input_file_bytes}).' + ) + + return resolved + + +def _write_rows_to_temp_csv( + rows: list[dict[str, Any]], + settings: APISettings, +) -> tuple[str, str]: + if len(rows) == 0: + raise ValueError("`input_rows` cannot be empty.") + if len(rows) > settings.max_input_rows: + raise ValueError( + f"`input_rows` exceeds CATPRED_API_MAX_INPUT_ROWS ({settings.max_input_rows})." + ) + + tmp_dir = Path(settings.temp_root).resolve() + tmp_dir.mkdir(parents=True, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(prefix="api_input_", suffix=".csv", dir=str(tmp_dir)) + os.close(fd) + pd.DataFrame(rows).to_csv(tmp_path, index=False) + return tmp_path, tmp_path + + +def _resolve_input_file( + payload: PredictRequest, + settings: APISettings, +) -> tuple[str, Optional[str]]: + if payload.input_file: + resolved = _resolve_input_file_path(payload.input_file, settings) + return str(resolved), None + + return _write_rows_to_temp_csv(payload.input_rows or [], settings=settings) + + +def _preview_output(output_file: str, preview_limit: int) -> tuple[int, list[dict[str, Any]]]: + df = pd.read_csv(output_file) + return len(df), df.head(preview_limit).to_dict(orient="records") + + +def create_app( + router: Optional[InferenceBackendRouter] = None, + settings: Optional[APISettings] = None, +) -> FastAPI: + api_settings = settings or APISettings.from_env() + app = FastAPI(title="CatPred API", version="0.2.0") + backend_router = router or InferenceBackendRouter() + default_fallback = _env_flag("CATPRED_MODAL_FALLBACK_TO_LOCAL", default=False) + static_root = (Path(__file__).resolve().parent / "static").resolve() + + if static_root.exists(): + app.mount("/static", StaticFiles(directory=str(static_root)), name="static") + + @app.get("/", include_in_schema=False) + def root() -> FileResponse: + dist_index = static_root / "dist" / "index.html" + if not dist_index.exists(): + raise HTTPException( + status_code=404, + detail="Frontend build not found. Run `npm run build` in catpred/web/frontend/.", + ) + return FileResponse(dist_index) + + @app.get("/health") + def health() -> dict[str, str]: + return {"status": "ok"} + + @app.get("/ready") + def ready() -> dict[str, Any]: + readiness = backend_router.readiness() + checkpoint_root = Path(api_settings.checkpoint_root).resolve() + available_checkpoints = _discover_available_checkpoints(checkpoint_root) + readiness["api"] = { + "allow_input_file": api_settings.allow_input_file, + "allow_unsafe_request_overrides": api_settings.allow_unsafe_request_overrides, + "max_input_rows": api_settings.max_input_rows, + "max_input_file_bytes": api_settings.max_input_file_bytes, + "input_root": str(Path(api_settings.input_root).resolve()), + "results_root": str(Path(api_settings.results_root).resolve()), + "temp_root": str(Path(api_settings.temp_root).resolve()), + "checkpoint_root": str(checkpoint_root), + "available_checkpoints": available_checkpoints, + "missing_checkpoints": [ + parameter for parameter in _SUPPORTED_PARAMETERS if parameter not in available_checkpoints + ], + } + return readiness + + @app.post("/predict", response_model=PredictResponse) + def predict(payload: PredictRequest) -> PredictResponse: + temp_file: Optional[str] = None + try: + repo_root = _resolve_repo_root(payload.repo_root, api_settings) + python_executable = _resolve_python_executable(payload.python_executable, api_settings) + input_file, temp_file = _resolve_input_file(payload, settings=api_settings) + safe_results_dir = _resolve_results_dir(payload.results_dir, api_settings) + fallback = payload.fallback_to_local + if fallback is None: + fallback = default_fallback + selected_backend = (payload.backend or backend_router.settings.default_backend).lower() + checkpoint_dir = payload.checkpoint_dir + if selected_backend == "local" or fallback: + checkpoint_dir = _resolve_checkpoint_dir(payload.checkpoint_dir, api_settings) + request_obj = PredictionRequest( + parameter=payload.parameter.lower(), + input_file=input_file, + checkpoint_dir=checkpoint_dir, + use_gpu=payload.use_gpu, + repo_root=str(repo_root), + python_executable=python_executable, + ) + + result = backend_router.predict( + request_obj=request_obj, + results_dir=safe_results_dir, + backend_name=payload.backend, + fallback_to_local=fallback, + ) + + row_count, preview_rows = _preview_output( + result.output_file, + preview_limit=api_settings.preview_rows, + ) + return PredictResponse( + backend=result.backend_name, + output_file=result.output_file, + row_count=row_count, + preview_rows=preview_rows, + metadata=result.metadata, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except (FileNotFoundError, InferenceBackendError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except subprocess.CalledProcessError as exc: + raise HTTPException( + status_code=500, + detail=f"Prediction command failed with exit code {exc.returncode}.", + ) from exc + finally: + if temp_file and Path(temp_file).exists(): + Path(temp_file).unlink() + + return app + + +app = create_app() diff --git a/catpred/web/frontend/index.html b/catpred/web/frontend/index.html new file mode 100644 index 0000000..75a4818 --- /dev/null +++ b/catpred/web/frontend/index.html @@ -0,0 +1,23 @@ + + + + + + CatPred | Enzyme Kinetics Prediction + + + + + + +
+ + + + diff --git a/catpred/web/frontend/package-lock.json b/catpred/web/frontend/package-lock.json new file mode 100644 index 0000000..0444aa6 --- /dev/null +++ b/catpred/web/frontend/package-lock.json @@ -0,0 +1,1500 @@ +{ + "name": "catpred-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "catpred-frontend", + "version": "1.0.0", + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.3", + "typescript": "~5.7.3", + "vite": "^6.4.2", + "vue-tsc": "^2.2.8" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/catpred/web/frontend/package.json b/catpred/web/frontend/package.json new file mode 100644 index 0000000..d00b5d5 --- /dev/null +++ b/catpred/web/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "catpred-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.3", + "typescript": "~5.7.3", + "vite": "^6.4.2", + "vue-tsc": "^2.2.8" + } +} diff --git a/catpred/web/frontend/src/App.vue b/catpred/web/frontend/src/App.vue new file mode 100644 index 0000000..de1464e --- /dev/null +++ b/catpred/web/frontend/src/App.vue @@ -0,0 +1,263 @@ + + + + + diff --git a/catpred/web/frontend/src/api/client.ts b/catpred/web/frontend/src/api/client.ts new file mode 100644 index 0000000..e41fc9c --- /dev/null +++ b/catpred/web/frontend/src/api/client.ts @@ -0,0 +1,63 @@ +import type { ReadyResponse, PredictPayload, PredictResponse } from './types' + +const PREDICT_TIMEOUT_MS = 120_000 + +export async function fetchReady(signal?: AbortSignal): Promise { + const res = await fetch('/ready', { + method: 'GET', + headers: { Accept: 'application/json' }, + signal, + }) + if (!res.ok) { + throw new Error(`Service check failed (${res.status})`) + } + return res.json() +} + +export async function fetchPredict( + payload: PredictPayload, + signal?: AbortSignal, +): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), PREDICT_TIMEOUT_MS) + + // Combine external signal with timeout + if (signal) { + signal.addEventListener('abort', () => controller.abort()) + } + + try { + const res = await fetch('/predict', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(payload), + signal: controller.signal, + }) + + if (!res.ok) { + let detail = 'Prediction could not be completed.' + try { + const data = await res.json() + if (data?.detail) detail = String(data.detail) + } catch { + // ignore parse error + } + throw new Error(detail) + } + + return res.json() + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') { + throw new Error( + `Prediction timed out after ${Math.floor(PREDICT_TIMEOUT_MS / 1000)}s. ` + + 'This may be a cold-start delay โ€” try again.', + ) + } + throw err + } finally { + clearTimeout(timeout) + } +} diff --git a/catpred/web/frontend/src/api/types.ts b/catpred/web/frontend/src/api/types.ts new file mode 100644 index 0000000..64046b3 --- /dev/null +++ b/catpred/web/frontend/src/api/types.ts @@ -0,0 +1,74 @@ +export interface ReadyResponse { + ready: boolean + default_backend: string + fallback_to_local_enabled: boolean + backends: { + local?: { ready: boolean } + modal?: { ready: boolean } + } + api?: { + allow_input_file: boolean + allow_unsafe_request_overrides: boolean + max_input_rows: number + max_input_file_bytes: number + available_checkpoints: Record + missing_checkpoints: string[] + } +} + +export interface InputRow { + SMILES: string + sequence: string + pdbpath: string +} + +export interface PredictPayload { + parameter: string + checkpoint_dir: string + input_rows: InputRow[] + use_gpu: boolean + results_dir: string + backend: string + fallback_to_local: boolean +} + +export interface PredictResponse { + backend: string + output_file: string + row_count: number + preview_rows: Record[] + metadata: Record +} + +export type Parameter = 'kcat' | 'km' | 'ki' + +export const SUPPORTED_PARAMETERS: Parameter[] = ['kcat', 'km', 'ki'] + +export const PARAMETER_LABELS: Record = { + kcat: 'kcat', + km: 'Km', + ki: 'Ki', +} + +export interface ParsedPrediction { + linear: number | null + linearKey: string + unit: string + log10: number | null + sdTotal: number | null + sdAleatoric: number | null + sdEpistemic: number | null +} + +export type PredictionMode = 'substrate' | 'inhibition' + +export interface SubstrateEntry { + id: number + smiles: string + isPrimary: boolean +} + +export interface PredictionResultEntry { + parameter: Parameter + response: PredictResponse +} diff --git a/catpred/web/frontend/src/components/AppFooter.vue b/catpred/web/frontend/src/components/AppFooter.vue new file mode 100644 index 0000000..bbf29a7 --- /dev/null +++ b/catpred/web/frontend/src/components/AppFooter.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/catpred/web/frontend/src/components/AppHeader.vue b/catpred/web/frontend/src/components/AppHeader.vue new file mode 100644 index 0000000..e1a42f8 --- /dev/null +++ b/catpred/web/frontend/src/components/AppHeader.vue @@ -0,0 +1,92 @@ + + + diff --git a/catpred/web/frontend/src/components/BatchUpload.vue b/catpred/web/frontend/src/components/BatchUpload.vue new file mode 100644 index 0000000..350ef5b --- /dev/null +++ b/catpred/web/frontend/src/components/BatchUpload.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/catpred/web/frontend/src/components/InputPanel.vue b/catpred/web/frontend/src/components/InputPanel.vue new file mode 100644 index 0000000..2bff371 --- /dev/null +++ b/catpred/web/frontend/src/components/InputPanel.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/catpred/web/frontend/src/components/InputRow.vue b/catpred/web/frontend/src/components/InputRow.vue new file mode 100644 index 0000000..bb8dbb7 --- /dev/null +++ b/catpred/web/frontend/src/components/InputRow.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/catpred/web/frontend/src/components/ParameterSelector.vue b/catpred/web/frontend/src/components/ParameterSelector.vue new file mode 100644 index 0000000..f4b9143 --- /dev/null +++ b/catpred/web/frontend/src/components/ParameterSelector.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/catpred/web/frontend/src/components/ResultPanel.vue b/catpred/web/frontend/src/components/ResultPanel.vue new file mode 100644 index 0000000..7d41a6f --- /dev/null +++ b/catpred/web/frontend/src/components/ResultPanel.vue @@ -0,0 +1,425 @@ + + + + + diff --git a/catpred/web/frontend/src/components/SkipLink.vue b/catpred/web/frontend/src/components/SkipLink.vue new file mode 100644 index 0000000..d3c8075 --- /dev/null +++ b/catpred/web/frontend/src/components/SkipLink.vue @@ -0,0 +1,22 @@ + + + diff --git a/catpred/web/frontend/src/components/StatusBar.vue b/catpred/web/frontend/src/components/StatusBar.vue new file mode 100644 index 0000000..1b0a15e --- /dev/null +++ b/catpred/web/frontend/src/components/StatusBar.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/catpred/web/frontend/src/components/SubstrateInputs.vue b/catpred/web/frontend/src/components/SubstrateInputs.vue new file mode 100644 index 0000000..68dd215 --- /dev/null +++ b/catpred/web/frontend/src/components/SubstrateInputs.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/catpred/web/frontend/src/composables/useInputRows.ts b/catpred/web/frontend/src/composables/useInputRows.ts new file mode 100644 index 0000000..216671c --- /dev/null +++ b/catpred/web/frontend/src/composables/useInputRows.ts @@ -0,0 +1,357 @@ +import { ref, computed } from 'vue' +import type { InputRow, Parameter, PredictionMode, SubstrateEntry } from '../api/types' + +export interface InputRowEntry { + id: number + substrates: SubstrateEntry[] + inhibitorSmiles: string + sequence: string + pdbpath: string +} + +// Human glucokinase / hexokinase-4 (UniProt P35557) + D-glucose +// Related to paper example (Boorla & Maranas, Nat. Commun. 2025, Fig 7a) +const GCK_SEQUENCE = + 'MLDDRARMEAAKKEKVEQILAEFQLQEEDLKKVMRRMQKEMDRGLRLETHEEASVKMLPTYVRSTPEGS' + + 'EVGDFLSLDLGGTNFRVMLVKVGEGEEGQWSVKTKHQMYSIPEDAMTGTAEMLFDYISECISDFLDKHQM' + + 'KHKKLPLGFTFSFPVRHEDIDKGILLNWTKGFKASGAEGNNVVGLLRDAIKRRGDFEMDVVAMVNDTVAT' + + 'MISCYYEDHQCEVGMIVGTGCNACYMEEMQNVELVEGDEGRMCVNTEWGAFGDSGELDEFLLEYDRLVDE' + + 'SSANPGQQLYEKLIGGKYMGELVRLVLLRLVDENLLFHGEASEQLRTRGAFETRFVSQVESDTGDRKQIYN' + + 'ILSTLGLRPSTTDCDIVRRACESVSTRAAHMCSAGLAGVINRMRESRSEDVMRITVGVDGSVYKLHPSFKE' + + 'RFHASVRRLTPSCEITFIESEEGSGRGAALVSAVACKKACMLGQ' + +// Classic enzyme: Human L-lactate dehydrogenase A (UniProt P00338) + pyruvate +const LDHA_SEQUENCE = + 'MATLKDQLIYNLLKEEQTPQNKITVVGVGAVGMACAISILMKDLADELALVDVIEDKLKGEMMDLQHGS' + + 'LFLRTPKIVSGKDYNVTANSKLVIITAGARQQEGESRLNLVQRNVNIFKFIIPNVVKYSPNCKLLIV' + + 'SNPVDILTYVAWKISGFPKNRVIGSGCNLDSARFRYLMGERLGVHPLSCHGWVLGEHGDSSVPVWSGMNV' + + 'AGVSLKTLHPDLGTDKDKEQWKEVHKQVVESAYEVIKLKGYTSWAIGLSVADLAESIMKNLRRVHPVSTM' + + 'IKGLYGIKDDVFLSVPCILGQNGISDLVKVTLTSEEEARLKKSADTLWGIQKELQF' + +// Phenylalanine ammonia-lyase (UniProt P11544) โ€” Ki sample enzyme +const P11544_SEQUENCE = + 'MAPSLDSISHSFANGVASAKQAVNGASTNLAVAGSHLPTTQVTQVDIVEKMLAAPTDSTLELDGYSLNLG' + + 'DVVSAARKGRPVRVKDSDEIRSKIDKSVEFLRSQLSMSVYGVTTGFGGSADTRTEDAISLQKALLEHQLCG' + + 'VLPSSFDSFRLGRGLENSLPLEVVRGAMTIRVNSLTRGHSAVRLVVLEALTNFLNHGITPIVPLRGTISAS' + + 'GDLSPLSYIAAAISGHPDSKVHVVHEGKEKILYAREAMALFNLEPVVLGPKEGLGLVNGTAVSASMATLA' + + 'LHDAHMLSLLSQSLTAMTVEAMVGHAGSFHPFLHDVTRPHPTQIEVAGNIRKLLEGSRFAVHHEEEVKVKD' + + 'DEGILRQDRYPLRTSPQWLGPLVSDLIHAHAVLTIEAGQSTTDNPLIDVENKTSHHGGNFQAAAVANTMEK' + + 'TRLGLAQIGKLNFTQLTEMLNAGMNRGLPSCLAAEDPSLSYHCKGLDIAAAAYTSELGHLANPVTTHVQPA' + + 'EMANQAVNSLALISARRTTESNDVLSLLLATHLYCVLQAIDLRAIEFEFKKQFGPAIVSLIDQHFGSAMTG' + + 'SNLRDELVEKVNKTLAKRLEQTNSYDLVPRWHDAFSFAAGTVVEVLSSTSLSLAAVNAWKVAAAESAISLT' + + 'RQVRETFWSAASTSSPALSYLSPRTQILYAFVREELGVKARRGDVFLGKQEVTIGSNVSKIYEAIKSGRINN' + + 'VLLKMLA' + +const GLUCOSE_SMILES = 'C(C1C(C(C(C(O1)O)O)O)O)O' +const ATP_SMILES = 'C1=NC(=C2C(=N1)N(C=N2)C3C(C(C(O3)COP(=O)(O)OP(=O)(O)OP(=O)(O)O)O)O)N' +const PYRUVATE_SMILES = 'CC(=O)C(=O)O' +const NADH_SMILES = 'C1C=CN(C=C1C(=O)N)C2C(C(C(O2)COP(=O)(O)OP(=O)(O)OCC3C(C(C(O3)N4C=NC5=C(N=CN=C54)N)O)O)O)O' +const COUMARIC_ACID_SMILES = 'C1=CC(=CC=C1/C=C/C(=O)O)O' + +const SAMPLE_SUBSTRATE: Omit[] = [ + { + substrates: [ + { id: 1, smiles: GLUCOSE_SMILES, isPrimary: true }, + { id: 2, smiles: ATP_SMILES, isPrimary: false }, + ], + inhibitorSmiles: '', + sequence: GCK_SEQUENCE, + pdbpath: 'GCK_HUMAN', + }, + { + substrates: [ + { id: 1, smiles: PYRUVATE_SMILES, isPrimary: true }, + { id: 2, smiles: NADH_SMILES, isPrimary: false }, + ], + inhibitorSmiles: '', + sequence: LDHA_SEQUENCE, + pdbpath: 'LDHA_HUMAN', + }, +] + +const SAMPLE_INHIBITION: Omit[] = [ + { + substrates: [], + inhibitorSmiles: COUMARIC_ACID_SMILES, + sequence: P11544_SEQUENCE, + pdbpath: 'P11544', + }, +] + +// Validation helpers +const SMILES_VALID_CHARS = /^[A-Za-z0-9@+\-\[\]\\\/().=#%$:~&!*]+$/ +const AMINO_ACIDS = new Set('ACDEFGHIKLMNPQRSTVWY'.split('')) + +export function validateSmiles(s: string): string { + if (!s.trim()) return 'SMILES is required.' + if (!SMILES_VALID_CHARS.test(s.trim())) return 'Invalid SMILES characters.' + return '' +} + +export function validateSequence(s: string): string { + if (!s.trim()) return 'Sequence is required.' + const upper = s.trim().toUpperCase() + for (const ch of upper) { + if (!AMINO_ACIDS.has(ch)) return `Invalid amino acid: "${ch}".` + } + return '' +} + +export function validatePdbpath(s: string): string { + if (!s.trim()) return 'Sequence ID is required.' + return '' +} + +export function useInputRows() { + let nextRowId = 1 + let nextSubId = 100 + const rows = ref([]) + + function formatSeqId(n: number): string { + return `seq_${String(n).padStart(3, '0')}` + } + + function getNextSeqId(): string { + let maxSeen = 0 + for (const row of rows.value) { + const match = row.pdbpath.match(/^seq_(\d+)$/i) + if (match) { + maxSeen = Math.max(maxSeen, Number(match[1])) + } + } + if (maxSeen > 0) return formatSeqId(maxSeen + 1) + return formatSeqId(rows.value.length + 1) + } + + function makeSubstrate(smiles = '', isPrimary = false): SubstrateEntry { + return { id: nextSubId++, smiles, isPrimary } + } + + function addRow(values?: Partial>) { + const substrates = values?.substrates?.length + ? values.substrates.map((s) => makeSubstrate(s.smiles, s.isPrimary)) + : [makeSubstrate('', true)] + + rows.value.push({ + id: nextRowId++, + substrates, + inhibitorSmiles: values?.inhibitorSmiles ?? '', + sequence: values?.sequence ?? '', + pdbpath: values?.pdbpath || getNextSeqId(), + }) + } + + function removeRow(id: number) { + if (rows.value.length <= 1) return + rows.value = rows.value.filter((r) => r.id !== id) + } + + function updateField( + id: number, + field: 'sequence' | 'pdbpath' | 'inhibitorSmiles', + value: string, + ) { + const row = rows.value.find((r) => r.id === id) + if (row) { + row[field] = value + } + } + + function addSubstrate(rowId: number) { + const row = rows.value.find((r) => r.id === rowId) + if (row) { + row.substrates.push(makeSubstrate('', false)) + } + } + + function removeSubstrate(rowId: number, subId: number) { + const row = rows.value.find((r) => r.id === rowId) + if (!row || row.substrates.length <= 1) return + const wasPrimary = row.substrates.find((s) => s.id === subId)?.isPrimary + row.substrates = row.substrates.filter((s) => s.id !== subId) + if (wasPrimary && row.substrates.length > 0) { + row.substrates[0].isPrimary = true + } + } + + function updateSubstrateSmiles(rowId: number, subId: number, smiles: string) { + const row = rows.value.find((r) => r.id === rowId) + if (!row) return + const sub = row.substrates.find((s) => s.id === subId) + if (sub) sub.smiles = smiles + } + + function setPrimary(rowId: number, subId: number) { + const row = rows.value.find((r) => r.id === rowId) + if (!row) return + for (const sub of row.substrates) { + sub.isPrimary = sub.id === subId + } + } + + function loadSample(mode: PredictionMode) { + rows.value = [] + nextRowId = 1 + nextSubId = 100 + const samples = mode === 'substrate' ? SAMPLE_SUBSTRATE : SAMPLE_INHIBITION + for (const s of samples) { + addRow(s) + } + } + + function clear() { + rows.value = [] + nextRowId = 1 + nextSubId = 100 + addRow() + } + + function importCsv(text: string, mode: PredictionMode): string { + const lines = text.trim().split('\n') + if (lines.length < 2) return 'CSV must have a header row and at least one data row.' + + const header = lines[0].split(',').map((h) => h.trim()) + const smilesIdx = header.findIndex((h) => h.toLowerCase() === 'smiles') + const seqIdx = header.findIndex((h) => h.toLowerCase() === 'sequence') + const pdbIdx = header.findIndex((h) => h.toLowerCase() === 'pdbpath') + + if (smilesIdx === -1 || seqIdx === -1) { + return 'CSV must have SMILES and sequence columns.' + } + + const newRows: Partial>[] = [] + for (let i = 1; i < lines.length; i++) { + const cols = lines[i].split(',').map((c) => c.trim()) + if (!cols[smilesIdx] && !cols[seqIdx]) continue + + const smiles = cols[smilesIdx] || '' + const entry: Partial> = { + sequence: cols[seqIdx] || '', + pdbpath: cols[pdbIdx] || formatSeqId(i), + } + + if (mode === 'substrate') { + const parts = smiles.split('.') + entry.substrates = parts.map((s, idx) => ({ + id: idx + 1, + smiles: s, + isPrimary: idx === 0, + })) + } else { + entry.inhibitorSmiles = smiles + } + + newRows.push(entry) + } + + if (newRows.length === 0) return 'No valid data rows found.' + + rows.value = [] + nextRowId = 1 + nextSubId = 100 + for (const r of newRows) { + addRow(r) + } + return '' + } + + function collectRowsForParameter( + mode: PredictionMode, + parameter: Parameter, + ): InputRow[] { + return rows.value + .filter((r) => { + if (mode === 'substrate') { + return ( + r.substrates.some((s) => s.smiles.trim()) && + r.sequence.trim() && + r.pdbpath.trim() + ) + } + return r.inhibitorSmiles.trim() && r.sequence.trim() && r.pdbpath.trim() + }) + .map((r) => { + let smiles: string + if (mode === 'inhibition') { + smiles = r.inhibitorSmiles.trim() + } else if (parameter === 'km') { + const primary = r.substrates.find((s) => s.isPrimary) + smiles = primary ? primary.smiles.trim() : r.substrates[0]?.smiles.trim() || '' + } else { + smiles = r.substrates + .filter((s) => s.smiles.trim()) + .map((s) => s.smiles.trim()) + .join('.') + } + return { + SMILES: smiles, + sequence: r.sequence.trim().toUpperCase(), + pdbpath: r.pdbpath.trim(), + } + }) + } + + function validateAll(mode: PredictionMode): string { + if (rows.value.length === 0) return 'Please add at least one entry.' + + for (const row of rows.value) { + if (mode === 'substrate') { + if (!row.substrates.some((s) => s.smiles.trim())) { + return 'Each entry needs at least one substrate SMILES.' + } + for (const sub of row.substrates) { + if (sub.smiles.trim()) { + const err = validateSmiles(sub.smiles) + if (err) return err + } + } + } else { + const err = validateSmiles(row.inhibitorSmiles) + if (err) return `Inhibitor: ${err}` + } + + const seqErr = validateSequence(row.sequence) + if (seqErr) return seqErr + + const idErr = validatePdbpath(row.pdbpath) + if (idErr) return idErr + } + + const collected = + mode === 'substrate' + ? collectRowsForParameter(mode, 'kcat') + : collectRowsForParameter(mode, 'ki') + + const mapping = new Map() + for (const row of collected) { + const existing = mapping.get(row.pdbpath) + if (existing && existing !== row.sequence) { + return 'Each Sequence ID must map to one unique enzyme sequence.' + } + mapping.set(row.pdbpath, row.sequence) + } + return '' + } + + const rowCount = computed(() => rows.value.length) + + // Init with first substrate sample + addRow(SAMPLE_SUBSTRATE[0]) + + return { + rows, + rowCount, + addRow, + removeRow, + updateField, + addSubstrate, + removeSubstrate, + updateSubstrateSmiles, + setPrimary, + loadSample, + clear, + importCsv, + collectRowsForParameter, + validateAll, + } +} diff --git a/catpred/web/frontend/src/composables/usePrediction.ts b/catpred/web/frontend/src/composables/usePrediction.ts new file mode 100644 index 0000000..be6d6c8 --- /dev/null +++ b/catpred/web/frontend/src/composables/usePrediction.ts @@ -0,0 +1,176 @@ +import { ref, computed } from 'vue' +import { fetchPredict } from '../api/client' +import type { + Parameter, + PredictPayload, + PredictionResultEntry, + ParsedPrediction, +} from '../api/types' + +export type PredictionStatus = 'idle' | 'running' | 'success' | 'error' + +export interface PredictionJob { + parameter: Parameter + payload: PredictPayload +} + +const STORAGE_KEY = 'catpred:lastResults' + +function loadStoredResults(): PredictionResultEntry[] { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed as PredictionResultEntry[] + } catch { + return [] + } +} + +function storeResults(data: PredictionResultEntry[]) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)) + } catch { + // storage full or unavailable + } +} + +export function parsePrediction(row: Record): ParsedPrediction { + const keys = Object.keys(row) + const linearKey = keys.find((k) => k.startsWith('Prediction_(')) + const unitMatch = linearKey?.match(/^Prediction_\((.*)\)$/) + const unit = unitMatch ? unitMatch[1] : '' + + return { + linear: linearKey ? (row[linearKey] as number | null) : null, + linearKey: linearKey || 'Prediction', + unit, + log10: (row.Prediction_log10 as number | null) ?? null, + sdTotal: (row.SD_total as number | null) ?? null, + sdAleatoric: (row.SD_aleatoric as number | null) ?? null, + sdEpistemic: (row.SD_epistemic as number | null) ?? null, + } +} + +const SUPERSCRIPT: Record = { + '0': '\u2070', '1': '\u00B9', '2': '\u00B2', '3': '\u00B3', + '4': '\u2074', '5': '\u2075', '6': '\u2076', '7': '\u2077', + '8': '\u2078', '9': '\u2079', '-': '\u207B', '+': '\u207A', +} + +export function formatUnit(unit: string): string { + if (!unit) return '' + // Replace ^(...) and ^N patterns with Unicode superscripts + return unit.replace(/\^(?:\(([^)]+)\)|([0-9+-]+))/g, (_, group, bare) => { + const content = group ?? bare + return [...content].map((ch) => SUPERSCRIPT[ch] ?? ch).join('') + }) +} + +export function formatNumber(value: unknown): string { + const n = Number(value) + if (!Number.isFinite(n)) return '\u2014' + return n.toFixed(1) +} + +export function confidenceRange( + log10: number | null, + sd: number | null, +): [string, string] | null { + if (log10 === null || sd === null || !Number.isFinite(log10) || !Number.isFinite(sd)) { + return null + } + const lo = Math.pow(10, log10 - sd) + const hi = Math.pow(10, log10 + sd) + return [formatNumber(lo), formatNumber(hi)] +} + +export function formatElapsed(seconds: number): string { + const safe = Math.max(0, Math.floor(seconds)) + const mins = Math.floor(safe / 60) + const secs = safe % 60 + return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}` +} + +export function usePrediction() { + const status = ref('idle') + const error = ref('') + const results = ref(loadStoredResults()) + const elapsedSeconds = ref(0) + const lastJobs = ref(null) + + let startTime = 0 + let timerInterval: ReturnType | null = null + let abortController: AbortController | null = null + + const isRunning = computed(() => status.value === 'running') + const hasResults = computed(() => results.value.length > 0) + + function startTimer() { + stopTimer() + startTime = Date.now() + elapsedSeconds.value = 0 + timerInterval = setInterval(() => { + elapsedSeconds.value = Math.floor((Date.now() - startTime) / 1000) + }, 1000) + } + + function stopTimer() { + if (timerInterval) { + clearInterval(timerInterval) + timerInterval = null + } + } + + async function runAll(jobs: PredictionJob[]) { + abortController?.abort() + abortController = new AbortController() + lastJobs.value = jobs + status.value = 'running' + error.value = '' + results.value = [] + startTimer() + + try { + const settled = await Promise.all( + jobs.map(async (job) => { + const data = await fetchPredict(job.payload, abortController!.signal) + return { parameter: job.parameter, response: data } as PredictionResultEntry + }), + ) + results.value = settled + storeResults(settled) + status.value = 'success' + } catch (err) { + error.value = err instanceof Error ? err.message : 'Prediction failed.' + status.value = 'error' + } finally { + stopTimer() + abortController = null + } + } + + function retry() { + if (lastJobs.value) { + runAll(lastJobs.value) + } + } + + function cancel() { + abortController?.abort() + } + + return { + status, + error, + results, + elapsedSeconds, + lastJobs, + isRunning, + hasResults, + runAll, + retry, + cancel, + } +} diff --git a/catpred/web/frontend/src/composables/useReadiness.ts b/catpred/web/frontend/src/composables/useReadiness.ts new file mode 100644 index 0000000..30f682d --- /dev/null +++ b/catpred/web/frontend/src/composables/useReadiness.ts @@ -0,0 +1,103 @@ +import { ref, computed } from 'vue' +import { fetchReady } from '../api/client' +import type { Parameter } from '../api/types' +import { SUPPORTED_PARAMETERS } from '../api/types' + +export type ServiceStatus = 'checking' | 'online' | 'limited' | 'offline' + +export function useReadiness() { + const status = ref('checking') + const statusHint = ref('') + const defaultBackend = ref('local') + const modalReady = ref(false) + const localReady = ref(false) + const fallbackToLocalEnabled = ref(false) + const availableCheckpoints = ref>(new Set(SUPPORTED_PARAMETERS)) + const localCheckpoints = ref>(new Set(SUPPORTED_PARAMETERS)) + + function isParameterAvailable(param: Parameter): boolean { + return availableCheckpoints.value.has(param) + } + + const firstAvailable = computed(() => { + for (const p of SUPPORTED_PARAMETERS) { + if (availableCheckpoints.value.has(p)) return p + } + return null + }) + + function chooseBackend(): string { + if (defaultBackend.value === 'modal' && modalReady.value) return 'modal' + if (defaultBackend.value === 'local' && localCheckpoints.value.size > 0) return 'local' + if (modalReady.value) return 'modal' + if (localReady.value) return 'local' + return defaultBackend.value || 'local' + } + + function shouldFallback(requestBackend: string, param: Parameter): boolean { + if (requestBackend !== 'modal') return false + if (!fallbackToLocalEnabled.value) return false + if (!localReady.value) return false + return localCheckpoints.value.has(param) + } + + function parseCheckpoints(available: Record | undefined): Set { + if (!available) return new Set(SUPPORTED_PARAMETERS) + return new Set( + Object.keys(available) + .map((k) => k.toLowerCase() as Parameter) + .filter((k) => SUPPORTED_PARAMETERS.includes(k)), + ) + } + + async function check() { + status.value = 'checking' + statusHint.value = '' + + try { + const data = await fetchReady() + + defaultBackend.value = data.default_backend || 'local' + modalReady.value = Boolean(data.backends?.modal?.ready) + localReady.value = Boolean(data.backends?.local?.ready) + fallbackToLocalEnabled.value = Boolean(data.fallback_to_local_enabled) + + localCheckpoints.value = parseCheckpoints(data.api?.available_checkpoints) + + if (localCheckpoints.value.size > 0) { + availableCheckpoints.value = new Set(localCheckpoints.value) + } else if (modalReady.value) { + availableCheckpoints.value = new Set(SUPPORTED_PARAMETERS) + } else { + availableCheckpoints.value = new Set() + } + + if (data.ready) { + status.value = 'online' + const params = Array.from(localCheckpoints.value).join(', ') + statusHint.value = params + ? `${data.default_backend} ยท ${params}` + : `${data.default_backend} ยท no local checkpoints` + } else { + status.value = 'limited' + statusHint.value = modalReady.value + ? 'Backend available in limited mode' + : 'No local checkpoints found' + } + } catch { + status.value = 'offline' + statusHint.value = 'Could not contact service' + } + } + + return { + status, + statusHint, + availableCheckpoints, + firstAvailable, + isParameterAvailable, + chooseBackend, + shouldFallback, + check, + } +} diff --git a/catpred/web/frontend/src/env.d.ts b/catpred/web/frontend/src/env.d.ts new file mode 100644 index 0000000..3885263 --- /dev/null +++ b/catpred/web/frontend/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent + export default component +} diff --git a/catpred/web/frontend/src/main.ts b/catpred/web/frontend/src/main.ts new file mode 100644 index 0000000..43370bb --- /dev/null +++ b/catpred/web/frontend/src/main.ts @@ -0,0 +1,8 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './styles/tokens.css' +import './styles/reset.css' +import './styles/base.css' +import './styles/utilities.css' + +createApp(App).mount('#app') diff --git a/catpred/web/frontend/src/styles/base.css b/catpred/web/frontend/src/styles/base.css new file mode 100644 index 0000000..53766b1 --- /dev/null +++ b/catpred/web/frontend/src/styles/base.css @@ -0,0 +1,41 @@ +body { + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.6; +} + +::selection { + background: var(--accent-light); + color: var(--text); +} + +:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--focus-ring); + border-radius: var(--radius-sm); +} + +.container { + width: min(960px, calc(100% - 2rem)); + margin-inline: auto; +} + +code { + font-family: var(--font-mono); + font-size: 0.875em; + padding: 0.125em 0.35em; + border-radius: var(--radius-sm); + background: var(--bg-muted); + border: 1px solid var(--border); +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/catpred/web/frontend/src/styles/reset.css b/catpred/web/frontend/src/styles/reset.css new file mode 100644 index 0000000..4fa50c8 --- /dev/null +++ b/catpred/web/frontend/src/styles/reset.css @@ -0,0 +1,61 @@ +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + height: 100%; + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +body { + min-height: 100%; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +img, picture, video, canvas, svg { + display: block; + max-width: 100%; +} + +input, button, textarea, select { + font: inherit; + color: inherit; +} + +button { + cursor: pointer; + background: none; + border: none; +} + +a { + color: inherit; + text-decoration: none; +} + +table { + border-collapse: collapse; +} + +h1, h2, h3, h4, h5, h6 { + overflow-wrap: break-word; + font-weight: inherit; +} + +p { + overflow-wrap: break-word; +} + +ul, ol { + list-style: none; +} + +[hidden] { + display: none !important; +} diff --git a/catpred/web/frontend/src/styles/tokens.css b/catpred/web/frontend/src/styles/tokens.css new file mode 100644 index 0000000..a201f4a --- /dev/null +++ b/catpred/web/frontend/src/styles/tokens.css @@ -0,0 +1,43 @@ +:root { + /* Background */ + --bg: #FAFAF9; + --bg-surface: #FFFFFF; + --bg-muted: #F5F5F4; + + /* Text */ + --text: #1A1A1A; + --text-secondary: #57534E; + --text-tertiary: #A8A29E; + + /* Accent */ + --accent: #059669; + --accent-hover: #047857; + --accent-light: #D1FAE5; + + /* Parameter colors */ + --color-kcat: #059669; + --color-km: #DC7B6A; + --color-ki: #8B7FC7; + + /* Semantic */ + --danger: #DC2626; + --ok: #059669; + + /* Borders & shadows */ + --border: #E7E5E4; + --focus-ring: rgba(5, 150, 105, 0.4); + + /* Radius */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06); + + /* Typography */ + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-serif: 'Newsreader', Georgia, serif; + --font-mono: 'IBM Plex Mono', 'SF Mono', 'Fira Code', monospace; +} diff --git a/catpred/web/frontend/src/styles/utilities.css b/catpred/web/frontend/src/styles/utilities.css new file mode 100644 index 0000000..44241b9 --- /dev/null +++ b/catpred/web/frontend/src/styles/utilities.css @@ -0,0 +1,11 @@ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/catpred/web/frontend/tsconfig.app.json b/catpred/web/frontend/tsconfig.app.json new file mode 100644 index 0000000..fab93e9 --- /dev/null +++ b/catpred/web/frontend/tsconfig.app.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/env.d.ts"] +} diff --git a/catpred/web/frontend/tsconfig.app.tsbuildinfo b/catpred/web/frontend/tsconfig.app.tsbuildinfo new file mode 100644 index 0000000..7da64e3 --- /dev/null +++ b/catpred/web/frontend/tsconfig.app.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/env.d.ts","./src/main.ts","./src/api/client.ts","./src/api/types.ts","./src/composables/useinputrows.ts","./src/composables/useprediction.ts","./src/composables/usereadiness.ts","./src/app.vue","./src/components/appfooter.vue","./src/components/appheader.vue","./src/components/batchupload.vue","./src/components/inputpanel.vue","./src/components/inputrow.vue","./src/components/parameterselector.vue","./src/components/resultpanel.vue","./src/components/skiplink.vue","./src/components/statusbar.vue","./src/components/substrateinputs.vue"],"version":"5.7.3"} \ No newline at end of file diff --git a/catpred/web/frontend/tsconfig.json b/catpred/web/frontend/tsconfig.json new file mode 100644 index 0000000..e4e65e5 --- /dev/null +++ b/catpred/web/frontend/tsconfig.json @@ -0,0 +1,6 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/catpred/web/frontend/vite.config.ts b/catpred/web/frontend/vite.config.ts new file mode 100644 index 0000000..0c1fa7f --- /dev/null +++ b/catpred/web/frontend/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + base: '/static/dist/', + build: { + outDir: '../static/dist', + emptyOutDir: true, + }, + server: { + proxy: { + '/ready': 'http://localhost:8000', + '/predict': 'http://localhost:8000', + '/health': 'http://localhost:8000', + }, + }, +}) diff --git a/catpred/web/run.py b/catpred/web/run.py new file mode 100644 index 0000000..41fc261 --- /dev/null +++ b/catpred/web/run.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import argparse + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Run CatPred web API server.") + parser.add_argument("--host", default="0.0.0.0", help="Host interface to bind") + parser.add_argument("--port", type=int, default=8000, help="Port to bind") + parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development") + parser.add_argument("--workers", type=int, default=1, help="Number of worker processes") + args = parser.parse_args(argv) + + try: + import uvicorn + except ImportError: + print("uvicorn is not installed. Install optional web dependencies with `pip install .[web]`.") + return 1 + + from .app import create_app + + app = create_app() + uvicorn.run(app, host=args.host, port=args.port, reload=args.reload, workers=args.workers) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/catpred/web/static/catpred.css b/catpred/web/static/catpred.css new file mode 100644 index 0000000..9b12b0c --- /dev/null +++ b/catpred/web/static/catpred.css @@ -0,0 +1,938 @@ +:root { + --bg: #f6f4f0; + --bg-warm: #efecea; + --surface: #ffffff; + --border: rgba(0, 0, 0, 0.06); + --border-soft: rgba(0, 0, 0, 0.04); + + --text: #1a1816; + --text-secondary: #6b6560; + --text-tertiary: #a09890; + --text-muted: #c4bdb5; + + --protein-rose: #c4897a; + --protein-sage: #7a9e8e; + --protein-slate: #7a8a9e; + --protein-lavender: #9a8aae; + + --focus: rgba(122, 158, 142, 0.2); + --danger: #9b4542; + --ok: #3d6c55; + + --radius-lg: 22px; + --radius-md: 14px; + --shadow-card: 0 12px 36px rgba(17, 15, 12, 0.07); + --shadow-float: 0 16px 32px rgba(17, 15, 12, 0.11); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; + height: 100%; + overscroll-behavior-y: auto; +} + +body { + min-height: 100%; + background: var(--bg); + color: var(--text); + font-family: "Outfit", "Segoe UI", sans-serif; + font-weight: 300; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + overflow-x: hidden; + position: relative; +} + +.texture { + position: fixed; + inset: 0; + pointer-events: none; + z-index: -2; + background: + radial-gradient(circle at 12% 14%, rgba(122, 158, 142, 0.12) 0%, transparent 32%), + radial-gradient(circle at 88% 22%, rgba(196, 137, 122, 0.11) 0%, transparent 33%), + radial-gradient(circle at 32% 80%, rgba(154, 138, 174, 0.08) 0%, transparent 36%); +} + +.texture::before { + content: ""; + position: absolute; + inset: 0; + background-image: + linear-gradient(to right, rgba(255, 255, 255, 0.16) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255, 255, 255, 0.11) 1px, transparent 1px); + background-size: 28px 28px; + mix-blend-mode: soft-light; + opacity: 0.85; +} + +::selection { + background: var(--protein-sage); + color: #fff; +} + +.container { + width: min(1280px, calc(100% - 2.8rem)); + margin-inline: auto; +} + +.top-nav { + position: sticky; + top: 0; + z-index: 90; + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + background: rgba(246, 244, 240, 0.84); + border-bottom: 1px solid var(--border-soft); +} + +.nav-content { + min-height: 78px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.72rem; + text-decoration: none; + color: var(--text); +} + +.brand-mark { + width: 30px; + height: 30px; + border-radius: 999px; + display: grid; + place-items: center; + color: var(--protein-sage); + background: rgba(122, 158, 142, 0.1); +} + +.brand-word { + font-family: "Newsreader", serif; + font-size: 1.32rem; + font-weight: 400; + letter-spacing: -0.02em; +} + +.desktop-nav { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.desktop-nav a { + display: inline-flex; + align-items: center; + min-height: 2.1rem; + padding: 0.35rem 0.7rem; + border: 1px solid var(--border); + border-radius: 999px; + background: rgba(255, 255, 255, 0.72); + text-decoration: none; + color: var(--text-secondary); + font-family: "IBM Plex Mono", monospace; + font-size: 0.68rem; + font-weight: 400; + letter-spacing: 0.04em; + text-transform: uppercase; + transition: color 0.2s ease, border-color 0.2s ease, background-color 0.2s ease, + transform 0.2s ease; +} + +.desktop-nav a:hover { + color: var(--text); + border-color: rgba(122, 158, 142, 0.34); + background: rgba(122, 158, 142, 0.08); + transform: translateY(-1px); +} + +.studio { + padding-block: clamp(1.2rem, 4vw, 2.4rem); +} + +.studio-head { + display: flex; + align-items: end; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.studio-head h1 { + font-family: "Newsreader", serif; + font-size: clamp(1.35rem, 2.2vw, 1.9rem); + font-weight: 400; + line-height: 1.05; + letter-spacing: -0.03em; +} + +.studio-tools { + display: flex; + align-items: flex-end; + justify-content: flex-end; + gap: 0.75rem; + flex-wrap: wrap; +} + +.workspace-grid { + display: grid; + grid-template-columns: minmax(0, 0.98fr) minmax(0, 1.02fr); + gap: 1rem; + align-items: start; +} + +.eyebrow { + font-family: "IBM Plex Mono", monospace; + font-size: 0.68rem; + font-weight: 300; + letter-spacing: 0.13em; + text-transform: uppercase; + color: var(--text-tertiary); + margin-bottom: 1.2rem; +} + +.lead code, +.muted code, +.helper code, +.faq-item code, +.steps code { + font-family: "IBM Plex Mono", monospace; + font-size: 0.82em; + padding: 0.08rem 0.28rem; + border-radius: 6px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.66); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; + border-radius: 999px; + border: 1px solid var(--border); + padding: 0.76rem 1.25rem; + text-decoration: none; + cursor: pointer; + font-family: "Outfit", sans-serif; + font-size: 0.87rem; + font-weight: 400; + letter-spacing: 0.01em; + transition: transform 0.26s ease, background-color 0.26s ease, color 0.26s ease, border-color 0.26s ease, + box-shadow 0.26s ease; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn.is-running { + position: relative; + cursor: wait; + transform: none; +} + +.btn.is-running::before { + content: ""; + width: 0.78rem; + height: 0.78rem; + border-radius: 999px; + border: 2px solid rgba(255, 255, 255, 0.45); + border-top-color: rgba(255, 255, 255, 0.96); + animation: spin 0.75s linear infinite; +} + +.btn.is-running:hover { + transform: none; +} + +.btn-dark { + background: var(--text); + border-color: var(--text); + color: var(--bg); + box-shadow: var(--shadow-float); +} + +.btn-dark:hover { + background: #2c2925; + border-color: #2c2925; +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary); +} + +.btn-ghost:hover { + color: var(--text); + border-color: rgba(0, 0, 0, 0.12); +} + +.btn-outline { + background: transparent; + color: var(--text); +} + +.btn-outline:hover { + background: var(--text); + color: var(--bg); + border-color: var(--text); +} + +.section { + padding-block: clamp(2.8rem, 7vw, 5.4rem); +} + +.snap-section { + min-height: auto; + scroll-margin-top: 84px; +} + +.section-alt { + background: var(--bg-warm); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.section-head { + margin-bottom: 1.2rem; +} + +.section-head.centered { + text-align: center; + max-width: 900px; + margin-inline: auto; +} + +.section-head h2 { + font-family: "Newsreader", serif; + font-size: clamp(1.95rem, 3.5vw, 3.1rem); + font-weight: 300; + line-height: 1.08; + letter-spacing: -0.02em; + margin-bottom: 0.56rem; +} + +.muted { + color: var(--text-secondary); + font-size: 0.98rem; + line-height: 1.72; +} + +.service-strip { + margin-top: 0.9rem; + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; + border: 1px solid var(--border); + border-radius: 999px; + background: rgba(255, 255, 255, 0.62); + padding: 0.36rem 0.62rem; +} + +.service-label { + font-family: "IBM Plex Mono", monospace; + font-size: 0.66rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-tertiary); +} + +.badge { + border-radius: 999px; + border: 1px solid var(--border); + padding: 0.16rem 0.52rem; + font-family: "IBM Plex Mono", monospace; + font-size: 0.68rem; + font-weight: 400; + color: var(--text-secondary); + background: #fff; +} + +.badge.ok { + color: var(--ok); + border-color: rgba(61, 108, 85, 0.38); + background: rgba(122, 158, 142, 0.1); +} + +.badge.error { + color: var(--danger); + border-color: rgba(155, 69, 66, 0.38); + background: rgba(196, 137, 122, 0.1); +} + +.service-hint { + color: var(--text-secondary); + font-size: 0.82rem; +} + +.resource-links { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; +} + +.resource-link { + display: inline-flex; + align-items: center; + min-height: 2rem; + padding: 0.34rem 0.68rem; + border: 1px solid var(--border); + border-radius: 999px; + background: rgba(255, 255, 255, 0.88); + color: var(--text); + text-decoration: none; + font-family: "IBM Plex Mono", monospace; + font-size: 0.67rem; + letter-spacing: 0.04em; + box-shadow: 0 8px 20px rgba(17, 15, 12, 0.05); + transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease, + transform 0.2s ease, box-shadow 0.2s ease; +} + +.resource-link:hover { + color: var(--text); + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(17, 15, 12, 0.08); +} + +.resource-link-paper { + border-color: rgba(196, 137, 122, 0.38); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(196, 137, 122, 0.08)); +} + +.resource-link-paper:hover { + border-color: rgba(196, 137, 122, 0.64); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(196, 137, 122, 0.14)); +} + +.resource-link-code { + border-color: rgba(122, 158, 142, 0.4); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(122, 158, 142, 0.08)); +} + +.resource-link-code:hover { + border-color: rgba(122, 158, 142, 0.66); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(122, 158, 142, 0.15)); +} + +.card { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.8); + box-shadow: var(--shadow-card); + padding: 1rem; +} + +.card h3 { + font-family: "Newsreader", serif; + font-size: 1.38rem; + font-weight: 400; + line-height: 1.2; +} + +.input-card { + position: static; +} + +.preset-row { + margin-top: 0.72rem; + display: flex; + gap: 0.45rem; + flex-wrap: wrap; +} + +.preset-btn { + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.42rem 0.78rem; + background: rgba(255, 255, 255, 0.7); + color: var(--text-secondary); + cursor: pointer; + font-family: "IBM Plex Mono", monospace; + font-size: 0.68rem; + letter-spacing: 0.05em; + text-transform: uppercase; + transition: all 0.24s ease; +} + +.preset-btn:hover, +.preset-btn.active { + color: var(--protein-sage); + border-color: rgba(122, 158, 142, 0.4); + background: rgba(122, 158, 142, 0.08); +} + +.preset-row + .row-container { + margin-top: 0.78rem; +} + +.field-label { + display: block; + margin-bottom: 0.3rem; + font-family: "IBM Plex Mono", monospace; + font-size: 0.66rem; + letter-spacing: 0.11em; + text-transform: uppercase; + color: var(--text-tertiary); +} + +input, +textarea, +select, +button { + font: inherit; +} + +input, +textarea, +select { + width: 100%; + border: 1px solid var(--border); + border-radius: 10px; + background: rgba(255, 255, 255, 0.9); + color: var(--text); + padding: 0.62rem 0.68rem; + font-size: 0.9rem; + font-weight: 300; +} + +textarea { + line-height: 1.46; + resize: vertical; +} + +input:focus, +textarea:focus, +select:focus, +button:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 1px; +} + +.row-container { + margin-top: 0.78rem; + display: grid; + gap: 0.72rem; +} + +.row-item { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.7); + padding: 0.72rem; +} + +.row-item-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; + margin-bottom: 0.58rem; +} + +.row-item-head[hidden] { + display: none !important; +} + +.row-item-head h4 { + font-family: "IBM Plex Mono", monospace; + font-size: 0.72rem; + color: var(--text-tertiary); + text-transform: none; + letter-spacing: 0.02em; + font-weight: 400; +} + +.row-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.55rem; +} + +.icon-btn { + border: 1px solid var(--border); + border-radius: 999px; + background: rgba(255, 255, 255, 0.82); + color: var(--danger); + padding: 0.34rem 0.6rem; + cursor: pointer; + font-family: "IBM Plex Mono", monospace; + font-size: 0.65rem; + transition: background-color 0.24s ease; +} + +.icon-btn:hover { + background: rgba(196, 137, 122, 0.09); +} + +.form-actions { + margin-top: 0.82rem; + display: flex; + flex-wrap: wrap; + gap: 0.55rem; +} + +.helper { + margin-top: 0.76rem; + color: var(--text-secondary); + font-size: 0.86rem; + line-height: 1.6; +} + +.status-box { + margin-top: 0.72rem; + position: relative; + overflow: hidden; + border-radius: 12px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.75); + min-height: 44px; + padding: 0.58rem 0.72rem; + display: flex; + align-items: center; + gap: 0.52rem; + color: var(--text-secondary); + font-family: "IBM Plex Mono", monospace; + font-size: 0.68rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.status-box.running { + color: var(--protein-sage); + border-color: rgba(122, 158, 142, 0.42); + background: linear-gradient( + 120deg, + rgba(122, 158, 142, 0.14), + rgba(122, 158, 142, 0.06) 46%, + rgba(122, 138, 158, 0.09) + ); +} + +.status-box.running::before { + content: ""; + width: 0.75rem; + height: 0.75rem; + flex: 0 0 auto; + border-radius: 999px; + border: 2px solid rgba(122, 158, 142, 0.32); + border-top-color: rgba(122, 158, 142, 0.96); + animation: spin 0.74s linear infinite; +} + +.status-box.ok { + color: var(--ok); + border-color: rgba(61, 108, 85, 0.35); + background: rgba(122, 158, 142, 0.08); +} + +.status-box.error { + color: var(--danger); + border-color: rgba(155, 69, 66, 0.36); + background: rgba(196, 137, 122, 0.09); +} + +.result-cards { + margin-top: 0.72rem; + display: grid; + gap: 0.68rem; +} + +.output-card.is-busy { + position: relative; +} + +.output-card.is-busy::after { + content: ""; + position: absolute; + inset: 0; + border-radius: var(--radius-lg); + pointer-events: none; + background: linear-gradient( + 110deg, + rgba(255, 255, 255, 0) 0%, + rgba(122, 158, 142, 0.08) 35%, + rgba(255, 255, 255, 0) 65% + ); + transform: translateX(-100%); + animation: busy-sweep 1.7s ease-in-out infinite; +} + +.output-card.is-busy .result-cards, +.output-card.is-busy .details-output { + opacity: 0.76; +} + +.empty-result { + border: 1px dashed var(--border); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.65); + padding: 0.92rem; + color: var(--text-secondary); +} + +.result-card { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.76); + padding: 0.84rem; +} + +.result-card-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.6rem; +} + +.result-card-head h4 { + font-family: "Newsreader", serif; + font-size: 1.08rem; + font-weight: 400; +} + +.result-chip { + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.15rem 0.5rem; + font-family: "IBM Plex Mono", monospace; + font-size: 0.64rem; + color: var(--text-secondary); +} + +.result-main { + margin-top: 0.52rem; + display: flex; + align-items: baseline; + gap: 0.45rem; + flex-wrap: wrap; +} + +.result-main strong { + font-family: "Newsreader", serif; + font-size: 1.82rem; + line-height: 1; + font-weight: 400; +} + +.result-main span { + color: var(--text-secondary); + font-size: 0.86rem; +} + +.metric-grid { + margin-top: 0.62rem; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.45rem; +} + +.metric-grid div { + border: 1px solid var(--border); + border-radius: 10px; + background: rgba(255, 255, 255, 0.74); + padding: 0.45rem; +} + +.metric-grid dt { + font-family: "IBM Plex Mono", monospace; + font-size: 0.62rem; + color: var(--text-tertiary); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.metric-grid dd { + margin-top: 0.16rem; + font-size: 0.9rem; + color: var(--text-secondary); +} + +.result-meta { + margin-top: 0.6rem; + display: flex; + flex-wrap: wrap; + gap: 0.42rem; +} + +.result-meta span { + font-family: "IBM Plex Mono", monospace; + font-size: 0.62rem; + color: var(--text-secondary); + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.14rem 0.48rem; + background: rgba(255, 255, 255, 0.66); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.details-output { + margin-top: 0.8rem; + border-top: 1px solid var(--border); + padding-top: 0.65rem; +} + +.details-output summary { + cursor: pointer; + font-family: "IBM Plex Mono", monospace; + font-size: 0.66rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-tertiary); +} + +.rows-wrap { + margin-top: 0.72rem; + overflow: auto; +} + +.input-table { + width: 100%; + min-width: 680px; + border-collapse: collapse; +} + +.input-table th, +.input-table td { + text-align: left; + border-bottom: 1px solid var(--border); + padding: 0.42rem; + vertical-align: top; + font-size: 0.86rem; + color: var(--text-secondary); +} + +.input-table th { + font-family: "IBM Plex Mono", monospace; + font-size: 0.65rem; + font-weight: 300; + letter-spacing: 0.1em; + color: var(--text-tertiary); + text-transform: uppercase; +} + +.mobile-stick { + position: fixed; + left: 1rem; + right: 1rem; + bottom: 1rem; + z-index: 70; + display: none; + text-align: center; + text-decoration: none; + border-radius: 999px; + padding: 0.82rem 1rem; + background: var(--text); + color: var(--bg); + font-family: "Outfit", sans-serif; + font-size: 0.88rem; + font-weight: 400; + box-shadow: var(--shadow-float); +} + +.reveal { + opacity: 1; + transform: none; + transition: none; +} + +.reveal.show { + opacity: 1; + transform: none; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes busy-sweep { + 100% { + transform: translateX(100%); + } +} + +@media (max-width: 1120px) { + .studio-head, + .workspace-grid { + grid-template-columns: 1fr; + } + + .studio-head { + display: grid; + align-items: start; + } + + .studio-tools { + justify-content: flex-start; + align-items: flex-start; + } + + .metric-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 780px) { + .container { + width: min(1280px, calc(100% - 1.5rem)); + } + + .studio { + padding-top: 1rem; + } + + .studio-head h1 { + font-size: 1.5rem; + } + + .desktop-nav { + gap: 0.4rem; + } + + .desktop-nav a { + min-height: 1.95rem; + padding-inline: 0.58rem; + font-size: 0.62rem; + } + + .service-strip { + display: flex; + border-radius: 14px; + padding: 0.5rem 0.62rem; + } + + .resource-links { + gap: 0.42rem; + } + + .resource-link { + width: 100%; + justify-content: center; + } + + .input-table { + min-width: 560px; + } + + .mobile-stick { + display: block; + } +} diff --git a/catpred/web/static/catpred.js b/catpred/web/static/catpred.js new file mode 100644 index 0000000..4abe371 --- /dev/null +++ b/catpred/web/static/catpred.js @@ -0,0 +1,814 @@ +(function () { + const form = document.getElementById("predictForm"); + const rowContainer = document.getElementById("rowContainer"); + const outputCard = document.querySelector(".output-card"); + + const addRowBtn = document.getElementById("addRowBtn"); + const loadSampleBtn = document.getElementById("loadSampleBtn"); + const runBtn = document.getElementById("runBtn"); + + const statusBox = document.getElementById("statusBox"); + const resultCards = document.getElementById("resultCards"); + const previewTable = document.getElementById("previewTable"); + + const serviceBadge = document.getElementById("serviceBadge"); + const serviceHint = document.getElementById("serviceHint"); + + const presetButtons = document.querySelectorAll(".preset-btn"); + const predictionTimeoutMs = 120000; + + const sampleRows = [ + { + SMILES: "CCO", + sequence: "ACDEFGHIK", + pdbpath: "seq_001", + }, + { + SMILES: "CCN", + sequence: "LMNPQRSTV", + pdbpath: "seq_002", + }, + ]; + + const supportedParameters = ["kcat", "km", "ki"]; + let availableCheckpointParams = new Set(supportedParameters); + let localCheckpointParams = new Set(supportedParameters); + let selectedParameter = "kcat"; + const runtimeState = { + defaultBackend: "local", + modalReady: false, + localReady: false, + fallbackToLocalEnabled: false, + }; + const runningPhaseMessages = [ + "validating input", + "building protein records", + "running model ensemble", + "aggregating uncertainty", + ]; + const defaultRunButtonLabel = runBtn ? runBtn.textContent : "Run prediction"; + let runningStatusInterval = null; + let runStartedAtMs = null; + + function jsonPretty(data) { + return JSON.stringify(data, null, 2); + } + + function escapeHtml(text) { + return String(text) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'"); + } + + function formatNumber(value) { + const n = Number(value); + if (!Number.isFinite(n)) { + return "โ€”"; + } + return n.toFixed(1); + } + + function truncateText(value, maxLength) { + const text = String(value || ""); + if (text.length <= maxLength) { + return text; + } + return text.slice(0, maxLength - 1) + "โ€ฆ"; + } + + function setStatus(text, kind) { + if (!statusBox) return; + statusBox.textContent = text; + statusBox.classList.remove("ok", "error", "running"); + if (kind) { + statusBox.classList.add(kind); + } + } + + function formatElapsed(seconds) { + const safeSeconds = Math.max(0, Math.floor(seconds)); + const minutes = Math.floor(safeSeconds / 60); + const remainder = safeSeconds % 60; + return String(minutes).padStart(2, "0") + ":" + String(remainder).padStart(2, "0"); + } + + function getElapsedSeconds() { + if (!runStartedAtMs) return 0; + return Math.floor((Date.now() - runStartedAtMs) / 1000); + } + + function setRunButtonState(isRunning, elapsedLabel) { + if (!runBtn) return; + if (isRunning) { + const suffix = elapsedLabel ? " " + elapsedLabel : ""; + runBtn.textContent = "Running" + suffix; + runBtn.classList.add("is-running"); + runBtn.disabled = true; + runBtn.setAttribute("aria-busy", "true"); + return; + } + + runBtn.textContent = defaultRunButtonLabel || "Run prediction"; + runBtn.classList.remove("is-running"); + runBtn.disabled = false; + runBtn.removeAttribute("aria-busy"); + } + + function setBusyUi(isBusy) { + if (form) { + form.classList.toggle("is-busy", isBusy); + form.setAttribute("aria-busy", isBusy ? "true" : "false"); + } + if (outputCard) { + outputCard.classList.toggle("is-busy", isBusy); + outputCard.setAttribute("aria-busy", isBusy ? "true" : "false"); + } + + if (addRowBtn) { + addRowBtn.disabled = isBusy; + } + if (loadSampleBtn) { + loadSampleBtn.disabled = isBusy; + } + if (presetButtons && presetButtons.length) { + presetButtons.forEach((btn) => { + if (!isBusy && !isParameterAvailable(btn.dataset.param)) { + btn.disabled = true; + return; + } + btn.disabled = isBusy; + }); + } + } + + function startRunningFeedback(payload) { + stopRunningFeedback(); + runStartedAtMs = Date.now(); + setBusyUi(true); + + const parameterLabel = String(payload.parameter || "kcat").toUpperCase(); + const rowCount = Array.isArray(payload.input_rows) ? payload.input_rows.length : 0; + const rowLabel = rowCount === 1 ? "1 row" : String(rowCount) + " rows"; + + const updateRunningStatus = () => { + const elapsed = getElapsedSeconds(); + const elapsedLabel = formatElapsed(elapsed); + const phaseIndex = Math.floor(elapsed / 3) % runningPhaseMessages.length; + const phaseText = runningPhaseMessages[phaseIndex]; + setRunButtonState(true, elapsedLabel); + setStatus( + "Running " + parameterLabel + " on " + rowLabel + " โ€ข " + elapsedLabel + " โ€ข " + phaseText, + "running" + ); + }; + + updateRunningStatus(); + runningStatusInterval = window.setInterval(updateRunningStatus, 1000); + } + + function stopRunningFeedback() { + if (runningStatusInterval) { + window.clearInterval(runningStatusInterval); + runningStatusInterval = null; + } + setBusyUi(false); + setRunButtonState(false); + } + + function setServiceState(text, hint, kind) { + if (serviceBadge) { + serviceBadge.textContent = text; + serviceBadge.classList.remove("ok", "error"); + if (kind) { + serviceBadge.classList.add(kind); + } + } + if (serviceHint) { + serviceHint.textContent = hint || ""; + } + } + + function setActivePreset(paramValue) { + if (!presetButtons || !presetButtons.length) return; + presetButtons.forEach((btn) => { + if (!(btn instanceof HTMLElement)) return; + btn.classList.toggle("active", btn.dataset.param === paramValue); + }); + } + + function getSelectedParameter() { + return String(selectedParameter || "kcat").toLowerCase(); + } + + function firstAvailableParameter() { + for (const param of supportedParameters) { + if (availableCheckpointParams.has(param)) { + return param; + } + } + return null; + } + + function parseAvailableCheckpointParams(availableCheckpoints) { + if (availableCheckpoints && typeof availableCheckpoints === "object") { + return new Set( + Object.keys(availableCheckpoints) + .map((key) => String(key).toLowerCase()) + .filter((key) => supportedParameters.includes(key)) + ); + } + return new Set(supportedParameters); + } + + function setParameterAvailability(availableCheckpoints) { + availableCheckpointParams = parseAvailableCheckpointParams(availableCheckpoints); + + if (presetButtons && presetButtons.length) { + presetButtons.forEach((btn) => { + const paramValue = String(btn.dataset.param || "").toLowerCase(); + const enabled = availableCheckpointParams.has(paramValue); + btn.disabled = !enabled; + btn.setAttribute("aria-disabled", String(!enabled)); + }); + } + + const fallbackParam = firstAvailableParameter(); + if (fallbackParam && !availableCheckpointParams.has(getSelectedParameter())) { + selectedParameter = fallbackParam; + setActivePreset(fallbackParam); + } + } + + function isParameterAvailable(paramValue) { + return availableCheckpointParams.has(String(paramValue || "").toLowerCase()); + } + + function chooseRequestBackend() { + if (runtimeState.defaultBackend === "modal" && runtimeState.modalReady) { + return "modal"; + } + if (runtimeState.defaultBackend === "local" && localCheckpointParams.size > 0) { + return "local"; + } + if (runtimeState.modalReady) { + return "modal"; + } + if (runtimeState.localReady) { + return "local"; + } + return runtimeState.defaultBackend || "local"; + } + + function shouldFallbackToLocal(requestBackend, targetParam) { + if (requestBackend !== "modal") { + return false; + } + if (!runtimeState.fallbackToLocalEnabled) { + return false; + } + if (!runtimeState.localReady) { + return false; + } + return localCheckpointParams.has(String(targetParam || "").toLowerCase()); + } + + function formatSequenceId(index) { + const safeIndex = Math.max(1, Number(index) || 1); + return "seq_" + String(safeIndex).padStart(3, "0"); + } + + function getNextSequenceId() { + if (!rowContainer) { + return formatSequenceId(1); + } + + let maxSeen = 0; + const idInputs = rowContainer.querySelectorAll('input[name="pdbpath"]'); + idInputs.forEach((input) => { + const raw = String(input.value || "").trim(); + const match = raw.match(/^seq_(\d+)$/i); + if (!match) return; + const parsed = Number(match[1]); + if (Number.isFinite(parsed)) { + maxSeen = Math.max(maxSeen, parsed); + } + }); + + if (maxSeen > 0) { + return formatSequenceId(maxSeen + 1); + } + + const currentRowCount = rowContainer.querySelectorAll(".row-item").length; + return formatSequenceId(currentRowCount + 1); + } + + function rowTemplate(index, values) { + const smiles = values && values.SMILES ? values.SMILES : ""; + const seq = values && values.sequence ? values.sequence : ""; + const pdb = values && values.pdbpath ? values.pdbpath : ""; + + return ( + '
' + + '
' + + "

Entry " + + (index + 1) + + "

" + + '' + + "
" + + '
' + + '' + + '' + + '" + + "
" + + "
" + ); + } + + function renumberRows() { + if (!rowContainer) return; + const items = rowContainer.querySelectorAll(".row-item"); + const shouldShowHeader = items.length > 1; + items.forEach((item, idx) => { + const head = item.querySelector(".row-item-head"); + if (head instanceof HTMLElement) { + head.hidden = !shouldShowHeader; + } + const heading = item.querySelector("h4"); + if (heading) { + heading.textContent = "Entry " + String(idx + 1); + } + }); + } + + function addRow(values) { + if (!rowContainer) return; + const nextValues = values ? { ...values } : { SMILES: "", sequence: "", pdbpath: "" }; + if (!String(nextValues.pdbpath || "").trim()) { + nextValues.pdbpath = getNextSequenceId(); + } + const index = rowContainer.querySelectorAll(".row-item").length; + rowContainer.insertAdjacentHTML("beforeend", rowTemplate(index, nextValues)); + const last = rowContainer.lastElementChild; + if (last) { + last.animate( + [ + { opacity: 0, transform: "translateY(8px)" }, + { opacity: 1, transform: "translateY(0)" }, + ], + { duration: 220, easing: "ease-out" } + ); + } + renumberRows(); + } + + function clearRows() { + if (!rowContainer) return; + rowContainer.innerHTML = ""; + } + + function loadSampleRows() { + clearRows(); + sampleRows.forEach((row) => addRow(row)); + } + + function collectRows() { + if (!rowContainer) return []; + + const rows = []; + const items = rowContainer.querySelectorAll(".row-item"); + + items.forEach((item) => { + const smilesInput = item.querySelector('input[name="SMILES"]'); + const sequenceInput = item.querySelector('textarea[name="sequence"]'); + const pdbpathInput = item.querySelector('input[name="pdbpath"]'); + + if (!smilesInput || !sequenceInput || !pdbpathInput) { + return; + } + + const smiles = smilesInput.value.trim(); + const sequence = sequenceInput.value.trim().toUpperCase(); + const pdbpath = pdbpathInput.value.trim(); + + if (!smiles || !sequence || !pdbpath) { + return; + } + + rows.push({ + SMILES: smiles, + sequence: sequence, + pdbpath: pdbpath, + }); + }); + + return rows; + } + + function validateRows(rows) { + if (!rows.length) { + return "Please add at least one complete input row."; + } + + const mapping = new Map(); + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const key = row.pdbpath; + if (mapping.has(key) && mapping.get(key) !== row.sequence) { + return "Each Sequence ID must map to one unique enzyme sequence."; + } + mapping.set(key, row.sequence); + } + + return ""; + } + + function buildPayload(rows) { + const target = getSelectedParameter(); + const requestBackend = chooseRequestBackend(); + + return { + parameter: target, + checkpoint_dir: target, + input_rows: rows, + use_gpu: false, + results_dir: "web-app", + backend: requestBackend, + fallback_to_local: shouldFallbackToLocal(requestBackend, target), + }; + } + + function parsePrediction(row) { + const keys = Object.keys(row || {}); + const linearKey = keys.find((key) => key.startsWith("Prediction_(")); + const unitMatch = linearKey ? linearKey.match(/^Prediction_\((.*)\)$/) : null; + const unit = unitMatch ? unitMatch[1] : ""; + + return { + linear: linearKey ? row[linearKey] : null, + linearKey: linearKey || "Prediction", + unit: unit, + log10: row.Prediction_log10, + sdTotal: row.SD_total, + sdAleatoric: row.SD_aleatoric, + sdEpistemic: row.SD_epistemic, + }; + } + + function renderResultCards(previewRows, selectedParam) { + if (!resultCards) return; + + if (!previewRows || !previewRows.length) { + resultCards.innerHTML = + '

No preview rows were returned for this run.

'; + return; + } + + const cardsHtml = previewRows + .map((row, idx) => { + const p = parsePrediction(row); + + return ( + '
' + + '
' + + "

Result " + + String(idx + 1) + + "

" + + '' + + escapeHtml(selectedParam.toUpperCase()) + + "" + + "
" + + '
' + + "" + + escapeHtml(formatNumber(p.linear)) + + "" + + "" + + escapeHtml(p.unit || "predicted unit") + + "" + + "
" + + '
' + + "
log10
" + + escapeHtml(formatNumber(p.log10)) + + "
" + + "
Total SD
" + + escapeHtml(formatNumber(p.sdTotal)) + + "
" + + "
Epistemic SD
" + + escapeHtml(formatNumber(p.sdEpistemic)) + + "
" + + "
" + + '
' + + "SMILES: " + + escapeHtml(truncateText(row.SMILES, 24)) + + "" + + "Sequence ID: " + + escapeHtml(row.pdbpath || "โ€”") + + "" + + "
" + + "
" + ); + }) + .join(""); + + resultCards.innerHTML = cardsHtml; + } + + function renderPreviewTable(previewRows) { + if (!previewTable) return; + + if (!previewRows || !previewRows.length) { + previewTable.innerHTML = "No rows to show."; + return; + } + + const keys = Object.keys(previewRows[0]); + const head = + "" + + keys.map((k) => "" + escapeHtml(k) + "").join("") + + ""; + + const bodyRows = previewRows + .map((row) => { + const cells = keys + .map((k) => { + const value = row[k]; + if (value === null || value === undefined) { + return ""; + } + const displayValue = typeof value === "number" ? formatNumber(value) : value; + return "" + escapeHtml(displayValue) + ""; + }) + .join(""); + return "" + cells + ""; + }) + .join(""); + + previewTable.innerHTML = head + "" + bodyRows + ""; + } + + async function fetchReady() { + setServiceState("Checking...", "", ""); + + try { + const response = await fetch("/ready", { + method: "GET", + headers: { Accept: "application/json" }, + }); + + let data; + try { + data = await response.json(); + } catch (_err) { + data = {}; + } + + if (!response.ok) { + setServiceState("Offline", "Service not reachable", "error"); + return; + } + + const backends = data && data.backends ? data.backends : {}; + runtimeState.defaultBackend = data && data.default_backend ? String(data.default_backend) : "local"; + runtimeState.modalReady = Boolean(backends.modal && backends.modal.ready); + runtimeState.localReady = Boolean(backends.local && backends.local.ready); + runtimeState.fallbackToLocalEnabled = Boolean(data && data.fallback_to_local_enabled); + + localCheckpointParams = parseAvailableCheckpointParams( + data && data.api ? data.api.available_checkpoints : null + ); + + if (localCheckpointParams.size > 0) { + setParameterAvailability(Object.fromEntries(Array.from(localCheckpointParams).map((key) => [key, key]))); + } else if (runtimeState.modalReady) { + setParameterAvailability({ kcat: "kcat", km: "km", ki: "ki" }); + } else { + setParameterAvailability({}); + } + + const availableParams = Array.from(availableCheckpointParams.values()); + const localParams = Array.from(localCheckpointParams.values()); + + if (data && data.ready) { + const backend = data.default_backend ? String(data.default_backend) : "default"; + let hint = ""; + if (localParams.length) { + hint = "Backend: " + backend + " | Checkpoints: " + localParams.join(", "); + } else if (runtimeState.modalReady) { + hint = "Backend: " + backend + " | Remote checkpoints: " + availableParams.join(", "); + } else { + hint = "Backend: " + backend + " | No local checkpoints found"; + } + setServiceState("Online", hint, "ok"); + } else { + const hint = runtimeState.modalReady + ? "Backend available in limited mode" + : "Backend configuration needed | No local checkpoints found"; + setServiceState("Limited", hint, "error"); + } + } catch (_err) { + setServiceState("Offline", "Could not contact service", "error"); + } + } + + async function submitPrediction(event) { + event.preventDefault(); + + const rows = collectRows(); + const rowError = validateRows(rows); + if (rowError) { + setStatus(rowError, "error"); + return; + } + + const selectedParameter = getSelectedParameter(); + if (!isParameterAvailable(selectedParameter)) { + setStatus("No local checkpoint available for " + selectedParameter.toUpperCase() + ".", "error"); + return; + } + + const payload = buildPayload(rows); + startRunningFeedback(payload); + await new Promise((resolve) => window.requestAnimationFrame(resolve)); + + const requestController = new AbortController(); + const requestTimeout = window.setTimeout(() => { + requestController.abort(); + }, predictionTimeoutMs); + + try { + const response = await fetch("/predict", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + signal: requestController.signal, + body: JSON.stringify(payload), + }); + + let data; + try { + data = await response.json(); + } catch (_err) { + data = { detail: "Unexpected response format." }; + } + + if (!response.ok) { + const message = data && data.detail ? String(data.detail) : "Prediction could not be completed."; + renderResultCards([], payload.parameter); + renderPreviewTable([]); + const elapsedLabel = formatElapsed(getElapsedSeconds()); + setStatus(message + " (" + elapsedLabel + ")", "error"); + return; + } + + renderResultCards(data.preview_rows || [], payload.parameter); + renderPreviewTable(data.preview_rows || []); + const elapsedLabel = formatElapsed(getElapsedSeconds()); + setStatus("Prediction complete โ€ข " + elapsedLabel, "ok"); + } catch (err) { + renderResultCards([], payload.parameter); + renderPreviewTable([]); + const elapsedLabel = formatElapsed(getElapsedSeconds()); + if (err && err.name === "AbortError") { + const timeoutLabel = formatElapsed(Math.floor(predictionTimeoutMs / 1000)); + setStatus( + "Prediction timed out after " + + timeoutLabel + + ". This is often a cold-start delay; retry once or check backend logs. (" + + elapsedLabel + + ")", + "error" + ); + } else { + setStatus("Network error while running prediction. (" + elapsedLabel + ")", "error"); + } + } finally { + window.clearTimeout(requestTimeout); + stopRunningFeedback(); + runStartedAtMs = null; + } + } + + function setupFaq() { + const faqRoot = document.getElementById("faqList"); + if (!faqRoot) return; + + const items = faqRoot.querySelectorAll(".faq-item"); + items.forEach((item) => { + const button = item.querySelector("button"); + if (!button) return; + + button.addEventListener("click", function () { + const isOpen = item.classList.contains("open"); + items.forEach((row) => { + row.classList.remove("open"); + const rowButton = row.querySelector("button"); + if (rowButton) { + rowButton.setAttribute("aria-expanded", "false"); + } + }); + if (!isOpen) { + item.classList.add("open"); + button.setAttribute("aria-expanded", "true"); + } + }); + }); + } + + function setupReveal() { + const revealItems = document.querySelectorAll(".reveal"); + if (!revealItems.length) return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add("show"); + observer.unobserve(entry.target); + } + }); + }, + { + threshold: 0.12, + rootMargin: "0px 0px -36px 0px", + } + ); + + revealItems.forEach((item) => observer.observe(item)); + } + + function setupEvents() { + if (presetButtons && presetButtons.length) { + presetButtons.forEach((btn) => { + btn.addEventListener("click", function () { + if (btn.disabled) return; + const chosenParam = btn.dataset.param || "kcat"; + selectedParameter = String(chosenParam).toLowerCase(); + setActivePreset(chosenParam); + }); + }); + } + + if (addRowBtn) { + addRowBtn.addEventListener("click", function () { + addRow({ SMILES: "", sequence: "", pdbpath: "" }); + }); + } + + if (loadSampleBtn) { + loadSampleBtn.addEventListener("click", function () { + loadSampleRows(); + setStatus("Sample inputs loaded", "ok"); + }); + } + + if (rowContainer) { + rowContainer.addEventListener("click", function (event) { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + if (!target.matches("[data-remove-row]")) return; + + const row = target.closest(".row-item"); + if (!row) return; + + if (rowContainer.querySelectorAll(".row-item").length === 1) { + setStatus("At least one row is required", "error"); + return; + } + + row.remove(); + renumberRows(); + }); + } + + if (form) { + form.addEventListener("submit", submitPrediction); + } + } + + function bootstrap() { + clearRows(); + addRow(sampleRows[0]); + setActivePreset("kcat"); + + renderResultCards([], "kcat"); + renderPreviewTable([]); + + setStatus("Ready when you are.", "ok"); + setupEvents(); + setupFaq(); + setupReveal(); + fetchReady(); + } + + bootstrap(); +})(); diff --git a/catpred/web/static/dist/assets/index-B_EBo3Xr.js b/catpred/web/static/dist/assets/index-B_EBo3Xr.js new file mode 100644 index 0000000..2d9d8d4 --- /dev/null +++ b/catpred/web/static/dist/assets/index-B_EBo3Xr.js @@ -0,0 +1,20 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))n(r);new MutationObserver(r=>{for(const i of r)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function s(r){const i={};return r.integrity&&(i.integrity=r.integrity),r.referrerPolicy&&(i.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?i.credentials="include":r.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(r){if(r.ep)return;r.ep=!0;const i=s(r);fetch(r.href,i)}})();/** +* @vue/shared v3.5.30 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function Hs(e){const t=Object.create(null);for(const s of e.split(","))t[s]=1;return s=>s in t}const te={},yt=[],ke=()=>{},zn=()=>!1,fs=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),js=e=>e.startsWith("onUpdate:"),ae=Object.assign,Us=(e,t)=>{const s=e.indexOf(t);s>-1&&e.splice(s,1)},ai=Object.prototype.hasOwnProperty,Q=(e,t)=>ai.call(e,t),F=Array.isArray,_t=e=>Bt(e)==="[object Map]",Zn=e=>Bt(e)==="[object Set]",mn=e=>Bt(e)==="[object Date]",N=e=>typeof e=="function",re=e=>typeof e=="string",Ne=e=>typeof e=="symbol",J=e=>e!==null&&typeof e=="object",Xn=e=>(J(e)||N(e))&&N(e.then)&&N(e.catch),er=Object.prototype.toString,Bt=e=>er.call(e),ci=e=>Bt(e).slice(8,-1),tr=e=>Bt(e)==="[object Object]",Bs=e=>re(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,Mt=Hs(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),ds=e=>{const t=Object.create(null);return(s=>t[s]||(t[s]=e(s)))},ui=/-\w/g,Ce=ds(e=>e.replace(ui,t=>t.slice(1).toUpperCase())),fi=/\B([A-Z])/g,rt=ds(e=>e.replace(fi,"-$1").toLowerCase()),sr=ds(e=>e.charAt(0).toUpperCase()+e.slice(1)),ys=ds(e=>e?`on${sr(e)}`:""),$e=(e,t)=>!Object.is(e,t),_s=(e,...t)=>{for(let s=0;s{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:n,value:s})},di=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let bn;const hs=()=>bn||(bn=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function kt(e){if(F(e)){const t={};for(let s=0;s{if(s){const n=s.split(pi);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function We(e){let t="";if(re(e))t=e;else if(F(e))for(let s=0;s!!(e&&e.__v_isRef===!0),q=e=>re(e)?e:e==null?"":F(e)||J(e)&&(e.toString===er||!N(e.toString))?ir(e)?q(e.value):JSON.stringify(e,or,2):String(e),or=(e,t)=>ir(t)?or(e,t.value):_t(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((s,[n,r],i)=>(s[Es(n,i)+" =>"]=r,s),{})}:Zn(t)?{[`Set(${t.size})`]:[...t.values()].map(s=>Es(s))}:Ne(t)?Es(t):J(t)&&!F(t)&&!tr(t)?String(t):t,Es=(e,t="")=>{var s;return Ne(e)?`Symbol(${(s=e.description)!=null?s:t})`:e};/** +* @vue/reactivity v3.5.30 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let ye;class yi{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this.__v_skip=!0,this.parent=ye,!t&&ye&&(this.index=(ye.scopes||(ye.scopes=[])).push(this)-1)}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t0&&--this._on===0&&(ye=this.prevScope,this.prevScope=void 0)}stop(t){if(this._active){this._active=!1;let s,n;for(s=0,n=this.effects.length;s0)return;if(Dt){let t=Dt;for(Dt=void 0;t;){const s=t.next;t.next=void 0,t.flags&=-9,t=s}}let e;for(;Ot;){let t=Ot;for(Ot=void 0;t;){const s=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(n){e||(e=n)}t=s}}if(e)throw e}function ur(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function fr(e){let t,s=e.depsTail,n=s;for(;n;){const r=n.prevDep;n.version===-1?(n===s&&(s=r),Ys(n),Ei(n)):t=n,n.dep.activeLink=n.prevActiveLink,n.prevActiveLink=void 0,n=r}e.deps=t,e.depsTail=s}function Rs(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(dr(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function dr(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Nt)||(e.globalVersion=Nt,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Rs(e))))return;e.flags|=2;const t=e.dep,s=ee,n=xe;ee=e,xe=!0;try{ur(e);const r=e.fn(e._value);(t.version===0||$e(r,e._value))&&(e.flags|=128,e._value=r,t.version++)}catch(r){throw t.version++,r}finally{ee=s,xe=n,fr(e),e.flags&=-3}}function Ys(e,t=!1){const{dep:s,prevSub:n,nextSub:r}=e;if(n&&(n.nextSub=r,e.prevSub=void 0),r&&(r.prevSub=n,e.nextSub=void 0),s.subs===e&&(s.subs=n,!n&&s.computed)){s.computed.flags&=-5;for(let i=s.computed.deps;i;i=i.nextDep)Ys(i,!0)}!t&&!--s.sc&&s.map&&s.map.delete(s.key)}function Ei(e){const{prevDep:t,nextDep:s}=e;t&&(t.nextDep=s,e.prevDep=void 0),s&&(s.prevDep=t,e.nextDep=void 0)}let xe=!0;const hr=[];function Ye(){hr.push(xe),xe=!1}function Je(){const e=hr.pop();xe=e===void 0?!0:e}function gn(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const s=ee;ee=void 0;try{t()}finally{ee=s}}}let Nt=0;class Ai{constructor(t,s){this.sub=t,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class Js{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!ee||!xe||ee===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==ee)s=this.activeLink=new Ai(ee,this),ee.deps?(s.prevDep=ee.depsTail,ee.depsTail.nextDep=s,ee.depsTail=s):ee.deps=ee.depsTail=s,pr(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const n=s.nextDep;n.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=n),s.prevDep=ee.depsTail,s.nextDep=void 0,ee.depsTail.nextDep=s,ee.depsTail=s,ee.deps===s&&(ee.deps=n)}return s}trigger(t){this.version++,Nt++,this.notify(t)}notify(t){Ws();try{for(let s=this.subs;s;s=s.prevSub)s.sub.notify()&&s.sub.dep.notify()}finally{Qs()}}}function pr(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let n=t.deps;n;n=n.nextDep)pr(n)}const s=e.dep.subs;s!==e&&(e.prevSub=s,s&&(s.nextSub=e)),e.dep.subs=e}}const Ms=new WeakMap,pt=Symbol(""),Os=Symbol(""),Gt=Symbol("");function ue(e,t,s){if(xe&&ee){let n=Ms.get(e);n||Ms.set(e,n=new Map);let r=n.get(s);r||(n.set(s,r=new Js),r.map=n,r.key=s),r.track()}}function qe(e,t,s,n,r,i){const o=Ms.get(e);if(!o){Nt++;return}const l=a=>{a&&a.trigger()};if(Ws(),t==="clear")o.forEach(l);else{const a=F(e),d=a&&Bs(s);if(a&&s==="length"){const u=Number(n);o.forEach((h,p)=>{(p==="length"||p===Gt||!Ne(p)&&p>=u)&&l(h)})}else switch((s!==void 0||o.has(void 0))&&l(o.get(s)),d&&l(o.get(Gt)),t){case"add":a?d&&l(o.get("length")):(l(o.get(pt)),_t(e)&&l(o.get(Os)));break;case"delete":a||(l(o.get(pt)),_t(e)&&l(o.get(Os)));break;case"set":_t(e)&&l(o.get(pt));break}}Qs()}function gt(e){const t=W(e);return t===e?t:(ue(t,"iterate",Gt),we(e)?t:t.map(Le))}function ps(e){return ue(e=W(e),"iterate",Gt),e}function Ve(e,t){return ze(e)?wt(mt(e)?Le(t):t):Le(t)}const wi={__proto__:null,[Symbol.iterator](){return ws(this,Symbol.iterator,e=>Ve(this,e))},concat(...e){return gt(this).concat(...e.map(t=>F(t)?gt(t):t))},entries(){return ws(this,"entries",e=>(e[1]=Ve(this,e[1]),e))},every(e,t){return je(this,"every",e,t,void 0,arguments)},filter(e,t){return je(this,"filter",e,t,s=>s.map(n=>Ve(this,n)),arguments)},find(e,t){return je(this,"find",e,t,s=>Ve(this,s),arguments)},findIndex(e,t){return je(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return je(this,"findLast",e,t,s=>Ve(this,s),arguments)},findLastIndex(e,t){return je(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return je(this,"forEach",e,t,void 0,arguments)},includes(...e){return Cs(this,"includes",e)},indexOf(...e){return Cs(this,"indexOf",e)},join(e){return gt(this).join(e)},lastIndexOf(...e){return Cs(this,"lastIndexOf",e)},map(e,t){return je(this,"map",e,t,void 0,arguments)},pop(){return It(this,"pop")},push(...e){return It(this,"push",e)},reduce(e,...t){return vn(this,"reduce",e,t)},reduceRight(e,...t){return vn(this,"reduceRight",e,t)},shift(){return It(this,"shift")},some(e,t){return je(this,"some",e,t,void 0,arguments)},splice(...e){return It(this,"splice",e)},toReversed(){return gt(this).toReversed()},toSorted(e){return gt(this).toSorted(e)},toSpliced(...e){return gt(this).toSpliced(...e)},unshift(...e){return It(this,"unshift",e)},values(){return ws(this,"values",e=>Ve(this,e))}};function ws(e,t,s){const n=ps(e),r=n[t]();return n!==e&&!we(e)&&(r._next=r.next,r.next=()=>{const i=r._next();return i.done||(i.value=s(i.value)),i}),r}const Ci=Array.prototype;function je(e,t,s,n,r,i){const o=ps(e),l=o!==e&&!we(e),a=o[t];if(a!==Ci[t]){const h=a.apply(e,i);return l?Le(h):h}let d=s;o!==e&&(l?d=function(h,p){return s.call(this,Ve(e,h),p,e)}:s.length>2&&(d=function(h,p){return s.call(this,h,p,e)}));const u=a.call(o,d,n);return l&&r?r(u):u}function vn(e,t,s,n){const r=ps(e),i=r!==e&&!we(e);let o=s,l=!1;r!==e&&(i?(l=n.length===0,o=function(d,u,h){return l&&(l=!1,d=Ve(e,d)),s.call(this,d,Ve(e,u),h,e)}):s.length>3&&(o=function(d,u,h){return s.call(this,d,u,h,e)}));const a=r[t](o,...n);return l?Ve(e,a):a}function Cs(e,t,s){const n=W(e);ue(n,"iterate",Gt);const r=n[t](...s);return(r===-1||r===!1)&&en(s[0])?(s[0]=W(s[0]),n[t](...s)):r}function It(e,t,s=[]){Ye(),Ws();const n=W(e)[t].apply(e,s);return Qs(),Je(),n}const xi=Hs("__proto__,__v_isRef,__isVue"),mr=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(Ne));function Li(e){Ne(e)||(e=String(e));const t=W(this);return ue(t,"has",e),t.hasOwnProperty(e)}class br{constructor(t=!1,s=!1){this._isReadonly=t,this._isShallow=s}get(t,s,n){if(s==="__v_skip")return t.__v_skip;const r=this._isReadonly,i=this._isShallow;if(s==="__v_isReactive")return!r;if(s==="__v_isReadonly")return r;if(s==="__v_isShallow")return i;if(s==="__v_raw")return n===(r?i?$i:yr:i?Sr:vr).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(n)?t:void 0;const o=F(t);if(!r){let a;if(o&&(a=wi[s]))return a;if(s==="hasOwnProperty")return Li}const l=Reflect.get(t,s,de(t)?t:n);if((Ne(s)?mr.has(s):xi(s))||(r||ue(t,"get",s),i))return l;if(de(l)){const a=o&&Bs(s)?l:l.value;return r&&J(a)?Vs(a):a}return J(l)?r?Vs(l):Zs(l):l}}class gr extends br{constructor(t=!1){super(!1,t)}set(t,s,n,r){let i=t[s];const o=F(t)&&Bs(s);if(!this._isShallow){const d=ze(i);if(!we(n)&&!ze(n)&&(i=W(i),n=W(n)),!o&&de(i)&&!de(n))return d||(i.value=n),!0}const l=o?Number(s)e,Jt=e=>Reflect.getPrototypeOf(e);function Mi(e,t,s){return function(...n){const r=this.__v_raw,i=W(r),o=_t(i),l=e==="entries"||e===Symbol.iterator&&o,a=e==="keys"&&o,d=r[e](...n),u=s?Ds:t?wt:Le;return!t&&ue(i,"iterate",a?Os:pt),ae(Object.create(d),{next(){const{value:h,done:p}=d.next();return p?{value:h,done:p}:{value:l?[u(h[0]),u(h[1])]:u(h),done:p}}})}}function zt(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function Oi(e,t){const s={get(r){const i=this.__v_raw,o=W(i),l=W(r);e||($e(r,l)&&ue(o,"get",r),ue(o,"get",l));const{has:a}=Jt(o),d=t?Ds:e?wt:Le;if(a.call(o,r))return d(i.get(r));if(a.call(o,l))return d(i.get(l));i!==o&&i.get(r)},get size(){const r=this.__v_raw;return!e&&ue(W(r),"iterate",pt),r.size},has(r){const i=this.__v_raw,o=W(i),l=W(r);return e||($e(r,l)&&ue(o,"has",r),ue(o,"has",l)),r===l?i.has(r):i.has(r)||i.has(l)},forEach(r,i){const o=this,l=o.__v_raw,a=W(l),d=t?Ds:e?wt:Le;return!e&&ue(a,"iterate",pt),l.forEach((u,h)=>r.call(i,d(u),d(h),o))}};return ae(s,e?{add:zt("add"),set:zt("set"),delete:zt("delete"),clear:zt("clear")}:{add(r){const i=W(this),o=Jt(i),l=W(r),a=!t&&!we(r)&&!ze(r)?l:r;return o.has.call(i,a)||$e(r,a)&&o.has.call(i,r)||$e(l,a)&&o.has.call(i,l)||(i.add(a),qe(i,"add",a,a)),this},set(r,i){!t&&!we(i)&&!ze(i)&&(i=W(i));const o=W(this),{has:l,get:a}=Jt(o);let d=l.call(o,r);d||(r=W(r),d=l.call(o,r));const u=a.call(o,r);return o.set(r,i),d?$e(i,u)&&qe(o,"set",r,i):qe(o,"add",r,i),this},delete(r){const i=W(this),{has:o,get:l}=Jt(i);let a=o.call(i,r);a||(r=W(r),a=o.call(i,r)),l&&l.call(i,r);const d=i.delete(r);return a&&qe(i,"delete",r,void 0),d},clear(){const r=W(this),i=r.size!==0,o=r.clear();return i&&qe(r,"clear",void 0,void 0),o}}),["keys","values","entries",Symbol.iterator].forEach(r=>{s[r]=Mi(r,e,t)}),s}function zs(e,t){const s=Oi(e,t);return(n,r,i)=>r==="__v_isReactive"?!e:r==="__v_isReadonly"?e:r==="__v_raw"?n:Reflect.get(Q(s,r)&&r in n?s:n,r,i)}const Di={get:zs(!1,!1)},Vi={get:zs(!1,!0)},Ki={get:zs(!0,!1)};const vr=new WeakMap,Sr=new WeakMap,yr=new WeakMap,$i=new WeakMap;function Fi(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function ki(e){return e.__v_skip||!Object.isExtensible(e)?0:Fi(ci(e))}function Zs(e){return ze(e)?e:Xs(e,!1,Ii,Di,vr)}function Ni(e){return Xs(e,!1,Ri,Vi,Sr)}function Vs(e){return Xs(e,!0,Pi,Ki,yr)}function Xs(e,t,s,n,r){if(!J(e)||e.__v_raw&&!(t&&e.__v_isReactive))return e;const i=ki(e);if(i===0)return e;const o=r.get(e);if(o)return o;const l=new Proxy(e,i===2?n:s);return r.set(e,l),l}function mt(e){return ze(e)?mt(e.__v_raw):!!(e&&e.__v_isReactive)}function ze(e){return!!(e&&e.__v_isReadonly)}function we(e){return!!(e&&e.__v_isShallow)}function en(e){return e?!!e.__v_raw:!1}function W(e){const t=e&&e.__v_raw;return t?W(t):e}function Gi(e){return!Q(e,"__v_skip")&&Object.isExtensible(e)&&nr(e,"__v_skip",!0),e}const Le=e=>J(e)?Zs(e):e,wt=e=>J(e)?Vs(e):e;function de(e){return e?e.__v_isRef===!0:!1}function se(e){return Hi(e,!1)}function Hi(e,t){return de(e)?e:new ji(e,t)}class ji{constructor(t,s){this.dep=new Js,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=s?t:W(t),this._value=s?t:Le(t),this.__v_isShallow=s}get value(){return this.dep.track(),this._value}set value(t){const s=this._rawValue,n=this.__v_isShallow||we(t)||ze(t);t=n?t:W(t),$e(t,s)&&(this._rawValue=t,this._value=n?t:Le(t),this.dep.trigger())}}function V(e){return de(e)?e.value:e}const Ui={get:(e,t,s)=>t==="__v_raw"?e:V(Reflect.get(e,t,s)),set:(e,t,s,n)=>{const r=e[t];return de(r)&&!de(s)?(r.value=s,!0):Reflect.set(e,t,s,n)}};function _r(e){return mt(e)?e:new Proxy(e,Ui)}class Bi{constructor(t,s,n){this.fn=t,this.setter=s,this._value=void 0,this.dep=new Js(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=Nt-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!s,this.isSSR=n}notify(){if(this.flags|=16,!(this.flags&8)&&ee!==this)return cr(this,!0),!0}get value(){const t=this.dep.track();return dr(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function qi(e,t,s=!1){let n,r;return N(e)?n=e:(n=e.get,r=e.set),new Bi(n,r,s)}const Zt={},rs=new WeakMap;let ft;function Wi(e,t=!1,s=ft){if(s){let n=rs.get(s);n||rs.set(s,n=[]),n.push(e)}}function Qi(e,t,s=te){const{immediate:n,deep:r,once:i,scheduler:o,augmentJob:l,call:a}=s,d=C=>r?C:we(C)||r===!1||r===0?tt(C,1):tt(C);let u,h,p,b,I=!1,P=!1;if(de(e)?(h=()=>e.value,I=we(e)):mt(e)?(h=()=>d(e),I=!0):F(e)?(P=!0,I=e.some(C=>mt(C)||we(C)),h=()=>e.map(C=>{if(de(C))return C.value;if(mt(C))return d(C);if(N(C))return a?a(C,2):C()})):N(e)?t?h=a?()=>a(e,2):e:h=()=>{if(p){Ye();try{p()}finally{Je()}}const C=ft;ft=u;try{return a?a(e,3,[b]):e(b)}finally{ft=C}}:h=ke,t&&r){const C=h,T=r===!0?1/0:r;h=()=>tt(C(),T)}const G=_i(),H=()=>{u.stop(),G&&G.active&&Us(G.effects,u)};if(i&&t){const C=t;t=(...T)=>{C(...T),H()}}let k=P?new Array(e.length).fill(Zt):Zt;const E=C=>{if(!(!(u.flags&1)||!u.dirty&&!C))if(t){const T=u.run();if(r||I||(P?T.some((D,B)=>$e(D,k[B])):$e(T,k))){p&&p();const D=ft;ft=u;try{const B=[T,k===Zt?void 0:P&&k[0]===Zt?[]:k,b];k=T,a?a(t,3,B):t(...B)}finally{ft=D}}}else u.run()};return l&&l(E),u=new lr(h),u.scheduler=o?()=>o(E,!1):E,b=C=>Wi(C,!1,u),p=u.onStop=()=>{const C=rs.get(u);if(C){if(a)a(C,4);else for(const T of C)T();rs.delete(u)}},t?n?E(!0):k=u.run():o?o(E.bind(null,!0),!0):u.run(),H.pause=u.pause.bind(u),H.resume=u.resume.bind(u),H.stop=H,H}function tt(e,t=1/0,s){if(t<=0||!J(e)||e.__v_skip||(s=s||new Map,(s.get(e)||0)>=t))return e;if(s.set(e,t),t--,de(e))tt(e.value,t,s);else if(F(e))for(let n=0;n{tt(n,t,s)});else if(tr(e)){for(const n in e)tt(e[n],t,s);for(const n of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,n)&&tt(e[n],t,s)}return e}/** +* @vue/runtime-core v3.5.30 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function qt(e,t,s,n){try{return n?e(...n):e()}catch(r){ms(r,t,s)}}function Ge(e,t,s,n){if(N(e)){const r=qt(e,t,s,n);return r&&Xn(r)&&r.catch(i=>{ms(i,t,s)}),r}if(F(e)){const r=[];for(let i=0;i>>1,r=pe[n],i=Ht(r);i=Ht(s)?pe.push(e):pe.splice(zi(t),0,e),e.flags|=1,Ar()}}function Ar(){is||(is=Er.then(Cr))}function Zi(e){F(e)?Et.push(...e):et&&e.id===-1?et.splice(St+1,0,e):e.flags&1||(Et.push(e),e.flags|=1),Ar()}function Sn(e,t,s=De+1){for(;sHt(s)-Ht(n));if(Et.length=0,et){et.push(...t);return}for(et=t,St=0;Ste.id==null?e.flags&2?-1:1/0:e.id;function Cr(e){try{for(De=0;De{n._d&&Pn(-1);const i=os(t);let o;try{o=e(...r)}finally{os(i),n._d&&Pn(1)}return o};return n._n=!0,n._c=!0,n._d=!0,n}function ct(e,t,s,n){const r=e.dirs,i=t&&t.dirs;for(let o=0;o1)return s&&N(t)?t.call(n&&n.proxy):t}}const to=Symbol.for("v-scx"),so=()=>Xt(to);function es(e,t,s){return Lr(e,t,s)}function Lr(e,t,s=te){const{immediate:n,deep:r,flush:i,once:o}=s,l=ae({},s),a=t&&n||!t&&i!=="post";let d;if(Ut){if(i==="sync"){const b=so();d=b.__watcherHandles||(b.__watcherHandles=[])}else if(!a){const b=()=>{};return b.stop=ke,b.resume=ke,b.pause=ke,b}}const u=me;l.call=(b,I,P)=>Ge(b,u,I,P);let h=!1;i==="post"?l.scheduler=b=>{Se(b,u&&u.suspense)}:i!=="sync"&&(h=!0,l.scheduler=(b,I)=>{I?b():tn(b)}),l.augmentJob=b=>{t&&(b.flags|=4),h&&(b.flags|=2,u&&(b.id=u.uid,b.i=u))};const p=Qi(e,t,l);return Ut&&(d?d.push(p):a&&p()),p}function no(e,t,s){const n=this.proxy,r=re(e)?e.includes(".")?Tr(n,e):()=>n[e]:e.bind(n,n);let i;N(t)?i=t:(i=t.handler,s=t);const o=Wt(this),l=Lr(r,i.bind(n),s);return o(),l}function Tr(e,t){const s=t.split(".");return()=>{let n=e;for(let r=0;re.__isTeleport,oo=Symbol("_leaveCb");function sn(e,t){e.shapeFlag&6&&e.component?(e.transition=t,sn(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Ze(e,t){return N(e)?ae({name:e.name},t,{setup:e}):e}function Ir(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function yn(e,t){let s;return!!((s=Object.getOwnPropertyDescriptor(e,t))&&!s.configurable)}const ls=new WeakMap;function Vt(e,t,s,n,r=!1){if(F(e)){e.forEach((P,G)=>Vt(P,t&&(F(t)?t[G]:t),s,n,r));return}if(Kt(n)&&!r){n.shapeFlag&512&&n.type.__asyncResolved&&n.component.subTree.component&&Vt(e,t,s,n.component.subTree);return}const i=n.shapeFlag&4?cn(n.component):n.el,o=r?null:i,{i:l,r:a}=e,d=t&&t.r,u=l.refs===te?l.refs={}:l.refs,h=l.setupState,p=W(h),b=h===te?zn:P=>yn(u,P)?!1:Q(p,P),I=(P,G)=>!(G&&yn(u,G));if(d!=null&&d!==a){if(_n(t),re(d))u[d]=null,b(d)&&(h[d]=null);else if(de(d)){const P=t;I(d,P.k)&&(d.value=null),P.k&&(u[P.k]=null)}}if(N(a))qt(a,l,12,[o,u]);else{const P=re(a),G=de(a);if(P||G){const H=()=>{if(e.f){const k=P?b(a)?h[a]:u[a]:I()||!e.k?a.value:u[e.k];if(r)F(k)&&Us(k,i);else if(F(k))k.includes(i)||k.push(i);else if(P)u[a]=[i],b(a)&&(h[a]=u[a]);else{const E=[i];I(a,e.k)&&(a.value=E),e.k&&(u[e.k]=E)}}else P?(u[a]=o,b(a)&&(h[a]=o)):G&&(I(a,e.k)&&(a.value=o),e.k&&(u[e.k]=o))};if(o){const k=()=>{H(),ls.delete(e)};k.id=-1,ls.set(e,k),Se(k,s)}else _n(e),H()}}}function _n(e){const t=ls.get(e);t&&(t.flags|=8,ls.delete(e))}hs().requestIdleCallback;hs().cancelIdleCallback;const Kt=e=>!!e.type.__asyncLoader,Pr=e=>e.type.__isKeepAlive;function lo(e,t){Rr(e,"a",t)}function ao(e,t){Rr(e,"da",t)}function Rr(e,t,s=me){const n=e.__wdc||(e.__wdc=()=>{let r=s;for(;r;){if(r.isDeactivated)return;r=r.parent}return e()});if(bs(t,n,s),s){let r=s.parent;for(;r&&r.parent;)Pr(r.parent.vnode)&&co(n,t,s,r),r=r.parent}}function co(e,t,s,n){const r=bs(t,e,n,!0);Mr(()=>{Us(n[t],r)},s)}function bs(e,t,s=me,n=!1){if(s){const r=s[e]||(s[e]=[]),i=t.__weh||(t.__weh=(...o)=>{Ye();const l=Wt(s),a=Ge(t,s,e,o);return l(),Je(),a});return n?r.unshift(i):r.push(i),i}}const Xe=e=>(t,s=me)=>{(!Ut||e==="sp")&&bs(e,(...n)=>t(...n),s)},uo=Xe("bm"),nn=Xe("m"),fo=Xe("bu"),ho=Xe("u"),po=Xe("bum"),Mr=Xe("um"),mo=Xe("sp"),bo=Xe("rtg"),go=Xe("rtc");function vo(e,t=me){bs("ec",e,t)}const So=Symbol.for("v-ndc");function dt(e,t,s,n){let r;const i=s,o=F(e);if(o||re(e)){const l=o&&mt(e);let a=!1,d=!1;l&&(a=!we(e),d=ze(e),e=ps(e)),r=new Array(e.length);for(let u=0,h=e.length;ut(l,a,void 0,i));else{const l=Object.keys(e);r=new Array(l.length);for(let a=0,d=l.length;ae?Xr(e)?cn(e):Ks(e.parent):null,$t=ae(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>Ks(e.parent),$root:e=>Ks(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Dr(e),$forceUpdate:e=>e.f||(e.f=()=>{tn(e.update)}),$nextTick:e=>e.n||(e.n=Ji.bind(e.proxy)),$watch:e=>no.bind(e)}),xs=(e,t)=>e!==te&&!e.__isScriptSetup&&Q(e,t),yo={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:s,setupState:n,data:r,props:i,accessCache:o,type:l,appContext:a}=e;if(t[0]!=="$"){const p=o[t];if(p!==void 0)switch(p){case 1:return n[t];case 2:return r[t];case 4:return s[t];case 3:return i[t]}else{if(xs(n,t))return o[t]=1,n[t];if(r!==te&&Q(r,t))return o[t]=2,r[t];if(Q(i,t))return o[t]=3,i[t];if(s!==te&&Q(s,t))return o[t]=4,s[t];$s&&(o[t]=0)}}const d=$t[t];let u,h;if(d)return t==="$attrs"&&ue(e.attrs,"get",""),d(e);if((u=l.__cssModules)&&(u=u[t]))return u;if(s!==te&&Q(s,t))return o[t]=4,s[t];if(h=a.config.globalProperties,Q(h,t))return h[t]},set({_:e},t,s){const{data:n,setupState:r,ctx:i}=e;return xs(r,t)?(r[t]=s,!0):n!==te&&Q(n,t)?(n[t]=s,!0):Q(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(i[t]=s,!0)},has({_:{data:e,setupState:t,accessCache:s,ctx:n,appContext:r,props:i,type:o}},l){let a;return!!(s[l]||e!==te&&l[0]!=="$"&&Q(e,l)||xs(t,l)||Q(i,l)||Q(n,l)||Q($t,l)||Q(r.config.globalProperties,l)||(a=o.__cssModules)&&a[l])},defineProperty(e,t,s){return s.get!=null?e._.accessCache[t]=0:Q(s,"value")&&this.set(e,t,s.value,null),Reflect.defineProperty(e,t,s)}};function En(e){return F(e)?e.reduce((t,s)=>(t[s]=null,t),{}):e}let $s=!0;function _o(e){const t=Dr(e),s=e.proxy,n=e.ctx;$s=!1,t.beforeCreate&&An(t.beforeCreate,e,"bc");const{data:r,computed:i,methods:o,watch:l,provide:a,inject:d,created:u,beforeMount:h,mounted:p,beforeUpdate:b,updated:I,activated:P,deactivated:G,beforeDestroy:H,beforeUnmount:k,destroyed:E,unmounted:C,render:T,renderTracked:D,renderTriggered:B,errorCaptured:U,serverPrefetch:ce,expose:be,inheritAttrs:ne,components:Te,directives:it,filters:ot}=t;if(d&&Eo(d,n,null),o)for(const z in o){const Z=o[z];N(Z)&&(n[z]=Z.bind(s))}if(r){const z=r.call(s,s);J(z)&&(e.data=Zs(z))}if($s=!0,i)for(const z in i){const Z=i[z],lt=N(Z)?Z.bind(s,s):N(Z.get)?Z.get.bind(s,s):ke,Qt=!N(Z)&&N(Z.set)?Z.set.bind(s):ke,at=nt({get:lt,set:Qt});Object.defineProperty(n,z,{enumerable:!0,configurable:!0,get:()=>at.value,set:Ie=>at.value=Ie})}if(l)for(const z in l)Or(l[z],n,s,z);if(a){const z=N(a)?a.call(s):a;Reflect.ownKeys(z).forEach(Z=>{eo(Z,z[Z])})}u&&An(u,e,"c");function ie(z,Z){F(Z)?Z.forEach(lt=>z(lt.bind(s))):Z&&z(Z.bind(s))}if(ie(uo,h),ie(nn,p),ie(fo,b),ie(ho,I),ie(lo,P),ie(ao,G),ie(vo,U),ie(go,D),ie(bo,B),ie(po,k),ie(Mr,C),ie(mo,ce),F(be))if(be.length){const z=e.exposed||(e.exposed={});be.forEach(Z=>{Object.defineProperty(z,Z,{get:()=>s[Z],set:lt=>s[Z]=lt,enumerable:!0})})}else e.exposed||(e.exposed={});T&&e.render===ke&&(e.render=T),ne!=null&&(e.inheritAttrs=ne),Te&&(e.components=Te),it&&(e.directives=it),ce&&Ir(e)}function Eo(e,t,s=ke){F(e)&&(e=Fs(e));for(const n in e){const r=e[n];let i;J(r)?"default"in r?i=Xt(r.from||n,r.default,!0):i=Xt(r.from||n):i=Xt(r),de(i)?Object.defineProperty(t,n,{enumerable:!0,configurable:!0,get:()=>i.value,set:o=>i.value=o}):t[n]=i}}function An(e,t,s){Ge(F(e)?e.map(n=>n.bind(t.proxy)):e.bind(t.proxy),t,s)}function Or(e,t,s,n){let r=n.includes(".")?Tr(s,n):()=>s[n];if(re(e)){const i=t[e];N(i)&&es(r,i)}else if(N(e))es(r,e.bind(s));else if(J(e))if(F(e))e.forEach(i=>Or(i,t,s,n));else{const i=N(e.handler)?e.handler.bind(s):t[e.handler];N(i)&&es(r,i,e)}}function Dr(e){const t=e.type,{mixins:s,extends:n}=t,{mixins:r,optionsCache:i,config:{optionMergeStrategies:o}}=e.appContext,l=i.get(t);let a;return l?a=l:!r.length&&!s&&!n?a=t:(a={},r.length&&r.forEach(d=>as(a,d,o,!0)),as(a,t,o)),J(t)&&i.set(t,a),a}function as(e,t,s,n=!1){const{mixins:r,extends:i}=t;i&&as(e,i,s,!0),r&&r.forEach(o=>as(e,o,s,!0));for(const o in t)if(!(n&&o==="expose")){const l=Ao[o]||s&&s[o];e[o]=l?l(e[o],t[o]):t[o]}return e}const Ao={data:wn,props:Cn,emits:Cn,methods:Rt,computed:Rt,beforeCreate:he,created:he,beforeMount:he,mounted:he,beforeUpdate:he,updated:he,beforeDestroy:he,beforeUnmount:he,destroyed:he,unmounted:he,activated:he,deactivated:he,errorCaptured:he,serverPrefetch:he,components:Rt,directives:Rt,watch:Co,provide:wn,inject:wo};function wn(e,t){return t?e?function(){return ae(N(e)?e.call(this,this):e,N(t)?t.call(this,this):t)}:t:e}function wo(e,t){return Rt(Fs(e),Fs(t))}function Fs(e){if(F(e)){const t={};for(let s=0;st==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${Ce(t)}Modifiers`]||e[`${rt(t)}Modifiers`];function Io(e,t,...s){if(e.isUnmounted)return;const n=e.vnode.props||te;let r=s;const i=t.startsWith("update:"),o=i&&To(n,t.slice(7));o&&(o.trim&&(r=s.map(u=>re(u)?u.trim():u)),o.number&&(r=s.map(di)));let l,a=n[l=ys(t)]||n[l=ys(Ce(t))];!a&&i&&(a=n[l=ys(rt(t))]),a&&Ge(a,e,6,r);const d=n[l+"Once"];if(d){if(!e.emitted)e.emitted={};else if(e.emitted[l])return;e.emitted[l]=!0,Ge(d,e,6,r)}}const Po=new WeakMap;function Kr(e,t,s=!1){const n=s?Po:t.emitsCache,r=n.get(e);if(r!==void 0)return r;const i=e.emits;let o={},l=!1;if(!N(e)){const a=d=>{const u=Kr(d,t,!0);u&&(l=!0,ae(o,u))};!s&&t.mixins.length&&t.mixins.forEach(a),e.extends&&a(e.extends),e.mixins&&e.mixins.forEach(a)}return!i&&!l?(J(e)&&n.set(e,null),null):(F(i)?i.forEach(a=>o[a]=null):ae(o,i),J(e)&&n.set(e,o),o)}function gs(e,t){return!e||!fs(t)?!1:(t=t.slice(2).replace(/Once$/,""),Q(e,t[0].toLowerCase()+t.slice(1))||Q(e,rt(t))||Q(e,t))}function xn(e){const{type:t,vnode:s,proxy:n,withProxy:r,propsOptions:[i],slots:o,attrs:l,emit:a,render:d,renderCache:u,props:h,data:p,setupState:b,ctx:I,inheritAttrs:P}=e,G=os(e);let H,k;try{if(s.shapeFlag&4){const C=r||n,T=C;H=Ke(d.call(T,C,u,h,b,p,I)),k=l}else{const C=t;H=Ke(C.length>1?C(h,{attrs:l,slots:o,emit:a}):C(h,null)),k=t.props?l:Ro(l)}}catch(C){Ft.length=0,ms(C,e,1),H=le(st)}let E=H;if(k&&P!==!1){const C=Object.keys(k),{shapeFlag:T}=E;C.length&&T&7&&(i&&C.some(js)&&(k=Mo(k,i)),E=Ct(E,k,!1,!0))}return s.dirs&&(E=Ct(E,null,!1,!0),E.dirs=E.dirs?E.dirs.concat(s.dirs):s.dirs),s.transition&&sn(E,s.transition),H=E,os(G),H}const Ro=e=>{let t;for(const s in e)(s==="class"||s==="style"||fs(s))&&((t||(t={}))[s]=e[s]);return t},Mo=(e,t)=>{const s={};for(const n in e)(!js(n)||!(n.slice(9)in t))&&(s[n]=e[n]);return s};function Oo(e,t,s){const{props:n,children:r,component:i}=e,{props:o,children:l,patchFlag:a}=t,d=i.emitsOptions;if(t.dirs||t.transition)return!0;if(s&&a>=0){if(a&1024)return!0;if(a&16)return n?Ln(n,o,d):!!o;if(a&8){const u=t.dynamicProps;for(let h=0;hObject.create(Fr),Nr=e=>Object.getPrototypeOf(e)===Fr;function Vo(e,t,s,n=!1){const r={},i=kr();e.propsDefaults=Object.create(null),Gr(e,t,r,i);for(const o in e.propsOptions[0])o in r||(r[o]=void 0);s?e.props=n?r:Ni(r):e.type.props?e.props=r:e.props=i,e.attrs=i}function Ko(e,t,s,n){const{props:r,attrs:i,vnode:{patchFlag:o}}=e,l=W(r),[a]=e.propsOptions;let d=!1;if((n||o>0)&&!(o&16)){if(o&8){const u=e.vnode.dynamicProps;for(let h=0;h{a=!0;const[p,b]=Hr(h,t,!0);ae(o,p),b&&l.push(...b)};!s&&t.mixins.length&&t.mixins.forEach(u),e.extends&&u(e.extends),e.mixins&&e.mixins.forEach(u)}if(!i&&!a)return J(e)&&n.set(e,yt),yt;if(F(i))for(let u=0;ue==="_"||e==="_ctx"||e==="$stable",on=e=>F(e)?e.map(Ke):[Ke(e)],Fo=(e,t,s)=>{if(t._n)return t;const n=Xi((...r)=>on(t(...r)),s);return n._c=!1,n},jr=(e,t,s)=>{const n=e._ctx;for(const r in e){if(rn(r))continue;const i=e[r];if(N(i))t[r]=Fo(r,i,n);else if(i!=null){const o=on(i);t[r]=()=>o}}},Ur=(e,t)=>{const s=on(t);e.slots.default=()=>s},Br=(e,t,s)=>{for(const n in t)(s||!rn(n))&&(e[n]=t[n])},ko=(e,t,s)=>{const n=e.slots=kr();if(e.vnode.shapeFlag&32){const r=t._;r?(Br(n,t,s),s&&nr(n,"_",r,!0)):jr(t,n)}else t&&Ur(e,t)},No=(e,t,s)=>{const{vnode:n,slots:r}=e;let i=!0,o=te;if(n.shapeFlag&32){const l=t._;l?s&&l===1?i=!1:Br(r,t,s):(i=!t.$stable,jr(t,r)),o=t}else t&&(Ur(e,t),o={default:1});if(i)for(const l in r)!rn(l)&&o[l]==null&&delete r[l]},Se=Bo;function Go(e){return Ho(e)}function Ho(e,t){const s=hs();s.__VUE__=!0;const{insert:n,remove:r,patchProp:i,createElement:o,createText:l,createComment:a,setText:d,setElementText:u,parentNode:h,nextSibling:p,setScopeId:b=ke,insertStaticContent:I}=e,P=(c,f,m,y=null,g=null,v=null,x=void 0,w=null,A=!!f.dynamicChildren)=>{if(c===f)return;c&&!Pt(c,f)&&(y=Yt(c),Ie(c,g,v,!0),c=null),f.patchFlag===-2&&(A=!1,f.dynamicChildren=null);const{type:S,ref:M,shapeFlag:L}=f;switch(S){case vs:G(c,f,m,y);break;case st:H(c,f,m,y);break;case ts:c==null&&k(f,m,y,x);break;case oe:Te(c,f,m,y,g,v,x,w,A);break;default:L&1?T(c,f,m,y,g,v,x,w,A):L&6?it(c,f,m,y,g,v,x,w,A):(L&64||L&128)&&S.process(c,f,m,y,g,v,x,w,A,Lt)}M!=null&&g?Vt(M,c&&c.ref,v,f||c,!f):M==null&&c&&c.ref!=null&&Vt(c.ref,null,v,c,!0)},G=(c,f,m,y)=>{if(c==null)n(f.el=l(f.children),m,y);else{const g=f.el=c.el;f.children!==c.children&&d(g,f.children)}},H=(c,f,m,y)=>{c==null?n(f.el=a(f.children||""),m,y):f.el=c.el},k=(c,f,m,y)=>{[c.el,c.anchor]=I(c.children,f,m,y,c.el,c.anchor)},E=({el:c,anchor:f},m,y)=>{let g;for(;c&&c!==f;)g=p(c),n(c,m,y),c=g;n(f,m,y)},C=({el:c,anchor:f})=>{let m;for(;c&&c!==f;)m=p(c),r(c),c=m;r(f)},T=(c,f,m,y,g,v,x,w,A)=>{if(f.type==="svg"?x="svg":f.type==="math"&&(x="mathml"),c==null)D(f,m,y,g,v,x,w,A);else{const S=c.el&&c.el._isVueCE?c.el:null;try{S&&S._beginPatch(),ce(c,f,g,v,x,w,A)}finally{S&&S._endPatch()}}},D=(c,f,m,y,g,v,x,w)=>{let A,S;const{props:M,shapeFlag:L,transition:R,dirs:$}=c;if(A=c.el=o(c.type,v,M&&M.is,M),L&8?u(A,c.children):L&16&&U(c.children,A,null,y,g,Ls(c,v),x,w),$&&ct(c,null,y,"created"),B(A,c,c.scopeId,x,y),M){for(const X in M)X!=="value"&&!Mt(X)&&i(A,X,null,M[X],v,y);"value"in M&&i(A,"value",null,M.value,v),(S=M.onVnodeBeforeMount)&&Oe(S,y,c)}$&&ct(c,null,y,"beforeMount");const j=jo(g,R);j&&R.beforeEnter(A),n(A,f,m),((S=M&&M.onVnodeMounted)||j||$)&&Se(()=>{S&&Oe(S,y,c),j&&R.enter(A),$&&ct(c,null,y,"mounted")},g)},B=(c,f,m,y,g)=>{if(m&&b(c,m),y)for(let v=0;v{for(let S=A;S{const w=f.el=c.el;let{patchFlag:A,dynamicChildren:S,dirs:M}=f;A|=c.patchFlag&16;const L=c.props||te,R=f.props||te;let $;if(m&&ut(m,!1),($=R.onVnodeBeforeUpdate)&&Oe($,m,f,c),M&&ct(f,c,m,"beforeUpdate"),m&&ut(m,!0),(L.innerHTML&&R.innerHTML==null||L.textContent&&R.textContent==null)&&u(w,""),S?be(c.dynamicChildren,S,w,m,y,Ls(f,g),v):x||Z(c,f,w,null,m,y,Ls(f,g),v,!1),A>0){if(A&16)ne(w,L,R,m,g);else if(A&2&&L.class!==R.class&&i(w,"class",null,R.class,g),A&4&&i(w,"style",L.style,R.style,g),A&8){const j=f.dynamicProps;for(let X=0;X{$&&Oe($,m,f,c),M&&ct(f,c,m,"updated")},y)},be=(c,f,m,y,g,v,x)=>{for(let w=0;w{if(f!==m){if(f!==te)for(const v in f)!Mt(v)&&!(v in m)&&i(c,v,f[v],null,g,y);for(const v in m){if(Mt(v))continue;const x=m[v],w=f[v];x!==w&&v!=="value"&&i(c,v,w,x,g,y)}"value"in m&&i(c,"value",f.value,m.value,g)}},Te=(c,f,m,y,g,v,x,w,A)=>{const S=f.el=c?c.el:l(""),M=f.anchor=c?c.anchor:l("");let{patchFlag:L,dynamicChildren:R,slotScopeIds:$}=f;$&&(w=w?w.concat($):$),c==null?(n(S,m,y),n(M,m,y),U(f.children||[],m,M,g,v,x,w,A)):L>0&&L&64&&R&&c.dynamicChildren&&c.dynamicChildren.length===R.length?(be(c.dynamicChildren,R,m,g,v,x,w),(f.key!=null||g&&f===g.subTree)&&qr(c,f,!0)):Z(c,f,m,M,g,v,x,w,A)},it=(c,f,m,y,g,v,x,w,A)=>{f.slotScopeIds=w,c==null?f.shapeFlag&512?g.ctx.activate(f,m,y,x,A):ot(f,m,y,g,v,x,A):bt(c,f,A)},ot=(c,f,m,y,g,v,x)=>{const w=c.component=Xo(c,y,g);if(Pr(c)&&(w.ctx.renderer=Lt),tl(w,!1,x),w.asyncDep){if(g&&g.registerDep(w,ie,x),!c.el){const A=w.subTree=le(st);H(null,A,f,m),c.placeholder=A.el}}else ie(w,c,f,m,g,v,x)},bt=(c,f,m)=>{const y=f.component=c.component;if(Oo(c,f,m))if(y.asyncDep&&!y.asyncResolved){z(y,f,m);return}else y.next=f,y.update();else f.el=c.el,y.vnode=f},ie=(c,f,m,y,g,v,x)=>{const w=()=>{if(c.isMounted){let{next:L,bu:R,u:$,parent:j,vnode:X}=c;{const Re=Wr(c);if(Re){L&&(L.el=X.el,z(c,L,x)),Re.asyncDep.then(()=>{Se(()=>{c.isUnmounted||S()},g)});return}}let Y=L,ge;ut(c,!1),L?(L.el=X.el,z(c,L,x)):L=X,R&&_s(R),(ge=L.props&&L.props.onVnodeBeforeUpdate)&&Oe(ge,j,L,X),ut(c,!0);const ve=xn(c),Pe=c.subTree;c.subTree=ve,P(Pe,ve,h(Pe.el),Yt(Pe),c,g,v),L.el=ve.el,Y===null&&Do(c,ve.el),$&&Se($,g),(ge=L.props&&L.props.onVnodeUpdated)&&Se(()=>Oe(ge,j,L,X),g)}else{let L;const{el:R,props:$}=f,{bm:j,m:X,parent:Y,root:ge,type:ve}=c,Pe=Kt(f);ut(c,!1),j&&_s(j),!Pe&&(L=$&&$.onVnodeBeforeMount)&&Oe(L,Y,f),ut(c,!0);{ge.ce&&ge.ce._hasShadowRoot()&&ge.ce._injectChildStyle(ve,c.parent?c.parent.type:void 0);const Re=c.subTree=xn(c);P(null,Re,m,y,c,g,v),f.el=Re.el}if(X&&Se(X,g),!Pe&&(L=$&&$.onVnodeMounted)){const Re=f;Se(()=>Oe(L,Y,Re),g)}(f.shapeFlag&256||Y&&Kt(Y.vnode)&&Y.vnode.shapeFlag&256)&&c.a&&Se(c.a,g),c.isMounted=!0,f=m=y=null}};c.scope.on();const A=c.effect=new lr(w);c.scope.off();const S=c.update=A.run.bind(A),M=c.job=A.runIfDirty.bind(A);M.i=c,M.id=c.uid,A.scheduler=()=>tn(M),ut(c,!0),S()},z=(c,f,m)=>{f.component=c;const y=c.vnode.props;c.vnode=f,c.next=null,Ko(c,f.props,y,m),No(c,f.children,m),Ye(),Sn(c),Je()},Z=(c,f,m,y,g,v,x,w,A=!1)=>{const S=c&&c.children,M=c?c.shapeFlag:0,L=f.children,{patchFlag:R,shapeFlag:$}=f;if(R>0){if(R&128){Qt(S,L,m,y,g,v,x,w,A);return}else if(R&256){lt(S,L,m,y,g,v,x,w,A);return}}$&8?(M&16&&xt(S,g,v),L!==S&&u(m,L)):M&16?$&16?Qt(S,L,m,y,g,v,x,w,A):xt(S,g,v,!0):(M&8&&u(m,""),$&16&&U(L,m,y,g,v,x,w,A))},lt=(c,f,m,y,g,v,x,w,A)=>{c=c||yt,f=f||yt;const S=c.length,M=f.length,L=Math.min(S,M);let R;for(R=0;RM?xt(c,g,v,!0,!1,L):U(f,m,y,g,v,x,w,A,L)},Qt=(c,f,m,y,g,v,x,w,A)=>{let S=0;const M=f.length;let L=c.length-1,R=M-1;for(;S<=L&&S<=R;){const $=c[S],j=f[S]=A?Be(f[S]):Ke(f[S]);if(Pt($,j))P($,j,m,null,g,v,x,w,A);else break;S++}for(;S<=L&&S<=R;){const $=c[L],j=f[R]=A?Be(f[R]):Ke(f[R]);if(Pt($,j))P($,j,m,null,g,v,x,w,A);else break;L--,R--}if(S>L){if(S<=R){const $=R+1,j=$R)for(;S<=L;)Ie(c[S],g,v,!0),S++;else{const $=S,j=S,X=new Map;for(S=j;S<=R;S++){const _e=f[S]=A?Be(f[S]):Ke(f[S]);_e.key!=null&&X.set(_e.key,S)}let Y,ge=0;const ve=R-j+1;let Pe=!1,Re=0;const Tt=new Array(ve);for(S=0;S=ve){Ie(_e,g,v,!0);continue}let Me;if(_e.key!=null)Me=X.get(_e.key);else for(Y=j;Y<=R;Y++)if(Tt[Y-j]===0&&Pt(_e,f[Y])){Me=Y;break}Me===void 0?Ie(_e,g,v,!0):(Tt[Me-j]=S+1,Me>=Re?Re=Me:Pe=!0,P(_e,f[Me],m,null,g,v,x,w,A),ge++)}const dn=Pe?Uo(Tt):yt;for(Y=dn.length-1,S=ve-1;S>=0;S--){const _e=j+S,Me=f[_e],hn=f[_e+1],pn=_e+1{const{el:v,type:x,transition:w,children:A,shapeFlag:S}=c;if(S&6){at(c.component.subTree,f,m,y);return}if(S&128){c.suspense.move(f,m,y);return}if(S&64){x.move(c,f,m,Lt);return}if(x===oe){n(v,f,m);for(let L=0;Lw.enter(v),g);else{const{leave:L,delayLeave:R,afterLeave:$}=w,j=()=>{c.ctx.isUnmounted?r(v):n(v,f,m)},X=()=>{v._isLeaving&&v[oo](!0),L(v,()=>{j(),$&&$()})};R?R(v,j,X):X()}else n(v,f,m)},Ie=(c,f,m,y=!1,g=!1)=>{const{type:v,props:x,ref:w,children:A,dynamicChildren:S,shapeFlag:M,patchFlag:L,dirs:R,cacheIndex:$}=c;if(L===-2&&(g=!1),w!=null&&(Ye(),Vt(w,null,m,c,!0),Je()),$!=null&&(f.renderCache[$]=void 0),M&256){f.ctx.deactivate(c);return}const j=M&1&&R,X=!Kt(c);let Y;if(X&&(Y=x&&x.onVnodeBeforeUnmount)&&Oe(Y,f,c),M&6)li(c.component,m,y);else{if(M&128){c.suspense.unmount(m,y);return}j&&ct(c,null,f,"beforeUnmount"),M&64?c.type.remove(c,f,m,Lt,y):S&&!S.hasOnce&&(v!==oe||L>0&&L&64)?xt(S,f,m,!1,!0):(v===oe&&L&384||!g&&M&16)&&xt(A,f,m),y&&un(c)}(X&&(Y=x&&x.onVnodeUnmounted)||j)&&Se(()=>{Y&&Oe(Y,f,c),j&&ct(c,null,f,"unmounted")},m)},un=c=>{const{type:f,el:m,anchor:y,transition:g}=c;if(f===oe){oi(m,y);return}if(f===ts){C(c);return}const v=()=>{r(m),g&&!g.persisted&&g.afterLeave&&g.afterLeave()};if(c.shapeFlag&1&&g&&!g.persisted){const{leave:x,delayLeave:w}=g,A=()=>x(m,v);w?w(c.el,v,A):A()}else v()},oi=(c,f)=>{let m;for(;c!==f;)m=p(c),r(c),c=m;r(f)},li=(c,f,m)=>{const{bum:y,scope:g,job:v,subTree:x,um:w,m:A,a:S}=c;In(A),In(S),y&&_s(y),g.stop(),v&&(v.flags|=8,Ie(x,c,f,m)),w&&Se(w,f),Se(()=>{c.isUnmounted=!0},f)},xt=(c,f,m,y=!1,g=!1,v=0)=>{for(let x=v;x{if(c.shapeFlag&6)return Yt(c.component.subTree);if(c.shapeFlag&128)return c.suspense.next();const f=p(c.anchor||c.el),m=f&&f[ro];return m?p(m):f};let Ss=!1;const fn=(c,f,m)=>{let y;c==null?f._vnode&&(Ie(f._vnode,null,null,!0),y=f._vnode.component):P(f._vnode||null,c,f,null,null,null,m),f._vnode=c,Ss||(Ss=!0,Sn(y),wr(),Ss=!1)},Lt={p:P,um:Ie,m:at,r:un,mt:ot,mc:U,pc:Z,pbc:be,n:Yt,o:e};return{render:fn,hydrate:void 0,createApp:Lo(fn)}}function Ls({type:e,props:t},s){return s==="svg"&&e==="foreignObject"||s==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:s}function ut({effect:e,job:t},s){s?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function jo(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function qr(e,t,s=!1){const n=e.children,r=t.children;if(F(n)&&F(r))for(let i=0;i>1,e[s[l]]0&&(t[n]=s[i-1]),s[i]=n)}}for(i=s.length,o=s[i-1];i-- >0;)s[i]=o,o=t[o];return s}function Wr(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:Wr(t)}function In(e){if(e)for(let t=0;te.__isSuspense;function Bo(e,t){t&&t.pendingBranch?F(e)?t.effects.push(...e):t.effects.push(e):Zi(e)}const oe=Symbol.for("v-fgt"),vs=Symbol.for("v-txt"),st=Symbol.for("v-cmt"),ts=Symbol.for("v-stc"),Ft=[];let Ee=null;function O(e=!1){Ft.push(Ee=e?null:[])}function qo(){Ft.pop(),Ee=Ft[Ft.length-1]||null}let jt=1;function Pn(e,t=!1){jt+=e,e<0&&Ee&&t&&(Ee.hasOnce=!0)}function Jr(e){return e.dynamicChildren=jt>0?Ee||yt:null,qo(),jt>0&&Ee&&Ee.push(e),e}function K(e,t,s,n,r,i){return Jr(_(e,t,s,n,r,i,!0))}function ln(e,t,s,n,r){return Jr(le(e,t,s,n,r,!0))}function zr(e){return e?e.__v_isVNode===!0:!1}function Pt(e,t){return e.type===t.type&&e.key===t.key}const Zr=({key:e})=>e??null,ss=({ref:e,ref_key:t,ref_for:s})=>(typeof e=="number"&&(e=""+e),e!=null?re(e)||de(e)||N(e)?{i:Fe,r:e,k:t,f:!!s}:e:null);function _(e,t=null,s=null,n=0,r=null,i=e===oe?0:1,o=!1,l=!1){const a={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&Zr(t),ref:t&&ss(t),scopeId:xr,slotScopeIds:null,children:s,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:i,patchFlag:n,dynamicProps:r,dynamicChildren:null,appContext:null,ctx:Fe};return l?(an(a,s),i&128&&e.normalize(a)):s&&(a.shapeFlag|=re(s)?8:16),jt>0&&!o&&Ee&&(a.patchFlag>0||i&6)&&a.patchFlag!==32&&Ee.push(a),a}const le=Wo;function Wo(e,t=null,s=null,n=0,r=null,i=!1){if((!e||e===So)&&(e=st),zr(e)){const l=Ct(e,t,!0);return s&&an(l,s),jt>0&&!i&&Ee&&(l.shapeFlag&6?Ee[Ee.indexOf(e)]=l:Ee.push(l)),l.patchFlag=-2,l}if(il(e)&&(e=e.__vccOpts),t){t=Qo(t);let{class:l,style:a}=t;l&&!re(l)&&(t.class=We(l)),J(a)&&(en(a)&&!F(a)&&(a=ae({},a)),t.style=kt(a))}const o=re(e)?1:Yr(e)?128:io(e)?64:J(e)?4:N(e)?2:0;return _(e,t,s,n,r,o,i,!0)}function Qo(e){return e?en(e)||Nr(e)?ae({},e):e:null}function Ct(e,t,s=!1,n=!1){const{props:r,ref:i,patchFlag:o,children:l,transition:a}=e,d=t?Jo(r||{},t):r,u={__v_isVNode:!0,__v_skip:!0,type:e.type,props:d,key:d&&Zr(d),ref:t&&t.ref?s&&i?F(i)?i.concat(ss(t)):[i,ss(t)]:ss(t):i,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:l,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==oe?o===-1?16:o|16:o,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:a,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&Ct(e.ssContent),ssFallback:e.ssFallback&&Ct(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return a&&n&&sn(u,a.clone(u)),u}function Qe(e=" ",t=0){return le(vs,null,e,t)}function Yo(e,t){const s=le(ts,null,e);return s.staticCount=t,s}function fe(e="",t=!1){return t?(O(),ln(st,null,e)):le(st,null,e)}function Ke(e){return e==null||typeof e=="boolean"?le(st):F(e)?le(oe,null,e.slice()):zr(e)?Be(e):le(vs,null,String(e))}function Be(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:Ct(e)}function an(e,t){let s=0;const{shapeFlag:n}=e;if(t==null)t=null;else if(F(t))s=16;else if(typeof t=="object")if(n&65){const r=t.default;r&&(r._c&&(r._d=!1),an(e,r()),r._c&&(r._d=!0));return}else{s=32;const r=t._;!r&&!Nr(t)?t._ctx=Fe:r===3&&Fe&&(Fe.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else N(t)?(t={default:t,_ctx:Fe},s=32):(t=String(t),n&64?(s=16,t=[Qe(t)]):s=8);e.children=t,e.shapeFlag|=s}function Jo(...e){const t={};for(let s=0;sme||Fe;let cs,Ns;{const e=hs(),t=(s,n)=>{let r;return(r=e[s])||(r=e[s]=[]),r.push(n),i=>{r.length>1?r.forEach(o=>o(i)):r[0](i)}};cs=t("__VUE_INSTANCE_SETTERS__",s=>me=s),Ns=t("__VUE_SSR_SETTERS__",s=>Ut=s)}const Wt=e=>{const t=me;return cs(e),e.scope.on(),()=>{e.scope.off(),cs(t)}},Rn=()=>{me&&me.scope.off(),cs(null)};function Xr(e){return e.vnode.shapeFlag&4}let Ut=!1;function tl(e,t=!1,s=!1){t&&Ns(t);const{props:n,children:r}=e.vnode,i=Xr(e);Vo(e,n,i,t),ko(e,r,s||t);const o=i?sl(e,t):void 0;return t&&Ns(!1),o}function sl(e,t){const s=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,yo);const{setup:n}=s;if(n){Ye();const r=e.setupContext=n.length>1?rl(e):null,i=Wt(e),o=qt(n,e,0,[e.props,r]),l=Xn(o);if(Je(),i(),(l||e.sp)&&!Kt(e)&&Ir(e),l){if(o.then(Rn,Rn),t)return o.then(a=>{Mn(e,a)}).catch(a=>{ms(a,e,0)});e.asyncDep=o}else Mn(e,o)}else ei(e)}function Mn(e,t,s){N(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:J(t)&&(e.setupState=_r(t)),ei(e)}function ei(e,t,s){const n=e.type;e.render||(e.render=n.render||ke);{const r=Wt(e);Ye();try{_o(e)}finally{Je(),r()}}}const nl={get(e,t){return ue(e,"get",""),e[t]}};function rl(e){const t=s=>{e.exposed=s||{}};return{attrs:new Proxy(e.attrs,nl),slots:e.slots,emit:e.emit,expose:t}}function cn(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(_r(Gi(e.exposed)),{get(t,s){if(s in t)return t[s];if(s in $t)return $t[s](e)},has(t,s){return s in t||s in $t}})):e.proxy}function il(e){return N(e)&&"__vccOpts"in e}const nt=(e,t)=>qi(e,t,Ut),ol="3.5.30";/** +* @vue/runtime-dom v3.5.30 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let Gs;const On=typeof window<"u"&&window.trustedTypes;if(On)try{Gs=On.createPolicy("vue",{createHTML:e=>e})}catch{}const ti=Gs?e=>Gs.createHTML(e):e=>e,ll="http://www.w3.org/2000/svg",al="http://www.w3.org/1998/Math/MathML",Ue=typeof document<"u"?document:null,Dn=Ue&&Ue.createElement("template"),cl={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,n)=>{const r=t==="svg"?Ue.createElementNS(ll,e):t==="mathml"?Ue.createElementNS(al,e):s?Ue.createElement(e,{is:s}):Ue.createElement(e);return e==="select"&&n&&n.multiple!=null&&r.setAttribute("multiple",n.multiple),r},createText:e=>Ue.createTextNode(e),createComment:e=>Ue.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Ue.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,n,r,i){const o=s?s.previousSibling:t.lastChild;if(r&&(r===i||r.nextSibling))for(;t.insertBefore(r.cloneNode(!0),s),!(r===i||!(r=r.nextSibling)););else{Dn.innerHTML=ti(n==="svg"?`${e}`:n==="mathml"?`${e}`:e);const l=Dn.content;if(n==="svg"||n==="mathml"){const a=l.firstChild;for(;a.firstChild;)l.appendChild(a.firstChild);l.removeChild(a)}t.insertBefore(l,s)}return[o?o.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},ul=Symbol("_vtc");function fl(e,t,s){const n=e[ul];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const Vn=Symbol("_vod"),dl=Symbol("_vsh"),hl=Symbol(""),pl=/(?:^|;)\s*display\s*:/;function ml(e,t,s){const n=e.style,r=re(s);let i=!1;if(s&&!r){if(t)if(re(t))for(const o of t.split(";")){const l=o.slice(0,o.indexOf(":")).trim();s[l]==null&&ns(n,l,"")}else for(const o in t)s[o]==null&&ns(n,o,"");for(const o in s)o==="display"&&(i=!0),ns(n,o,s[o])}else if(r){if(t!==s){const o=n[hl];o&&(s+=";"+o),n.cssText=s,i=pl.test(s)}}else t&&e.removeAttribute("style");Vn in e&&(e[Vn]=i?n.display:"",e[dl]&&(n.display="none"))}const Kn=/\s*!important$/;function ns(e,t,s){if(F(s))s.forEach(n=>ns(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const n=bl(e,t);Kn.test(s)?e.setProperty(rt(n),s.replace(Kn,""),"important"):e[n]=s}}const $n=["Webkit","Moz","ms"],Ts={};function bl(e,t){const s=Ts[t];if(s)return s;let n=Ce(t);if(n!=="filter"&&n in e)return Ts[t]=n;n=sr(n);for(let r=0;r<$n.length;r++){const i=$n[r]+n;if(i in e)return Ts[t]=i}return t}const Fn="http://www.w3.org/1999/xlink";function kn(e,t,s,n,r,i=vi(t)){n&&t.startsWith("xlink:")?s==null?e.removeAttributeNS(Fn,t.slice(6,t.length)):e.setAttributeNS(Fn,t,s):s==null||i&&!rr(s)?e.removeAttribute(t):e.setAttribute(t,i?"":Ne(s)?String(s):s)}function Nn(e,t,s,n,r){if(t==="innerHTML"||t==="textContent"){s!=null&&(e[t]=t==="innerHTML"?ti(s):s);return}const i=e.tagName;if(t==="value"&&i!=="PROGRESS"&&!i.includes("-")){const l=i==="OPTION"?e.getAttribute("value")||"":e.value,a=s==null?e.type==="checkbox"?"on":"":String(s);(l!==a||!("_value"in e))&&(e.value=a),s==null&&e.removeAttribute(t),e._value=s;return}let o=!1;if(s===""||s==null){const l=typeof e[t];l==="boolean"?s=rr(s):s==null&&l==="string"?(s="",o=!0):l==="number"&&(s=0,o=!0)}try{e[t]=s}catch{}o&&e.removeAttribute(r||t)}function gl(e,t,s,n){e.addEventListener(t,s,n)}function vl(e,t,s,n){e.removeEventListener(t,s,n)}const Gn=Symbol("_vei");function Sl(e,t,s,n,r=null){const i=e[Gn]||(e[Gn]={}),o=i[t];if(n&&o)o.value=n;else{const[l,a]=yl(t);if(n){const d=i[t]=Al(n,r);gl(e,l,d,a)}else o&&(vl(e,l,o,a),i[t]=void 0)}}const Hn=/(?:Once|Passive|Capture)$/;function yl(e){let t;if(Hn.test(e)){t={};let n;for(;n=e.match(Hn);)e=e.slice(0,e.length-n[0].length),t[n[0].toLowerCase()]=!0}return[e[2]===":"?e.slice(3):rt(e.slice(2)),t]}let Is=0;const _l=Promise.resolve(),El=()=>Is||(_l.then(()=>Is=0),Is=Date.now());function Al(e,t){const s=n=>{if(!n._vts)n._vts=Date.now();else if(n._vts<=s.attached)return;Ge(wl(n,s.value),t,5,[n])};return s.value=e,s.attached=El(),s}function wl(e,t){if(F(t)){const s=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{s.call(e),e._stopped=!0},t.map(n=>r=>!r._stopped&&n&&n(r))}else return t}const jn=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Cl=(e,t,s,n,r,i)=>{const o=r==="svg";t==="class"?fl(e,n,o):t==="style"?ml(e,s,n):fs(t)?js(t)||Sl(e,t,s,n,i):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):xl(e,t,n,o))?(Nn(e,t,n),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&kn(e,t,n,o,i,t!=="value")):e._isVueCE&&(Ll(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!re(n)))?Nn(e,Ce(t),n,i,t):(t==="true-value"?e._trueValue=n:t==="false-value"&&(e._falseValue=n),kn(e,t,n,o))};function xl(e,t,s,n){if(n)return!!(t==="innerHTML"||t==="textContent"||t in e&&jn(t)&&N(s));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const r=e.tagName;if(r==="IMG"||r==="VIDEO"||r==="CANVAS"||r==="SOURCE")return!1}return jn(t)&&re(s)?!1:t in e}function Ll(e,t){const s=e._def.props;if(!s)return!1;const n=Ce(t);return Array.isArray(s)?s.some(r=>Ce(r)===n):Object.keys(s).some(r=>Ce(r)===n)}const Tl=["ctrl","shift","alt","meta"],Il={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>Tl.some(s=>e[`${s}Key`]&&!t.includes(s))},si=(e,t)=>{if(!e)return e;const s=e._withMods||(e._withMods={}),n=t.join(".");return s[n]||(s[n]=((r,...i)=>{for(let o=0;o{const s=e._withKeys||(e._withKeys={}),n=t.join(".");return s[n]||(s[n]=(r=>{if(!("key"in r))return;const i=rt(r.key);if(t.some(o=>o===i||Pl[o]===i))return e(r)}))},Rl=ae({patchProp:Cl},cl);let Bn;function Ml(){return Bn||(Bn=Go(Rl))}const Ol=((...e)=>{const t=Ml().createApp(...e),{mount:s}=t;return t.mount=n=>{const r=Vl(n);if(!r)return;const i=t._component;!N(i)&&!i.render&&!i.template&&(i.template=r.innerHTML),r.nodeType===1&&(r.textContent="");const o=s(r,!1,Dl(r));return r instanceof Element&&(r.removeAttribute("v-cloak"),r.setAttribute("data-v-app","")),o},t});function Dl(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function Vl(e){return re(e)?document.querySelector(e):e}const qn=12e4;async function Kl(e){const t=await fetch("/ready",{method:"GET",headers:{Accept:"application/json"},signal:e});if(!t.ok)throw new Error(`Service check failed (${t.status})`);return t.json()}async function $l(e,t){const s=new AbortController,n=setTimeout(()=>s.abort(),qn);t&&t.addEventListener("abort",()=>s.abort());try{const r=await fetch("/predict",{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify(e),signal:s.signal});if(!r.ok){let i="Prediction could not be completed.";try{const o=await r.json();o!=null&&o.detail&&(i=String(o.detail))}catch{}throw new Error(i)}return r.json()}catch(r){throw r instanceof DOMException&&r.name==="AbortError"?new Error(`Prediction timed out after ${Math.floor(qn/1e3)}s. This may be a cold-start delay โ€” try again.`):r}finally{clearTimeout(n)}}const vt=["kcat","km","ki"],Wn={kcat:"kcat",km:"Km",ki:"Ki"};function Fl(){const e=se("checking"),t=se(""),s=se("local"),n=se(!1),r=se(!1),i=se(!1),o=se(new Set(vt)),l=se(new Set(vt));function a(I){return o.value.has(I)}const d=nt(()=>{for(const I of vt)if(o.value.has(I))return I;return null});function u(){return s.value==="modal"&&n.value?"modal":s.value==="local"&&l.value.size>0?"local":n.value?"modal":r.value?"local":s.value||"local"}function h(I,P){return I!=="modal"||!i.value||!r.value?!1:l.value.has(P)}function p(I){return I?new Set(Object.keys(I).map(P=>P.toLowerCase()).filter(P=>vt.includes(P))):new Set(vt)}async function b(){var I,P,G,H,k;e.value="checking",t.value="";try{const E=await Kl();if(s.value=E.default_backend||"local",n.value=!!((P=(I=E.backends)==null?void 0:I.modal)!=null&&P.ready),r.value=!!((H=(G=E.backends)==null?void 0:G.local)!=null&&H.ready),i.value=!!E.fallback_to_local_enabled,l.value=p((k=E.api)==null?void 0:k.available_checkpoints),l.value.size>0?o.value=new Set(l.value):n.value?o.value=new Set(vt):o.value=new Set,E.ready){e.value="online";const C=Array.from(l.value).join(", ");t.value=C?`${E.default_backend} ยท ${C}`:`${E.default_backend} ยท no local checkpoints`}else e.value="limited",t.value=n.value?"Backend available in limited mode":"No local checkpoints found"}catch{e.value="offline",t.value="Could not contact service"}}return{status:e,statusHint:t,availableCheckpoints:o,firstAvailable:d,isParameterAvailable:a,chooseBackend:u,shouldFallback:h,check:b}}const ni="catpred:lastResults";function kl(){try{const e=localStorage.getItem(ni);if(!e)return[];const t=JSON.parse(e);return Array.isArray(t)?t:[]}catch{return[]}}function Nl(e){try{localStorage.setItem(ni,JSON.stringify(e))}catch{}}function Ae(e){const s=Object.keys(e).find(i=>i.startsWith("Prediction_(")),n=s==null?void 0:s.match(/^Prediction_\((.*)\)$/),r=n?n[1]:"";return{linear:s?e[s]:null,linearKey:s||"Prediction",unit:r,log10:e.Prediction_log10??null,sdTotal:e.SD_total??null,sdAleatoric:e.SD_aleatoric??null,sdEpistemic:e.SD_epistemic??null}}const Gl={0:"โฐ",1:"ยน",2:"ยฒ",3:"ยณ",4:"โด",5:"โต",6:"โถ",7:"โท",8:"โธ",9:"โน","-":"โป","+":"โบ"};function Qn(e){return e?e.replace(/\^(?:\(([^)]+)\)|([0-9+-]+))/g,(t,s,n)=>[...s??n].map(i=>Gl[i]??i).join("")):""}function ht(e){const t=Number(e);return Number.isFinite(t)?t.toFixed(1):"โ€”"}function Ps(e,t){if(e===null||t===null||!Number.isFinite(e)||!Number.isFinite(t))return null;const s=Math.pow(10,e-t),n=Math.pow(10,e+t);return[ht(s),ht(n)]}function Yn(e){const t=Math.max(0,Math.floor(e)),s=Math.floor(t/60),n=t%60;return`${String(s).padStart(2,"0")}:${String(n).padStart(2,"0")}`}function Hl(){const e=se("idle"),t=se(""),s=se(kl()),n=se(0),r=se(null);let i=0,o=null,l=null;const a=nt(()=>e.value==="running"),d=nt(()=>s.value.length>0);function u(){h(),i=Date.now(),n.value=0,o=setInterval(()=>{n.value=Math.floor((Date.now()-i)/1e3)},1e3)}function h(){o&&(clearInterval(o),o=null)}async function p(P){l==null||l.abort(),l=new AbortController,r.value=P,e.value="running",t.value="",s.value=[],u();try{const G=await Promise.all(P.map(async H=>{const k=await $l(H.payload,l.signal);return{parameter:H.parameter,response:k}}));s.value=G,Nl(G),e.value="success"}catch(G){t.value=G instanceof Error?G.message:"Prediction failed.",e.value="error"}finally{h(),l=null}}function b(){r.value&&p(r.value)}function I(){l==null||l.abort()}return{status:e,error:t,results:s,elapsedSeconds:n,lastJobs:r,isRunning:a,hasResults:d,runAll:p,retry:b,cancel:I}}const jl="MLDDRARMEAAKKEKVEQILAEFQLQEEDLKKVMRRMQKEMDRGLRLETHEEASVKMLPTYVRSTPEGSEVGDFLSLDLGGTNFRVMLVKVGEGEEGQWSVKTKHQMYSIPEDAMTGTAEMLFDYISECISDFLDKHQMKHKKLPLGFTFSFPVRHEDIDKGILLNWTKGFKASGAEGNNVVGLLRDAIKRRGDFEMDVVAMVNDTVATMISCYYEDHQCEVGMIVGTGCNACYMEEMQNVELVEGDEGRMCVNTEWGAFGDSGELDEFLLEYDRLVDESSANPGQQLYEKLIGGKYMGELVRLVLLRLVDENLLFHGEASEQLRTRGAFETRFVSQVESDTGDRKQIYNILSTLGLRPSTTDCDIVRRACESVSTRAAHMCSAGLAGVINRMRESRSEDVMRITVGVDGSVYKLHPSFKERFHASVRRLTPSCEITFIESEEGSGRGAALVSAVACKKACMLGQ",Ul="MATLKDQLIYNLLKEEQTPQNKITVVGVGAVGMACAISILMKDLADELALVDVIEDKLKGEMMDLQHGSLFLRTPKIVSGKDYNVTANSKLVIITAGARQQEGESRLNLVQRNVNIFKFIIPNVVKYSPNCKLLIVSNPVDILTYVAWKISGFPKNRVIGSGCNLDSARFRYLMGERLGVHPLSCHGWVLGEHGDSSVPVWSGMNVAGVSLKTLHPDLGTDKDKEQWKEVHKQVVESAYEVIKLKGYTSWAIGLSVADLAESIMKNLRRVHPVSTMIKGLYGIKDDVFLSVPCILGQNGISDLVKVTLTSEEEARLKKSADTLWGIQKELQF",Bl="MAPSLDSISHSFANGVASAKQAVNGASTNLAVAGSHLPTTQVTQVDIVEKMLAAPTDSTLELDGYSLNLGDVVSAARKGRPVRVKDSDEIRSKIDKSVEFLRSQLSMSVYGVTTGFGGSADTRTEDAISLQKALLEHQLCGVLPSSFDSFRLGRGLENSLPLEVVRGAMTIRVNSLTRGHSAVRLVVLEALTNFLNHGITPIVPLRGTISASGDLSPLSYIAAAISGHPDSKVHVVHEGKEKILYAREAMALFNLEPVVLGPKEGLGLVNGTAVSASMATLALHDAHMLSLLSQSLTAMTVEAMVGHAGSFHPFLHDVTRPHPTQIEVAGNIRKLLEGSRFAVHHEEEVKVKDDEGILRQDRYPLRTSPQWLGPLVSDLIHAHAVLTIEAGQSTTDNPLIDVENKTSHHGGNFQAAAVANTMEKTRLGLAQIGKLNFTQLTEMLNAGMNRGLPSCLAAEDPSLSYHCKGLDIAAAAYTSELGHLANPVTTHVQPAEMANQAVNSLALISARRTTESNDVLSLLLATHLYCVLQAIDLRAIEFEFKKQFGPAIVSLIDQHFGSAMTGSNLRDELVEKVNKTLAKRLEQTNSYDLVPRWHDAFSFAAGTVVEVLSSTSLSLAAVNAWKVAAAESAISLTRQVRETFWSAASTSSPALSYLSPRTQILYAFVREELGVKARRGDVFLGKQEVTIGSNVSKIYEAIKSGRINNVLLKMLA",ql="C(C1C(C(C(C(O1)O)O)O)O)O",Wl="C1=NC(=C2C(=N1)N(C=N2)C3C(C(C(O3)COP(=O)(O)OP(=O)(O)OP(=O)(O)O)O)O)N",Ql="CC(=O)C(=O)O",Yl="C1C=CN(C=C1C(=O)N)C2C(C(C(O2)COP(=O)(O)OP(=O)(O)OCC3C(C(C(O3)N4C=NC5=C(N=CN=C54)N)O)O)O)O",Jl="C1=CC(=CC=C1/C=C/C(=O)O)O",Jn=[{substrates:[{id:1,smiles:ql,isPrimary:!0},{id:2,smiles:Wl,isPrimary:!1}],inhibitorSmiles:"",sequence:jl,pdbpath:"GCK_HUMAN"},{substrates:[{id:1,smiles:Ql,isPrimary:!0},{id:2,smiles:Yl,isPrimary:!1}],inhibitorSmiles:"",sequence:Ul,pdbpath:"LDHA_HUMAN"}],zl=[{substrates:[],inhibitorSmiles:Jl,sequence:Bl,pdbpath:"P11544"}],Zl=/^[A-Za-z0-9@+\-\[\]\\\/().=#%$:~&!*]+$/,Xl=new Set("ACDEFGHIKLMNPQRSTVWY".split(""));function us(e){return e.trim()?Zl.test(e.trim())?"":"Invalid SMILES characters.":"SMILES is required."}function ri(e){if(!e.trim())return"Sequence is required.";const t=e.trim().toUpperCase();for(const s of t)if(!Xl.has(s))return`Invalid amino acid: "${s}".`;return""}function ii(e){return e.trim()?"":"Sequence ID is required."}function ea(){let e=1,t=100;const s=se([]);function n(E){return`seq_${String(E).padStart(3,"0")}`}function r(){let E=0;for(const C of s.value){const T=C.pdbpath.match(/^seq_(\d+)$/i);T&&(E=Math.max(E,Number(T[1])))}return E>0?n(E+1):n(s.value.length+1)}function i(E="",C=!1){return{id:t++,smiles:E,isPrimary:C}}function o(E){var T;const C=(T=E==null?void 0:E.substrates)!=null&&T.length?E.substrates.map(D=>i(D.smiles,D.isPrimary)):[i("",!0)];s.value.push({id:e++,substrates:C,inhibitorSmiles:(E==null?void 0:E.inhibitorSmiles)??"",sequence:(E==null?void 0:E.sequence)??"",pdbpath:(E==null?void 0:E.pdbpath)||r()})}function l(E){s.value.length<=1||(s.value=s.value.filter(C=>C.id!==E))}function a(E,C,T){const D=s.value.find(B=>B.id===E);D&&(D[C]=T)}function d(E){const C=s.value.find(T=>T.id===E);C&&C.substrates.push(i("",!1))}function u(E,C){var B;const T=s.value.find(U=>U.id===E);if(!T||T.substrates.length<=1)return;const D=(B=T.substrates.find(U=>U.id===C))==null?void 0:B.isPrimary;T.substrates=T.substrates.filter(U=>U.id!==C),D&&T.substrates.length>0&&(T.substrates[0].isPrimary=!0)}function h(E,C,T){const D=s.value.find(U=>U.id===E);if(!D)return;const B=D.substrates.find(U=>U.id===C);B&&(B.smiles=T)}function p(E,C){const T=s.value.find(D=>D.id===E);if(T)for(const D of T.substrates)D.isPrimary=D.id===C}function b(E){s.value=[],e=1,t=100;const C=E==="substrate"?Jn:zl;for(const T of C)o(T)}function I(){s.value=[],e=1,t=100,o()}function P(E,C){const T=E.trim().split(` +`);if(T.length<2)return"CSV must have a header row and at least one data row.";const D=T[0].split(",").map(ne=>ne.trim()),B=D.findIndex(ne=>ne.toLowerCase()==="smiles"),U=D.findIndex(ne=>ne.toLowerCase()==="sequence"),ce=D.findIndex(ne=>ne.toLowerCase()==="pdbpath");if(B===-1||U===-1)return"CSV must have SMILES and sequence columns.";const be=[];for(let ne=1;nebt.trim());if(!Te[B]&&!Te[U])continue;const it=Te[B]||"",ot={sequence:Te[U]||"",pdbpath:Te[ce]||n(ne)};if(C==="substrate"){const bt=it.split(".");ot.substrates=bt.map((ie,z)=>({id:z+1,smiles:ie,isPrimary:z===0}))}else ot.inhibitorSmiles=it;be.push(ot)}if(be.length===0)return"No valid data rows found.";s.value=[],e=1,t=100;for(const ne of be)o(ne);return""}function G(E,C){return s.value.filter(T=>E==="substrate"?T.substrates.some(D=>D.smiles.trim())&&T.sequence.trim()&&T.pdbpath.trim():T.inhibitorSmiles.trim()&&T.sequence.trim()&&T.pdbpath.trim()).map(T=>{var B;let D;if(E==="inhibition")D=T.inhibitorSmiles.trim();else if(C==="km"){const U=T.substrates.find(ce=>ce.isPrimary);D=U?U.smiles.trim():((B=T.substrates[0])==null?void 0:B.smiles.trim())||""}else D=T.substrates.filter(U=>U.smiles.trim()).map(U=>U.smiles.trim()).join(".");return{SMILES:D,sequence:T.sequence.trim().toUpperCase(),pdbpath:T.pdbpath.trim()}})}function H(E){if(s.value.length===0)return"Please add at least one entry.";for(const D of s.value){if(E==="substrate"){if(!D.substrates.some(ce=>ce.smiles.trim()))return"Each entry needs at least one substrate SMILES.";for(const ce of D.substrates)if(ce.smiles.trim()){const be=us(ce.smiles);if(be)return be}}else{const ce=us(D.inhibitorSmiles);if(ce)return`Inhibitor: ${ce}`}const B=ri(D.sequence);if(B)return B;const U=ii(D.pdbpath);if(U)return U}const C=E==="substrate"?G(E,"kcat"):G(E,"ki"),T=new Map;for(const D of C){const B=T.get(D.pdbpath);if(B&&B!==D.sequence)return"Each Sequence ID must map to one unique enzyme sequence.";T.set(D.pdbpath,D.sequence)}return""}const k=nt(()=>s.value.length);return o(Jn[0]),{rows:s,rowCount:k,addRow:o,removeRow:l,updateField:a,addSubstrate:d,removeSubstrate:u,updateSubstrateSmiles:h,setPrimary:p,loadSample:b,clear:I,importCsv:P,collectRowsForParameter:G,validateAll:H}}const He=(e,t)=>{const s=e.__vccOpts||e;for(const[n,r]of t)s[n]=r;return s},ta={},sa={href:"#main-content",class:"skip-link"};function na(e,t){return O(),K("a",sa,"Skip to content")}const ra=He(ta,[["render",na],["__scopeId","data-v-b3acbf9d"]]),ia={class:"footer"},oa={class:"container footer-inner"},la={key:0,class:"visit-count"},aa=Ze({__name:"AppFooter",setup(e){const t=se(null);return nn(async()=>{try{const s=await fetch("https://catpred.goatcounter.com/counter/TOTAL.json");if(s.ok){const n=await s.json();t.value=n.count_unique??n.count??null}}catch{}}),(s,n)=>(O(),K("footer",ia,[_("div",oa,[n[0]||(n[0]=_("p",{class:"footer-text"},[_("span",{class:"footer-brand"},"CatPred"),_("span",{class:"footer-sep"},"ยท"),Qe(" Developed in "),_("a",{href:"https://www.maranasgroup.com/",target:"_blank",rel:"noreferrer",class:"footer-link"},"Maranas Group"),Qe(" at Penn State ")],-1)),t.value?(O(),K("p",la,q(t.value)+" visits",1)):fe("",!0)])]))}}),ca=He(aa,[["__scopeId","data-v-d5b7fe41"]]),ua={class:"mode-selector"},fa={class:"mode-pills",role:"radiogroup","aria-label":"Prediction mode"},da=["aria-checked","disabled","tabindex"],ha=["aria-checked","disabled","tabindex"],pa={key:0,class:"param-checks"},ma=["checked","disabled"],ba=["checked","disabled"],ga=Ze({__name:"ParameterSelector",props:{mode:{},predictKcat:{type:Boolean},predictKm:{type:Boolean},available:{}},emits:["update:mode","update:predictKcat","update:predictKm"],setup(e,{emit:t}){const s=e,n=t,r=nt(()=>s.available.has("kcat")||s.available.has("km")),i=nt(()=>s.available.has("ki"));function o(d){d==="substrate"&&!r.value||d==="inhibition"&&!i.value||n("update:mode",d)}function l(){const d=!s.predictKcat;!d&&!s.predictKm||n("update:predictKcat",d)}function a(){const d=!s.predictKm;!d&&!s.predictKcat||n("update:predictKm",d)}return(d,u)=>(O(),K("div",ua,[_("div",fa,[_("button",{type:"button",role:"radio","aria-checked":e.mode==="substrate",disabled:!r.value,class:We(["mode-pill","mode-substrate",{active:e.mode==="substrate"}]),tabindex:e.mode==="substrate"?0:-1,onClick:u[0]||(u[0]=h=>o("substrate"))},"kcat / Km",10,da),_("button",{type:"button",role:"radio","aria-checked":e.mode==="inhibition",disabled:!i.value,class:We(["mode-pill","mode-inhibition",{active:e.mode==="inhibition"}]),tabindex:e.mode==="inhibition"?0:-1,onClick:u[1]||(u[1]=h=>o("inhibition"))},"Ki",10,ha)]),e.mode==="substrate"?(O(),K("div",pa,[_("label",{class:We(["check-label",{disabled:!e.available.has("kcat")}])},[_("input",{type:"checkbox",checked:e.predictKcat,disabled:!e.available.has("kcat")||e.predictKcat&&!e.predictKm,onChange:l},null,40,ma),u[2]||(u[2]=_("span",null,"kcat",-1))],2),_("label",{class:We(["check-label",{disabled:!e.available.has("km")}])},[_("input",{type:"checkbox",checked:e.predictKm,disabled:!e.available.has("km")||e.predictKm&&!e.predictKcat,onChange:a},null,40,ba),u[3]||(u[3]=_("span",null,"Km",-1))],2)])):fe("",!0)]))}}),va=He(ga,[["__scopeId","data-v-06223ab0"]]),Sa={class:"substrate-inputs"},ya={class:"substrate-list"},_a=["title"],Ea=["name","checked","onChange"],Aa={class:"primary-label"},wa={class:"sub-field"},Ca=["value","aria-invalid","onInput","onBlur"],xa={key:0,class:"field-error",role:"alert"},La=["onClick"],Ta=Ze({__name:"SubstrateInputs",props:{substrates:{},rowId:{}},emits:["addSubstrate","removeSubstrate","updateSmiles","setPrimary"],setup(e,{emit:t}){const s=t,n=se({});function r(i){i.smiles.trim()?n.value[i.id]=us(i.smiles):n.value[i.id]=""}return(i,o)=>(O(),K("div",Sa,[o[1]||(o[1]=_("label",{class:"field-label"},"Substrates",-1)),_("div",ya,[(O(!0),K(oe,null,dt(e.substrates,l=>(O(),K("div",{key:l.id,class:"substrate-row"},[_("label",{class:"primary-radio",title:l.isPrimary?"Primary substrate (used for Km)":"Set as primary (for Km)"},[_("input",{type:"radio",name:`primary-${e.rowId}`,checked:l.isPrimary,onChange:a=>s("setPrimary",e.rowId,l.id)},null,40,Ea),_("span",Aa,q(l.isPrimary?"Primary":""),1)],8,_a),_("div",wa,[_("input",{type:"text",value:l.smiles,placeholder:"SMILES","aria-label":"Substrate SMILES","aria-invalid":n.value[l.id]?"true":void 0,onInput:a=>s("updateSmiles",e.rowId,l.id,a.target.value),onBlur:a=>r(l)},null,40,Ca),n.value[l.id]?(O(),K("p",xa,q(n.value[l.id]),1)):fe("",!0)]),e.substrates.length>1?(O(),K("button",{key:0,type:"button",class:"remove-sub-btn","aria-label":"Remove substrate",onClick:a=>s("removeSubstrate",e.rowId,l.id)},"ร—",8,La)):fe("",!0)]))),128))]),_("button",{type:"button",class:"add-sub-btn",onClick:o[0]||(o[0]=l=>s("addSubstrate",e.rowId))},"+ Add substrate")]))}}),Ia=He(Ta,[["__scopeId","data-v-812d7d46"]]),Pa={class:"row-item"},Ra={key:0,class:"row-head"},Ma={class:"row-title"},Oa=["aria-label"],Da={class:"row-fields"},Va={class:"field"},Ka=["for"],$a=["id","value","aria-invalid","aria-describedby"],Fa=["id"],ka={class:"field"},Na=["for"],Ga=["id","value","aria-invalid","aria-describedby"],Ha=["id"],ja={key:1,class:"field"},Ua=["for"],Ba=["id","value","aria-invalid","aria-describedby"],qa=["id"],Wa=Ze({__name:"InputRow",props:{row:{},mode:{},index:{},showHeader:{type:Boolean},canRemove:{type:Boolean}},emits:["remove","updateField","addSubstrate","removeSubstrate","updateSubstrateSmiles","setPrimary"],setup(e,{emit:t}){const s=t,n=se(""),r=se(""),i=se("");function o(d){const u=d.target.value;n.value=us(u)}function l(d){const u=d.target.value;r.value=ri(u)}function a(d){const u=d.target.value;i.value=ii(u)}return(d,u)=>(O(),K("article",Pa,[e.showHeader?(O(),K("div",Ra,[_("h4",Ma,"Entry "+q(e.index+1),1),e.canRemove?(O(),K("button",{key:0,type:"button",class:"remove-btn","aria-label":`Remove entry ${e.index+1}`,onClick:u[0]||(u[0]=h=>s("remove",e.row.id))},"Remove",8,Oa)):fe("",!0)])):fe("",!0),_("div",Da,[_("div",Va,[_("label",{for:`seq-${e.row.id}`,class:"field-label"},"Enzyme sequence",8,Ka),_("textarea",{id:`seq-${e.row.id}`,value:e.row.sequence,rows:"2",placeholder:"ACDEFGHIK",required:"","aria-invalid":r.value?"true":void 0,"aria-describedby":r.value?`seq-err-${e.row.id}`:void 0,onInput:u[1]||(u[1]=h=>s("updateField",e.row.id,"sequence",h.target.value)),onBlur:l},null,40,$a),r.value?(O(),K("p",{key:0,id:`seq-err-${e.row.id}`,class:"field-error",role:"alert"},q(r.value),9,Fa)):fe("",!0)]),_("div",ka,[_("label",{for:`pdbpath-${e.row.id}`,class:"field-label"},"Sequence ID",8,Na),_("input",{id:`pdbpath-${e.row.id}`,type:"text",value:e.row.pdbpath,placeholder:"seq_001",required:"","aria-invalid":i.value?"true":void 0,"aria-describedby":i.value?`pdbpath-err-${e.row.id}`:void 0,onInput:u[2]||(u[2]=h=>s("updateField",e.row.id,"pdbpath",h.target.value)),onBlur:a},null,40,Ga),i.value?(O(),K("p",{key:0,id:`pdbpath-err-${e.row.id}`,class:"field-error",role:"alert"},q(i.value),9,Ha)):fe("",!0)]),e.mode==="substrate"?(O(),ln(Ia,{key:0,substrates:e.row.substrates,"row-id":e.row.id,onAddSubstrate:u[3]||(u[3]=h=>s("addSubstrate",h)),onRemoveSubstrate:u[4]||(u[4]=(h,p)=>s("removeSubstrate",h,p)),onUpdateSmiles:u[5]||(u[5]=(h,p,b)=>s("updateSubstrateSmiles",h,p,b)),onSetPrimary:u[6]||(u[6]=(h,p)=>s("setPrimary",h,p))},null,8,["substrates","row-id"])):(O(),K("div",ja,[_("label",{for:`inhibitor-${e.row.id}`,class:"field-label"},"Inhibitor (SMILES)",8,Ua),_("input",{id:`inhibitor-${e.row.id}`,type:"text",value:e.row.inhibitorSmiles,placeholder:"CCO",required:"","aria-invalid":n.value?"true":void 0,"aria-describedby":n.value?`inhibitor-err-${e.row.id}`:void 0,onInput:u[7]||(u[7]=h=>s("updateField",e.row.id,"inhibitorSmiles",h.target.value)),onBlur:o},null,40,Ba),n.value?(O(),K("p",{key:0,id:`inhibitor-err-${e.row.id}`,class:"field-error",role:"alert"},q(n.value),9,qa)):fe("",!0)]))])]))}}),Qa=He(Wa,[["__scopeId","data-v-d4696ebf"]]),Ya=["onKeydown"],Ja=Ze({__name:"BatchUpload",emits:["import"],setup(e,{emit:t}){const s=t,n=se(!1),r=se(null);function i(p){p.preventDefault(),n.value=!0}function o(){n.value=!1}function l(p){var I;p.preventDefault(),n.value=!1;const b=(I=p.dataTransfer)==null?void 0:I.files[0];b&&d(b)}function a(p){var I;const b=(I=p.target.files)==null?void 0:I[0];b&&d(b)}function d(p){if(!p.name.endsWith(".csv")&&p.type!=="text/csv")return;const b=new FileReader;b.onload=()=>{typeof b.result=="string"&&s("import",b.result)},b.readAsText(p)}function u(p){var I;const b=(I=p.clipboardData)==null?void 0:I.getData("text/plain");b&&b.includes(",")&&b.includes(` +`)&&(p.preventDefault(),s("import",b))}function h(){var p;(p=r.value)==null||p.click()}return(p,b)=>(O(),K("div",{class:We(["upload-zone",{dragging:n.value}]),onDragover:i,onDragleave:o,onDrop:l,onPaste:u,tabindex:"0",role:"button","aria-label":"Upload CSV file or paste CSV data",onClick:h,onKeydown:[Un(h,["enter"]),Un(si(h,["prevent"]),["space"])]},[_("input",{ref_key:"fileInput",ref:r,type:"file",accept:".csv,text/csv",class:"sr-only",onChange:a},null,544),b[0]||(b[0]=_("p",{class:"upload-text"},[_("strong",null,"Drop CSV here"),Qe(" or click to browse ")],-1)),b[1]||(b[1]=_("p",{class:"upload-hint"},"Columns: SMILES, sequence, pdbpath",-1))],42,Ya))}}),za=He(Ja,[["__scopeId","data-v-bd35de81"]]),Za=["aria-busy"],Xa={class:"rows-list"},ec={class:"actions"},tc=["disabled"],sc=["disabled"],nc=["disabled","aria-busy"],rc={key:0,class:"spinner","aria-hidden":"true"},ic={key:0,class:"helper-text"},oc={key:1,class:"helper-text"},lc=Ze({__name:"InputPanel",props:{rows:{},mode:{},disabled:{type:Boolean}},emits:["addRow","removeRow","updateField","addSubstrate","removeSubstrate","updateSubstrateSmiles","setPrimary","loadSample","importCsv","submit"],setup(e,{emit:t}){const s=t;return(n,r)=>(O(),K("form",{class:"input-panel",novalidate:"","aria-busy":e.disabled,onSubmit:r[9]||(r[9]=si(i=>s("submit"),["prevent"]))},[r[11]||(r[11]=_("h3",{class:"panel-title"},"Inputs",-1)),_("div",Xa,[(O(!0),K(oe,null,dt(e.rows,(i,o)=>(O(),ln(Qa,{key:i.id,row:i,mode:e.mode,index:o,"show-header":e.rows.length>1,"can-remove":e.rows.length>1,onRemove:r[0]||(r[0]=l=>s("removeRow",l)),onUpdateField:r[1]||(r[1]=(l,a,d)=>s("updateField",l,a,d)),onAddSubstrate:r[2]||(r[2]=l=>s("addSubstrate",l)),onRemoveSubstrate:r[3]||(r[3]=(l,a)=>s("removeSubstrate",l,a)),onUpdateSubstrateSmiles:r[4]||(r[4]=(l,a,d)=>s("updateSubstrateSmiles",l,a,d)),onSetPrimary:r[5]||(r[5]=(l,a)=>s("setPrimary",l,a))},null,8,["row","mode","index","show-header","can-remove"]))),128))]),le(za,{onImport:r[6]||(r[6]=i=>s("importCsv",i))}),_("div",ec,[_("button",{type:"button",class:"btn btn-ghost",disabled:e.disabled,onClick:r[7]||(r[7]=i=>s("addRow"))},"Add row",8,tc),_("button",{type:"button",class:"btn btn-ghost",disabled:e.disabled,onClick:r[8]||(r[8]=i=>s("loadSample"))},"Load sample",8,sc),_("button",{type:"submit",class:"btn btn-primary",disabled:e.disabled,"aria-busy":e.disabled},[e.disabled?(O(),K("span",rc)):fe("",!0),Qe(" "+q(e.disabled?"Running...":"Run prediction"),1)],8,nc)]),e.mode==="substrate"?(O(),K("p",ic,[...r[10]||(r[10]=[Qe(" Add substrates per entry. The ",-1),_("strong",null,"primary",-1),Qe(" substrate is used for Km; all substrates (joined) are used for kcat. ",-1)])])):(O(),K("p",oc," Enter the inhibitor compound SMILES, enzyme sequence, and a Sequence ID. "))],40,Za))}}),ac=He(lc,[["__scopeId","data-v-fe6ff3ab"]]),cc={class:"status-bar",role:"status","aria-live":"polite"},uc={class:"status-row"},fc={key:0,class:"status-hint"},dc={key:0,class:"status-row prediction-row"},hc={key:0,class:"status-running"},pc={key:1,class:"status-success"},mc={key:2,class:"status-error"},bc=Ze({__name:"StatusBar",props:{serviceStatus:{},serviceHint:{},predictionStatus:{},predictionError:{},elapsed:{},canRetry:{type:Boolean}},emits:["retry"],setup(e,{emit:t}){const s=t;return(n,r)=>(O(),K("div",cc,[_("div",uc,[r[1]||(r[1]=_("span",{class:"status-label"},"Service",-1)),_("span",{class:We(["badge",e.serviceStatus])},q(e.serviceStatus==="checking"?"Checking...":e.serviceStatus==="online"?"Online":e.serviceStatus==="limited"?"Limited":"Offline"),3),e.serviceHint?(O(),K("span",fc,q(e.serviceHint),1)):fe("",!0)]),e.predictionStatus!=="idle"?(O(),K("div",dc,[e.predictionStatus==="running"?(O(),K("span",hc,[r[2]||(r[2]=_("span",{class:"spinner","aria-hidden":"true"},null,-1)),Qe(" Running "+q(V(Yn)(e.elapsed)),1)])):e.predictionStatus==="success"?(O(),K("span",pc," Prediction complete ยท "+q(V(Yn)(e.elapsed)),1)):e.predictionStatus==="error"?(O(),K("span",mc,[Qe(q(e.predictionError||"Prediction failed")+" ",1),e.canRetry?(O(),K("button",{key:0,type:"button",class:"retry-btn",onClick:r[0]||(r[0]=i=>s("retry"))},"Retry")):fe("",!0)])):fe("",!0)])):fe("",!0)]))}}),gc=He(bc,[["__scopeId","data-v-d5b2b2e6"]]),vc={class:"result-panel","aria-live":"polite"},Sc={key:0,class:"empty-state"},yc={key:1,class:"result-groups"},_c={class:"group-head"},Ec=["onClick"],Ac={class:"result-cards"},wc={class:"card-head"},Cc={class:"card-main"},xc={class:"card-unit"},Lc={key:0,class:"card-range"},Tc={class:"metrics"},Ic={class:"metric"},Pc={class:"metric"},Rc={class:"metric"},Mc={class:"card-meta"},Oc={class:"details-table"},Dc={class:"table-wrap"},Vc=Ze({__name:"ResultPanel",props:{results:{}},setup(e){const t=e,s=nt(()=>t.results.some(a=>a.response.preview_rows.length>0));function n(a,d){const u=String(a??"");return u.length<=d?u:u.slice(0,d-1)+"โ€ฆ"}function r(a){return a==null?"":typeof a=="number"?ht(a):String(a)}function i(a){return{kcat:"var(--color-kcat)",km:"var(--color-km)",ki:"var(--color-ki)"}[a]}function o(a){const d=a.response.preview_rows;if(!d.length)return;const u=Object.keys(d[0]),h=u.join(","),p=d.map(H=>u.map(k=>{const E=H[k];return E==null?"":String(E)}).join(",")),b=[h,...p].join(` +`),I=new Blob([b],{type:"text/csv;charset=utf-8"}),P=URL.createObjectURL(I),G=document.createElement("a");G.href=P,G.download=`catpred-${a.parameter}-results.csv`,G.click(),URL.revokeObjectURL(P)}function l(a){const d=a.response.preview_rows;return d.length?Object.keys(d[0]):[]}return(a,d)=>(O(),K("section",vc,[d[4]||(d[4]=_("div",{class:"panel-head"},[_("h3",{class:"panel-title"},"Results")],-1)),s.value?(O(),K("div",yc,[(O(!0),K(oe,null,dt(e.results,u=>(O(),K("div",{key:u.parameter,class:"result-group"},[_("div",_c,[_("span",{class:"param-chip",style:kt({color:i(u.parameter),borderColor:i(u.parameter)})},q(V(Wn)[u.parameter]),5),_("button",{type:"button",class:"export-btn",onClick:h=>o(u)},"Export CSV",8,Ec)]),_("div",Ac,[(O(!0),K(oe,null,dt(u.response.preview_rows,(h,p)=>(O(),K("article",{key:p,class:"result-card"},[_("div",wc,[_("h4",null,"Result "+q(p+1),1)]),_("div",Cc,[_("strong",{style:kt({color:i(u.parameter)})},q(V(ht)(V(Ae)(h).linear)),5),_("span",xc,q(V(Qn)(V(Ae)(h).unit)||"predicted unit"),1)]),V(Ps)(V(Ae)(h).log10,V(Ae)(h).sdTotal)?(O(),K("p",Lc," ยฑ1 SD range: "+q(V(Ps)(V(Ae)(h).log10,V(Ae)(h).sdTotal)[0])+" โ€“ "+q(V(Ps)(V(Ae)(h).log10,V(Ae)(h).sdTotal)[1])+" "+q(V(Qn)(V(Ae)(h).unit)),1)):fe("",!0),_("dl",Tc,[_("div",Ic,[d[1]||(d[1]=_("dt",null,"logโ‚โ‚€",-1)),_("dd",null,q(V(ht)(V(Ae)(h).log10)),1)]),_("div",Pc,[d[2]||(d[2]=_("dt",null,"SD total (logโ‚โ‚€)",-1)),_("dd",null,q(V(ht)(V(Ae)(h).sdTotal)),1)]),_("div",Rc,[d[3]||(d[3]=_("dt",null,"SD epistemic (logโ‚โ‚€)",-1)),_("dd",null,q(V(ht)(V(Ae)(h).sdEpistemic)),1)])]),_("div",Mc,[_("span",null,"SMILES: "+q(n(h.SMILES,24)),1),_("span",null,"ID: "+q(h.pdbpath||"โ€”"),1)])]))),128))]),_("details",Oc,[_("summary",null,"View detailed "+q(V(Wn)[u.parameter])+" output table",1),_("div",Dc,[_("table",null,[_("thead",null,[_("tr",null,[(O(!0),K(oe,null,dt(l(u),h=>(O(),K("th",{key:h},q(h),1))),128))])]),_("tbody",null,[(O(!0),K(oe,null,dt(u.response.preview_rows,(h,p)=>(O(),K("tr",{key:p},[(O(!0),K(oe,null,dt(l(u),b=>(O(),K("td",{key:b},q(r(h[b])),1))),128))]))),128))])])])])]))),128))])):(O(),K("div",Sc,[...d[0]||(d[0]=[_("svg",{class:"empty-icon",viewBox:"0 0 48 48",width:"48",height:"48",fill:"none","aria-hidden":"true"},[_("circle",{cx:"24",cy:"24",r:"18",stroke:"currentColor","stroke-width":"1","stroke-dasharray":"4 3",opacity:"0.4"}),_("path",{d:"M17 30c2-4.5 4-8 7-10 3 2 5 5.5 7 10",stroke:"currentColor","stroke-width":"1.2","stroke-linecap":"round",opacity:"0.35"}),_("circle",{cx:"24",cy:"18",r:"2.5",stroke:"currentColor","stroke-width":"1",opacity:"0.3"})],-1),_("p",null,"Run a prediction to see results here.",-1)])]))]))}}),Kc=He(Vc,[["__scopeId","data-v-7efc8274"]]),$c={id:"main-content",class:"main"},Fc={class:"container"},kc={class:"page-header"},Nc={class:"param-row"},Gc={key:0,class:"form-error",role:"alert"},Hc={class:"grid"},jc=Ze({__name:"App",setup(e){const t=Fl(),s=Hl(),n=ea(),r=se("substrate"),i=se(!0),o=se(!0),l=se("");nn(()=>{t.check()}),es(r,()=>{n.clear(),l.value=""});function a(h,p){const b=t.chooseBackend();return{parameter:h,checkpoint_dir:h,input_rows:p,use_gpu:!1,results_dir:"web-app",backend:b,fallback_to_local:t.shouldFallback(b,h)}}function d(){l.value="";const h=n.validateAll(r.value);if(h){l.value=h;return}const p=[];if(r.value==="substrate"){if(i.value&&t.isParameterAvailable("kcat")){const b=n.collectRowsForParameter("substrate","kcat");p.push({parameter:"kcat",payload:a("kcat",b)})}if(o.value&&t.isParameterAvailable("km")){const b=n.collectRowsForParameter("substrate","km");p.push({parameter:"km",payload:a("km",b)})}}else{if(!t.isParameterAvailable("ki")){l.value="No checkpoint available for Ki.";return}const b=n.collectRowsForParameter("inhibition","ki");p.push({parameter:"ki",payload:a("ki",b)})}if(p.length===0){l.value="No available parameters to predict. Check server status.";return}s.runAll(p)}function u(h){const p=n.importCsv(h,r.value);l.value=p}return(h,p)=>(O(),K(oe,null,[le(ra),_("main",$c,[_("div",Fc,[_("div",kc,[p[12]||(p[12]=Yo('',1)),le(gc,{"service-status":V(t).status.value,"service-hint":V(t).statusHint.value,"prediction-status":V(s).status.value,"prediction-error":V(s).error.value,elapsed:V(s).elapsedSeconds.value,"can-retry":V(s).lastJobs.value!==null&&V(s).status.value==="error",onRetry:p[0]||(p[0]=b=>V(s).retry())},null,8,["service-status","service-hint","prediction-status","prediction-error","elapsed","can-retry"])]),_("div",Nc,[le(va,{mode:r.value,"predict-kcat":i.value,"predict-km":o.value,available:V(t).availableCheckpoints.value,"onUpdate:mode":p[1]||(p[1]=b=>r.value=b),"onUpdate:predictKcat":p[2]||(p[2]=b=>i.value=b),"onUpdate:predictKm":p[3]||(p[3]=b=>o.value=b)},null,8,["mode","predict-kcat","predict-km","available"])]),l.value?(O(),K("p",Gc,q(l.value),1)):fe("",!0),_("div",Hc,[le(ac,{rows:V(n).rows.value,mode:r.value,disabled:V(s).isRunning.value,onAddRow:p[4]||(p[4]=b=>V(n).addRow()),onRemoveRow:p[5]||(p[5]=b=>V(n).removeRow(b)),onUpdateField:p[6]||(p[6]=(b,I,P)=>V(n).updateField(b,I,P)),onAddSubstrate:p[7]||(p[7]=b=>V(n).addSubstrate(b)),onRemoveSubstrate:p[8]||(p[8]=(b,I)=>V(n).removeSubstrate(b,I)),onUpdateSubstrateSmiles:p[9]||(p[9]=(b,I,P)=>V(n).updateSubstrateSmiles(b,I,P)),onSetPrimary:p[10]||(p[10]=(b,I)=>V(n).setPrimary(b,I)),onLoadSample:p[11]||(p[11]=b=>V(n).loadSample(r.value)),onImportCsv:u,onSubmit:d},null,8,["rows","mode","disabled"]),le(Kc,{results:V(s).results.value},null,8,["results"])])])]),le(ca)],64))}}),Uc=He(jc,[["__scopeId","data-v-f6296e8b"]]);Ol(Uc).mount("#app"); diff --git a/catpred/web/static/dist/assets/index-DOG0cTDK.css b/catpred/web/static/dist/assets/index-DOG0cTDK.css new file mode 100644 index 0000000..220758c --- /dev/null +++ b/catpred/web/static/dist/assets/index-DOG0cTDK.css @@ -0,0 +1 @@ +.skip-link[data-v-b3acbf9d]{position:absolute;top:-100%;left:1rem;z-index:999;padding:.5rem 1rem;background:var(--accent);color:#fff;border-radius:var(--radius-sm);font-size:.875rem;font-weight:500}.skip-link[data-v-b3acbf9d]:focus{top:.5rem}.footer[data-v-d5b7fe41]{border-top:1px solid var(--border);padding:1.5rem 0}.footer-inner[data-v-d5b7fe41]{text-align:center}.footer-text[data-v-d5b7fe41]{color:var(--text-tertiary);font-size:.8rem}.footer-brand[data-v-d5b7fe41]{font-family:var(--font-serif);color:var(--text-secondary)}.footer-sep[data-v-d5b7fe41]{margin:0 .25rem;opacity:.5}.footer-link[data-v-d5b7fe41]{color:var(--text-secondary);text-decoration:none;border-bottom:1px solid transparent;transition:color .15s,border-color .15s}.footer-link[data-v-d5b7fe41]:hover{color:var(--accent);border-bottom-color:var(--accent)}.visit-count[data-v-d5b7fe41]{margin-top:.25rem;font-family:var(--font-mono);font-size:.7rem;color:var(--text-tertiary);opacity:.7}.mode-selector[data-v-06223ab0]{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.mode-pills[data-v-06223ab0]{display:flex;gap:.375rem}.mode-pill[data-v-06223ab0]{height:34px;padding:0 .875rem;border:1px solid var(--border);border-radius:999px;background:var(--bg-surface);font-family:var(--font-mono);font-size:.75rem;font-weight:500;letter-spacing:.03em;color:var(--text-secondary);transition:all .15s}.mode-pill[data-v-06223ab0]:hover:not(:disabled){border-color:var(--text-tertiary)}.mode-pill[data-v-06223ab0]:disabled{opacity:.4;cursor:not-allowed}.mode-substrate.active[data-v-06223ab0]{color:var(--color-kcat);border-color:var(--color-kcat);background:#05966914}.mode-inhibition.active[data-v-06223ab0]{color:var(--color-ki);border-color:var(--color-ki);background:#8b7fc714}.param-checks[data-v-06223ab0]{display:flex;gap:.5rem;align-items:center}.check-label[data-v-06223ab0]{display:flex;align-items:center;gap:.25rem;font-family:var(--font-mono);font-size:.75rem;font-weight:500;color:var(--text-secondary);cursor:pointer;-webkit-user-select:none;user-select:none}.check-label.disabled[data-v-06223ab0]{opacity:.4;cursor:not-allowed}.check-label input[type=checkbox][data-v-06223ab0]{width:14px;height:14px;accent-color:var(--accent);cursor:inherit}.field-label[data-v-812d7d46]{display:block;margin-bottom:.25rem;font-family:var(--font-mono);font-size:.68rem;font-weight:500;letter-spacing:.06em;text-transform:uppercase;color:var(--text-tertiary)}.substrate-list[data-v-812d7d46]{display:grid;gap:.375rem}.substrate-row[data-v-812d7d46]{display:flex;align-items:flex-start;gap:.375rem}.primary-radio[data-v-812d7d46]{display:flex;align-items:center;gap:.25rem;flex-shrink:0;min-width:70px;padding-top:.5rem;cursor:pointer}.primary-radio input[type=radio][data-v-812d7d46]{width:14px;height:14px;accent-color:var(--accent);cursor:pointer}.primary-label[data-v-812d7d46]{font-family:var(--font-mono);font-size:.6rem;font-weight:500;color:var(--text-tertiary);letter-spacing:.04em}.sub-field[data-v-812d7d46]{flex:1;min-width:0}.sub-field input[data-v-812d7d46]{width:100%;padding:.5rem .625rem;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg);font-size:.875rem;line-height:1.5;transition:border-color .15s}.sub-field input[data-v-812d7d46]:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--focus-ring)}.sub-field input[aria-invalid=true][data-v-812d7d46]{border-color:var(--danger)}.remove-sub-btn[data-v-812d7d46]{flex-shrink:0;width:28px;height:34px;margin-top:.125rem;border:1px solid var(--border);border-radius:var(--radius-sm);font-size:1rem;color:var(--text-tertiary);transition:all .15s;display:flex;align-items:center;justify-content:center}.remove-sub-btn[data-v-812d7d46]:hover{color:var(--danger);border-color:#dc26264d;background:#dc26260a}.add-sub-btn[data-v-812d7d46]{margin-top:.25rem;padding:.25rem .5rem;border:1px dashed var(--border);border-radius:var(--radius-sm);font-family:var(--font-mono);font-size:.68rem;font-weight:500;color:var(--text-tertiary);transition:all .15s}.add-sub-btn[data-v-812d7d46]:hover{border-color:var(--accent);color:var(--accent);background:#0596690a}.field-error[data-v-812d7d46]{margin-top:.25rem;font-size:.75rem;color:var(--danger)}.row-item[data-v-d4696ebf]{border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg-surface);padding:.75rem}.row-head[data-v-d4696ebf]{display:flex;align-items:center;justify-content:space-between;margin-bottom:.5rem}.row-title[data-v-d4696ebf]{font-family:var(--font-mono);font-size:.7rem;font-weight:500;color:var(--text-tertiary);letter-spacing:.02em}.remove-btn[data-v-d4696ebf]{height:28px;padding:0 .6rem;border:1px solid var(--border);border-radius:999px;font-family:var(--font-mono);font-size:.65rem;font-weight:500;color:var(--danger);transition:background .15s,border-color .15s}.remove-btn[data-v-d4696ebf]:hover{background:#dc26260f;border-color:#dc26264d}.row-fields[data-v-d4696ebf]{display:grid;gap:.5rem}.field-label[data-v-d4696ebf]{display:block;margin-bottom:.25rem;font-family:var(--font-mono);font-size:.68rem;font-weight:500;letter-spacing:.06em;text-transform:uppercase;color:var(--text-tertiary)}input[data-v-d4696ebf],textarea[data-v-d4696ebf]{width:100%;padding:.5rem .625rem;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg);font-size:.875rem;line-height:1.5;transition:border-color .15s}input[data-v-d4696ebf]:focus,textarea[data-v-d4696ebf]:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--focus-ring)}input[aria-invalid=true][data-v-d4696ebf],textarea[aria-invalid=true][data-v-d4696ebf]{border-color:var(--danger)}input[aria-invalid=true][data-v-d4696ebf]:focus,textarea[aria-invalid=true][data-v-d4696ebf]:focus{box-shadow:0 0 0 2px #dc26264d}textarea[data-v-d4696ebf]{resize:vertical;min-height:48px}.field-error[data-v-d4696ebf]{margin-top:.25rem;font-size:.75rem;color:var(--danger)}.upload-zone[data-v-bd35de81]{border:1px dashed var(--border);border-radius:var(--radius-md);padding:1rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s}.upload-zone[data-v-bd35de81]:hover,.upload-zone.dragging[data-v-bd35de81]{border-color:var(--accent);background:#0596690a}.upload-text[data-v-bd35de81]{font-size:.8rem;color:var(--text-secondary)}.upload-text strong[data-v-bd35de81]{font-weight:500;color:var(--text)}.upload-hint[data-v-bd35de81]{margin-top:.25rem;font-family:var(--font-mono);font-size:.68rem;color:var(--text-tertiary)}.sr-only[data-v-bd35de81]{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.input-panel[data-v-fe6ff3ab]{background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:1rem;box-shadow:var(--shadow-sm)}.panel-title[data-v-fe6ff3ab]{font-family:var(--font-serif);font-size:1.25rem;font-weight:400;color:var(--text);margin-bottom:.75rem}.rows-list[data-v-fe6ff3ab]{display:grid;gap:.5rem;margin-bottom:.75rem}.actions[data-v-fe6ff3ab]{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.75rem}.btn[data-v-fe6ff3ab]{display:inline-flex;align-items:center;justify-content:center;gap:.375rem;height:40px;padding:0 1rem;border:1px solid var(--border);border-radius:999px;font-size:.8rem;font-weight:500;transition:all .15s;min-width:44px}.btn[data-v-fe6ff3ab]:disabled{opacity:.5;cursor:not-allowed}.btn-ghost[data-v-fe6ff3ab]{background:transparent;color:var(--text-secondary)}.btn-ghost[data-v-fe6ff3ab]:hover:not(:disabled){background:var(--bg-muted);color:var(--text)}.btn-primary[data-v-fe6ff3ab]{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:0 1px 3px #05966933}.btn-primary[data-v-fe6ff3ab]:hover:not(:disabled){background:var(--accent-hover);border-color:var(--accent-hover)}.spinner[data-v-fe6ff3ab]{width:14px;height:14px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin-fe6ff3ab .6s linear infinite}@keyframes spin-fe6ff3ab{to{transform:rotate(360deg)}}.helper-text[data-v-fe6ff3ab]{margin-top:.75rem;font-size:.8rem;color:var(--text-tertiary);line-height:1.5}.helper-text strong[data-v-fe6ff3ab]{font-weight:600;color:var(--text-secondary)}.status-bar[data-v-d5b2b2e6]{border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg-surface);padding:.5rem .75rem;display:grid;gap:.375rem}.status-row[data-v-d5b2b2e6]{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;min-height:28px}.status-label[data-v-d5b2b2e6]{font-family:var(--font-mono);font-size:.65rem;font-weight:500;letter-spacing:.06em;text-transform:uppercase;color:var(--text-tertiary)}.badge[data-v-d5b2b2e6]{display:inline-flex;align-items:center;height:22px;padding:0 .5rem;border:1px solid var(--border);border-radius:999px;font-family:var(--font-mono);font-size:.65rem;font-weight:500}.badge.online[data-v-d5b2b2e6]{color:var(--ok);border-color:#0596694d;background:#0596690f}.badge.offline[data-v-d5b2b2e6],.badge.limited[data-v-d5b2b2e6]{color:var(--danger);border-color:#dc26264d;background:#dc26260f}.badge.checking[data-v-d5b2b2e6]{color:var(--text-tertiary)}.status-hint[data-v-d5b2b2e6]{font-family:var(--font-mono);font-size:.7rem;color:var(--text-tertiary)}.prediction-row[data-v-d5b2b2e6]{border-top:1px solid var(--border);padding-top:.375rem}.status-running[data-v-d5b2b2e6]{display:flex;align-items:center;gap:.375rem;font-family:var(--font-mono);font-size:.72rem;color:var(--accent);font-weight:500}.spinner[data-v-d5b2b2e6]{width:12px;height:12px;border:2px solid rgba(5,150,105,.2);border-top-color:var(--accent);border-radius:50%;animation:spin-d5b2b2e6 .6s linear infinite}@keyframes spin-d5b2b2e6{to{transform:rotate(360deg)}}.status-success[data-v-d5b2b2e6]{font-family:var(--font-mono);font-size:.72rem;color:var(--ok);font-weight:500}.status-error[data-v-d5b2b2e6]{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;font-family:var(--font-mono);font-size:.72rem;color:var(--danger)}.retry-btn[data-v-d5b2b2e6]{height:24px;padding:0 .5rem;border:1px solid rgba(220,38,38,.3);border-radius:999px;font-family:var(--font-mono);font-size:.65rem;font-weight:500;color:var(--danger);transition:background .15s}.retry-btn[data-v-d5b2b2e6]:hover{background:#dc26260f}.result-panel[data-v-7efc8274]{background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:1rem;box-shadow:var(--shadow-sm)}.panel-head[data-v-7efc8274]{display:flex;align-items:center;justify-content:space-between;margin-bottom:.75rem}.panel-title[data-v-7efc8274]{font-family:var(--font-serif);font-size:1.25rem;font-weight:400}.empty-state[data-v-7efc8274]{border:1px dashed var(--border);border-radius:var(--radius-md);padding:2rem 1rem;text-align:center;color:var(--text-tertiary);display:flex;flex-direction:column;align-items:center;gap:.75rem}.empty-icon[data-v-7efc8274]{opacity:.5}.empty-state p[data-v-7efc8274]{font-size:.85rem}.result-groups[data-v-7efc8274]{display:grid;gap:1rem}.group-head[data-v-7efc8274]{display:flex;align-items:center;justify-content:space-between;margin-bottom:.5rem}.param-chip[data-v-7efc8274]{height:22px;padding:0 .5rem;border:1px solid;border-radius:999px;font-family:var(--font-mono);font-size:.65rem;font-weight:500;display:inline-flex;align-items:center}.export-btn[data-v-7efc8274]{height:28px;padding:0 .6rem;border:1px solid var(--border);border-radius:999px;font-family:var(--font-mono);font-size:.65rem;font-weight:500;color:var(--text-secondary);transition:all .15s}.export-btn[data-v-7efc8274]:hover{border-color:var(--accent);color:var(--accent);background:#0596690a}.result-cards[data-v-7efc8274]{display:grid;gap:.5rem}.result-card[data-v-7efc8274]{border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg);padding:.75rem;animation:card-in-7efc8274 .3s ease backwards}.result-card[data-v-7efc8274]:nth-child(2){animation-delay:.06s}.result-card[data-v-7efc8274]:nth-child(3){animation-delay:.12s}.result-card[data-v-7efc8274]:nth-child(4){animation-delay:.18s}.result-card[data-v-7efc8274]:nth-child(5){animation-delay:.24s}@keyframes card-in-7efc8274{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.card-head[data-v-7efc8274]{display:flex;justify-content:space-between;align-items:center}.card-head h4[data-v-7efc8274]{font-family:var(--font-serif);font-size:1rem;font-weight:400}.card-main[data-v-7efc8274]{margin-top:.375rem;display:flex;align-items:baseline;gap:.375rem}.card-main strong[data-v-7efc8274]{font-family:var(--font-serif);font-size:1.75rem;font-weight:400;line-height:1}.card-unit[data-v-7efc8274]{font-size:.8rem;color:var(--text-secondary)}.card-range[data-v-7efc8274]{margin-top:.25rem;font-family:var(--font-mono);font-size:.7rem;color:var(--text-tertiary)}.metrics[data-v-7efc8274]{margin-top:.5rem;display:grid;grid-template-columns:repeat(3,1fr);gap:.375rem}.metric[data-v-7efc8274]{border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg-surface);padding:.375rem}.metric dt[data-v-7efc8274]{font-family:var(--font-mono);font-size:.6rem;font-weight:500;color:var(--text-tertiary);letter-spacing:.04em;text-transform:uppercase}.metric dd[data-v-7efc8274]{margin-top:.125rem;font-size:.85rem;color:var(--text-secondary)}.card-meta[data-v-7efc8274]{margin-top:.5rem;display:flex;flex-wrap:wrap;gap:.375rem}.card-meta span[data-v-7efc8274]{font-family:var(--font-mono);font-size:.6rem;color:var(--text-secondary);border:1px solid var(--border);border-radius:999px;padding:.125rem .4rem;background:var(--bg-surface);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.details-table[data-v-7efc8274]{margin-top:.5rem;border-top:1px solid var(--border);padding-top:.5rem}.details-table summary[data-v-7efc8274]{cursor:pointer;font-family:var(--font-mono);font-size:.68rem;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--text-tertiary)}.table-wrap[data-v-7efc8274]{margin-top:.5rem;overflow:auto}table[data-v-7efc8274]{width:100%;min-width:600px}th[data-v-7efc8274],td[data-v-7efc8274]{text-align:left;border-bottom:1px solid var(--border);padding:.375rem .5rem;font-size:.8rem;color:var(--text-secondary)}th[data-v-7efc8274]{font-family:var(--font-mono);font-size:.65rem;font-weight:500;letter-spacing:.06em;text-transform:uppercase;color:var(--text-tertiary)}@media(max-width:640px){.metrics[data-v-7efc8274]{grid-template-columns:1fr}}.main[data-v-f6296e8b]{padding:2rem 0 3rem}.page-header[data-v-f6296e8b]{display:flex;align-items:flex-end;justify-content:space-between;gap:1rem;margin-bottom:1.25rem}.page-title[data-v-f6296e8b]{font-family:var(--font-serif);font-size:clamp(1.5rem,3vw,2rem);font-weight:400;letter-spacing:-.02em;color:var(--text)}.page-links[data-v-f6296e8b]{display:flex;align-items:center;gap:.75rem;flex-shrink:0}.page-link[data-v-f6296e8b]{display:inline-flex;align-items:center;gap:.3rem;font-size:.875rem;font-weight:450;color:var(--text-secondary);text-decoration:none;padding-bottom:1px;border-bottom:1px solid transparent;transition:color .15s,border-color .15s}.page-link[data-v-f6296e8b]:hover{color:var(--accent);border-bottom-color:var(--accent)}.link-arrow[data-v-f6296e8b]{font-size:.8em;opacity:.6}.page-link:hover .link-arrow[data-v-f6296e8b]{opacity:1}.link-sep[data-v-f6296e8b]{color:var(--text-tertiary);font-size:.75rem}@media(max-width:640px){.page-header[data-v-f6296e8b]{flex-direction:column;align-items:flex-start;gap:.5rem}}.param-row[data-v-f6296e8b]{margin:.75rem 0}.form-error[data-v-f6296e8b]{margin-bottom:.75rem;padding:.5rem .75rem;border:1px solid rgba(220,38,38,.3);border-radius:var(--radius-sm);background:#dc26260a;color:var(--danger);font-size:.8rem}.grid[data-v-f6296e8b]{display:grid;grid-template-columns:1.2fr .8fr;gap:1rem;align-items:start}@media(max-width:1024px){.grid[data-v-f6296e8b]{grid-template-columns:1fr}}:root{--bg: #FAFAF9;--bg-surface: #FFFFFF;--bg-muted: #F5F5F4;--text: #1A1A1A;--text-secondary: #57534E;--text-tertiary: #A8A29E;--accent: #059669;--accent-hover: #047857;--accent-light: #D1FAE5;--color-kcat: #059669;--color-km: #DC7B6A;--color-ki: #8B7FC7;--danger: #DC2626;--ok: #059669;--border: #E7E5E4;--focus-ring: rgba(5, 150, 105, .4);--radius-sm: 6px;--radius-md: 10px;--radius-lg: 16px;--shadow-sm: 0 1px 2px rgba(0, 0, 0, .04);--shadow-md: 0 4px 12px rgba(0, 0, 0, .06);--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--font-serif: "Newsreader", Georgia, serif;--font-mono: "IBM Plex Mono", "SF Mono", "Fira Code", monospace}*,*:before,*:after{margin:0;padding:0;box-sizing:border-box}html{height:100%;-webkit-text-size-adjust:100%;text-size-adjust:100%}body{min-height:100%;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}img,picture,video,canvas,svg{display:block;max-width:100%}input,button,textarea,select{font:inherit;color:inherit}button{cursor:pointer;background:none;border:none}a{color:inherit;text-decoration:none}table{border-collapse:collapse}h1,h2,h3,h4,h5,h6{overflow-wrap:break-word;font-weight:inherit}p{overflow-wrap:break-word}ul,ol{list-style:none}[hidden]{display:none!important}body{background:var(--bg);color:var(--text);font-family:var(--font-sans);font-size:15px;line-height:1.6}::selection{background:var(--accent-light);color:var(--text)}:focus-visible{outline:none;box-shadow:0 0 0 2px var(--focus-ring);border-radius:var(--radius-sm)}.container{width:min(960px,calc(100% - 2rem));margin-inline:auto}code{font-family:var(--font-mono);font-size:.875em;padding:.125em .35em;border-radius:var(--radius-sm);background:var(--bg-muted);border:1px solid var(--border)}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} diff --git a/catpred/web/static/dist/index.html b/catpred/web/static/dist/index.html new file mode 100644 index 0000000..87f7997 --- /dev/null +++ b/catpred/web/static/dist/index.html @@ -0,0 +1,24 @@ + + + + + + CatPred | Enzyme Kinetics Prediction + + + + + + + + +
+ + + diff --git a/catpred/web/static/index.html b/catpred/web/static/index.html new file mode 100644 index 0000000..954c827 --- /dev/null +++ b/catpred/web/static/index.html @@ -0,0 +1,209 @@ + + + + + + CatPred | Enzyme Kinetics Prediction + + + + + + + + + + +
+ +
+ +
+
+
+ Published in Nature Communications +

Predict Enzyme
Kinetic Parameters

+

Deep learning ensemble predictions for kcat, Km, and Ki with built-in uncertainty quantification.

+ Start predicting โ†’ +
+
+ +
+
+
+

Enzyme Kinetics Prediction

+

kcat, Km, and Ki with uncertainty estimates

+
+ +
+
+ Service status + Checking... + +
+ + +
+
+ +
+
+

Inputs

+ +
+ + + +
+ +
+ +
+ + + +
+ +

+ Each row needs: SMILES, sequence, and a Sequence ID. + Sequence IDs auto-fill as seq_001, seq_002, etc. (editable). + Use one Sequence ID per unique sequence. No structure file upload is required. + For CSV/API usage, this field maps to pdbpath. +

+
+ +
+

Results

+
Ready when you are.
+ +
+
+ +

Run a prediction to see kinetic
estimates here.

+
+
+ +
+ View detailed output table +
+
+
+
+
+
+
+ +
+
+
+

How It Works

+

Three steps to enzyme kinetics predictions

+
+
+
+ 01 +

Enter Data

+

Provide substrate SMILES notation and enzyme amino acid sequence for each entry.

+
+
+ 02 +

Run Prediction

+

Our deep learning ensemble processes inputs through multiple model checkpoints.

+
+
+ 03 +

Get Results

+

Receive predicted kinetic parameters with uncertainty estimates and detailed metrics.

+
+
+
+
+ +
+
+
+

Peer-Reviewed Research

+

CatPred is backed by rigorous scientific methodology

+
+
+ +
+

CatPred: A comprehensive framework for deep learning in vitro enzyme kinetic parameters kcat, Km and Ki

+

Nature Communications, 2025 — Veda Sheersh Boorla, Costas D. Maranas

+ Read the paper โ†’ +
+
+
+
+ +
+ +
+
+ + +
+
+ + Run prediction + + + + diff --git a/demo_run.py b/demo_run.py index 8a78739..e6c3d08 100644 --- a/demo_run.py +++ b/demo_run.py @@ -1,150 +1,64 @@ """ -Enzyme Kinetics Parameter Prediction Script - -This script predicts enzyme kinetics parameters (kcat, Km, or Ki) using a pre-trained model. -It processes input data, generates predictions, and saves the results. +Enzyme kinetics parameter prediction CLI for local/demo usage. Usage: - python demo_run.py --parameter --input_file --checkpoint_dir [--use_gpu] - -Dependencies: - pandas, numpy, rdkit, IPython, argparse + python demo_run.py --parameter --input_file --checkpoint_dir [--use_gpu] """ -import time -import os -import pandas as pd -import numpy as np -from IPython.display import Image, display -from rdkit import Chem -from IPython.display import display, Latex, Math import argparse +import subprocess -def create_csv_sh(parameter, input_file_path, checkpoint_dir): - df = pd.read_csv(input_file_path) - smiles_list = df.SMILES - seq_list = df.sequence - smiles_list_new = [] - - for i, smi in enumerate(smiles_list): - try: - mol = Chem.MolFromSmiles(smi) - smi = Chem.MolToSmiles(mol) - if parameter == 'kcat' and '.' in smi: - smi = '.'.join(sorted(smi.split('.'))) - smiles_list_new.append(smi) - except: - print(f'Invalid SMILES input in input row {i}') - print('Correct your input! Exiting..') - return None - - valid_aas = set('ACDEFGHIKLMNPQRSTVWY') - for i, seq in enumerate(seq_list): - if not set(seq).issubset(valid_aas): - print(f'Invalid Enzyme sequence input in row {i}!') - print('Correct your input! Exiting..') - return None - - input_file_new_path = f'{input_file_path[:-4]}_input.csv' - df['SMILES'] = smiles_list_new - df.to_csv(input_file_new_path) - - with open('predict.sh', 'w') as f: - f.write(f''' - TEST_FILE_PREFIX={input_file_new_path[:-4]} - RECORDS_FILE=${{TEST_FILE_PREFIX}}.json - CHECKPOINT_DIR={checkpoint_dir} - - python ./scripts/create_pdbrecords.py --data_file ${{TEST_FILE_PREFIX}}.csv --out_file ${{RECORDS_FILE}} - python predict.py --test_path ${{TEST_FILE_PREFIX}}.csv --preds_path ${{TEST_FILE_PREFIX}}_output.csv --checkpoint_dir $CHECKPOINT_DIR --uncertainty_method mve --smiles_column SMILES --individual_ensemble_predictions --protein_records_path $RECORDS_FILE - ''') - - return input_file_new_path[:-4]+'_output.csv' - -def get_predictions(parameter, outfile): - """ - Process prediction results and add additional metrics. - - Args: - parameter (str): The kinetics parameter that was predicted. - outfile (str): Path to the output CSV file from the prediction. - - Returns: - pandas.DataFrame: Processed predictions with additional metrics. - """ - df = pd.read_csv(outfile) - pred_col, pred_logcol, pred_sd_totcol, pred_sd_aleacol, pred_sd_epicol = [], [], [], [], [] +from catpred.inference import PredictionRequest, run_prediction_pipeline - unit = 'mM' - if parameter == 'kcat': - target_col = 'log10kcat_max' - unit = 's^(-1)' - elif parameter == 'km': - target_col = 'log10km_mean' - else: - target_col = 'log10ki_mean' - unc_col = f'{target_col}_mve_uncal_var' +def main(args: argparse.Namespace) -> int: + request = PredictionRequest( + parameter=args.parameter.lower(), + input_file=args.input_file, + checkpoint_dir=args.checkpoint_dir, + use_gpu=args.use_gpu, + repo_root=".", + ) - for _, row in df.iterrows(): - model_cols = [col for col in row.index if col.startswith(target_col) and 'model_' in col] + print("Predicting.. This will take a while..") + try: + final_output = run_prediction_pipeline(request=request, results_dir="../results") + except (ValueError, FileNotFoundError) as exc: + print(str(exc)) + return 1 + except subprocess.CalledProcessError as exc: + print(f"Prediction command failed with exit code {exc.returncode}.") + return exc.returncode if exc.returncode is not None else 1 - unc = row[unc_col] - prediction = row[target_col] - prediction_linear = np.power(10, prediction) + print(f"Output saved to {final_output}") + return 0 - model_outs = np.array([row[col] for col in model_cols]) - epi_unc = np.var(model_outs) - alea_unc = unc - epi_unc - epi_unc = np.sqrt(epi_unc) - alea_unc = np.sqrt(alea_unc) - unc = np.sqrt(unc) - - pred_col.append(prediction_linear) - pred_logcol.append(prediction) - pred_sd_totcol.append(unc) - pred_sd_aleacol.append(alea_unc) - pred_sd_epicol.append(epi_unc) - - df[f'Prediction_({unit})'] = pred_col - df['Prediction_log10'] = pred_logcol - df['SD_total'] = pred_sd_totcol - df['SD_aleatoric'] = pred_sd_aleacol - df['SD_epistemic'] = pred_sd_epicol - - return df - -def main(args): - print(os.getcwd()) - - outfile = create_csv_sh(args.parameter, args.input_file, args.checkpoint_dir) - if outfile is None: - return - - print('Predicting.. This will take a while..') - - if args.use_gpu: - os.system("export PROTEIN_EMBED_USE_CPU=0;./predict.sh") - else: - os.system("export PROTEIN_EMBED_USE_CPU=1;./predict.sh") - - output_final = get_predictions(args.parameter, outfile) - filename = outfile.split('/')[-1] - output_final.to_csv(f'../results/{filename}') - print('Output saved to results/', filename) if __name__ == "__main__": parser = argparse.ArgumentParser(description="Predict enzyme kinetics parameters.") - parser.add_argument("--parameter", type=str, choices=["kcat", "km", "ki"], required=True, - help="Kinetics parameter to predict (kcat, km, or ki)") - parser.add_argument("--input_file", type=str, required=True, - help="Path to the input CSV file") - parser.add_argument("--use_gpu", action="store_true", - help="Use GPU for prediction (default is CPU)") - parser.add_argument("--checkpoint_dir", type=str, required=True, - help="Path to the model checkpoint directory") - - args = parser.parse_args() - args.parameter = args.parameter.lower() - - main(args) + parser.add_argument( + "--parameter", + type=str, + choices=["kcat", "km", "ki"], + required=True, + help="Kinetics parameter to predict (kcat, km, or ki)", + ) + parser.add_argument( + "--input_file", + type=str, + required=True, + help="Path to the input CSV file", + ) + parser.add_argument( + "--use_gpu", + action="store_true", + help="Use GPU for prediction (default is CPU)", + ) + parser.add_argument( + "--checkpoint_dir", + type=str, + required=True, + help="Path to the model checkpoint directory", + ) + + raise SystemExit(main(parser.parse_args())) diff --git a/environment.yml b/environment.yml index 082e55c..890460c 100644 --- a/environment.yml +++ b/environment.yml @@ -20,7 +20,6 @@ dependencies: - faiss-cpu - pytorch-scatter - pyg - - kimlab::stride - pip: - ipdb - fair-esm diff --git a/modal_app.py b/modal_app.py new file mode 100644 index 0000000..b1bdee0 --- /dev/null +++ b/modal_app.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from pathlib import Path +import os +import tempfile +from typing import Any, Optional + +from fastapi import Header, HTTPException +import modal +from pydantic import BaseModel, Field + + +image = ( + modal.Image.debian_slim(python_version="3.10") + .apt_install( + "libxrender1", + "libxext6", + "libsm6", + ) + .pip_install( + "fastapi[standard]>=0.115,<1.0", + "pydantic>=1.10,<2.0", + "pandas>=1.5,<2.3", + "numpy>=1.26,<2.3", + "scikit-learn>=1.3,<1.7", + "scipy>=1.10,<1.16", + "torch>=2.1,<2.7", + "tqdm>=4.66", + "typed-argument-parser>=1.10", + "rdkit-pypi>=2022.9.5", + "descriptastorus>=2.6", + "transformers>=4.47,<5", + "sentencepiece>=0.2.0", + "fair-esm==2.0.0", + "progres==0.2.7", + "rotary-embedding-torch==0.6.5", + "ipdb==0.13.13", + "pandas-flavor>=0.6.0", + ) + .add_local_python_source("catpred") + .add_local_dir("scripts", remote_path="/root/scripts") + .add_local_file("predict.py", remote_path="/root/predict.py") +) + +app = modal.App("catpred-modal-api", image=image) +checkpoints_volume = modal.Volume.from_name("catpred-checkpoints", create_if_missing=True) + + +class PredictPayload(BaseModel): + parameter: str = Field(..., description="One of: kcat, km, ki") + checkpoint_dir: str = Field(..., description="Checkpoint subdirectory inside /checkpoints") + use_gpu: bool = Field(default=False) + input_rows: list[dict[str, Any]] = Field(default_factory=list) + input_filename: Optional[str] = Field(default=None) + + +def _safe_checkpoint_path(raw_checkpoint_dir: str) -> Path: + checkpoint_root = Path("/checkpoints").resolve() + checkpoint_dir = (checkpoint_root / raw_checkpoint_dir).resolve() + try: + checkpoint_dir.relative_to(checkpoint_root) + except ValueError as exc: + raise ValueError("checkpoint_dir must stay inside /checkpoints.") from exc + if not checkpoint_dir.is_dir(): + raise ValueError(f'Checkpoint directory not found: "{checkpoint_dir}"') + return checkpoint_dir + + +@app.function( + timeout=60 * 15, + cpu=4.0, + memory=16384, + volumes={"/checkpoints": checkpoints_volume}, +) +@modal.fastapi_endpoint(method="POST", docs=True) +def predict( + payload: PredictPayload, + authorization: Optional[str] = Header(default=None), +) -> dict[str, Any]: + import pandas as pd + + from catpred.inference.service import run_prediction_pipeline + from catpred.inference.types import PredictionRequest + + expected_token = os.environ.get("CATPRED_MODAL_AUTH_TOKEN") + if expected_token: + provided = "" + if authorization: + lower = authorization.lower() + if lower.startswith("bearer "): + provided = authorization[7:].strip() + else: + provided = authorization.strip() + if provided != expected_token: + raise HTTPException(status_code=401, detail="Unauthorized") + + if not payload.input_rows: + raise HTTPException(status_code=400, detail="input_rows cannot be empty.") + + parameter = payload.parameter.lower() + if parameter not in {"kcat", "km", "ki"}: + raise HTTPException(status_code=400, detail="parameter must be one of: kcat, km, ki.") + + try: + checkpoint_dir = _safe_checkpoint_path(payload.checkpoint_dir) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + safe_name = Path(payload.input_filename or "api_input.csv").name + if not safe_name.endswith(".csv"): + safe_name = f"{safe_name}.csv" + + runtime_dir = Path("/tmp/catpred-modal").resolve() + runtime_dir.mkdir(parents=True, exist_ok=True) + fd, tmp_input_path = tempfile.mkstemp(prefix="modal_input_", suffix=".csv", dir=str(runtime_dir)) + os.close(fd) + + try: + pd.DataFrame(payload.input_rows).to_csv(tmp_input_path, index=False) + + request_obj = PredictionRequest( + parameter=parameter, + input_file=tmp_input_path, + checkpoint_dir=str(checkpoint_dir), + use_gpu=payload.use_gpu, + repo_root="/root", + python_executable="python", + ) + results_dir = str((runtime_dir / "results").resolve()) + output_file = run_prediction_pipeline(request_obj, results_dir=results_dir) + output_df = pd.read_csv(output_file) + + return { + "output_rows": output_df.to_dict(orient="records"), + "output_filename": Path(output_file).name, + "row_count": int(len(output_df)), + "backend": "modal", + } + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Modal prediction failed: {exc}") from exc + finally: + input_path = Path(tmp_input_path) + if input_path.exists(): + input_path.unlink() diff --git a/predict.sh b/predict.sh index 2caad26..93d8036 100644 --- a/predict.sh +++ b/predict.sh @@ -1,7 +1,9 @@ -TEST_FILE_PREFIX=./demo/batch_kcat_input -RECORDS_FILE=${TEST_FILE_PREFIX}.json -CHECKPOINT_DIR=../data/pretrained/production/kcat/ +#!/usr/bin/env bash +set -euo pipefail -python ./scripts/create_pdbrecords.py --data_file ${TEST_FILE_PREFIX}.csv --out_file ${RECORDS_FILE}.gz +TEST_FILE_PREFIX="${TEST_FILE_PREFIX:-./demo/batch_kcat_input}" +RECORDS_FILE="${RECORDS_FILE:-${TEST_FILE_PREFIX}.json.gz}" +CHECKPOINT_DIR="${CHECKPOINT_DIR:-../data/pretrained/production/kcat}" -python predict.py --test_path ${TEST_FILE_PREFIX}.csv --preds_path ${TEST_FILE_PREFIX}_output.csv --checkpoint_dir $CHECKPOINT_DIR --uncertainty_method mve --smiles_column SMILES --individual_ensemble_predictions --protein_records_path ${RECORDS_FILE}.gz +python ./scripts/create_pdbrecords.py --data_file "${TEST_FILE_PREFIX}.csv" --out_file "${RECORDS_FILE}" +python predict.py --test_path "${TEST_FILE_PREFIX}.csv" --preds_path "${TEST_FILE_PREFIX}_output.csv" --checkpoint_dir "${CHECKPOINT_DIR}" --uncertainty_method mve --smiles_column SMILES --individual_ensemble_predictions --protein_records_path "${RECORDS_FILE}" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..621e861 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.95,<0.100 +pydantic>=1.10,<2.0 +pandas>=1.5,<2.3 diff --git a/scripts/baseline_analysis.py b/scripts/baseline_analysis.py index e2076a1..e87507d 100644 --- a/scripts/baseline_analysis.py +++ b/scripts/baseline_analysis.py @@ -1,6 +1,5 @@ import os import sys -import pickle import pandas as pd from tqdm import tqdm from concurrent.futures import ProcessPoolExecutor @@ -8,14 +7,17 @@ from sklearn.metrics import r2_score, mean_absolute_error import ipdb import csv +from catpred.security import load_pickle_artifact OUTPUT_DIR="../results/reproduce_results" DATA_DIR = "../data/external/Baseline/" def load_identity_data(parameter): """Load pre-calculated identity dictionary and mappings.""" - with open(f'{DATA_DIR}/{parameter}/{parameter}_test_train_identities_updated.pkl', 'rb') as f: - data = pickle.load(f) + data = load_pickle_artifact( + f"{DATA_DIR}/{parameter}/{parameter}_test_train_identities_updated.pkl", + purpose="baseline identity pickle", + ) train_seqs_dict = {val: key for key, val in data['train_seq_mapping'].items()} test_seqs_dict = {val: key for key, val in data['test_seq_mapping'].items()} return data, train_seqs_dict, test_seqs_dict @@ -142,4 +144,4 @@ def main(PARAMETER, OUTPUT_FILE, recalculate=False): if sys.argv[1]=='recalculate': main(PARAMETER, OUTPUT_FILE, True) else: - main(PARAMETER, OUTPUT_FILE, False) \ No newline at end of file + main(PARAMETER, OUTPUT_FILE, False) diff --git a/scripts/create_pdbrecords.py b/scripts/create_pdbrecords.py index 0360b81..54c2736 100644 --- a/scripts/create_pdbrecords.py +++ b/scripts/create_pdbrecords.py @@ -1,42 +1,76 @@ -import pandas as pd import argparse +import gzip import json -def parse_args(): - """Prepare argument parser. +import os - Args: +import pandas as pd - Return: - """ +def parse_args(): parser = argparse.ArgumentParser( - description="Generate json records for test file with only sequences" + description="Generate gzipped JSON protein records from an input CSV file." ) + parser.add_argument("--data_file", required=True, help="Path to CSV file") parser.add_argument( - "--data_file", - help="Path to csv file", + "--out_file", required=True, + help="Output path for gzipped JSON protein records", ) - - parser.add_argument("--out_file", help="output file for json records") + return parser.parse_args() - args = parser.parse_args() - return args -args = parse_args() -df = pd.read_csv(args.data_file) -assert('pdbpath' in df.columns) -assert('sequence' in df.columns) +def _as_clean_str(value): + return value.strip() if isinstance(value, str) else value -import json -dic_full = {} -for ind, row in df.iterrows(): - dic = {} - dic['name'] = row.pdbpath - dic['seq'] = row.sequence - dic_full[row.pdbpath] = dic -import gzip -# Writing the dictionary to a gzipped file -with gzip.open(args.out_file, 'wb') as f: - f.write(json.dumps(dic_full).encode('utf-8')) +def build_records(df: pd.DataFrame, data_file: str) -> dict: + required = {"pdbpath", "sequence"} + missing = required.difference(df.columns) + if missing: + raise ValueError( + f'Missing required column(s) in "{data_file}": {", ".join(sorted(missing))}' + ) + + records = {} + conflicts = [] + + for index, row in df.iterrows(): + row_num = index + 2 # account for header row + pdbpath = _as_clean_str(row["pdbpath"]) + sequence = _as_clean_str(row["sequence"]) + + if not pdbpath: + raise ValueError(f'Empty "pdbpath" in row {row_num} of "{data_file}".') + if not sequence: + raise ValueError(f'Empty "sequence" in row {row_num} of "{data_file}".') + + key = os.path.basename(pdbpath) + existing = records.get(key) + if existing is not None and existing["seq"] != sequence: + conflicts.append((row_num, key)) + continue + + records[key] = {"name": key, "seq": sequence} + + if conflicts: + preview = ", ".join( + [f'{key} (row {row_num})' for row_num, key in conflicts[:5]] + ) + raise ValueError( + "Found pdbpath basenames reused for different sequences. " + f"Each unique sequence must have a unique pdbpath. Examples: {preview}" + ) + + return records + + +def main(): + args = parse_args() + df = pd.read_csv(args.data_file) + records = build_records(df, args.data_file) + with gzip.open(args.out_file, "wt", encoding="utf-8") as handle: + json.dump(records, handle) + + +if __name__ == "__main__": + main() diff --git a/setup.cfg b/setup.cfg index 168dee3..b2c6b99 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,10 +1,10 @@ [metadata] name = catpred version = 0.0.1 -author = -author_email = +author = Veda Sheersh Boorla, Costas D. Maranas +author_email = mailforveda@gmail.com license = MIT -description = +description = A comprehensive framework for deep learning in vitro enzyme kinetic parameters kcat, Km and Ki keywords = protein language model machine learning @@ -21,7 +21,7 @@ classifiers = License :: OSI Approved :: MIT License Operating System :: OS Independent project_urls = - Documentation = + Documentation = https://github.com/maranasgroup/catpred/ Source = https://github.com/maranasgroup/catpred PyPi = Demo = http://tiny.cc/catpred @@ -51,14 +51,18 @@ console_scripts = catpred_train=catpred.train:catpred_train catpred_predict=catpred.train:catpred_predict catpred_fingerprint=catpred.train:catpred_fingerprint - catpred_hyperopt=catpred.hyperparameter_optimization:catpred_hyperopt - catpred_interpret=catpred.interpret:catpred_interpret - catpred_web=catpred.web.run:catpred_web - sklearn_train=catpred.sklearn_train:sklearn_train - sklearn_predict=catpred.sklearn_predict:sklearn_predict + catpred_web=catpred.web.run:main [options.extras_require] test = pytest>=6.2.2; parameterized>=0.8.1 +web = + fastapi>=0.95,<1.0 + uvicorn>=0.22,<1.0 [options.package_data] -catpred = py.typed +catpred = + py.typed + web/static/*.html + web/static/*.css + web/static/*.js + web/static/*.svg diff --git a/setup.py b/setup.py index 5de8c5d..b357122 100644 --- a/setup.py +++ b/setup.py @@ -1,62 +1,6 @@ -from setuptools import find_packages, setup +from setuptools import setup -__version__ = "0.0.1" -# Load README -with open("README.md", encoding="utf-8") as f: - long_description = f.read() - -setup( - name="catpred", - author="Veda Sheersh Boorla, Costas D. Maranas", - author_email="mailforveda@gmail.com", - description="A comprehensive framework for deep learning in vitro enzyme kinetic parameters kcat, Km and Ki", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/maranasgroup/catpred", - download_url=f"https://github.com/maranasgroup/catpred/v_{__version__}.tar.gz", - project_urls={ - "Documentation": "https://github.com/maranasgroup/catpred/", - "Source": "https://github.com/maranasgroup/catpred", - "PyPi": "", - "Demo": "https://tiny.cc/catpred", - }, - license="MIT", - packages=find_packages(), - package_data={"catpred": ["py.typed"]}, - entry_points={ - "console_scripts": [ - "catpred_train=catpred.train:catpred_train", - "catpred_predict=catpred.train:catpred_predict", - ] - }, - install_requires=[ - "matplotlib>=3.1.3", - "numpy>=1.18.1", - "pandas>=1.0.3", - "pandas-flavor>=0.2.0", - "scikit-learn>=0.22.2.post1", - "tensorboardX>=2.0", - "sphinx>=3.1.2", - "torch>=1.4.0", - "tqdm>=4.45.0", - "typed-argument-parser>=1.6.1", - "rdkit>=2020.03.1.0", - "scipy<1.11 ; python_version=='3.7'", - "scipy>=1.9 ; python_version=='3.8'", - ], - python_requires=">=3.7", - classifiers=[ - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - ], - keywords=[ - "bioinformatics", - "machine learning", - "enzyme function prediction", - "message passing neural network", - ], -) +if __name__ == "__main__": + # Keep setup.py as a thin shim so setup.cfg is the single source of packaging metadata. + setup() diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..c146952 --- /dev/null +++ b/vercel.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "version": 2, + "builds": [ + { + "src": "api/index.py", + "use": "@vercel/python" + } + ], + "routes": [ + { + "src": "/(.*)", + "dest": "api/index.py" + } + ] +}