diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 866ecce0..d79c5ff5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -86,6 +86,9 @@ repos: hooks: - id: black args: + - --line-length + - '120' + - --fast - ./src - ./tests diff --git a/README.md b/README.md index 6b60ed18..7d99926f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,12 @@ | Server Docker Image | [![Docker Image Size](https://img.shields.io/docker/image-size/yaoyinying/revodesign-pssm-gremlin?style=social&logo=docker&label=server%20image%20size)](https://hub.docker.com/r/yaoyinying/revodesign-pssm-gremlin) | | Code Quality Control | [![Hook](https://img.shields.io/badge/autofix-pre--commit--hooks-000000.svg)](https://github.com/pre-commit/pre-commit-hooks) [![linting: pylint](https://img.shields.io/badge/linting-pylint-yellowgreen)](https://github.com/pylint-dev/pylint) [![imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) [![syntax upgrade: pyupgrade](https://img.shields.io/badge/pyupgrade-white?style=plastic&logo=python&logoColor=ebedf0&label=syntax&labelColor=da394b&color=white&link=https%3A%2F%2Fgithub.com%2Fasottile%2Fpyupgrade)](https://github.com/asottile/pyupgrade) [![pycln](https://img.shields.io/badge/pycln-white?style=plastic&logo=python&logoColor=35475c&label=imports&labelColor=c4fcfd&color=c6fdbc&link=https%3A%2F%2Fgithub.com%2Fhadialqattan%2Fpycln)](https://github.com/hadialqattan/pycln) [![Flake8](https://img.shields.io/badge/flake8-white?style=plastic&logo=python&logoColor=silver&label=style&link=https%3A%2F%2Fgithub.com%2FPyCQA%2Fflake8)](https://github.com/PyCQA/flake8) [![autoflake](https://img.shields.io/badge/autoflake-yellow?style=plastic&logo=python&logoColor=cyan&label=style&link=https%3A%2F%2Fgithub.com%2FPyCQA%2Fautoflake)](https://github.com/PyCQA/autoflake) [![autopep8](https://img.shields.io/badge/autopep8-orange?style=plastic&logo=python&logoColor=red&label=style&link=https%3A%2F%2Fgithub.com%2FPyCQA%2Fautopep8)](https://github.com/hhatto/autopep8) [![Code style: black](https://img.shields.io/badge/black-black.svg?logo=python&logoColor=black&label=style)](https://github.com/psf/black)| +REvoDesign requires no non-standard hardware to run. + +### Dependency + +All dependencies and extras are listed in `pyproject.toml`. + --- @@ -53,32 +59,7 @@ In brief, the **REvoDesign** PyMOL plugin provides a set of essential tools to h **REvoDesign** is your indispensable companion in the intricate journey of enzyme redesign. Whether you are a seasoned protein designer or just beginning your exploration, **REvoDesign** Toolkit is here to simplify your workflow and enhance your enzyme engineering endeavors. -Please refer to the [documentation(WIP)](link_to_documentation) for detailed instructions on how to use the toolkit and make the most of its features. - ---- - -## Dependencies wait list - -> [!NOTE] -> These are the dependencies that are not yet implemented but will be added in the toolkit. - -### Designers - -- [ ] [SaProt](https://github.com/westlake-repl/SaProt) -- [ ] [Prime](https://github.com/ai4protein/Pro-Prime) -- [ ] [ProtSSN](https://github.com/tyang816/ProtSSN) -- [ ] [Native Pythia-ddG](https://github.com/Wublab/Pythia) -- [ ] [CarbonDesign](https://github.com/zhanghaicang/carbonmatrix_public) -- [ ] [ProtMamba](https://github.com/Bitbol-Lab/ProtMamba-ssm) -- [ ] [LigandMPNN](https://github.com/dauparas/LigandMPNN) -- [ ] [UniKP](https://github.com/Luo-SynBioLab/UniKP) -- [ ] [ByProt](https://github.com/BytedProtein/ByProt) - -### Sidechain Solvers - -- [ ] [AttnPacker](https://github.com/MattMcPartlon/AttnPacker) -- [ ] [opus_rota4](https://github.com/OPUS-MaLab/opus_rota4) -- [ ] [GeoPacker](https://github.com/PKUliujl/GeoPacker) +Please refer to the [manual]([link_to_documentation](https://raw.githubusercontent.com/YaoYinYing/REvoDesignTutorial/refs/heads/main/manual-REvoDesign-%20English.demo_case.docx)) for detailed instructions on how to use the toolkit and make the most of its features. --- @@ -168,6 +149,8 @@ The REvoDesign Package Manager simplifies the installation of the core **REvoDes ![Install with Extras](https://github-image-cache.yaoyy.moe/2024/11/aa5b50c6a603b20bcad614910bbbc440.png) +The typical installation process will take several minutes, depending on your network speed, as well as the extras you choose. + --- #### Uninstallation @@ -206,11 +189,7 @@ You are now ready to use **REvoDesign** for your enzyme redesign projects! **Extras table** -| extras tag and packages | references | explanations | -| :---------------------: | :------------------------------------------------------- | :-------------: | -| `ColabDesign` | | with JAX | -| `DLPacker` | | with TensorFlow | -| `PIPPack` | | with PyTorch | +For detailed plugin, please refer to the [Extras table](jsons/REvoDesignExtrasTableRich.json). > [!WARNING] > ColabDesign uses Jax, which requires **Python >= 3.9** @@ -233,10 +212,82 @@ You are now ready to use **REvoDesign** for your enzyme redesign projects! Happy enzyme redesigning with **REvoDesign**! If you encounter any issues during installation or usage, please consult the documentation or seek assistance from **REvoDesign** toolkit's support resources. ---- -[![CircleCI](https://dl.circleci.com/insights-snapshot/circleci/97VjoN5in7mMaQdymWj7Qk/EVmMjwc2AXdvw6kpYNfFPj/main/test/badge.svg?window=30d&circle-token=465c8a4e66021ab11dd31f920a60a452b09a4cb8)](https://app.circleci.com/insights/circleci/97VjoN5in7mMaQdymWj7Qk/EVmMjwc2AXdvw6kpYNfFPj/workflows/test/overview?branch=main&reporting-window=last-30-days&insights-snapshot=true) [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FYaoYinYing%2FREvoDesign.svg?type=large&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2FYaoYinYing%2FREvoDesign?ref=badge_large&issueType=license) +## Demo Cases + +REvoDesign is tested on various test cases, which can be found in the `tests/cases/tabs` directory, with related test data under `tests/data` directory. + +A detailed tutorial of using REvoDesign can be found as [this manual](https://raw.githubusercontent.com/YaoYinYing/REvoDesignTutorial/refs/heads/main/manual-REvoDesign-%20English.demo_case.docx). + +### Expected demo results and runtime + +Please refer to the test cases and tutorial document. + +## Reproduce + +To reproduce our data, please refer to our manuscript as well as the input data section below. Note that during software development, results may fluctuate. This does not affect the results and conclusion in our manuscript. + +### T5aH + +#### Input Data + +1. Sequence: [`T5aH2.fasta`](https://github.com/YaoYinYing/REvoDesignTutorial/raw/refs/heads/main/T5aH2.fasta) + +2. docked structure: [`T5aH2-HEM-T31_initial_4263.pdb`](https://github.com/YaoYinYing/REvoDesignTutorial/raw/refs/heads/main/T5aH2-HEM-T31_initial_4263.pdb) + +3. PSSM: [`T5aH2_PSSM_ascii_mtx_file`](https://github.com/YaoYinYing/REvoDesignTutorial/raw/refs/heads/main/T5aH2_PSSM_ascii_mtx_file) +4. Full PSSM GREMLIN archive: [`T5aH2_wt_PSSM_GREMLIN_results.zip`](https://github.com/YaoYinYing/REvoDesignTutorial/releases/download/T5aH2/T5aH2_wt_PSSM_GREMLIN_results.zip) +5. Pythia ddG scan result: [`T5aH2-pythia-ddg.zip`](https://github.com/YaoYinYing/REvoDesignTutorial/releases/download/T5aH2/T5aH2-pythia-ddg.zip) + +### Online tools -## API Reference +- [Pythia-ddG](https://biolib.com/YaoYinYing/pythia-wubianlab/) +- [Caver PyMOL Plugin](https://github.com/YaoYinYing/caver-pymol-plugin) -See `docs/api/INDEX.md` for a browsable API reference with examples. +## Timings + +Most of the design tasks w/ REvoDesign will not take very long time, as the software is designed and optimized w/ the most common desktop machines. We recently added a thread pool management mechanism so that users can check and control the detailed wall time of their calculations. + +## Testing + +REvoDesign uses pytest for testing. These cases cover from unit to integration testing. Tests are classified into three categories: + +1. `make fast-test` fast tests: quick tests that run quickly and in parallel and can be run frequently. +2. `make serial-test` serial tests: tests that should be run serially due to the resource consumption. +3. `make slow-test` slow tests: tests that take a long time to run and should be run at very last order. + +To run the tests, use the following command: + +```bash +make all-test +``` + +Keyword tests can be run with the following command: + +```bash +make kw-test PYTEST_KW='test_menu_window_pops' # single keyword +# or +make kw-test PYTEST_KW='"citable or citation"' # multiple keywords, should be in double quotes +``` + +### Cleanup + +Use this one-liner command to clean up the test workspace. + +```bash +make clean +``` + +For more testing options, please refer to the `Makefile` in the root directory. + +### Expected Full testing time + +Less than 35 minutes in total on GH Action runner, Ubuntu 24.04 + +## Formatting + +REvoDesign uses several tools for formatting. To format the code, use the following command: + +```bash +make black +``` diff --git a/playground/print_mut.py b/playground/print_mut.py new file mode 100644 index 00000000..e69de29b diff --git a/src/REvoDesign/sidechain/mutate_runner/README.md b/src/REvoDesign/sidechain/mutate_runner/README.md index 3870975c..51c1b391 100644 --- a/src/REvoDesign/sidechain/mutate_runner/README.md +++ b/src/REvoDesign/sidechain/mutate_runner/README.md @@ -1,5 +1,8 @@ # Call Mutate Runners within a Python script/prompt + +## Mutate and Model + ```python from RosettaPy.common.mutation import RosettaPyProteinSequence @@ -23,3 +26,17 @@ d=DLPacker_worker(pdb_file, 6) # run the mutate mfp=d.run_mutate_parallel(mut_objs, 6) ``` + +## Validate the mutation info + +```python +from RosettaPy.common.mutation import Mutant + +def print_mut_info(wt: str, mut: str): + m=Mutant.from_pdb(wt, [mut]) + print(f'Mutant {mut.removesuffix(".pdb")} (compare to {wt.removesuffix(".pdb")}): {m[0].format_as()}') + + +for i in [1, 2, 3]: + print_mut_info('WT.pdb', f'M{i}.pdb') +``` diff --git a/src/REvoDesign/tools/cgo_utils.py b/src/REvoDesign/tools/cgo_utils.py index af54cd5c..b38c41ce 100644 --- a/src/REvoDesign/tools/cgo_utils.py +++ b/src/REvoDesign/tools/cgo_utils.py @@ -2577,7 +2577,7 @@ def _aptx4869(): cmd.set("movie_fps", 90) - print(__easter_egg.__doc__) + print(_aptx4869.__doc__) cmd.mplay() diff --git a/src/REvoDesign/tools/measure_utils.py b/src/REvoDesign/tools/measure_utils.py new file mode 100644 index 00000000..2d412857 --- /dev/null +++ b/src/REvoDesign/tools/measure_utils.py @@ -0,0 +1,717 @@ +""" +# Read Measurement from a PyMOL session and print Gromacs index input strings. + +**Author: Yinying Yao** +**Date: 2026-02-03** + +Github Copilot was prompted to generate all the contents below based on the codebase of the pymol-open-source repository. +- ref: https://github.com/schrodinger/pymol-open-source/blob/462dd320b8db5e4bed300a068edd1555b7accd5b/layer2/ObjectDist.cpp#L321 + +# Original Prompts: +- explain the cObjectMeasurement object structure and it's representation at cmd.get_session()['names'] list. +- create a python dataclass `Measurement` to represent the object. create a classmethod to serialize measurement objects from the cmd.get_session()['names'] list +- a property like `atoms` (a list of atoms) would be nice for `Measurement` object, if one need to find the atoms (object/chain/segment/resi/resn/atom-index, etc.) + +# Known Issues: +1. The code is not well commented. +2. The code currently only works on distance measurements. +3. The code is currently an experimental prototype and lacks comprehensive testing. + + +# Usage: +1. run this script in pymol console: `run /path/to/measure.py` +2. call the extended command: `read_measurement [start,[debug]]` +""" + +# TODO: +# 1. refactor the code to make it more readable and maintainable, simple and clean +# 2. read non-distance measurements +# 3. Tests and cases. +# 4. add regex measurement object name filter: `read_measurement ,[start,[debug]]`, default is '(all)' + + +""" +# Code for Gromacs indexing system + + 0 System : 113812 atoms + 1 Protein : 8773 atoms + 2 Protein-H : 4414 atoms + 3 C-alpha : 594 atoms + 4 Backbone : 1782 atoms + 5 MainChain : 2375 atoms + 6 MainChain+Cb : 2902 atoms + 7 MainChain+H : 2941 atoms + 8 SideChain : 5832 atoms + 9 SideChain-H : 2039 atoms + 10 Prot-Masses : 8773 atoms + 11 non-Protein : 105039 atoms + 12 Other : 140 atoms + 13 FAD : 84 atoms ; case by case + 14 C18 : 56 atoms ; case by case + 15 NA : 4 atoms ; according to system + 16 Water : 104895 atoms + 17 SOL : 104895 atoms + 18 non-Water : 8917 atoms + 19 Ion : 4 atoms + 20 Water_and_ions : 104899 atoms + 21 Protein_FAD_C18 : 8913 atoms ; case by case + + nr : group '!': not 'name' nr name 'splitch' nr Enter: list groups + 'a': atom '&': and 'del' nr 'splitres' nr 'l': list residues + 't': atom type '|': or 'keep' nr 'splitat' nr 'h': help + 'r': residue 'res' nr 'chain' char + "name": group 'case': case sensitive 'q': save and quit + 'ri': residue index + + +""" + + +import math +from collections.abc import Iterable, Sequence +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple + +from pymol import cmd + +""" +A proper measure object looks like this: + + +m=[ + 'measure1', 0, 1, None, 4, + [ + [ + 4, + 'measure1', + 7, + 2060287, + [-25.08300018310547, 68.54199981689453, -9.894000053405762], # a2 coords + [-8.956000328063965, 78.06800079345703, -7.281000137329102], # a1 coords + 1, 0, None, 1, 0, + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + , 0, None + ], 1, + [ + [ + 4, + [ + -8.956000328063965, + 68.54199981689453, + -7.281000137329102, + + -25.08300018310547, + 78.06800079345703, + -9.894000053405762, + + -8.956000328063965, + 68.54199981689453, + -7.281000137329102, + + -25.08300018310547, + 78.06800079345703, + -9.894000053405762 + ], + None, 0, None, 0, None, None, None, + [ + [2, [1, 2], [0, 0]], + [0, [1, 2], [0, 0]] + ] + ] + ], 0 + ], + '' +] + +""" + + +@dataclass +class MeasureInfo: + offset: int + ids: list[int] + states: list[int] + + @classmethod + def from_pylist(cls, item: Sequence[Any]) -> "MeasureInfo": + offset = int(item[0]) + ids = [int(x) for x in item[1]] if item[1] is not None else [] + states = [int(x) for x in item[2]] if item[2] is not None else [] + return cls(offset=offset, ids=ids, states=states) + + +# the following case only works w/ distances +# + + +@dataclass +class DistSet: + nindex: int + coord: Optional[list[float]] = None + labcoord: Optional[Any] = None + nangleindex: int = 0 + anglecoord: Optional[list[float]] = None + ndihedralindex: int = 0 + dihedralcoord: Optional[list[float]] = None + setting: Optional[Any] = None + labpos: Optional[list[Any]] = None + measure_info: list[MeasureInfo] = field(default_factory=list) + + @classmethod + def from_pylist(cls, py: Sequence[Any]) -> "DistSet": + if not isinstance(py, (list, tuple)): + raise TypeError("DistSet.from_pylist expects a list or tuple") + py = list(py) + [None] * max(0, 10 - len(py)) + nindex = int(py[0]) if py[0] is not None else 0 + coord = list(py[1]) if py[1] is not None else None + labcoord = py[2] + nangleindex = int(py[3]) if py[3] is not None else 0 + anglecoord = list(py[4]) if py[4] is not None else None + ndihedralindex = int(py[5]) if py[5] is not None else 0 + dihedralcoord = list(py[6]) if py[6] is not None else None + setting = py[7] + labpos = list(py[8]) if py[8] is not None else None + measure_info_list = [] + if py[9] is not None: + for mi in py[9]: + measure_info_list.append(MeasureInfo.from_pylist(mi)) + return cls( + nindex=nindex, + coord=coord, + labcoord=labcoord, + nangleindex=nangleindex, + anglecoord=anglecoord, + ndihedralindex=ndihedralindex, + dihedralcoord=dihedralcoord, + setting=setting, + labpos=labpos, + measure_info=measure_info_list, + ) + + def get_vertex_coords_for_measure(self, mi: MeasureInfo) -> list[tuple[float, float, float]]: + """ + Return list of (x,y,z) triples for a given MeasureInfo using measureType implied + by ids length: + - 2 => use self.coord + - 3 => use self.anglecoord + - 4 => use self.dihedralcoord + offset is the index into the corresponding flattened array (in units of vertex triples). + """ + ids_len = len(mi.ids) + if ids_len == 2: + arr = self.coord + elif ids_len == 3: + arr = self.anglecoord + else: + arr = self.dihedralcoord + if not arr: + return [] + off = mi.offset + coords = [] + base = off * 3 + for i in range(ids_len): + idx = base + i * 3 + if idx + 2 < len(arr): + coords.append((float(arr[idx]), float(arr[idx + 1]), float(arr[idx + 2]))) + else: + coords.append((math.nan, math.nan, math.nan)) + return coords + + +@dataclass +class AtomDescriptor: + obj: str + atom_index: int # index of atom within object (0-based) + chain: Optional[str] + segi: Optional[str] + resi: Optional[str] + resn: Optional[str] + name: Optional[str] + unique_id: Optional[int] + coord: Optional[tuple[float, float, float]] + + +# --- helper to build global atom list once --- +def _build_scene_atom_list(cmd_module): + """ + Return a list of AtomDescriptor for all atoms in the scene (all objects). + This uses only reliable attributes from cmd.get_model() atom objects. + """ + atom_list: list[AtomDescriptor] = [] + if cmd_module is None: + return atom_list + + # get object list robustly + try: + if hasattr(cmd_module, "get_object_list"): + objects = list(cmd_module.get_object_list()) + else: + objects = list(cmd_module.get_names("objects")) + except Exception: + objects = list(cmd_module.get_names("objects")) if hasattr(cmd_module, "get_names") else [] + + for obj in objects: + try: + model = cmd_module.get_model(obj, state=-1) + except Exception: + try: + model = cmd_module.get_model(obj) + except Exception: + continue + for a in model.atom: + # coords + coord = None + if hasattr(a, "coord"): + c = getattr(a, "coord") + if isinstance(c, (list, tuple)) and len(c) >= 3: + coord = (float(c[0]), float(c[1]), float(c[2])) + elif hasattr(a, "x") and hasattr(a, "y") and hasattr(a, "z"): + try: + coord = (float(getattr(a, "x")), float(getattr(a, "y")), float(getattr(a, "z"))) + except Exception: + coord = None + + # prefer explicit unique_id attribute; do NOT fall back to 'id' or 'serial' + unique_id = None + if hasattr(a, "unique_id"): + try: + unique_id = int(getattr(a, "unique_id")) + except Exception: + unique_id = None + + atom_index = int(getattr(a, "index", -1)) + chain = getattr(a, "chain", None) + segi = getattr(a, "segi", None) + resi = getattr(a, "resi", None) + resn = getattr(a, "resn", None) + name = getattr(a, "name", None) + + atom_list.append( + AtomDescriptor( + obj=obj, + atom_index=atom_index, + chain=str(chain) if chain is not None else None, + segi=str(segi) if segi is not None else None, + resi=str(resi) if resi is not None else None, + resn=str(resn) if resn is not None else None, + name=str(name) if name is not None else None, + unique_id=unique_id, + coord=coord, + ) + ) + + return atom_list + + +# --- nearest neighbor helper --- +def _nearest_atom_by_coord(target: tuple[float, float, float], atom_list: list[AtomDescriptor]): + best = None + best_d2 = float("inf") + tx, ty, tz = target + for a in atom_list: + if a.coord is None: + continue + dx = a.coord[0] - tx + dy = a.coord[1] - ty + dz = a.coord[2] - tz + d2 = dx * dx + dy * dy + dz * dz + if d2 < best_d2: + best_d2 = d2 + best = (a, d2) + return best # (AtomDescriptor, d2) or None + + +@dataclass +class Measurement: + name: str + header: Optional[Any] = None + dsets: list[DistSet] = field(default_factory=list) + raw_obj_pylist: Optional[list[Any]] = None + extra: Optional[Any] = None + + _atoms_cache: Optional[list[AtomDescriptor]] = None + + def _collect_unique_ids(self) -> list[int]: + unique_ids = [] + for ds in self.dsets: + for mi in ds.measure_info: + for uid in mi.ids: + if uid is not None: + unique_ids.append(int(uid)) + seen = set() + result = [] + for u in unique_ids: + if u not in seen: + seen.add(u) + result.append(u) + return result + + def atoms(self, cmd_module=None, coord_tol=0.9) -> list[AtomDescriptor]: + """ + Resolve measurement atoms to AtomDescriptor objects. + - cmd_module: the pymol.cmd module (optional; uses global cmd) + - coord_tol: coordinate tolerance (Å) for matching measurement vertex to scene atom + """ + if self._atoms_cache is not None: + return self._atoms_cache + + if cmd_module is None: + cmd_module = cmd + + unique_ids = self._collect_unique_ids() + # Build scene atom list once + scene_atoms = _build_scene_atom_list(cmd_module) + + # Build a map unique_id -> AtomDescriptor only when atom.unique_id is present + unique_map: dict[int, AtomDescriptor] = {} + for a in scene_atoms: + if a.unique_id is not None: + unique_map[a.unique_id] = a + + resolved: list[AtomDescriptor] = [] + for uid in unique_ids: + # prefer direct mapping (only uses a.unique_id, no ambiguous fallbacks) + if uid in unique_map: + resolved.append(unique_map[uid]) + continue + + # coordinate-based fallback: find a vertex coordinate for this uid from DistSets + found_coord = None + for ds in self.dsets: + for mi in ds.measure_info: + if uid in mi.ids: + coords = ds.get_vertex_coords_for_measure(mi) + if coords: + # position in ids order -> corresponding coords index + try: + pos_idx = mi.ids.index(uid) + if pos_idx < len(coords): + found_coord = coords[pos_idx] + break + except ValueError: + continue + if found_coord is not None: + break + + if found_coord is None: + # can't resolve at all + resolved.append(AtomDescriptor("(unresolved)", -1, None, None, None, None, None, uid, None)) + continue + + # nearest atom in scene + best = _nearest_atom_by_coord(found_coord, scene_atoms) + if best: + atom_descr, d2 = best + dist = math.sqrt(d2) + if dist <= coord_tol: + # good match + # ensure returned descriptor includes the unique_id we expected (if absent, set it) + if atom_descr.unique_id is None: + atom_descr = AtomDescriptor( + obj=atom_descr.obj, + atom_index=atom_descr.atom_index, + chain=atom_descr.chain, + segi=atom_descr.segi, + resi=atom_descr.resi, + resn=atom_descr.resn, + name=atom_descr.name, + unique_id=uid, + coord=atom_descr.coord, + ) + resolved.append(atom_descr) + continue + # nothing matched within tolerance + resolved.append(AtomDescriptor("(unresolved)", -1, None, None, None, None, None, uid, found_coord)) + + self._atoms_cache = resolved + return resolved + + @classmethod + def from_names_entry(cls, entry: Sequence[Any]) -> Optional["Measurement"]: + if not isinstance(entry, (list, tuple)): + return None + name = entry[0] if len(entry) > 0 and isinstance(entry[0], str) else "" + obj_type = entry[4] if len(entry) > 4 else None + obj_pylist = entry[5] if len(entry) > 5 else None + # measurement type in C++ is cObjectMeasurement == 4 + if obj_type == 4 and isinstance(obj_pylist, (list, tuple)): + raw_obj_pylist = list(obj_pylist) + header = raw_obj_pylist[0] if len(raw_obj_pylist) > 0 else None + dset_pylist = raw_obj_pylist[2] if len(raw_obj_pylist) > 2 else None + dsets = [] + if isinstance(dset_pylist, (list, tuple)): + for ds_item in dset_pylist: + if ds_item is None: + continue + dsets.append(DistSet.from_pylist(ds_item)) + return cls(name=name or "", header=header, dsets=dsets, raw_obj_pylist=raw_obj_pylist) + # fallback: maybe entry is already the ObjectDistAsPyList + if len(entry) >= 3 and isinstance(entry[2], (list, tuple)): + raw_obj_pylist = list(entry) + header = raw_obj_pylist[0] + dset_pylist = raw_obj_pylist[2] + dsets = [] + if isinstance(dset_pylist, (list, tuple)): + for ds_item in dset_pylist: + if ds_item is None: + continue + dsets.append(DistSet.from_pylist(ds_item)) + derived_name = "" + try: + if isinstance(header, (list, tuple)) and len(header) > 1 and isinstance(header[1], str): + derived_name = header[1] + except Exception: + pass + return cls(name=derived_name, header=header, dsets=dsets, raw_obj_pylist=raw_obj_pylist) + return None + + @classmethod + def from_session_names(cls, names: Iterable[Sequence[Any]]) -> list["Measurement"]: + out = [] + for entry in names: + try: + m = cls.from_names_entry(entry) + except Exception as e: + print(f"Failed to load measurement {entry}: {e}") + m = None + if m: + out.append(m) + return out + + def _build_uniqueid_to_atom_map(self, cmd_module) -> dict[int, AtomDescriptor]: + """ + Build mapping unique_id -> AtomDescriptor by iterating over all objects & atoms. + Uses cmd.get_model(obj, state=-1) to collect object atoms. + This depends on the PyMOL Atomic object exposing attributes that include 'unique_id' or similar. + """ + mapping: dict[int, AtomDescriptor] = {} + if cmd_module is None: + return mapping + + # Get all object names in the session + try: + # cmd.get_object_list() is available in newer PyMOL; fallback to cmd.get_names for 'objects' + if hasattr(cmd_module, "get_object_list"): + obj_list = list(cmd_module.get_object_list()) + else: + obj_list = list(cmd_module.get_names("objects")) + except Exception: + obj_list = list(cmd_module.get_names("objects")) if hasattr(cmd_module, "get_names") else [] + + for obj in obj_list: + try: + model = cmd_module.get_model(obj, state=-1) # full model, all atoms + except Exception: + # fallback: try without state arg + try: + model = cmd_module.get_model(obj) + except Exception: + continue + # model.atom is list of Atom objects; attributes vary with PyMOL version. + for a in model.atom: + # try several attribute names for unique id and coords + unique_id = None + coord = None + atom_index = getattr(a, "index", None) + # Common names that may exist: 'unique_id', 'uniq', 'id', 'serial' - check them + for attr in ("unique_id", "uniq", "id", "serial"): + if hasattr(a, attr): + try: + unique_id = int(getattr(a, attr)) + break + except Exception: + pass + # coords + if hasattr(a, "coord"): + try: + c = getattr(a, "coord") + if isinstance(c, (list, tuple)) and len(c) >= 3: + coord = (float(c[0]), float(c[1]), float(c[2])) + except Exception: + coord = None + elif hasattr(a, "x") and hasattr(a, "y") and hasattr(a, "z"): + try: + coord = (float(getattr(a, "x")), float(getattr(a, "y")), float(getattr(a, "z"))) + except Exception: + coord = None + + chain = getattr(a, "chain", None) + segi = getattr(a, "segi", None) + resi = getattr(a, "resi", None) + resn = getattr(a, "resn", None) + name = getattr(a, "name", None) + + if unique_id is not None: + mapping[unique_id] = AtomDescriptor( + obj=obj, + atom_index=int(atom_index) if atom_index is not None else -1, + chain=str(chain) if chain is not None else None, + segi=str(segi) if segi is not None else None, + resi=str(resi) if resi is not None else None, + resn=str(resn) if resn is not None else None, + name=str(name) if name is not None else None, + unique_id=unique_id, + coord=coord, + ) + return mapping + + @staticmethod + def _distance_sq(a: tuple[float, float, float], b: tuple[float, float, float]) -> float: + return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2 + + def _resolve_by_coords( + self, uid: int, target_coord: tuple[float, float, float], cmd_module + ) -> Optional[AtomDescriptor]: + """ + Fallback: find the nearest atom in the entire scene to target_coord. + Returns AtomDescriptor or None. + """ + if cmd_module is None: + return None + + best = None + best_d2 = float("inf") + try: + # iterate all objects and atoms (similar to map building above) + if hasattr(cmd_module, "get_object_list"): + obj_list = list(cmd_module.get_object_list()) + else: + obj_list = list(cmd_module.get_names("objects")) + except Exception: + obj_list = list(cmd_module.get_names("objects")) if hasattr(cmd_module, "get_names") else [] + + for obj in obj_list: + try: + model = cmd_module.get_model(obj, state=-1) + except Exception: + try: + model = cmd_module.get_model(obj) + except Exception: + continue + for a in model.atom: + # get atom coords + coord = None + if hasattr(a, "coord"): + c = getattr(a, "coord") + if isinstance(c, (list, tuple)) and len(c) >= 3: + coord = (float(c[0]), float(c[1]), float(c[2])) + elif hasattr(a, "x") and hasattr(a, "y") and hasattr(a, "z"): + coord = (float(getattr(a, "x")), float(getattr(a, "y")), float(getattr(a, "z"))) + if coord is None: + continue + d2 = self._distance_sq(coord, target_coord) + if d2 < best_d2: + best_d2 = d2 + best = AtomDescriptor( + obj=obj, + atom_index=int(getattr(a, "index", -1)), + chain=str(getattr(a, "chain", None)) if getattr(a, "chain", None) is not None else None, + segi=str(getattr(a, "segi", None)) if getattr(a, "segi", None) is not None else None, + resi=str(getattr(a, "resi", None)) if getattr(a, "resi", None) is not None else None, + resn=str(getattr(a, "resn", None)) if getattr(a, "resn", None) is not None else None, + name=str(getattr(a, "name", None)) if getattr(a, "name", None) is not None else None, + unique_id=None, + coord=coord, + ) + # Optionally: ignore matches that are far away (very large distance) + if best is not None: + return best + return None + + def summarize(self, cmd_module=None) -> str: + lines = [f"Measurement: {self.name} ({len(self.dsets)} DistSet(s))"] + atoms = self.atoms(cmd_module=cmd_module) + lines.append("Atoms:") + for a in atoms: + lines.append( + f" unique_id={a.unique_id}, object={a.obj}, atom_index={a.atom_index}, " + f"chain={a.chain}, segi={a.segi}, resi={a.resi}, resn={a.resn}, name={a.name}, coord={a.coord}" + ) + # per-distset summary + for i, ds in enumerate(self.dsets): + lines.append(f"DistSet {i}: nindex={ds.nindex}, nangle={ds.nangleindex}, ndihedral={ds.ndihedralindex}") + for mi in ds.measure_info: + mtype = {2: "distance", 3: "angle", 4: "dihedral"}.get(len(mi.ids), "unknown") + lines.append(f" {mtype}: ids={mi.ids}, states={mi.states}, offset={mi.offset}") + return "\n".join(lines) + + +# + + +def read_measurement(start: str | int, debug: int = 0) -> Measurement: + """ + This function reads the measurement from the PyMOL session and prints atoms as gromacs index strings. + + Parameters + start : str or int + The starting atom index. + debug : bool int, optional + If non-zero, prints debug information. The default is 0. + + + ``` + 9 & r 111 ; select the non-glycine residue sidechain group at 111 + name 2 r111 ; rename it from 2 to r111 + 8 & r 514 ; select the glycine residue backbone group at 514 + name 3 r514 ; rename it from 3 to r514 + ``` + """ + DEBUG = bool(int(debug)) + + start = int(start) + from pymol import cmd + + atoms: dict[int, str] = {} + pairs: dict = {} + + session = cmd.get_session() + hits = Measurement.from_session_names(session["names"]) + + if not hits: + raise ValueError( + f"measurement not found in session {[m.name for m in hits]}", + ) + + for hit in hits: + if DEBUG: + print("-=" * 30) + print(f"[DEBUG] {hit.summarize(cmd)}") + pair = [] + for a in hit.atoms(cmd): + # + pair.append(f"r{a.resi}") + + if f"r{a.resi}" in atoms.values(): + if DEBUG: + print(f"[DEBUG] skiping {a.resi} to avoid duplicates") + continue + + start += 1 + + print(f'{"8" if a.resn == "GLY" else "9"} & r {a.resi}') + print(f"name {start} r{a.resi}") + + atoms[start] = f"r{a.resi}" + + pairs[hit.name] = pair + if DEBUG: + print(f"[DEBUG] {hit.name} {pair}") + print("-=" * 30) + + if DEBUG: + print(pairs) + print("-=" * 30) + # re-organize the strings + names = [f"'{x}'" for x in pairs.keys()] + atom_a = [f"'{x[0]}'" for x in pairs.values()] + atom_b = [f"'{x[1]}'" for x in pairs.values()] + + print(f'labels=({" ".join(names)})') + print(f'grp_as=({" ".join(atom_a)})') + print(f'grp_bs=({" ".join(atom_b)})') + + return hits + + +cmd.extend("read_measurement", read_measurement) diff --git a/tests/data/test_data.py b/tests/data/test_data.py index 09ed68d1..f43f0398 100644 --- a/tests/data/test_data.py +++ b/tests/data/test_data.py @@ -23,12 +23,12 @@ class TestData: # dataset PSSM&GREMLIN PSSM_GREMLIN_DATA_URL: str = ( - "https://github.com/YaoYinYing/REvoDesign-test-data/releases/download/1SUO/1SUO_A_PSSM_GREMLIN_results.zip" + "https://github.com/YaoYinYing/REvoDesignTutorial/releases/download/1SUO/1SUO_A_PSSM_GREMLIN_results.zip" ) PSSM_GREMLIN_DATA_MD5: str = "md5:5fc8ab8f657051ae8117a678924ac471" PYTHIA_DDG_CSV_URL: str = ( - "https://github.com/YaoYinYing/REvoDesign-test-data/releases/download/1SUO/1SUO_pred_mask.csv" + "https://github.com/YaoYinYing/REvoDesignTutorial/releases/download/1SUO/1SUO_pred_mask.csv" ) PYTHIA_DDG_CSV_MD5: str = "md5:982eda8c8056c388d9741407dea8e750" @@ -114,7 +114,7 @@ def pocket_design_pse(self): entropy_to_ddg_group_id: str = "surf.pssm.ddg" EVALUATION_PSE_URL: str = ( - "https://github.com/YaoYinYing/REvoDesign-test-data/releases/download/1SUO/1SUO.xtal.surface.ent.pssm.2.pze" + "https://github.com/YaoYinYing/REvoDesignTutorial/releases/download/1SUO/1SUO.xtal.surface.ent.pssm.2.pze" ) EVALUATION_PSE_MD5: str = "md5:225128f0958ad622de9af6b485de5e86" @@ -236,7 +236,7 @@ def gremlin_monomer_pse(self): "remove r. hoh;set cartoon_cylindrical_helices;spectrum chain, blue_white_red, 4MB8" ) gremlin_homomer_profile_url: str = ( - "https://github.com/YaoYinYing/REvoDesign-test-data/releases/download/4MB8/4MB8_A_PSSM_GREMLIN_results.zip" + "https://github.com/YaoYinYing/REvoDesignTutorial/releases/download/4MB8/4MB8_A_PSSM_GREMLIN_results.zip" ) gremlin_homomer_profile_md5: str = "md5:999af75bd166b15594ed0435066b4e2d" @@ -390,7 +390,7 @@ def expand_zip(self, compressed_file: str): @dataclass() class TestDataOnLocalMac(TestData): - test_data_repo: str = "/Users/yyy/Documents/protein_design/REvoDesign-test-data/" + test_data_repo: str = "/Users/yyy/Documents/protein_design/REvoDesignTutorial/" if __name__ == "__main__": diff --git a/tests/test_thread_ui_bridge.py b/tests/test_thread_ui_bridge.py index 78142e49..520d6d5b 100644 --- a/tests/test_thread_ui_bridge.py +++ b/tests/test_thread_ui_bridge.py @@ -1,13 +1,10 @@ -import threading -import time - import pytest - -pytest.importorskip("PyQt5") from PyQt5 import QtCore, QtWidgets from REvoDesign.tools import package_manager +pytest.importorskip("PyQt5") + @pytest.fixture(scope="module") def qapp(): diff --git a/tools/license_notice.py b/tools/license_notice.py index a0614647..aa9f9138 100644 --- a/tools/license_notice.py +++ b/tools/license_notice.py @@ -1,9 +1,5 @@ -# Copyright (c) 2023 The Revodesign Developers. -# Distributed under the terms of the BSD 3-Clause License. -# SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2018 The Pooch Developers. -# Distributed under the terms of the BSD 3-Clause License. -# SPDX-License-Identifier: BSD-3-Clause + +raise NotImplementedError('This script needs refactor before using.') """ Add license notice to every source file if not present