From 7a4471e1aeb96a74fecea7957b9c13fe25f969f5 Mon Sep 17 00:00:00 2001 From: PerinMu <143702874+PerinMu@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:25:22 +0800 Subject: [PATCH 1/4] feat: add graphical modification builder --- CHANGELOG.md | 2 + README.md | 24 +++ README.zh-CN.md | 22 +++ docs/modifications.md | 24 +++ pyproject.toml | 4 + src/moltranscode/__init__.py | 2 + src/moltranscode/cli.py | 20 ++ src/moltranscode/gui.py | 138 +++++++++++++ src/moltranscode/gui_app.py | 315 ++++++++++++++++++++++++++++++ src/moltranscode/modifications.py | 158 ++++++++------- tests/test_gui.py | 87 +++++++++ tests/test_modifications.py | 13 ++ 12 files changed, 742 insertions(+), 67 deletions(-) create mode 100644 src/moltranscode/gui.py create mode 100644 src/moltranscode/gui_app.py create mode 100644 tests/test_gui.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c986c7e..7ea5adf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ versioning after the initial alpha releases. - Site-aware aliases for selected Unimod accessions. - Layerable JSON custom-modification registries, a JSON Schema, Python registration APIs, and explicit override protection. +- Optional bilingual local GUI for creating, validating, previewing, importing, and downloading + custom modification registries. - English project homepage with linked Simplified Chinese documentation. - Structural formula tests, registry validation tests, custom CLI examples, and modification-system documentation. diff --git a/README.md b/README.md index 0224d33..d819bf9 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ command-line interface. - Linear L/D peptide construction with explicit termini, backbone N-methylation, curated PTMs, and site-aware Unimod aliases - Layerable JSON registries and a Python API for custom side-chain and terminal structures +- Optional bilingual browser GUI for building, validating, previewing, importing, and exporting + custom modification registries - Morgan, RDKit path, MACCS, Atom Pair, Topological Torsion, and sequence k-mer fingerprints - Formula, exact mass, MolLogP, TPSA, HBD/HBA, rotatable-bond, heavy-atom, and charge descriptors - Optional PubChem PUG-REST name resolution, kept separate from deterministic local conversion @@ -51,6 +53,16 @@ pytest ruff check . ``` +Install the optional graphical modification builder: + +```bash +python -m pip install -e '.[gui]' +moltranscode gui +``` + +The interface opens locally at `http://localhost:8501`. It does not upload structures to a remote +service. Use `--no-browser`, `--host`, or `--port` when a different launch configuration is needed. + ## Quick start ```bash @@ -171,6 +183,18 @@ RDKit; registry provenance should be archived with generated data. See [Modification system](docs/modifications.md) for the schema, attachment semantics, safety rules, and Python registration API. +### Graphical modification builder + +Run `moltranscode gui` to open the optional English/Chinese interface. It provides: + +- forms for side-chain, N-terminal, and C-terminal definitions; +- immediate fragment, collision, and schema validation; +- an interactive peptide test with formula, exact mass, charge, canonical SMILES, and a 2D drawing; +- JSON import, removal of custom entries, explicit override control, and registry download. + +The interface exports the same schema accepted by `--mod-config`; no separate GUI-only project +format is introduced. + ## Batch conversion ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 014d4f4..a393116 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -20,6 +20,7 @@ 以及依赖单体元数据的 FASTA/sequence/HELM - 肽结构:混合 L/D 氨基酸、端基、骨架 N-甲基化、常见 PTM、按残基位点解析的 Unimod 别名 - 自定义修饰:可叠加的 JSON 配置和 Python 注册 API,无需修改源代码 +- 可选中英文浏览器图形界面,用于创建、验证、预览、导入和导出自定义修饰 - 指纹:Morgan、RDKit path、MACCS、Atom Pair、Topological Torsion、序列 k-mer - 描述符:分子式、精确质量、MolLogP、TPSA、HBD/HBA、可旋转键、重原子数、电荷 - 可选 PubChem PUG-REST 名称解析;批处理中逐条保留 `status` 和 `error` @@ -45,6 +46,16 @@ pytest ruff check . ``` +安装可选图形界面: + +```bash +python -m pip install -e '.[gui]' +moltranscode gui +``` + +界面默认在本机 `http://localhost:8501` 打开,不会把结构上传到远程服务。也可使用 +`--no-browser`、`--host` 和 `--port` 调整启动方式。 + ## 快速开始 ```bash @@ -153,6 +164,17 @@ sanitization,但用户仍需对自定义片段的化学含义负责,并随 完整字段、连接规则和 Python API 见[修饰系统说明](docs/modifications.md)。 +### 图形化修饰构建器 + +运行 `moltranscode gui` 可以打开中英文界面,支持: + +- 手动填写侧链、N 端和 C 端修饰; +- 即时检查 SMILES 片段、名称冲突和 JSON 字段; +- 输入测试肽并查看分子式、精确质量、电荷、canonical SMILES 与二维结构图; +- 导入现有 JSON、删除自定义条目、控制显式覆盖并下载最终修饰库。 + +图形界面导出的文件与 `--mod-config` 使用相同格式,不会产生只能由 GUI 读取的项目文件。 + ## 批处理与 Python API ```bash diff --git a/docs/modifications.md b/docs/modifications.md index caca04e..61066f8 100644 --- a/docs/modifications.md +++ b/docs/modifications.md @@ -144,6 +144,30 @@ result = convert("AK[AzK]", "peptide", "smiles", modification_registry=registry) Registry instances are mutable by design. Create one during application initialization and treat it as read-only while conversions run; call `clone()` before making request-specific changes. +## Graphical registry builder + +Install and launch the optional local interface: + +```bash +python -m pip install -e '.[gui]' +moltranscode gui +``` + +The Streamlit application supports English and Simplified Chinese. It validates each definition +against the same runtime loader used by the CLI, previews a test peptide as descriptors and a 2D +structure, imports existing registries, and downloads schema-versioned JSON. All processing occurs +in the local Python process; the application does not send molecular structures to MolTranscode or +another hosted service. + +For a headless or remote development environment: + +```bash +moltranscode gui --no-browser --host 127.0.0.1 --port 8765 +``` + +Binding to a non-loopback interface may expose the application to other machines. Add appropriate +network access controls before doing so, especially when working with confidential structures. + ## Provenance and contribution rules Archive custom JSON files alongside generated outputs and record the MolTranscode and RDKit diff --git a/pyproject.toml b/pyproject.toml index 80b9f89..4e23949 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,9 +33,13 @@ dev = [ "pytest-cov>=5.0", "ruff>=0.5", ] +gui = [ + "streamlit>=1.60", +] [project.scripts] moltranscode = "moltranscode.cli:main" +moltranscode-gui = "moltranscode.gui:main" [project.urls] Homepage = "https://github.com/PerinMu/MolTranscode" diff --git a/src/moltranscode/__init__.py b/src/moltranscode/__init__.py index 367d596..d09f788 100644 --- a/src/moltranscode/__init__.py +++ b/src/moltranscode/__init__.py @@ -12,6 +12,7 @@ ModificationRegistry, default_modification_registry, load_modification_registry, + modification_registry_from_dict, ) from .peptide import parse_peptide, peptide_to_smiles @@ -27,6 +28,7 @@ "describe", "fingerprint", "load_modification_registry", + "modification_registry_from_dict", "parse_peptide", "peptide_to_smiles", ] diff --git a/src/moltranscode/cli.py b/src/moltranscode/cli.py index 2f92f10..5411e25 100644 --- a/src/moltranscode/cli.py +++ b/src/moltranscode/cli.py @@ -101,6 +101,16 @@ def _base_parser() -> argparse.ArgumentParser: formats_parser.add_argument("--json", action="store_true", help="emit machine-readable JSON") _add_modification_config_argument(formats_parser) formats_parser.set_defaults(handler=_handle_formats) + + gui_parser = subparsers.add_parser( + "gui", help="launch the optional graphical modification builder" + ) + gui_parser.add_argument("--host", default="localhost", help="interface to bind (localhost)") + gui_parser.add_argument("--port", type=int, default=8501, help="local web interface port") + gui_parser.add_argument( + "--no-browser", action="store_true", help="do not open a browser automatically" + ) + gui_parser.set_defaults(handler=_handle_gui) return parser @@ -251,6 +261,16 @@ def _handle_formats(args: argparse.Namespace) -> int: return 0 +def _handle_gui(args: argparse.Namespace) -> int: + from .gui import launch_gui + + return launch_gui( + host=args.host, + port=args.port, + open_browser=not args.no_browser, + ) + + def main(argv: list[str] | None = None) -> int: parser = _base_parser() args = parser.parse_args(argv) diff --git a/src/moltranscode/gui.py b/src/moltranscode/gui.py new file mode 100644 index 0000000..ee063b9 --- /dev/null +++ b/src/moltranscode/gui.py @@ -0,0 +1,138 @@ +"""Launcher and testable helpers for the optional Streamlit interface.""" + +from __future__ import annotations + +import copy +import importlib.util +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +from .exceptions import InputFormatError +from .modifications import modification_registry_from_dict + +REGISTRY_SECTIONS = ( + "side_chain_modifications", + "n_terminal_modifications", + "c_terminal_modifications", +) + + +def new_registry_payload() -> dict[str, Any]: + """Return an empty schema-versioned custom modification registry.""" + + return { + "schema_version": 1, + "override": False, + "side_chain_modifications": [], + "n_terminal_modifications": [], + "c_terminal_modifications": [], + } + + +def normalize_registry_payload(payload: dict[str, Any]) -> dict[str, Any]: + """Fill optional sections and validate a GUI/import payload.""" + + normalized = new_registry_payload() + normalized.update(copy.deepcopy(payload)) + for section in REGISTRY_SECTIONS: + normalized.setdefault(section, []) + modification_registry_from_dict(normalized, source="GUI registry") + return normalized + + +def append_registry_entry( + payload: dict[str, Any], + section: str, + entry: dict[str, Any], +) -> dict[str, Any]: + """Return a validated payload containing one additional definition.""" + + if section not in REGISTRY_SECTIONS: + raise InputFormatError(f"Unknown modification section: {section!r}.") + updated = normalize_registry_payload(payload) + updated[section].append(copy.deepcopy(entry)) + modification_registry_from_dict(updated, source="GUI registry") + return updated + + +def remove_registry_entry( + payload: dict[str, Any], + section: str, + index: int, +) -> dict[str, Any]: + """Return a validated payload with one custom definition removed.""" + + if section not in REGISTRY_SECTIONS: + raise InputFormatError(f"Unknown modification section: {section!r}.") + updated = normalize_registry_payload(payload) + try: + del updated[section][index] + except IndexError as exc: + raise InputFormatError(f"No modification at index {index} in {section}.") from exc + modification_registry_from_dict(updated, source="GUI registry") + return updated + + +def aliases_from_text(value: str) -> list[str]: + """Parse comma-separated aliases while preserving order.""" + + aliases: list[str] = [] + for item in value.split(","): + alias = item.strip() + if alias and alias not in aliases: + aliases.append(alias) + return aliases + + +def registry_payload_json(payload: dict[str, Any]) -> str: + """Serialize a validated registry for download.""" + + normalized = normalize_registry_payload(payload) + return json.dumps(normalized, ensure_ascii=False, indent=2) + "\n" + + +def launch_gui( + *, + host: str = "localhost", + port: int = 8501, + open_browser: bool = True, +) -> int: + """Launch the optional local Streamlit application.""" + + if not 1 <= port <= 65535: + raise InputFormatError("GUI port must be between 1 and 65535.") + if importlib.util.find_spec("streamlit") is None: + raise InputFormatError( + "The GUI dependency is not installed. Run: python -m pip install -e '.[gui]'" + ) + app_path = Path(__file__).with_name("gui_app.py") + command = [ + sys.executable, + "-m", + "streamlit", + "run", + str(app_path), + "--server.address", + host, + "--server.port", + str(port), + "--server.headless", + "false" if open_browser else "true", + "--browser.gatherUsageStats", + "false", + ] + try: + return subprocess.run(command, check=False).returncode + except KeyboardInterrupt: + return 130 + + +def main() -> int: + try: + return launch_gui() + except InputFormatError as exc: + print(f"moltranscode-gui: error: {exc}", file=sys.stderr) + return 2 diff --git a/src/moltranscode/gui_app.py b/src/moltranscode/gui_app.py new file mode 100644 index 0000000..8f15d61 --- /dev/null +++ b/src/moltranscode/gui_app.py @@ -0,0 +1,315 @@ +"""Streamlit application for building and testing modification registries.""" + +from __future__ import annotations + +import json +from typing import Any + +import streamlit as st +from rdkit import Chem +from rdkit.Chem import Draw + +from moltranscode.chemistry import describe +from moltranscode.exceptions import MolTranscodeError +from moltranscode.gui import ( + aliases_from_text, + append_registry_entry, + new_registry_payload, + normalize_registry_payload, + registry_payload_json, + remove_registry_entry, +) +from moltranscode.modifications import ( + STANDARD_AA, + default_modification_registry, + modification_registry_from_dict, +) + +SECTION_LABELS = { + "side_chain_modifications": "Side chain", + "n_terminal_modifications": "N terminus", + "c_terminal_modifications": "C terminus", +} + +TEXT = { + "English": { + "title": "MolTranscode Modification Builder", + "subtitle": "Build, validate, preview, and export attachment-aware peptide modifications.", + "language": "Language", + "import": "Import registry", + "import_button": "Import JSON", + "import_ok": "Registry imported and validated.", + "reset": "Reset custom registry", + "override": "Allow explicit overrides", + "override_help": "Required only when intentionally replacing a built-in name/site pair.", + "builtins": "Browse built-in modifications", + "build_tab": "Build", + "preview_tab": "Test & preview", + "export_tab": "Export", + "type": "Modification type", + "name": "Name", + "residue": "Residue", + "fragment": "Side-chain SMILES", + "d_fragment": "D-residue side-chain SMILES (optional)", + "prefix": "N-terminal prefix SMILES", + "terminus": "C-terminal terminus SMILES", + "aliases": "Aliases (comma separated)", + "description": "Description / provenance note", + "add": "Validate and add", + "added": "Modification added.", + "custom": "Custom definitions", + "empty": "No custom definitions yet.", + "remove": "Remove", + "peptide": "Test peptide notation", + "test": "Build and validate peptide", + "formula": "Formula", + "mass": "Exact mass", + "charge": "Formal charge", + "smiles": "Canonical isomeric SMILES", + "download": "Download JSON registry", + "filename": "Download filename", + "json": "Registry JSON", + "cli": "Use the downloaded file from the command line", + "warning": ( + "A sanitizable graph can still represent the wrong chemistry. Review attachment " + "semantics and archive the JSON with your results." + ), + }, + "简体中文": { + "title": "MolTranscode 修饰构建器", + "subtitle": "手动创建、验证、预览并导出带连接语义的肽修饰。", + "language": "语言", + "import": "导入修饰库", + "import_button": "导入 JSON", + "import_ok": "修饰库已导入并通过验证。", + "reset": "清空自定义修饰", + "override": "允许显式覆盖", + "override_help": "仅在确实需要替换内置名称/位点组合时启用。", + "builtins": "查看内置修饰", + "build_tab": "创建修饰", + "preview_tab": "测试与预览", + "export_tab": "导出", + "type": "修饰类型", + "name": "名称", + "residue": "残基", + "fragment": "侧链 SMILES", + "d_fragment": "D-残基侧链 SMILES(可选)", + "prefix": "N 端前缀 SMILES", + "terminus": "C 端末端 SMILES", + "aliases": "别名(逗号分隔)", + "description": "说明或来源", + "add": "验证并添加", + "added": "修饰已添加。", + "custom": "自定义定义", + "empty": "还没有自定义修饰。", + "remove": "删除", + "peptide": "测试肽表示", + "test": "构建并验证肽", + "formula": "分子式", + "mass": "精确质量", + "charge": "形式电荷", + "smiles": "Canonical isomeric SMILES", + "download": "下载 JSON 修饰库", + "filename": "下载文件名", + "json": "修饰库 JSON", + "cli": "在命令行中使用下载的文件", + "warning": ( + "能够通过结构检查不等于化学含义一定正确。请核对连接规则,并将 JSON 与结果一起保存。" + ), + }, +} + + +def _payload() -> dict[str, Any]: + if "registry_payload" not in st.session_state: + st.session_state.registry_payload = new_registry_payload() + return st.session_state.registry_payload + + +def _example_for_entry(section: str, entry: dict[str, Any]) -> str: + name = entry["name"] + if section == "side_chain_modifications": + return f"A{entry['residue']}[{name}]A" + if section == "n_terminal_modifications": + return f"[{name}]-AKA" + return f"AKA-[{name}]" + + +def _entry_summary(section: str, entry: dict[str, Any]) -> str: + if section == "side_chain_modifications": + return f"{entry['residue']}[{entry['name']}] · {entry['side_chain_smiles']}" + field = "prefix_smiles" if section == "n_terminal_modifications" else "terminus_smiles" + return f"{entry['name']} · {entry[field]}" + + +def _render_sidebar(t: dict[str, str]) -> None: + uploaded = st.sidebar.file_uploader(t["import"], type=["json"]) + if st.sidebar.button(t["import_button"], disabled=uploaded is None): + try: + imported = json.loads(uploaded.getvalue().decode("utf-8")) + st.session_state.registry_payload = normalize_registry_payload(imported) + except (UnicodeDecodeError, json.JSONDecodeError, MolTranscodeError) as exc: + st.sidebar.error(str(exc)) + else: + st.sidebar.success(t["import_ok"]) + st.rerun() + + payload = _payload() + allow_override = st.sidebar.checkbox( + t["override"], + value=bool(payload.get("override", False)), + help=t["override_help"], + ) + if allow_override != payload.get("override", False): + payload["override"] = allow_override + + if st.sidebar.button(t["reset"]): + st.session_state.registry_payload = new_registry_payload() + st.session_state.test_peptide = "AKA" + st.rerun() + + with st.sidebar.expander(t["builtins"]): + summary = default_modification_registry().summary() + st.write("**N:** " + ", ".join(summary["n_terminal_modifications"])) + st.write("**C:** " + ", ".join(summary["c_terminal_modifications"])) + for residue, names in summary["side_chain_modifications"].items(): + st.write(f"**{residue}:** {', '.join(names)}") + + +def _render_builder(t: dict[str, str]) -> None: + section = st.radio( + t["type"], + list(SECTION_LABELS), + format_func=lambda value: SECTION_LABELS[value], + horizontal=True, + ) + with st.form("add_modification", clear_on_submit=False): + name = st.text_input(t["name"], placeholder="AzidoLys") + residue = "K" + d_fragment = "" + if section == "side_chain_modifications": + residue = st.selectbox(t["residue"], sorted(STANDARD_AA), index=8) + fragment = st.text_input(t["fragment"], placeholder="CCCCN=[N+]=[N-]") + d_fragment = st.text_input(t["d_fragment"]) + elif section == "n_terminal_modifications": + fragment = st.text_input(t["prefix"], placeholder="CCC(=O)") + else: + fragment = st.text_input(t["terminus"], placeholder="C(=O)NC") + aliases_text = st.text_input(t["aliases"], placeholder="AzK, UNIMOD:xxxx") + description = st.text_area(t["description"], height=80) + submitted = st.form_submit_button(t["add"], type="primary") + + if submitted: + aliases = aliases_from_text(aliases_text) + entry: dict[str, Any] = {"name": name.strip()} + if aliases: + entry["aliases"] = aliases + if description.strip(): + entry["description"] = description.strip() + if section == "side_chain_modifications": + entry.update({"residue": residue, "side_chain_smiles": fragment.strip()}) + if d_fragment.strip(): + entry["d_side_chain_smiles"] = d_fragment.strip() + elif section == "n_terminal_modifications": + entry["prefix_smiles"] = fragment.strip() + else: + entry["terminus_smiles"] = fragment.strip() + try: + updated = append_registry_entry(_payload(), section, entry) + except MolTranscodeError as exc: + st.error(str(exc)) + else: + st.session_state.registry_payload = updated + st.session_state.test_peptide = _example_for_entry(section, entry) + st.success(t["added"]) + st.rerun() + + st.subheader(t["custom"]) + total = sum(len(_payload()[key]) for key in SECTION_LABELS) + if not total: + st.info(t["empty"]) + return + for section_key, label in SECTION_LABELS.items(): + entries = _payload()[section_key] + if not entries: + continue + st.markdown(f"**{label}**") + for index, entry in enumerate(entries): + text_col, remove_col = st.columns([5, 1]) + text_col.code(_entry_summary(section_key, entry), language=None) + if remove_col.button(t["remove"], key=f"remove-{section_key}-{index}"): + st.session_state.registry_payload = remove_registry_entry( + _payload(), section_key, index + ) + st.rerun() + + +def _render_preview(t: dict[str, str]) -> None: + if "test_peptide" not in st.session_state: + st.session_state.test_peptide = "AKA" + peptide = st.text_input(t["peptide"], key="test_peptide") + if st.button(t["test"], type="primary"): + try: + registry = modification_registry_from_dict(_payload(), source="GUI registry") + properties = describe(peptide, "peptide", modification_registry=registry) + molecule = Chem.MolFromSmiles(properties["canonical_smiles"]) + except MolTranscodeError as exc: + st.error(str(exc)) + return + metric_columns = st.columns(3) + metric_columns[0].metric(t["formula"], properties["formula"]) + metric_columns[1].metric(t["mass"], f"{properties['exact_mw']:.6f}") + metric_columns[2].metric(t["charge"], properties["formal_charge"]) + st.text_area(t["smiles"], properties["canonical_smiles"], height=120) + if molecule is not None: + st.image(Draw.MolToImage(molecule, size=(900, 420)), width="stretch") + + +def _render_export(t: dict[str, str]) -> None: + try: + content = registry_payload_json(_payload()) + except MolTranscodeError as exc: + st.error(str(exc)) + return + filename = st.text_input(t["filename"], value="moltranscode-modifications.json") + st.download_button( + t["download"], + data=content, + file_name=filename or "moltranscode-modifications.json", + mime="application/json", + type="primary", + ) + st.caption(t["cli"]) + st.code( + f"moltranscode convert 'AK[YourModification]' --from peptide --to smiles " + f"--mod-config {filename or 'moltranscode-modifications.json'}", + language="bash", + ) + with st.expander(t["json"]): + st.code(content, language="json") + + +def run_app() -> None: + st.set_page_config( + page_title="MolTranscode Modification Builder", + page_icon="🧬", + layout="wide", + ) + language = st.sidebar.selectbox(TEXT["English"]["language"], list(TEXT)) + t = TEXT[language] + st.title(t["title"]) + st.caption(t["subtitle"]) + _render_sidebar(t) + build_tab, preview_tab, export_tab = st.tabs( + [t["build_tab"], t["preview_tab"], t["export_tab"]] + ) + with build_tab: + _render_builder(t) + with preview_tab: + _render_preview(t) + with export_tab: + _render_export(t) + st.warning(t["warning"], icon="⚠️") + + +run_app() diff --git a/src/moltranscode/modifications.py b/src/moltranscode/modifications.py index d9604aa..fb1c701 100644 --- a/src/moltranscode/modifications.py +++ b/src/moltranscode/modifications.py @@ -389,6 +389,96 @@ def _check_entry_keys( ) +def _merge_modification_payload( + registry: ModificationRegistry, + payload: dict[str, Any], + *, + source: str, +) -> None: + unknown = set(payload) - { + "schema_version", + "override", + "side_chain_modifications", + "n_terminal_modifications", + "c_terminal_modifications", + } + if unknown: + raise ModificationError( + f"Unknown registry keys in {source}: {', '.join(sorted(unknown))}." + ) + if payload.get("schema_version", 1) != 1: + raise ModificationError(f"Unsupported schema_version in {source}; expected 1.") + override = payload.get("override", False) + if not isinstance(override, bool): + raise ModificationError(f"override in {source} must be true or false.") + + for index, entry in enumerate(_entry_list(payload, "side_chain_modifications"), start=1): + context = f"{source}: side_chain_modifications[{index}]" + _check_entry_keys( + entry, + { + "name", + "residue", + "side_chain_smiles", + "d_side_chain_smiles", + "aliases", + "description", + }, + context=context, + ) + registry.register_side_chain( + name=_required_string(entry, "name", context=context), + residue=_required_string(entry, "residue", context=context), + side_chain_smiles=_required_string(entry, "side_chain_smiles", context=context), + d_side_chain_smiles=_optional_string( + entry, "d_side_chain_smiles", context=context + ), + aliases=_as_aliases(entry.get("aliases"), context=context), + override=override, + ) + for index, entry in enumerate(_entry_list(payload, "n_terminal_modifications"), start=1): + context = f"{source}: n_terminal_modifications[{index}]" + _check_entry_keys( + entry, + {"name", "prefix_smiles", "aliases", "description"}, + context=context, + ) + registry.register_n_terminus( + name=_required_string(entry, "name", context=context), + prefix_smiles=_required_string(entry, "prefix_smiles", context=context), + aliases=_as_aliases(entry.get("aliases"), context=context), + override=override, + ) + for index, entry in enumerate(_entry_list(payload, "c_terminal_modifications"), start=1): + context = f"{source}: c_terminal_modifications[{index}]" + _check_entry_keys( + entry, + {"name", "terminus_smiles", "aliases", "description"}, + context=context, + ) + registry.register_c_terminus( + name=_required_string(entry, "name", context=context), + terminus_smiles=_required_string(entry, "terminus_smiles", context=context), + aliases=_as_aliases(entry.get("aliases"), context=context), + override=override, + ) + + +def modification_registry_from_dict( + payload: dict[str, Any], + *, + base: ModificationRegistry | None = None, + source: str = "in-memory registry", +) -> ModificationRegistry: + """Validate and merge an in-memory registry payload into a fresh registry.""" + + if not isinstance(payload, dict): + raise ModificationError(f"Modification registry {source} must be a JSON object.") + registry = base.clone() if base else default_modification_registry() + _merge_modification_payload(registry, payload, source=source) + return registry + + def load_modification_registry( paths: str | Path | list[str | Path] | tuple[str | Path, ...], *, @@ -408,71 +498,5 @@ def load_modification_registry( raise ModificationError(f"Invalid JSON in modification registry {path}: {exc}") from exc if not isinstance(payload, dict): raise ModificationError(f"Modification registry {path} must contain a JSON object.") - unknown = set(payload) - { - "schema_version", - "override", - "side_chain_modifications", - "n_terminal_modifications", - "c_terminal_modifications", - } - if unknown: - raise ModificationError( - f"Unknown registry keys in {path}: {', '.join(sorted(unknown))}." - ) - if payload.get("schema_version", 1) != 1: - raise ModificationError(f"Unsupported schema_version in {path}; expected 1.") - override = payload.get("override", False) - if not isinstance(override, bool): - raise ModificationError(f"override in {path} must be true or false.") - - for index, entry in enumerate(_entry_list(payload, "side_chain_modifications"), start=1): - context = f"side_chain_modifications[{index}]" - _check_entry_keys( - entry, - { - "name", - "residue", - "side_chain_smiles", - "d_side_chain_smiles", - "aliases", - "description", - }, - context=context, - ) - registry.register_side_chain( - name=_required_string(entry, "name", context=context), - residue=_required_string(entry, "residue", context=context), - side_chain_smiles=_required_string(entry, "side_chain_smiles", context=context), - d_side_chain_smiles=_optional_string( - entry, "d_side_chain_smiles", context=context - ), - aliases=_as_aliases(entry.get("aliases"), context=context), - override=override, - ) - for index, entry in enumerate(_entry_list(payload, "n_terminal_modifications"), start=1): - context = f"n_terminal_modifications[{index}]" - _check_entry_keys( - entry, - {"name", "prefix_smiles", "aliases", "description"}, - context=context, - ) - registry.register_n_terminus( - name=_required_string(entry, "name", context=context), - prefix_smiles=_required_string(entry, "prefix_smiles", context=context), - aliases=_as_aliases(entry.get("aliases"), context=context), - override=override, - ) - for index, entry in enumerate(_entry_list(payload, "c_terminal_modifications"), start=1): - context = f"c_terminal_modifications[{index}]" - _check_entry_keys( - entry, - {"name", "terminus_smiles", "aliases", "description"}, - context=context, - ) - registry.register_c_terminus( - name=_required_string(entry, "name", context=context), - terminus_smiles=_required_string(entry, "terminus_smiles", context=context), - aliases=_as_aliases(entry.get("aliases"), context=context), - override=override, - ) + _merge_modification_payload(registry, payload, source=str(path)) return registry diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 0000000..c610639 --- /dev/null +++ b/tests/test_gui.py @@ -0,0 +1,87 @@ +import json +from types import SimpleNamespace + +import pytest +from rdkit import Chem + +from moltranscode.exceptions import InputFormatError, ModificationError +from moltranscode.gui import ( + aliases_from_text, + append_registry_entry, + launch_gui, + main, + new_registry_payload, + registry_payload_json, + remove_registry_entry, +) +from moltranscode.modifications import modification_registry_from_dict +from moltranscode.peptide import peptide_to_smiles + + +def test_gui_payload_add_preview_export_and_remove(): + payload = append_registry_entry( + new_registry_payload(), + "side_chain_modifications", + { + "name": "AzidoLys", + "residue": "K", + "side_chain_smiles": "CCCCN=[N+]=[N-]", + "aliases": ["AzK"], + }, + ) + registry = modification_registry_from_dict(payload) + assert Chem.MolFromSmiles(peptide_to_smiles("AK[AzK]", registry)) is not None + + exported = json.loads(registry_payload_json(payload)) + assert exported["side_chain_modifications"][0]["name"] == "AzidoLys" + + emptied = remove_registry_entry(payload, "side_chain_modifications", 0) + assert emptied["side_chain_modifications"] == [] + + +def test_gui_payload_rejects_builtin_collision(): + with pytest.raises(ModificationError, match="override=true"): + append_registry_entry( + new_registry_payload(), + "side_chain_modifications", + {"name": "Acetyl", "residue": "K", "side_chain_smiles": "CCCCN"}, + ) + + +def test_alias_text_is_trimmed_and_deduplicated(): + assert aliases_from_text(" AzK, azido lysine, AzK, ") == ["AzK", "azido lysine"] + + +def test_gui_launcher_reports_missing_optional_dependency(monkeypatch): + monkeypatch.setattr("moltranscode.gui.importlib.util.find_spec", lambda name: None) + with pytest.raises(InputFormatError, match=r"\[gui\]"): + launch_gui() + + +def test_gui_console_script_reports_missing_dependency(monkeypatch, capsys): + monkeypatch.setattr("moltranscode.gui.importlib.util.find_spec", lambda name: None) + assert main() == 2 + assert "pip install -e '.[gui]'" in capsys.readouterr().err + + +def test_gui_launcher_uses_streamlit_without_shell(monkeypatch): + captured = {} + + monkeypatch.setattr("moltranscode.gui.importlib.util.find_spec", lambda name: object()) + + def fake_run(command, *, check): + captured["command"] = command + captured["check"] = check + return SimpleNamespace(returncode=0) + + monkeypatch.setattr("moltranscode.gui.subprocess.run", fake_run) + assert launch_gui(host="127.0.0.1", port=8765, open_browser=False) == 0 + assert captured["command"][1:4] == ["-m", "streamlit", "run"] + assert "--server.headless" in captured["command"] + assert "true" in captured["command"] + assert captured["check"] is False + + +def test_gui_launcher_rejects_invalid_port(): + with pytest.raises(InputFormatError, match="between 1 and 65535"): + launch_gui(port=0) diff --git a/tests/test_modifications.py b/tests/test_modifications.py index d115628..f1bb2f9 100644 --- a/tests/test_modifications.py +++ b/tests/test_modifications.py @@ -9,6 +9,7 @@ from moltranscode.modifications import ( default_modification_registry, load_modification_registry, + modification_registry_from_dict, ) from moltranscode.peptide import parse_peptide, peptide_to_smiles @@ -179,3 +180,15 @@ def test_registry_summary_contains_extended_modifications(): "Myristoyl", "Palmitoyl", ] + + +def test_in_memory_registry_payload_uses_the_same_validation(): + registry = modification_registry_from_dict( + { + "side_chain_modifications": [ + {"name": "AzidoLys", "residue": "K", "side_chain_smiles": "CCCCN=[N+]=[N-]"} + ] + }, + source="unit test", + ) + assert registry.resolve_side_chain("K", "AzidoLys")[0] == "AzidoLys" From f98e11187f2cb695c130aa000c4117fee10677ce Mon Sep 17 00:00:00 2001 From: PerinMu <143702874+PerinMu@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:46:15 +0800 Subject: [PATCH 2/4] feat: add single-entry conversion workbench --- CHANGELOG.md | 4 +- README.md | 24 +- README.zh-CN.md | 19 +- docs/gui.md | 69 +++++ docs/modifications.md | 15 +- src/moltranscode/cli.py | 2 +- src/moltranscode/gui.py | 122 +++++++- src/moltranscode/gui_app.py | 551 ++++++++++++++++++++++++++++++------ tests/test_gui.py | 57 +++- 9 files changed, 745 insertions(+), 118 deletions(-) create mode 100644 docs/gui.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ea5adf..d02360f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,8 @@ versioning after the initial alpha releases. - Site-aware aliases for selected Unimod accessions. - Layerable JSON custom-modification registries, a JSON Schema, Python registration APIs, and explicit override protection. -- Optional bilingual local GUI for creating, validating, previewing, importing, and downloading - custom modification registries. +- Optional bilingual local workbench for one-at-a-time conversions, visual peptide modification, + fingerprints, descriptors, result history, and custom modification registries. - English project homepage with linked Simplified Chinese documentation. - Structural formula tests, registry validation tests, custom CLI examples, and modification-system documentation. diff --git a/README.md b/README.md index d819bf9..c22663d 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ command-line interface. - Linear L/D peptide construction with explicit termini, backbone N-methylation, curated PTMs, and site-aware Unimod aliases - Layerable JSON registries and a Python API for custom side-chain and terminal structures -- Optional bilingual browser GUI for building, validating, previewing, importing, and exporting - custom modification registries +- Optional bilingual browser workbench for one-at-a-time conversion, visual peptide modification, + fingerprints, descriptors, result history, and custom registry editing - Morgan, RDKit path, MACCS, Atom Pair, Topological Torsion, and sequence k-mer fingerprints - Formula, exact mass, MolLogP, TPSA, HBD/HBA, rotatable-bond, heavy-atom, and charge descriptors - Optional PubChem PUG-REST name resolution, kept separate from deterministic local conversion @@ -53,7 +53,7 @@ pytest ruff check . ``` -Install the optional graphical modification builder: +Install the optional graphical workbench: ```bash python -m pip install -e '.[gui]' @@ -183,17 +183,21 @@ RDKit; registry provenance should be archived with generated data. See [Modification system](docs/modifications.md) for the schema, attachment semantics, safety rules, and Python registration API. -### Graphical modification builder +### Graphical single-entry workbench -Run `moltranscode gui` to open the optional English/Chinese interface. It provides: +Run `moltranscode gui` to open the optional English/Chinese interface. It is designed for manual, +non-batch work and provides: -- forms for side-chain, N-terminal, and C-terminal definitions; -- immediate fragment, collision, and schema validation; -- an interactive peptide test with formula, exact mass, charge, canonical SMILES, and a 2D drawing; -- JSON import, removal of custom entries, explicit override control, and registry download. +- one-at-a-time input for peptides, sequences, SMILES, InChI, SELFIES, HELM, and structure blocks; +- visual residue-position PTM selection, N/C termini, backbone N-methylation, D-residue notation, + and an editable generated peptide annotation; +- selectable molecular output formats, fingerprints, or descriptor calculation; +- result cards with formula, exact mass, charge, canonical SMILES, a 2D drawing, and downloads; +- persistent session history with JSON/CSV export while additional entries are submitted; +- custom side-chain/N-terminal/C-terminal forms with immediate validation and registry import/export. The interface exports the same schema accepted by `--mod-config`; no separate GUI-only project -format is introduced. +format is introduced. See the [GUI guide](docs/gui.md) for the complete workflow. ## Batch conversion diff --git a/README.zh-CN.md b/README.zh-CN.md index a393116..9f66d8c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -20,7 +20,7 @@ 以及依赖单体元数据的 FASTA/sequence/HELM - 肽结构:混合 L/D 氨基酸、端基、骨架 N-甲基化、常见 PTM、按残基位点解析的 Unimod 别名 - 自定义修饰:可叠加的 JSON 配置和 Python 注册 API,无需修改源代码 -- 可选中英文浏览器图形界面,用于创建、验证、预览、导入和导出自定义修饰 +- 可选中英文单条转换工作台,支持可视化肽修饰、指纹、描述符、结果历史和自定义修饰库 - 指纹:Morgan、RDKit path、MACCS、Atom Pair、Topological Torsion、序列 k-mer - 描述符:分子式、精确质量、MolLogP、TPSA、HBD/HBA、可旋转键、重原子数、电荷 - 可选 PubChem PUG-REST 名称解析;批处理中逐条保留 `status` 和 `error` @@ -46,7 +46,7 @@ pytest ruff check . ``` -安装可选图形界面: +安装可选图形工作台: ```bash python -m pip install -e '.[gui]' @@ -164,16 +164,19 @@ sanitization,但用户仍需对自定义片段的化学含义负责,并随 完整字段、连接规则和 Python API 见[修饰系统说明](docs/modifications.md)。 -### 图形化修饰构建器 +### 图形化单条转换工作台 -运行 `moltranscode gui` 可以打开中英文界面,支持: +运行 `moltranscode gui` 可以打开中英文界面,适合手动处理非批量数据,支持: -- 手动填写侧链、N 端和 C 端修饰; -- 即时检查 SMILES 片段、名称冲突和 JSON 字段; -- 输入测试肽并查看分子式、精确质量、电荷、canonical SMILES 与二维结构图; -- 导入现有 JSON、删除自定义条目、控制显式覆盖并下载最终修饰库。 +- 逐条输入肽、序列、SMILES、InChI、SELFIES、HELM 或结构块; +- 按残基位置选择 PTM、N/C 端基、骨架 N-甲基化和 D-残基,并生成可编辑的肽表示; +- 选择分子格式转换、指纹生成或描述符计算; +- 查看分子式、精确质量、电荷、canonical SMILES、二维结构图并下载结果; +- 连续添加下一条数据,并将当前会话历史导出为 JSON 或 CSV; +- 创建侧链/N 端/C 端自定义修饰,即时验证并导入或下载修饰库。 图形界面导出的文件与 `--mod-config` 使用相同格式,不会产生只能由 GUI 读取的项目文件。 +完整操作说明见[图形界面指南](docs/gui.md)。 ## 批处理与 Python API diff --git a/docs/gui.md b/docs/gui.md new file mode 100644 index 0000000..6933b2f --- /dev/null +++ b/docs/gui.md @@ -0,0 +1,69 @@ +# Graphical single-entry workbench + +The optional GUI is intended for manual, non-batch exploration. It runs locally, uses the same +conversion and modification APIs as the CLI, and keeps successful entries in an in-memory session +history. For large files or reproducible pipelines, use the CLI batch commands instead. + +## Start the interface + +```bash +python -m pip install -e '.[gui]' +moltranscode gui +``` + +The default address is `http://localhost:8501`. Closing the terminal or pressing `Ctrl+C` stops the +server. Alternative launch options include: + +```bash +moltranscode gui --no-browser --host 127.0.0.1 --port 8765 +``` + +## Convert entries one at a time + +Open **Single-entry workbench** and repeat this workflow for each record: + +1. Select the input format. Peptide, sequence, SMILES, InChI, SELFIES, FASTA, HELM, MOL, SDF, PDB, + and conservative automatic detection are available. +2. Enter exactly one value or structure block. +3. Choose **Convert representation**, **Generate fingerprint**, or **Calculate descriptors**. +4. Select the output format or fingerprint parameters and run the entry. +5. Inspect the result card and 2D drawing, then enter the next record. + +Successful runs are added to **Entry history**. Individual results can be downloaded or removed, +and the complete session can be exported as JSON or CSV. History is stored in the current browser +session only; download it before stopping the application when it must be retained. + +## Build a modified peptide visually + +Choose `peptide` as the input format. Enter a one-letter sequence, using lowercase letters for D +residues. The editor then exposes: + +- N- and C-terminal groups from the active registry; +- residue positions labeled as `position: residue`; +- only the side-chain modifications registered for the selected residue; +- backbone `NMe` at residues with an available backbone N-H; +- the generated bracketed peptide notation. + +One side-chain modification plus `NMe` may be applied to a residue. Applying a new side-chain +modification at the same position replaces the previous side-chain selection. Changing the base +sequence clears position-based modifications to avoid silently moving a PTM to another residue. + +Enable **Edit annotated notation manually** when direct notation entry is more convenient. The +final notation is still validated by the peptide parser before conversion. + +## Add laboratory-specific modifications + +Open **Custom modification library** to add side-chain, N-terminal, or C-terminal definitions. New +definitions become available immediately in the peptide editor. The sidebar imports an existing +registry, while **Registry export** downloads the current schema-versioned JSON for later CLI use. + +Custom fragments are validated for SMILES syntax, connectivity, wildcard use, site collisions, and +schema fields. This does not establish that a chemically valid graph matches the intended +experimental modification; review the attachment semantics in [Modification system](modifications.md). + +## Local-data and network behavior + +Molecular processing runs in the local Python process and MolTranscode does not upload structures +to a hosted service. The default `localhost` binding is accessible only from the local machine. +Binding to another interface can expose the workbench to the network; apply authentication and +network controls before doing so with confidential structures. diff --git a/docs/modifications.md b/docs/modifications.md index 61066f8..d19abe8 100644 --- a/docs/modifications.md +++ b/docs/modifications.md @@ -144,7 +144,7 @@ result = convert("AK[AzK]", "peptide", "smiles", modification_registry=registry) Registry instances are mutable by design. Create one during application initialization and treat it as read-only while conversions run; call `clone()` before making request-specific changes. -## Graphical registry builder +## Graphical workbench and registry builder Install and launch the optional local interface: @@ -153,11 +153,14 @@ python -m pip install -e '.[gui]' moltranscode gui ``` -The Streamlit application supports English and Simplified Chinese. It validates each definition -against the same runtime loader used by the CLI, previews a test peptide as descriptors and a 2D -structure, imports existing registries, and downloads schema-versioned JSON. All processing occurs -in the local Python process; the application does not send molecular structures to MolTranscode or -another hosted service. +The Streamlit application supports English and Simplified Chinese. Its main workbench accepts one +input at a time, includes a residue-position peptide modification editor, and can convert formats or +generate descriptors and fingerprints. A separate library tab validates each custom definition +against the same runtime loader used by the CLI, imports existing registries, and downloads +schema-versioned JSON. See the [GUI guide](gui.md). + +All processing occurs in the local Python process; the application does not send molecular +structures to MolTranscode or another hosted service. For a headless or remote development environment: diff --git a/src/moltranscode/cli.py b/src/moltranscode/cli.py index 5411e25..f7e266a 100644 --- a/src/moltranscode/cli.py +++ b/src/moltranscode/cli.py @@ -103,7 +103,7 @@ def _base_parser() -> argparse.ArgumentParser: formats_parser.set_defaults(handler=_handle_formats) gui_parser = subparsers.add_parser( - "gui", help="launch the optional graphical modification builder" + "gui", help="launch the optional single-entry conversion workbench" ) gui_parser.add_argument("--host", default="localhost", help="interface to bind (localhost)") gui_parser.add_argument("--port", type=int, default=8501, help="local web interface port") diff --git a/src/moltranscode/gui.py b/src/moltranscode/gui.py index ee063b9..a610a34 100644 --- a/src/moltranscode/gui.py +++ b/src/moltranscode/gui.py @@ -3,21 +3,27 @@ from __future__ import annotations import copy +import csv import importlib.util +import io import json import subprocess import sys +from collections.abc import Mapping, Sequence from pathlib import Path from typing import Any +from .chemistry import convert, describe, fingerprint from .exceptions import InputFormatError -from .modifications import modification_registry_from_dict +from .modifications import ModificationRegistry, modification_registry_from_dict +from .peptide import parse_peptide REGISTRY_SECTIONS = ( "side_chain_modifications", "n_terminal_modifications", "c_terminal_modifications", ) +GUI_TASKS = ("convert", "fingerprint", "describe") def new_registry_payload() -> dict[str, Any]: @@ -94,6 +100,120 @@ def registry_payload_json(payload: dict[str, Any]) -> str: return json.dumps(normalized, ensure_ascii=False, indent=2) + "\n" +def available_site_modifications( + registry: ModificationRegistry, + residue: str, +) -> list[str]: + """Return canonical modifications that can be applied at one residue.""" + + aa = residue.upper() + names = sorted(name for site, name in registry.side_chains if site == aa) + if aa != "P": + names.insert(0, "NMe") + return names + + +def build_peptide_notation( + sequence: str, + *, + n_terminus: str | None = None, + c_terminus: str | None = None, + residue_modifications: Mapping[int, Sequence[str]] | None = None, + registry: ModificationRegistry | None = None, +) -> str: + """Compose and validate peptide notation from visual-editor state.""" + + compact = "".join(sequence.split()) + if not compact: + raise InputFormatError("Peptide sequence is empty.") + modifications = residue_modifications or {} + for position in modifications: + if position < 0 or position >= len(compact): + raise InputFormatError( + f"Modified residue position {position + 1} is outside the sequence." + ) + body = "".join( + residue + + "".join(f"[{name}]" for name in modifications.get(index, ())) + for index, residue in enumerate(compact) + ) + notation = f"[{n_terminus}]-{body}" if n_terminus else body + if c_terminus: + notation += f"-[{c_terminus}]" + parse_peptide(notation, registry) + return notation + + +def run_single_entry( + value: str, + input_format: str, + *, + task: str = "convert", + output_format: str = "smiles", + fingerprint_kind: str = "morgan", + fingerprint_bits: int = 2048, + fingerprint_radius: int = 2, + fingerprint_k: int = 3, + fingerprint_output: str = "hex", + registry: ModificationRegistry | None = None, + identifier: int | str = 1, +) -> dict[str, Any]: + """Run one GUI job and return a serializable history record.""" + + if task not in GUI_TASKS: + raise InputFormatError(f"Unknown GUI task: {task!r}.") + properties = describe(value, input_format, modification_registry=registry) + if task == "convert": + result: Any = convert( + value, + input_format, + output_format, + modification_registry=registry, + ) + result_format = output_format + elif task == "fingerprint": + result = fingerprint( + value, + input_format, + kind=fingerprint_kind, + n_bits=fingerprint_bits, + radius=fingerprint_radius, + k=fingerprint_k, + output_format=fingerprint_output, + modification_registry=registry, + ) + result_format = f"fingerprint:{fingerprint_kind}:{fingerprint_output}" + else: + result = properties + result_format = "descriptors" + return { + "id": str(identifier), + "input": value, + "input_format": input_format, + "task": task, + "output_format": result_format, + "result": result, + "descriptors": properties, + } + + +def conversion_history_json(records: Sequence[dict[str, Any]]) -> str: + return json.dumps(list(records), ensure_ascii=False, indent=2) + "\n" + + +def conversion_history_csv(records: Sequence[dict[str, Any]]) -> str: + fields = ("id", "input", "input_format", "task", "output_format", "result") + buffer = io.StringIO() + writer = csv.DictWriter(buffer, fieldnames=fields) + writer.writeheader() + for record in records: + row = {field: record.get(field, "") for field in fields} + if isinstance(row["result"], (dict, list)): + row["result"] = json.dumps(row["result"], ensure_ascii=False) + writer.writerow(row) + return buffer.getvalue() + + def launch_gui( *, host: str = "localhost", diff --git a/src/moltranscode/gui_app.py b/src/moltranscode/gui_app.py index 8f15d61..1499b33 100644 --- a/src/moltranscode/gui_app.py +++ b/src/moltranscode/gui_app.py @@ -1,4 +1,4 @@ -"""Streamlit application for building and testing modification registries.""" +"""Streamlit single-entry workbench and modification-library editor.""" from __future__ import annotations @@ -9,44 +9,116 @@ from rdkit import Chem from rdkit.Chem import Draw -from moltranscode.chemistry import describe +from moltranscode.chemistry import FINGERPRINT_KINDS, OUTPUT_FORMATS from moltranscode.exceptions import MolTranscodeError from moltranscode.gui import ( aliases_from_text, append_registry_entry, + available_site_modifications, + build_peptide_notation, + conversion_history_csv, + conversion_history_json, new_registry_payload, normalize_registry_payload, registry_payload_json, remove_registry_entry, + run_single_entry, ) from moltranscode.modifications import ( STANDARD_AA, - default_modification_registry, + ModificationRegistry, modification_registry_from_dict, ) -SECTION_LABELS = { - "side_chain_modifications": "Side chain", - "n_terminal_modifications": "N terminus", - "c_terminal_modifications": "C terminus", +SECTION_LABEL_KEYS = { + "side_chain_modifications": "side_chain", + "n_terminal_modifications": "n_terminal", + "c_terminal_modifications": "c_terminal", } +DIRECT_INPUT_FORMATS = ( + "peptide", + "smiles", + "inchi", + "selfies", + "sequence", + "protein", + "fasta", + "helm", + "mol", + "sdf", + "pdb", + "auto", +) TEXT = { "English": { - "title": "MolTranscode Modification Builder", - "subtitle": "Build, validate, preview, and export attachment-aware peptide modifications.", + "title": "MolTranscode Workbench", + "subtitle": ( + "Enter structures one at a time, apply peptide modifications visually, and choose " + "the representation or feature you want to generate." + ), "language": "Language", - "import": "Import registry", + "import": "Import modification registry", "import_button": "Import JSON", "import_ok": "Registry imported and validated.", "reset": "Reset custom registry", "override": "Allow explicit overrides", "override_help": "Required only when intentionally replacing a built-in name/site pair.", - "builtins": "Browse built-in modifications", - "build_tab": "Build", - "preview_tab": "Test & preview", - "export_tab": "Export", + "builtins": "Browse active modifications", + "workbench_tab": "Single-entry workbench", + "library_tab": "Custom modification library", + "export_tab": "Registry export", + "new_entry": "1. Enter one molecule or sequence", + "input_format": "Input format", + "input_value": "Input value", + "input_help": "Paste one record. Use the CLI file mode for batch conversion.", + "peptide_sequence": "Peptide sequence", + "peptide_help": "Use one-letter amino-acid codes; lowercase letters represent D residues.", + "terminal_groups": "Terminal groups", + "n_terminal": "N terminus", + "c_terminal": "C terminus", + "none": "None", + "site_editor": "Residue modification editor", + "position": "Residue position", + "site_modification": "Modification", + "apply": "Apply modification", + "clear_site": "Clear selected residue", + "applied": "Applied modifications", + "no_site_mods": "No residue modifications applied.", + "generated_notation": "Generated peptide notation", + "manual_notation": "Edit annotated notation manually", + "manual_notation_value": "Annotated peptide notation", + "sequence_changed": "Residue modifications were cleared because the sequence changed.", + "task": "2. Choose an operation", + "convert": "Convert representation", + "fingerprint": "Generate fingerprint", + "describe": "Calculate descriptors", + "output_format": "Output format", + "fingerprint_kind": "Fingerprint kind", + "fingerprint_bits": "Fingerprint bits", + "fingerprint_radius": "Morgan radius", + "fingerprint_k": "Sequence k-mer size", + "fingerprint_output": "Fingerprint output", + "run": "Run and add result", + "result": "Latest result", + "no_result": "Run an entry to see its result here.", + "formula": "Formula", + "mass": "Exact mass", + "charge": "Formal charge", + "heavy_atoms": "Heavy atoms", + "canonical_smiles": "Canonical isomeric SMILES", + "result_value": "Result", + "download_result": "Download this result", + "history": "Entry history", + "history_help": "Each successful run is retained so you can enter the next record.", + "empty_history": "No entries have been run yet.", + "remove_result": "Remove result", + "clear_history": "Clear history", + "download_history_json": "Download history JSON", + "download_history_csv": "Download history CSV", + "entry": "Entry", "type": "Modification type", + "side_chain": "Side chain", "name": "Name", "residue": "Residue", "fragment": "Side-chain SMILES", @@ -60,24 +132,18 @@ "custom": "Custom definitions", "empty": "No custom definitions yet.", "remove": "Remove", - "peptide": "Test peptide notation", - "test": "Build and validate peptide", - "formula": "Formula", - "mass": "Exact mass", - "charge": "Formal charge", - "smiles": "Canonical isomeric SMILES", "download": "Download JSON registry", "filename": "Download filename", "json": "Registry JSON", "cli": "Use the downloaded file from the command line", "warning": ( "A sanitizable graph can still represent the wrong chemistry. Review attachment " - "semantics and archive the JSON with your results." + "semantics and archive the registry JSON with your results." ), }, "简体中文": { - "title": "MolTranscode 修饰构建器", - "subtitle": "手动创建、验证、预览并导出带连接语义的肽修饰。", + "title": "MolTranscode 单条转换工作台", + "subtitle": "逐条输入结构或序列,可视化添加肽修饰,并选择需要生成的分子表示或特征。", "language": "语言", "import": "导入修饰库", "import_button": "导入 JSON", @@ -85,11 +151,61 @@ "reset": "清空自定义修饰", "override": "允许显式覆盖", "override_help": "仅在确实需要替换内置名称/位点组合时启用。", - "builtins": "查看内置修饰", - "build_tab": "创建修饰", - "preview_tab": "测试与预览", - "export_tab": "导出", + "builtins": "查看当前可用修饰", + "workbench_tab": "单条转换工作台", + "library_tab": "自定义修饰库", + "export_tab": "修饰库导出", + "new_entry": "1. 输入一条分子或序列", + "input_format": "输入格式", + "input_value": "输入内容", + "input_help": "在此粘贴一条记录;批量转换请使用 CLI 文件模式。", + "peptide_sequence": "肽序列", + "peptide_help": "使用氨基酸单字母代码;小写字母表示 D-残基。", + "terminal_groups": "端基", + "n_terminal": "N 端", + "c_terminal": "C 端", + "none": "无", + "site_editor": "残基修饰编辑器", + "position": "残基位置", + "site_modification": "修饰", + "apply": "添加修饰", + "clear_site": "清除所选残基修饰", + "applied": "已添加修饰", + "no_site_mods": "尚未添加残基修饰。", + "generated_notation": "生成的肽表示", + "manual_notation": "手动编辑带注释的肽表示", + "manual_notation_value": "带注释的肽表示", + "sequence_changed": "序列发生变化,原残基修饰已清除。", + "task": "2. 选择操作", + "convert": "转换分子表示", + "fingerprint": "生成指纹", + "describe": "计算描述符", + "output_format": "输出格式", + "fingerprint_kind": "指纹类型", + "fingerprint_bits": "指纹位数", + "fingerprint_radius": "Morgan 半径", + "fingerprint_k": "序列 k-mer 长度", + "fingerprint_output": "指纹输出形式", + "run": "运行并添加结果", + "result": "最新结果", + "no_result": "运行一条数据后将在这里显示结果。", + "formula": "分子式", + "mass": "精确质量", + "charge": "形式电荷", + "heavy_atoms": "重原子数", + "canonical_smiles": "Canonical isomeric SMILES", + "result_value": "结果", + "download_result": "下载该结果", + "history": "条目历史", + "history_help": "每次成功转换都会保留,可继续输入下一条记录。", + "empty_history": "尚未运行任何条目。", + "remove_result": "删除结果", + "clear_history": "清空历史", + "download_history_json": "下载历史 JSON", + "download_history_csv": "下载历史 CSV", + "entry": "条目", "type": "修饰类型", + "side_chain": "侧链", "name": "名称", "residue": "残基", "fragment": "侧链 SMILES", @@ -103,18 +219,13 @@ "custom": "自定义定义", "empty": "还没有自定义修饰。", "remove": "删除", - "peptide": "测试肽表示", - "test": "构建并验证肽", - "formula": "分子式", - "mass": "精确质量", - "charge": "形式电荷", - "smiles": "Canonical isomeric SMILES", "download": "下载 JSON 修饰库", "filename": "下载文件名", "json": "修饰库 JSON", "cli": "在命令行中使用下载的文件", "warning": ( - "能够通过结构检查不等于化学含义一定正确。请核对连接规则,并将 JSON 与结果一起保存。" + "能够通过结构检查不等于化学含义一定正确。请核对连接规则,并将修饰库 JSON " + "与结果一起保存。" ), }, } @@ -126,13 +237,14 @@ def _payload() -> dict[str, Any]: return st.session_state.registry_payload -def _example_for_entry(section: str, entry: dict[str, Any]) -> str: - name = entry["name"] - if section == "side_chain_modifications": - return f"A{entry['residue']}[{name}]A" - if section == "n_terminal_modifications": - return f"[{name}]-AKA" - return f"AKA-[{name}]" +def _active_registry() -> ModificationRegistry: + return modification_registry_from_dict(_payload(), source="GUI registry") + + +def _history() -> list[dict[str, Any]]: + if "conversion_history" not in st.session_state: + st.session_state.conversion_history = [] + return st.session_state.conversion_history def _entry_summary(section: str, entry: dict[str, Any]) -> str: @@ -165,23 +277,311 @@ def _render_sidebar(t: dict[str, str]) -> None: if st.sidebar.button(t["reset"]): st.session_state.registry_payload = new_registry_payload() - st.session_state.test_peptide = "AKA" + st.session_state.peptide_site_mods = {} st.rerun() with st.sidebar.expander(t["builtins"]): - summary = default_modification_registry().summary() + summary = _active_registry().summary() st.write("**N:** " + ", ".join(summary["n_terminal_modifications"])) st.write("**C:** " + ", ".join(summary["c_terminal_modifications"])) for residue, names in summary["side_chain_modifications"].items(): st.write(f"**{residue}:** {', '.join(names)}") -def _render_builder(t: dict[str, str]) -> None: +def _visual_peptide_editor(t: dict[str, str], registry: ModificationRegistry) -> str: + sequence = st.text_input( + t["peptide_sequence"], + value="AKS", + help=t["peptide_help"], + key="peptide_sequence", + ) + compact = "".join(sequence.split()) + previous = st.session_state.get("peptide_sequence_snapshot") + if previous is not None and previous != compact: + st.session_state.peptide_site_mods = {} + st.info(t["sequence_changed"]) + st.session_state.peptide_sequence_snapshot = compact + if "peptide_site_mods" not in st.session_state: + st.session_state.peptide_site_mods = {} + modifications: dict[int, list[str]] = st.session_state.peptide_site_mods + modifications = { + int(position): list(names) + for position, names in modifications.items() + if int(position) < len(compact) + } + st.session_state.peptide_site_mods = modifications + + terminal_columns = st.columns(2) + n_options: list[str | None] = [None, *sorted(registry.n_termini)] + c_options: list[str | None] = [None, *sorted(registry.c_termini)] + n_terminus = terminal_columns[0].selectbox( + t["n_terminal"], + n_options, + format_func=lambda value: t["none"] if value is None else value, + key="peptide_n_terminus", + ) + c_terminus = terminal_columns[1].selectbox( + t["c_terminal"], + c_options, + format_func=lambda value: t["none"] if value is None else value, + key="peptide_c_terminus", + ) + + invalid = [residue for residue in compact if residue.upper() not in STANDARD_AA] + if not compact or invalid: + if invalid: + st.error(f"Unsupported residue(s): {', '.join(sorted(set(invalid)))}") + return compact + + st.markdown(f"**{t['site_editor']}**") + editor_columns = st.columns([1, 1.4]) + positions = list(range(len(compact))) + selected_position = editor_columns[0].selectbox( + t["position"], + positions, + format_func=lambda index: f"{index + 1}: {compact[index]}", + key="peptide_position", + ) + site_options = available_site_modifications(registry, compact[selected_position]) + selected_modification = editor_columns[1].selectbox( + t["site_modification"], + site_options, + key="peptide_site_modification", + disabled=not site_options, + ) + action_columns = st.columns(2) + if action_columns[0].button( + t["apply"], + disabled=selected_modification is None, + type="secondary", + ): + current = list(modifications.get(selected_position, [])) + if selected_modification == "NMe": + if "NMe" not in current: + current.insert(0, "NMe") + else: + current = [name for name in current if name == "NMe"] + current.append(selected_modification) + modifications[selected_position] = current + st.session_state.peptide_site_mods = modifications + st.rerun() + if action_columns[1].button( + t["clear_site"], + disabled=selected_position not in modifications, + ): + modifications.pop(selected_position, None) + st.session_state.peptide_site_mods = modifications + st.rerun() + + if modifications: + applied = [ + f"{position + 1}: {compact[position]} → {', '.join(names)}" + for position, names in sorted(modifications.items()) + ] + st.caption(t["applied"] + ": " + " | ".join(applied)) + else: + st.caption(t["no_site_mods"]) + + try: + notation = build_peptide_notation( + compact, + n_terminus=n_terminus, + c_terminus=c_terminus, + residue_modifications=modifications, + registry=registry, + ) + except MolTranscodeError as exc: + st.error(str(exc)) + notation = compact + st.caption(t["generated_notation"]) + st.code(notation, language=None) + manual = st.checkbox(t["manual_notation"], key="manual_notation_enabled") + if manual: + return st.text_area(t["manual_notation_value"], value=notation, height=100) + return notation + + +def _result_as_text(result: Any) -> str: + if isinstance(result, (dict, list)): + return json.dumps(result, ensure_ascii=False, indent=2) + return str(result) + + +def _result_extension(output_format: str) -> str: + if output_format in {"mol", "sdf", "pdb", "fasta"}: + return output_format + if output_format == "descriptors" or output_format.endswith("on-bits"): + return "json" + return "txt" + + +def _render_result_card(record: dict[str, Any], t: dict[str, str], *, latest: bool) -> None: + properties = record["descriptors"] + metrics = st.columns(4) + metrics[0].metric(t["formula"], properties["formula"]) + metrics[1].metric(t["mass"], f"{properties['exact_mw']:.6f}") + metrics[2].metric(t["charge"], properties["formal_charge"]) + metrics[3].metric(t["heavy_atoms"], properties["heavy_atoms"]) + result_text = _result_as_text(record["result"]) + language = "json" if isinstance(record["result"], (dict, list)) else None + st.code(result_text, language=language) + with st.expander(t["canonical_smiles"]): + st.code(properties["canonical_smiles"], language=None) + molecule = Chem.MolFromSmiles(properties["canonical_smiles"]) + if molecule is not None and latest: + st.image(Draw.MolToImage(molecule, size=(900, 420)), width="stretch") + extension = _result_extension(record["output_format"]) + st.download_button( + t["download_result"], + data=result_text + ("\n" if not result_text.endswith("\n") else ""), + file_name=f"moltranscode-entry-{record['id']}.{extension}", + mime="application/json" if extension == "json" else "text/plain", + key=f"download-result-{record['id']}-{'latest' if latest else 'history'}", + ) + + +def _render_workbench(t: dict[str, str]) -> None: + registry = _active_registry() + input_column, result_column = st.columns([1.12, 1]) + with input_column: + st.subheader(t["new_entry"]) + input_format = st.selectbox(t["input_format"], DIRECT_INPUT_FORMATS) + if input_format == "peptide": + value = _visual_peptide_editor(t, registry) + else: + value = st.text_area( + t["input_value"], + height=190, + help=t["input_help"], + placeholder="CC(=O)Oc1ccccc1C(=O)O" if input_format == "smiles" else "", + key="single_input_value", + ) + + st.subheader(t["task"]) + task = st.radio( + t["task"], + ("convert", "fingerprint", "describe"), + format_func=lambda value: t[value], + horizontal=True, + label_visibility="collapsed", + ) + output_format = "smiles" + fingerprint_kind = "morgan" + fingerprint_bits = 2048 + fingerprint_radius = 2 + fingerprint_k = 3 + fingerprint_output = "hex" + if task == "convert": + output_format = st.selectbox(t["output_format"], OUTPUT_FORMATS) + elif task == "fingerprint": + fingerprint_columns = st.columns(2) + fingerprint_kind = fingerprint_columns[0].selectbox( + t["fingerprint_kind"], FINGERPRINT_KINDS + ) + fingerprint_output = fingerprint_columns[1].selectbox( + t["fingerprint_output"], ("hex", "bitstring", "on-bits") + ) + fingerprint_bits = int( + fingerprint_columns[0].number_input( + t["fingerprint_bits"], min_value=16, max_value=65536, value=2048, step=16 + ) + ) + if fingerprint_kind == "morgan": + fingerprint_radius = int( + fingerprint_columns[1].number_input( + t["fingerprint_radius"], min_value=0, max_value=10, value=2 + ) + ) + elif fingerprint_kind == "kmer": + fingerprint_k = int( + fingerprint_columns[1].number_input( + t["fingerprint_k"], min_value=1, max_value=20, value=3 + ) + ) + + if st.button(t["run"], type="primary", width="stretch"): + identifier = int(st.session_state.get("next_entry_id", 1)) + try: + record = run_single_entry( + value, + input_format, + task=task, + output_format=output_format, + fingerprint_kind=fingerprint_kind, + fingerprint_bits=fingerprint_bits, + fingerprint_radius=fingerprint_radius, + fingerprint_k=fingerprint_k, + fingerprint_output=fingerprint_output, + registry=registry, + identifier=identifier, + ) + except MolTranscodeError as exc: + st.error(str(exc)) + else: + _history().append(record) + st.session_state.latest_result = record + st.session_state.next_entry_id = identifier + 1 + st.rerun() + + with result_column: + st.subheader(t["result"]) + latest = st.session_state.get("latest_result") + if latest: + st.caption( + f"#{latest['id']} · {latest['input_format']} → {latest['output_format']}" + ) + _render_result_card(latest, t, latest=True) + else: + st.info(t["no_result"]) + + st.divider() + st.subheader(t["history"]) + st.caption(t["history_help"]) + history = _history() + if not history: + st.info(t["empty_history"]) + return + download_columns = st.columns([1, 1, 1]) + download_columns[0].download_button( + t["download_history_json"], + conversion_history_json(history), + file_name="moltranscode-history.json", + mime="application/json", + ) + download_columns[1].download_button( + t["download_history_csv"], + conversion_history_csv(history), + file_name="moltranscode-history.csv", + mime="text/csv", + ) + if download_columns[2].button(t["clear_history"]): + st.session_state.conversion_history = [] + st.session_state.pop("latest_result", None) + st.session_state.next_entry_id = 1 + st.rerun() + for index in range(len(history) - 1, -1, -1): + record = history[index] + label = ( + f"{t['entry']} #{record['id']} · {record['input_format']} → " + f"{record['output_format']}" + ) + with st.expander(label): + st.caption(record["input"]) + _render_result_card(record, t, latest=False) + if st.button(t["remove_result"], key=f"remove-history-{record['id']}"): + removed = history.pop(index) + if st.session_state.get("latest_result", {}).get("id") == removed["id"]: + st.session_state.latest_result = history[-1] if history else None + st.rerun() + + +def _render_library(t: dict[str, str]) -> None: section = st.radio( t["type"], - list(SECTION_LABELS), - format_func=lambda value: SECTION_LABELS[value], + list(SECTION_LABEL_KEYS), + format_func=lambda value: t[SECTION_LABEL_KEYS[value]], horizontal=True, + key="library_section", ) with st.form("add_modification", clear_on_submit=False): name = st.text_input(t["name"], placeholder="AzidoLys") @@ -215,57 +615,34 @@ def _render_builder(t: dict[str, str]) -> None: else: entry["terminus_smiles"] = fragment.strip() try: - updated = append_registry_entry(_payload(), section, entry) + st.session_state.registry_payload = append_registry_entry(_payload(), section, entry) except MolTranscodeError as exc: st.error(str(exc)) else: - st.session_state.registry_payload = updated - st.session_state.test_peptide = _example_for_entry(section, entry) st.success(t["added"]) st.rerun() st.subheader(t["custom"]) - total = sum(len(_payload()[key]) for key in SECTION_LABELS) + total = sum(len(_payload()[key]) for key in SECTION_LABEL_KEYS) if not total: st.info(t["empty"]) return - for section_key, label in SECTION_LABELS.items(): + for section_key, label_key in SECTION_LABEL_KEYS.items(): entries = _payload()[section_key] if not entries: continue - st.markdown(f"**{label}**") + st.markdown(f"**{t[label_key]}**") for index, entry in enumerate(entries): - text_col, remove_col = st.columns([5, 1]) - text_col.code(_entry_summary(section_key, entry), language=None) - if remove_col.button(t["remove"], key=f"remove-{section_key}-{index}"): + text_column, remove_column = st.columns([5, 1]) + text_column.code(_entry_summary(section_key, entry), language=None) + if remove_column.button(t["remove"], key=f"remove-{section_key}-{index}"): st.session_state.registry_payload = remove_registry_entry( _payload(), section_key, index ) st.rerun() -def _render_preview(t: dict[str, str]) -> None: - if "test_peptide" not in st.session_state: - st.session_state.test_peptide = "AKA" - peptide = st.text_input(t["peptide"], key="test_peptide") - if st.button(t["test"], type="primary"): - try: - registry = modification_registry_from_dict(_payload(), source="GUI registry") - properties = describe(peptide, "peptide", modification_registry=registry) - molecule = Chem.MolFromSmiles(properties["canonical_smiles"]) - except MolTranscodeError as exc: - st.error(str(exc)) - return - metric_columns = st.columns(3) - metric_columns[0].metric(t["formula"], properties["formula"]) - metric_columns[1].metric(t["mass"], f"{properties['exact_mw']:.6f}") - metric_columns[2].metric(t["charge"], properties["formal_charge"]) - st.text_area(t["smiles"], properties["canonical_smiles"], height=120) - if molecule is not None: - st.image(Draw.MolToImage(molecule, size=(900, 420)), width="stretch") - - -def _render_export(t: dict[str, str]) -> None: +def _render_registry_export(t: dict[str, str]) -> None: try: content = registry_payload_json(_payload()) except MolTranscodeError as exc: @@ -290,25 +667,21 @@ def _render_export(t: dict[str, str]) -> None: def run_app() -> None: - st.set_page_config( - page_title="MolTranscode Modification Builder", - page_icon="🧬", - layout="wide", - ) + st.set_page_config(page_title="MolTranscode Workbench", page_icon="🧬", layout="wide") language = st.sidebar.selectbox(TEXT["English"]["language"], list(TEXT)) t = TEXT[language] st.title(t["title"]) st.caption(t["subtitle"]) _render_sidebar(t) - build_tab, preview_tab, export_tab = st.tabs( - [t["build_tab"], t["preview_tab"], t["export_tab"]] + workbench_tab, library_tab, export_tab = st.tabs( + [t["workbench_tab"], t["library_tab"], t["export_tab"]] ) - with build_tab: - _render_builder(t) - with preview_tab: - _render_preview(t) + with workbench_tab: + _render_workbench(t) + with library_tab: + _render_library(t) with export_tab: - _render_export(t) + _render_registry_export(t) st.warning(t["warning"], icon="⚠️") diff --git a/tests/test_gui.py b/tests/test_gui.py index c610639..2127798 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -8,13 +8,21 @@ from moltranscode.gui import ( aliases_from_text, append_registry_entry, + available_site_modifications, + build_peptide_notation, + conversion_history_csv, + conversion_history_json, launch_gui, main, new_registry_payload, registry_payload_json, remove_registry_entry, + run_single_entry, +) +from moltranscode.modifications import ( + default_modification_registry, + modification_registry_from_dict, ) -from moltranscode.modifications import modification_registry_from_dict from moltranscode.peptide import peptide_to_smiles @@ -52,6 +60,53 @@ def test_alias_text_is_trimmed_and_deduplicated(): assert aliases_from_text(" AzK, azido lysine, AzK, ") == ["AzK", "azido lysine"] +def test_visual_editor_builds_valid_modified_peptide_notation(): + registry = default_modification_registry() + notation = build_peptide_notation( + "aKs", + n_terminus="Acetyl", + c_terminus="Amide", + residue_modifications={1: ["NMe", "Dimethyl"], 2: ["Phospho"]}, + registry=registry, + ) + assert notation == "[Acetyl]-aK[NMe][Dimethyl]s[Phospho]-[Amide]" + assert Chem.MolFromSmiles(peptide_to_smiles(notation, registry)) is not None + assert "NMe" in available_site_modifications(registry, "K") + assert "Dimethyl" in available_site_modifications(registry, "K") + assert "NMe" not in available_site_modifications(registry, "P") + + +def test_visual_editor_rejects_out_of_range_site(): + with pytest.raises(InputFormatError, match="outside the sequence"): + build_peptide_notation("AK", residue_modifications={2: ["Acetyl"]}) + + +def test_single_entry_workbench_converts_and_generates_fingerprint(): + converted = run_single_entry( + "CCO", "smiles", task="convert", output_format="inchikey", identifier=1 + ) + assert converted["result"] == "LFQSCWFLJHTTHZ-UHFFFAOYSA-N" + assert converted["descriptors"]["formula"] == "C2H6O" + + fingerprinted = run_single_entry( + "CCO", + "smiles", + task="fingerprint", + fingerprint_kind="morgan", + fingerprint_bits=128, + identifier=2, + ) + assert len(fingerprinted["result"]) == 32 + assert fingerprinted["output_format"] == "fingerprint:morgan:hex" + + +def test_single_entry_history_exports_json_and_csv(): + record = run_single_entry("CCO", "smiles", task="describe", identifier="sample-1") + assert json.loads(conversion_history_json([record]))[0]["id"] == "sample-1" + csv_text = conversion_history_csv([record]) + assert "sample-1,CCO,smiles,describe,descriptors" in csv_text + + def test_gui_launcher_reports_missing_optional_dependency(monkeypatch): monkeypatch.setattr("moltranscode.gui.importlib.util.find_spec", lambda name: None) with pytest.raises(InputFormatError, match=r"\[gui\]"): From dd607c1422d229754728a0005204188bbc6c9780 Mon Sep 17 00:00:00 2001 From: PerinMu <143702874+PerinMu@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:09:44 +0800 Subject: [PATCH 3/4] feat: expand molecular workbench guidance --- README.md | 6 +- README.zh-CN.md | 8 +- docs/gui.md | 25 +++ docs/modifications.md | 21 +++ docs/scientific-basis.md | 11 ++ src/moltranscode/gui_app.py | 293 +++++++++++++++++++++++++++++- src/moltranscode/modifications.py | 163 ++++++++++++++++- tests/test_modifications.py | 26 +++ 8 files changed, 539 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index c22663d..73a3158 100644 --- a/README.md +++ b/README.md @@ -191,10 +191,14 @@ non-batch work and provides: - one-at-a-time input for peptides, sequences, SMILES, InChI, SELFIES, HELM, and structure blocks; - visual residue-position PTM selection, N/C termini, backbone N-methylation, D-residue notation, and an editable generated peptide annotation; +- structure-tested non-natural alpha-amino-acid presets including Nle, Orn, Dab, Dap, Hse, Hcy, + Sec, Cit, 4F-Phe, pAzF, Pra, Cha, and Tle; - selectable molecular output formats, fingerprints, or descriptor calculation; -- result cards with formula, exact mass, charge, canonical SMILES, a 2D drawing, and downloads; +- responsive result cards with untruncated formula, exact mass, charge, canonical SMILES, a 2D + drawing, interpretation notes, and downloads; - persistent session history with JSON/CSV export while additional entries are submitted; - custom side-chain/N-terminal/C-terminal forms with immediate validation and registry import/export. +- an in-app input guide, scope warnings, and links to the primary standards and reference databases. The interface exports the same schema accepted by `--mod-config`; no separate GUI-only project format is introduced. See the [GUI guide](docs/gui.md) for the complete workflow. diff --git a/README.zh-CN.md b/README.zh-CN.md index 9f66d8c..5b1259f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -170,10 +170,14 @@ sanitization,但用户仍需对自定义片段的化学含义负责,并随 - 逐条输入肽、序列、SMILES、InChI、SELFIES、HELM 或结构块; - 按残基位置选择 PTM、N/C 端基、骨架 N-甲基化和 D-残基,并生成可编辑的肽表示; +- 选择经过结构测试的非天然 α-氨基酸预设,包括 Nle、Orn、Dab、Dap、Hse、Hcy、 + Sec、Cit、4F-Phe、pAzF、Pra、Cha 和 Tle; - 选择分子格式转换、指纹生成或描述符计算; -- 查看分子式、精确质量、电荷、canonical SMILES、二维结构图并下载结果; +- 通过不会截断长分子式的响应式卡片查看精确质量、电荷、canonical SMILES、二维结构图, + 阅读指标解读并下载结果; - 连续添加下一条数据,并将当前会话历史导出为 JSON 或 CSV; -- 创建侧链/N 端/C 端自定义修饰,即时验证并导入或下载修饰库。 +- 创建侧链/N 端/C 端自定义修饰,即时验证并导入或下载修饰库; +- 在界面内查看输入格式说明、当前结构限制和主要标准/数据库参考文献。 图形界面导出的文件与 `--mod-config` 使用相同格式,不会产生只能由 GUI 读取的项目文件。 完整操作说明见[图形界面指南](docs/gui.md)。 diff --git a/docs/gui.md b/docs/gui.md index 6933b2f..03d97ef 100644 --- a/docs/gui.md +++ b/docs/gui.md @@ -44,6 +44,19 @@ residues. The editor then exposes: - backbone `NMe` at residues with an available backbone N-H; - the generated bracketed peptide notation. +The **Non-natural / non-canonical residue presets** panel filters the built-in catalogue to the +selected carrier residue. For example, enter `AKM`, select its `K`, and apply **Ornithine (Orn)**; +or select `M` and apply **Norleucine (Nle)**. The generated structure-resolving notation is +`K[Ornithine]` or `M[Norleucine]`. The catalogue also includes Nva, Dab, Dap, Hse, Hcy, Sec, Cit, +4F-Phe, pAzF, Pra, Cha, and Tle. Lowercase carrier letters retain the same preset side chain on a +D-configured alpha carbon. + +These presets are deliberately limited to alpha-amino acids that can be represented by replacing +only a side chain on the current backbone template. Aib and other alpha,alpha-disubstituted +residues, beta/gamma amino acids, cyclic backbones, cross-links, and glycans require a different +monomer/backbone model. Supply an explicit SMILES/MOL/SDF structure or an applicable HELM record +instead of approximating those molecules as ordinary side-chain replacements. + One side-chain modification plus `NMe` may be applied to a residue. Applying a new side-chain modification at the same position replaces the previous side-chain selection. Changing the base sequence clears position-based modifications to avoid silently moving a PTM to another residue. @@ -51,6 +64,18 @@ sequence clears position-based modifications to avoid silently moving a PTM to a Enable **Edit annotated notation manually** when direct notation entry is more convenient. The final notation is still validated by the peptide parser before conversion. +## Interpret the result + +The result panel uses wrapping property cards so long formulae remain visible on narrow screens. +Its molecular formula, exact mass, formal charge, and heavy-atom count describe the explicit graph +that MolTranscode generated. Formal charge is not a predicted net charge at a selected pH. +Canonical SMILES depends on the installed toolkit and settings, while fingerprints are lossy feature +vectors and must not be treated as structure identifiers. + +Open **Guide & references** for the complete input-format table, the non-natural-residue catalogue, +current structural limits, and links to ProForma 2.0, HELM, SwissSidechain, Unimod, and IUPAC +amino-acid nomenclature. + ## Add laboratory-specific modifications Open **Custom modification library** to add side-chain, N-terminal, or C-terminal definitions. New diff --git a/docs/modifications.md b/docs/modifications.md index d19abe8..cba76a5 100644 --- a/docs/modifications.md +++ b/docs/modifications.md @@ -40,6 +40,27 @@ The built-in structures include aliases for selected records in the These aliases resolve to MolTranscode's explicit structural conventions; they do not mean that all specificities or neutral losses recorded by Unimod are implemented. +## Built-in non-natural residue presets + +The GUI exposes a conservative catalogue of alpha-amino acids whose structures can be generated by +replacing a side chain without changing MolTranscode's standard peptide backbone: + +| Preset | Carrier notation | Preset | Carrier notation | +|---|---|---|---| +| Norvaline (Nva) | `V[Norvaline]` | Norleucine (Nle) | `M[Norleucine]` | +| Ornithine (Orn) | `K[Ornithine]` | Diaminobutyric acid (Dab) | `K[Dab]` | +| Diaminopropionic acid (Dap) | `K[Dap]` | Homoserine (Hse) | `S[Hse]` | +| Homocysteine (Hcy) | `C[Hcy]` | Selenocysteine (Sec) | `C[Sec]` | +| Citrulline (Cit) | `R[Cit]` | 4-Fluorophenylalanine | `F[4F-Phe]` | +| 4-Azidophenylalanine (pAzF) | `F[pAzF]` | Propargylglycine (Pra) | `A[Pra]` | +| Cyclohexylalanine (Cha) | `F[Cha]` | tert-Leucine (Tle) | `L[Tle]` | + +The carrier is an internal attachment template, not a claim that the preset is a modification of +that biological residue. Bracketed names are used because there is no universal one-letter code for +arbitrary non-natural amino acids. For a broader curated side-chain resource, see +[SwissSidechain](https://doi.org/10.1093/nar/gks991) and the scope discussion in +[Scientific basis](scientific-basis.md). + ## JSON registry schema Use [`examples/custom_modifications.json`](../examples/custom_modifications.json) as a working diff --git a/docs/scientific-basis.md b/docs/scientific-basis.md index 184b9d4..c8e03eb 100644 --- a/docs/scientific-basis.md +++ b/docs/scientific-basis.md @@ -53,6 +53,17 @@ Structure Representation,” *J. Chem. Inf. Model.* (2012), [RDKit molecule file API](https://rdkit.org/docs/source/rdkit.Chem.rdmolfiles.html) for the exact FASTA/HELM capabilities of the installed RDKit release. +MolTranscode's non-natural-residue presets are a small, structure-tested convenience catalogue, +not a comprehensive monomer ontology. They cover alpha-amino acids whose side chains can be +substituted without changing the standard peptide-backbone template. SwissSidechain provides a +broader curated molecular and structural resource for non-natural side chains: Gfeller et al., +“SwissSidechain: a molecular and structural database of non-natural sidechains,” *Nucleic Acids +Research* (2013), [doi:10.1093/nar/gks991](https://doi.org/10.1093/nar/gks991). IUPAC-IUB +[amino-acid nomenclature recommendations](https://iupac.qmul.ac.uk/AminoAcid/) are the reference +for conventional symbols and also document the limits of the one-letter system. There is no +universal one-letter alphabet for arbitrary non-natural monomers, so MolTranscode emits explicit +bracketed names such as `M[Norleucine]` rather than inventing new single-letter codes. + ### Structural conventions in version 0.2 - uppercase residues are L and lowercase residues are D; diff --git a/src/moltranscode/gui_app.py b/src/moltranscode/gui_app.py index 1499b33..6a0eced 100644 --- a/src/moltranscode/gui_app.py +++ b/src/moltranscode/gui_app.py @@ -2,6 +2,7 @@ from __future__ import annotations +import html import json from typing import Any @@ -25,8 +26,10 @@ run_single_entry, ) from moltranscode.modifications import ( + NONCANONICAL_RESIDUE_PRESETS, STANDARD_AA, ModificationRegistry, + NoncanonicalResiduePreset, modification_registry_from_dict, ) @@ -68,6 +71,11 @@ "workbench_tab": "Single-entry workbench", "library_tab": "Custom modification library", "export_tab": "Registry export", + "guide_tab": "Guide & references", + "workflow_hint": ( + "Workflow: choose an input format → enter one record → optionally edit a peptide → " + "choose an operation → inspect and download the result." + ), "new_entry": "1. Enter one molecule or sequence", "input_format": "Input format", "input_value": "Input value", @@ -83,6 +91,14 @@ "site_modification": "Modification", "apply": "Apply modification", "clear_site": "Clear selected residue", + "noncanonical_editor": "Non-natural / non-canonical residue presets", + "noncanonical_preset": "Residue preset", + "noncanonical_apply": "Replace this side chain", + "noncanonical_none": "No compatible preset for this carrier residue.", + "noncanonical_help": ( + "Presets replace the side chain while retaining the standard alpha-amino-acid " + "backbone. The selected sequence letter is the internal carrier shown below." + ), "applied": "Applied modifications", "no_site_mods": "No residue modifications applied.", "generated_notation": "Generated peptide notation", @@ -106,6 +122,12 @@ "mass": "Exact mass", "charge": "Formal charge", "heavy_atoms": "Heavy atoms", + "result_interpretation": "How to read this result", + "result_interpretation_text": ( + "Formula and exact mass describe the explicit molecular graph shown here. Formal " + "charge is not a pH-dependent net charge. Canonical SMILES is toolkit-specific, and " + "a fingerprint is a lossy feature vector rather than a molecular identifier." + ), "canonical_smiles": "Canonical isomeric SMILES", "result_value": "Result", "download_result": "Download this result", @@ -140,6 +162,34 @@ "A sanitizable graph can still represent the wrong chemistry. Review attachment " "semantics and archive the registry JSON with your results." ), + "guide_title": "What MolTranscode does", + "guide_intro": ( + "MolTranscode converts explicit molecular graphs and supported biological sequence " + "notations locally. It does not infer an unknown chemical structure from a name, a " + "mass shift, or an ambiguous sequence annotation." + ), + "input_guide_title": "Input and output guide", + "noncanonical_guide_title": "Non-natural amino acids", + "noncanonical_guide_text": ( + "The built-in presets below are alpha-amino acids that can be represented exactly by " + "replacing a side chain on MolTranscode's standard peptide backbone. Enter the carrier " + "letter, select its position, then apply the named preset. Lowercase carriers generate " + "the corresponding D residue." + ), + "limits_title": "Current structural limits", + "limits_text": ( + "Aib and other alpha,alpha-disubstituted residues, beta/gamma amino acids, cyclic " + "backbones, disulfides, cross-links, glycans, and ambiguous modification sites are not " + "constructed by the visual peptide builder. Use an explicit SMILES/MOL/SDF structure " + "or HELM where supported; never approximate these as a side-chain replacement." + ), + "references_title": "Scientific references and vocabularies", + "guide_format": "Format", + "guide_meaning": "Accepted content", + "preset_name": "Preset", + "preset_carrier": "Carrier letter", + "preset_notation": "Generated notation", + "preset_scope": "Structural note", }, "简体中文": { "title": "MolTranscode 单条转换工作台", @@ -155,6 +205,10 @@ "workbench_tab": "单条转换工作台", "library_tab": "自定义修饰库", "export_tab": "修饰库导出", + "guide_tab": "使用说明与参考文献", + "workflow_hint": ( + "流程:选择输入格式 → 输入一条数据 → 按需编辑肽 → 选择操作 → 检查并下载结果。" + ), "new_entry": "1. 输入一条分子或序列", "input_format": "输入格式", "input_value": "输入内容", @@ -170,6 +224,13 @@ "site_modification": "修饰", "apply": "添加修饰", "clear_site": "清除所选残基修饰", + "noncanonical_editor": "非天然/非经典氨基酸预设", + "noncanonical_preset": "残基预设", + "noncanonical_apply": "替换该位点侧链", + "noncanonical_none": "当前载体残基没有兼容预设。", + "noncanonical_help": ( + "这些预设保留标准 α-氨基酸主链,只替换侧链;所选序列字母是下方显示的内部载体。" + ), "applied": "已添加修饰", "no_site_mods": "尚未添加残基修饰。", "generated_notation": "生成的肽表示", @@ -193,6 +254,11 @@ "mass": "精确质量", "charge": "形式电荷", "heavy_atoms": "重原子数", + "result_interpretation": "如何理解结果", + "result_interpretation_text": ( + "分子式和精确质量对应这里明确画出的分子图;形式电荷不是特定 pH 下的净电荷。" + "Canonical SMILES 与工具版本有关,分子指纹是有损特征,不能作为唯一结构标识。" + ), "canonical_smiles": "Canonical isomeric SMILES", "result_value": "结果", "download_result": "下载该结果", @@ -227,8 +293,127 @@ "能够通过结构检查不等于化学含义一定正确。请核对连接规则,并将修饰库 JSON " "与结果一起保存。" ), + "guide_title": "MolTranscode 能做什么", + "guide_intro": ( + "MolTranscode 在本地转换明确的分子图和受支持的生物序列表示。它不会根据名称、" + "质量差或存在歧义的序列注释猜测未知化学结构。" + ), + "input_guide_title": "输入与输出说明", + "noncanonical_guide_title": "非天然氨基酸", + "noncanonical_guide_text": ( + "内置预设均为能够通过替换标准肽主链侧链而准确表示的 α-氨基酸。先输入载体字母," + "选择相应位置,再应用预设;使用小写载体字母可生成相应 D-残基。" + ), + "limits_title": "当前结构限制", + "limits_text": ( + "Aib 等 α,α-二取代残基、β/γ-氨基酸、环状主链、二硫键、交联、糖链和位点不明确" + "的修饰,当前不能由可视化肽构建器生成。请使用明确的 SMILES/MOL/SDF,或在支持" + "范围内使用 HELM;不要把这些结构近似为普通侧链替换。" + ), + "references_title": "科学文献与受控词表", + "guide_format": "格式", + "guide_meaning": "可接受内容", + "preset_name": "预设", + "preset_carrier": "载体字母", + "preset_notation": "生成表示", + "preset_scope": "结构说明", + }, +} + +INPUT_FORMAT_GUIDE = { + "English": { + "peptide": "Linear peptide with visual PTMs; lowercase letters mean D residues.", + "sequence": "Plain one-letter peptide/protein sequence without modification syntax.", + "protein": "Alias of sequence input for a single protein chain.", + "fasta": "Exactly one FASTA record, including its optional >header line.", + "smiles": "Atom-level SMILES; stereochemistry and formal charges are preserved.", + "inchi": "InChI molecular identifier beginning with InChI=.", + "selfies": "SELFIES molecular string such as [C][C][O].", + "helm": "HELM macromolecule notation within the subset supported by RDKit.", + "mol": "One MDL Mol block.", + "sdf": "One SDF structure record, not a multi-record batch file.", + "pdb": "One PDB structure block; bond perception depends on the supplied record.", + "auto": ( + "Conservative detection for recognizable text formats; explicit selection is safer." + ), }, + "简体中文": { + "peptide": "带可视化修饰的线性肽;小写字母表示 D-残基。", + "sequence": "不含修饰语法的普通蛋白/肽单字母序列。", + "protein": "单条蛋白链序列输入,与 sequence 模式相同。", + "fasta": "恰好一条 FASTA 记录,可包含 > 开头的标题行。", + "smiles": "原子级 SMILES;保留立体化学和形式电荷。", + "inchi": "以 InChI= 开头的分子标识符。", + "selfies": "SELFIES 分子字符串,例如 [C][C][O]。", + "helm": "RDKit 当前支持子集内的 HELM 大分子表示。", + "mol": "一条 MDL Mol block。", + "sdf": "一条 SDF 结构记录,不是多记录批处理文件。", + "pdb": "一条 PDB 结构记录;成键识别取决于输入内容。", + "auto": "仅保守识别明显格式;明确选择输入格式更可靠。", + }, +} + +REFERENCE_LINKS = ( + ( + "ProForma 2.0", + "LeDuc et al., Nature Methods (2022)", + "https://doi.org/10.1038/s41592-021-01196-8", + ), + ( + "HELM", + "Zhang et al., Journal of Chemical Information and Modeling (2012)", + "https://doi.org/10.1021/ci3001925", + ), + ( + "SwissSidechain", + "Gfeller et al., Nucleic Acids Research (2013)", + "https://doi.org/10.1093/nar/gks991", + ), + ( + "Unimod", + "Creasy and Cottrell, Proteomics (2004)", + "https://doi.org/10.1002/pmic.200300744", + ), + ( + "IUPAC amino-acid nomenclature", + "IUPAC-IUB recommendations and one-letter-code limits", + "https://iupac.qmul.ac.uk/AminoAcid/", + ), +) + +APP_STYLE = """ + +""" def _payload() -> dict[str, Any]: @@ -254,6 +439,10 @@ def _entry_summary(section: str, entry: dict[str, Any]) -> str: return f"{entry['name']} · {entry[field]}" +def _preset_label(preset: NoncanonicalResiduePreset) -> str: + return f"{preset.abbreviation} — {preset.name} · {preset.notation}" + + def _render_sidebar(t: dict[str, str]) -> None: uploaded = st.sidebar.file_uploader(t["import"], type=["json"]) if st.sidebar.button(t["import_button"], disabled=uploaded is None): @@ -373,6 +562,32 @@ def _visual_peptide_editor(t: dict[str, str], registry: ModificationRegistry) -> st.session_state.peptide_site_mods = modifications st.rerun() + compatible_presets = [ + preset + for preset in NONCANONICAL_RESIDUE_PRESETS + if preset.carrier == compact[selected_position].upper() + ] + with st.expander(t["noncanonical_editor"]): + st.caption(t["noncanonical_help"]) + if compatible_presets: + preset = st.selectbox( + t["noncanonical_preset"], + compatible_presets, + format_func=_preset_label, + key="peptide_noncanonical_preset", + ) + st.caption(preset.description) + if st.button(t["noncanonical_apply"], key="apply_noncanonical_preset"): + current = [ + name for name in modifications.get(selected_position, []) if name == "NMe" + ] + current.append(preset.modification_name) + modifications[selected_position] = current + st.session_state.peptide_site_mods = modifications + st.rerun() + else: + st.info(t["noncanonical_none"]) + if modifications: applied = [ f"{position + 1}: {compact[position]} → {', '.join(names)}" @@ -415,13 +630,31 @@ def _result_extension(output_format: str) -> str: return "txt" +def _render_property_cards(properties: dict[str, Any], t: dict[str, str]) -> None: + items = ( + (t["formula"], properties["formula"]), + (t["mass"], f"{properties['exact_mw']:.6f}"), + (t["charge"], properties["formal_charge"]), + (t["heavy_atoms"], properties["heavy_atoms"]), + ) + cards = "".join( + ( + '
' + f'
{html.escape(str(label))}
' + f'
{html.escape(str(value))}
' + "
" + ) + for label, value in items + ) + st.markdown(f'
{cards}
', unsafe_allow_html=True) + + def _render_result_card(record: dict[str, Any], t: dict[str, str], *, latest: bool) -> None: properties = record["descriptors"] - metrics = st.columns(4) - metrics[0].metric(t["formula"], properties["formula"]) - metrics[1].metric(t["mass"], f"{properties['exact_mw']:.6f}") - metrics[2].metric(t["charge"], properties["formal_charge"]) - metrics[3].metric(t["heavy_atoms"], properties["heavy_atoms"]) + _render_property_cards(properties, t) + if latest: + with st.expander(t["result_interpretation"]): + st.write(t["result_interpretation_text"]) result_text = _result_as_text(record["result"]) language = "json" if isinstance(record["result"], (dict, list)) else None st.code(result_text, language=language) @@ -440,12 +673,14 @@ def _render_result_card(record: dict[str, Any], t: dict[str, str], *, latest: bo ) -def _render_workbench(t: dict[str, str]) -> None: +def _render_workbench(t: dict[str, str], language: str) -> None: registry = _active_registry() - input_column, result_column = st.columns([1.12, 1]) + st.info(t["workflow_hint"], icon="ℹ️") + input_column, result_column = st.columns([1, 1.08]) with input_column: st.subheader(t["new_entry"]) input_format = st.selectbox(t["input_format"], DIRECT_INPUT_FORMATS) + st.caption(INPUT_FORMAT_GUIDE[language][input_format]) if input_format == "peptide": value = _visual_peptide_editor(t, registry) else: @@ -666,22 +901,60 @@ def _render_registry_export(t: dict[str, str]) -> None: st.code(content, language="json") +def _render_guide(t: dict[str, str], language: str) -> None: + st.subheader(t["guide_title"]) + st.write(t["guide_intro"]) + + st.markdown(f"### {t['input_guide_title']}") + format_rows = [ + { + t["guide_format"]: input_format, + t["guide_meaning"]: INPUT_FORMAT_GUIDE[language][input_format], + } + for input_format in DIRECT_INPUT_FORMATS + ] + st.dataframe(format_rows, width="stretch", hide_index=True) + + st.markdown(f"### {t['noncanonical_guide_title']}") + st.write(t["noncanonical_guide_text"]) + preset_rows = [ + { + t["preset_name"]: f"{preset.abbreviation} — {preset.name}", + t["preset_carrier"]: preset.carrier, + t["preset_notation"]: preset.notation, + t["preset_scope"]: preset.description, + } + for preset in NONCANONICAL_RESIDUE_PRESETS + ] + st.dataframe(preset_rows, width="stretch", hide_index=True) + + st.markdown(f"### {t['limits_title']}") + st.warning(t["limits_text"], icon="⚠️") + + st.markdown(f"### {t['references_title']}") + for title, citation, url in REFERENCE_LINKS: + st.markdown(f"- **[{title}]({url})** — {citation}") + + def run_app() -> None: st.set_page_config(page_title="MolTranscode Workbench", page_icon="🧬", layout="wide") + st.markdown(APP_STYLE, unsafe_allow_html=True) language = st.sidebar.selectbox(TEXT["English"]["language"], list(TEXT)) t = TEXT[language] st.title(t["title"]) st.caption(t["subtitle"]) _render_sidebar(t) - workbench_tab, library_tab, export_tab = st.tabs( - [t["workbench_tab"], t["library_tab"], t["export_tab"]] + workbench_tab, library_tab, export_tab, guide_tab = st.tabs( + [t["workbench_tab"], t["library_tab"], t["export_tab"], t["guide_tab"]] ) with workbench_tab: - _render_workbench(t) + _render_workbench(t, language) with library_tab: _render_library(t) with export_tab: _render_registry_export(t) + with guide_tab: + _render_guide(t, language) st.warning(t["warning"], icon="⚠️") diff --git a/src/moltranscode/modifications.py b/src/moltranscode/modifications.py index fb1c701..252e68e 100644 --- a/src/moltranscode/modifications.py +++ b/src/moltranscode/modifications.py @@ -57,6 +57,157 @@ def for_configuration(self, *, is_d: bool, residue: str, name: str) -> str: return self.l_smiles +@dataclass(frozen=True) +class NoncanonicalResiduePreset: + """Named alpha-amino-acid side-chain replacement exposed by the GUI.""" + + name: str + abbreviation: str + carrier: str + modification_name: str + side_chain_smiles: str | None + aliases: tuple[str, ...] = () + description: str = "" + + @property + def notation(self) -> str: + return f"{self.carrier}[{self.modification_name}]" + + +# These presets retain MolTranscode's standard alpha-amino-acid backbone and +# replace only the side chain. Backbone-altering monomers (for example Aib, +# beta-amino acids, and cyclic backbones) deliberately remain outside this +# table because representing them as side-chain replacements would be wrong. +NONCANONICAL_RESIDUE_PRESETS = ( + NoncanonicalResiduePreset( + "Norvaline", + "Nva", + "V", + "Norvaline", + "CCC", + ("Nva",), + "Straight-chain hydrophobic alpha-amino acid.", + ), + NoncanonicalResiduePreset( + "Norleucine", + "Nle", + "M", + "Norleucine", + "CCCC", + ("Nle",), + "Methionine isostere with an unbranched hydrocarbon side chain.", + ), + NoncanonicalResiduePreset( + "Ornithine", + "Orn", + "K", + "Ornithine", + "CCCN", + ("Orn",), + "Basic lysine homologue shortened by one methylene.", + ), + NoncanonicalResiduePreset( + "2,4-Diaminobutyric acid", + "Dab", + "K", + "DiaminobutyricAcid", + "CCN", + ("Dab", "2,4-Dab"), + "Short-chain diamino alpha-amino acid.", + ), + NoncanonicalResiduePreset( + "2,3-Diaminopropionic acid", + "Dap", + "K", + "DiaminopropionicAcid", + "CN", + ("Dap", "2,3-Dap"), + "Diamino alpha-amino acid with an aminomethyl side chain.", + ), + NoncanonicalResiduePreset( + "Homoserine", + "Hse", + "S", + "Homoserine", + "CCO", + ("Hse",), + "Serine homologue extended by one methylene.", + ), + NoncanonicalResiduePreset( + "Homocysteine", + "Hcy", + "C", + "Homocysteine", + "CCS", + ("Hcy",), + "Cysteine homologue extended by one methylene.", + ), + NoncanonicalResiduePreset( + "Selenocysteine", + "Sec", + "C", + "Selenocysteine", + "C[SeH]", + ("Sec", "U"), + "Selenium analogue of cysteine; represented in its neutral selenol form.", + ), + NoncanonicalResiduePreset( + "Citrulline", + "Cit", + "R", + "Citrullination", + None, + (), + "Ureido side chain; shares the tested citrullination structure.", + ), + NoncanonicalResiduePreset( + "4-Fluorophenylalanine", + "4F-Phe", + "F", + "4Fluorophenylalanine", + "Cc1ccc(F)cc1", + ("4F-Phe", "pFPhe"), + "Para-fluorinated phenylalanine analogue.", + ), + NoncanonicalResiduePreset( + "4-Azidophenylalanine", + "pAzF", + "F", + "4Azidophenylalanine", + "Cc1ccc(N=[N+]=[N-])cc1", + ("pAzF", "AzF"), + "Para-azido phenylalanine used as a photo/click-compatible handle.", + ), + NoncanonicalResiduePreset( + "Propargylglycine", + "Pra", + "A", + "Propargylglycine", + "CC#C", + ("Pra", "PraGly"), + "Terminal-alkyne alpha-amino acid for click chemistry.", + ), + NoncanonicalResiduePreset( + "Cyclohexylalanine", + "Cha", + "F", + "Cyclohexylalanine", + "CC1CCCCC1", + ("Cha",), + "Hydrophobic cyclohexyl analogue of phenylalanine.", + ), + NoncanonicalResiduePreset( + "tert-Leucine", + "Tle", + "L", + "TertLeucine", + "C(C)(C)C", + ("Tle", "tLeu"), + "Alpha-amino acid bearing a tert-butyl side chain.", + ), +) + + @dataclass class ModificationRegistry: """Structure-resolving peptide modifications and terminal groups.""" @@ -290,7 +441,7 @@ def _register_builtins(registry: ModificationRegistry) -> None: "R", "CCCNC(=O)N", None, - ("Deimination", "Deamidated", "UNIMOD:7"), + ("Citrulline", "Cit", "Deimination", "Deamidated", "UNIMOD:7"), ), ("Methyl", "K", "CCCCN(C)", None, ("Me", "UNIMOD:34")), ("Dimethyl", "K", "CCCCN(C)C", None, ("Me2", "UNIMOD:36")), @@ -318,6 +469,16 @@ def _register_builtins(registry: ModificationRegistry) -> None: aliases=aliases, ) + for preset in NONCANONICAL_RESIDUE_PRESETS: + if preset.side_chain_smiles is None: + continue + registry.register_side_chain( + name=preset.modification_name, + residue=preset.carrier, + side_chain_smiles=preset.side_chain_smiles, + aliases=preset.aliases, + ) + registry.register_n_terminus( name="Acetyl", prefix_smiles="CC(=O)", aliases=("Ac", "UNIMOD:1") ) diff --git a/tests/test_modifications.py b/tests/test_modifications.py index f1bb2f9..768a3e4 100644 --- a/tests/test_modifications.py +++ b/tests/test_modifications.py @@ -7,6 +7,7 @@ from moltranscode import convert from moltranscode.exceptions import ModificationError from moltranscode.modifications import ( + NONCANONICAL_RESIDUE_PRESETS, default_modification_registry, load_modification_registry, modification_registry_from_dict, @@ -182,6 +183,31 @@ def test_registry_summary_contains_extended_modifications(): ] +def test_noncanonical_residue_presets_are_sanitizable_and_registered(): + registry = default_modification_registry() + for preset in NONCANONICAL_RESIDUE_PRESETS: + molecule = Chem.MolFromSmiles(peptide_to_smiles(preset.notation, registry)) + assert molecule is not None, preset.name + assert registry.resolve_side_chain(preset.carrier, preset.modification_name)[0] == ( + preset.modification_name + ) + + +@pytest.mark.parametrize( + ("notation", "formula"), + [ + ("M[Nle]", "C6H13NO2"), + ("K[Orn]", "C5H12N2O2"), + ("C[Sec]", "C3H7NO2Se"), + ("F[4F-Phe]", "C9H10FNO2"), + ("A[Pra]", "C5H7NO2"), + ], +) +def test_selected_noncanonical_residue_formulas(notation, formula): + molecule = Chem.MolFromSmiles(peptide_to_smiles(notation)) + assert rdMolDescriptors.CalcMolFormula(molecule) == formula + + def test_in_memory_registry_payload_uses_the_same_validation(): registry = modification_registry_from_dict( { From bcd0357d25162997ae0e96215864f998562d2e9d Mon Sep 17 00:00:00 2001 From: PerinMu <143702874+PerinMu@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:25:03 +0800 Subject: [PATCH 4/4] refactor: simplify result cards --- README.md | 2 +- README.zh-CN.md | 4 ++-- docs/gui.md | 8 -------- src/moltranscode/gui_app.py | 14 -------------- 4 files changed, 3 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 73a3158..3282ba6 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,7 @@ non-batch work and provides: Sec, Cit, 4F-Phe, pAzF, Pra, Cha, and Tle; - selectable molecular output formats, fingerprints, or descriptor calculation; - responsive result cards with untruncated formula, exact mass, charge, canonical SMILES, a 2D - drawing, interpretation notes, and downloads; + drawing, and downloads; - persistent session history with JSON/CSV export while additional entries are submitted; - custom side-chain/N-terminal/C-terminal forms with immediate validation and registry import/export. - an in-app input guide, scope warnings, and links to the primary standards and reference databases. diff --git a/README.zh-CN.md b/README.zh-CN.md index 5b1259f..fd15db9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -173,8 +173,8 @@ sanitization,但用户仍需对自定义片段的化学含义负责,并随 - 选择经过结构测试的非天然 α-氨基酸预设,包括 Nle、Orn、Dab、Dap、Hse、Hcy、 Sec、Cit、4F-Phe、pAzF、Pra、Cha 和 Tle; - 选择分子格式转换、指纹生成或描述符计算; -- 通过不会截断长分子式的响应式卡片查看精确质量、电荷、canonical SMILES、二维结构图, - 阅读指标解读并下载结果; +- 通过不会截断长分子式的响应式卡片查看精确质量、电荷、canonical SMILES、二维结构图 + 并下载结果; - 连续添加下一条数据,并将当前会话历史导出为 JSON 或 CSV; - 创建侧链/N 端/C 端自定义修饰,即时验证并导入或下载修饰库; - 在界面内查看输入格式说明、当前结构限制和主要标准/数据库参考文献。 diff --git a/docs/gui.md b/docs/gui.md index 03d97ef..99dd173 100644 --- a/docs/gui.md +++ b/docs/gui.md @@ -64,14 +64,6 @@ sequence clears position-based modifications to avoid silently moving a PTM to a Enable **Edit annotated notation manually** when direct notation entry is more convenient. The final notation is still validated by the peptide parser before conversion. -## Interpret the result - -The result panel uses wrapping property cards so long formulae remain visible on narrow screens. -Its molecular formula, exact mass, formal charge, and heavy-atom count describe the explicit graph -that MolTranscode generated. Formal charge is not a predicted net charge at a selected pH. -Canonical SMILES depends on the installed toolkit and settings, while fingerprints are lossy feature -vectors and must not be treated as structure identifiers. - Open **Guide & references** for the complete input-format table, the non-natural-residue catalogue, current structural limits, and links to ProForma 2.0, HELM, SwissSidechain, Unimod, and IUPAC amino-acid nomenclature. diff --git a/src/moltranscode/gui_app.py b/src/moltranscode/gui_app.py index 6a0eced..e9c7897 100644 --- a/src/moltranscode/gui_app.py +++ b/src/moltranscode/gui_app.py @@ -122,12 +122,6 @@ "mass": "Exact mass", "charge": "Formal charge", "heavy_atoms": "Heavy atoms", - "result_interpretation": "How to read this result", - "result_interpretation_text": ( - "Formula and exact mass describe the explicit molecular graph shown here. Formal " - "charge is not a pH-dependent net charge. Canonical SMILES is toolkit-specific, and " - "a fingerprint is a lossy feature vector rather than a molecular identifier." - ), "canonical_smiles": "Canonical isomeric SMILES", "result_value": "Result", "download_result": "Download this result", @@ -254,11 +248,6 @@ "mass": "精确质量", "charge": "形式电荷", "heavy_atoms": "重原子数", - "result_interpretation": "如何理解结果", - "result_interpretation_text": ( - "分子式和精确质量对应这里明确画出的分子图;形式电荷不是特定 pH 下的净电荷。" - "Canonical SMILES 与工具版本有关,分子指纹是有损特征,不能作为唯一结构标识。" - ), "canonical_smiles": "Canonical isomeric SMILES", "result_value": "结果", "download_result": "下载该结果", @@ -652,9 +641,6 @@ def _render_property_cards(properties: dict[str, Any], t: dict[str, str]) -> Non def _render_result_card(record: dict[str, Any], t: dict[str, str], *, latest: bool) -> None: properties = record["descriptors"] _render_property_cards(properties, t) - if latest: - with st.expander(t["result_interpretation"]): - st.write(t["result_interpretation_text"]) result_text = _result_as_text(record["result"]) language = "json" if isinstance(record["result"], (dict, list)) else None st.code(result_text, language=language)