diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 10caace..6dc9e06 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -26,7 +26,7 @@ jobs: os: [macOS-latest, ubuntu-latest, windows-latest] python-version: ["3.9", "3.10", "3.11", "3.12"] chemlib: [obabel, rdkit] - graphlib: [nx, gt, all] + graphlib: [nx, gt, rx, all] exclude: # graph-tools does not work on Windows - {os: "windows-latest", graphlib: "gt"} diff --git a/CHANGELOG.md b/CHANGELOG.md index f87aa4d..aac3eb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Contributors: @RMeli, @takluyver, @Jnelen ### Added +* Support for `rustworkx` graph library [PR 111 | @RMeli] * Functionality to manually select the backend from CLI [PR #108 | @RMeli] * Functionality to manually select the backend [PR #107 | @Jnelen] * Python `3.12` to CI [PR #102 | @RMeli] diff --git a/README.md b/README.md index 8a926bb..526424a 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ If you find `spyrmsd` useful, please consider citing the following paper: `spyrmsd` is available on [PyPI](https://pypi.org/project/spyrmsd/) and [conda-forge](https://github.com/conda-forge/spyrmsd-feedstock) and can be easily installed from source. See [Dependencies](###Dependencies) for a description of all the dependencies. > [!NOTE] -> `spyrmsd` will install [NetworkX](https://networkx.github.io/) (multi-platform). You can install [graph-tool](https://graph-tool.skewed.de/) on macOS and Linux for higher performance. +> `spyrmsd` will install [NetworkX](https://networkx.github.io/) (multi-platform). You can install the other backends for higher performance. > [!WARNING] > If `spyrmsd` is used as a standalone tool, it is required to install either [RDKit](https://rdkit.org/) or [Open Babel](http://openbabel.org/). Neither is automatically installed with `pip` nor `conda`. @@ -71,12 +71,16 @@ pip install . The following packages are required to use `spyrmsd` as a module: -* [graph-tool](https://graph-tool.skewed.de/) or [NetworkX](https://networkx.github.io/) * [numpy](https://numpy.org/) * [scipy](https://www.scipy.org/) +One of the following graph libraries is required: +* [graph-tool] +* [NetworkX] +* [rustworkx] + > [!NOTE] -> `spyrmsd` uses [graph-tool](https://graph-tool.skewed.de/) by default but will fall back to [NetworkX](https://networkx.github.io/) if the former is not installed (e.g. on Windows). However, in order to support cross-platform installation [NetworkX](https://networkx.github.io/) is installed by default, and [graph-tool](https://graph-tool.skewed.de/) needs to be installed manually. +> `spyrmsd` uses the following priority when multiple graph libraries are present: [graph-tool], [NetworkX], [rustworkx]. *This order might change. Use `set_backend` to ensure you are always using the same backend, if needed.* However, in order to support cross-platform installation [NetworkX](https://networkx.github.io/) is installed by default, and the other graph library need to be installed manually. #### Standalone Tool @@ -118,7 +122,7 @@ def rmsd( ``` > [!NOTE] -> Atomic properties (`aprops`) can be any Python object when using [NetworkX](https://networkx.github.io/), or integers, floats, or strings when using [graph-tool](https://graph-tool.skewed.de/). +> Atomic properties (`aprops`) can be any Python object when using [NetworkX] and [rustworkx], or integers, floats, or strings when using [graph-tool]. #### Symmetry-Corrected RMSD @@ -142,11 +146,16 @@ def symmrmsd( ``` > [!NOTE] -> Atomic properties (`aprops`) can be any Python object when using [NetworkX](https://networkx.github.io/), or integers, floats, or strings when using [graph-tool](https://graph-tool.skewed.de/). +> Atomic properties (`aprops`) can be any Python object when using [NetworkX] and [rustworkx], or integers, floats, or strings when using [graph-tool](https://graph-tool.skewed.de/). #### Select Backend -`spyrmsd` supports both [NetworkX](https://networkx.github.io/) and [graph-tool](https://graph-tool.skewed.de/) for the calculation of graph isomorphisms. You can check which backend is being used with +`spyrmsd` supports the following graph libraries for the calculation of graph isomorphisms: +* [graph-tool] +* [NetworkX] +* [rustworkx] + + You can check which backend is being used with ```python spyrmsd.get_backend() @@ -176,7 +185,7 @@ Pre-commit `git` hooks can be installed with [pre-commit](https://pre-commit.com ## Copyright -Copyright (c) 2019-2021, Rocco Meli. +Copyright (c) 2019-2024, Rocco Meli. ## References @@ -185,3 +194,7 @@ References are tracked with [duecredit](https://github.com/duecredit/duecredit/) ### Acknowledgements Project based on the [Computational Molecular Science Python Cookiecutter](https://github.com/molssi/cookiecutter-cms) version `1.1`. + +[rustworkx]: https://www.rustworkx.org +[NetworkX]: https://networkx.github.io/ +[graph-tool]: https://graph-tool.skewed.de/ diff --git a/devtools/conda-envs/spyrmsd-test-obabel-rx.yaml b/devtools/conda-envs/spyrmsd-test-obabel-rx.yaml new file mode 100644 index 0000000..af3cf83 --- /dev/null +++ b/devtools/conda-envs/spyrmsd-test-obabel-rx.yaml @@ -0,0 +1,26 @@ +name: spyrmsd +channels: + - conda-forge +dependencies: + # Base + - python + - setuptools + + # Maths + - numpy + - scipy + - rustworkx + + # Chemistry + - openbabel + + # Testing + - pytest + - pytest-cov + - pytest-benchmark + + # Dev + - mypy + - flake8 + - black + - codecov diff --git a/devtools/conda-envs/spyrmsd-test-rdkit-all.yaml b/devtools/conda-envs/spyrmsd-test-rdkit-all.yaml index 9212381..d1d7ced 100644 --- a/devtools/conda-envs/spyrmsd-test-rdkit-all.yaml +++ b/devtools/conda-envs/spyrmsd-test-rdkit-all.yaml @@ -12,6 +12,7 @@ dependencies: - scipy - graph-tool - networkx>=2 + - rustworkx # Chemistry - rdkit diff --git a/devtools/conda-envs/spyrmsd-test-rdkit-rx.yaml b/devtools/conda-envs/spyrmsd-test-rdkit-rx.yaml new file mode 100644 index 0000000..e752f97 --- /dev/null +++ b/devtools/conda-envs/spyrmsd-test-rdkit-rx.yaml @@ -0,0 +1,27 @@ +name: spyrmsd +channels: + - conda-forge + - rdkit +dependencies: + # Base + - python + - setuptools + + # Maths + - numpy + - scipy + - rustworkx + + # Chemistry + - rdkit + + # Testing + - pytest + - pytest-cov + - pytest-benchmark + + # Dev + - mypy + - flake8 + - black + - codecov diff --git a/docs/source/conf.py b/docs/source/conf.py index 1be2b02..eb02994 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -23,7 +23,7 @@ project = "spyrmsd" copyright = ( - "2019-2021, Rocco Meli. Project structure based on the " + "2019-2024, Rocco Meli. Project structure based on the " "Computational Molecular Science Python Cookiecutter version 1.1" ) author = "Rocco Meli" diff --git a/spyrmsd/__main__.py b/spyrmsd/__main__.py index e61563d..4e8ba96 100644 --- a/spyrmsd/__main__.py +++ b/spyrmsd/__main__.py @@ -34,6 +34,9 @@ default=None, help="Graph library (backend)", ) + parser.add_argument( + "-v", "--verbose", action="store_true", help="Enable verbose mode" + ) args = parser.parse_args() @@ -63,6 +66,9 @@ warnings.simplefilter("ignore") spyrmsd.set_backend(args.graph_backend) + if args.verbose: + print(f"Graph library: {spyrmsd.get_backend()}") + # Loop over molecules within fil RMSDlist = rmsdwrapper( ref, diff --git a/spyrmsd/graph.py b/spyrmsd/graph.py index b788992..f777eed 100644 --- a/spyrmsd/graph.py +++ b/spyrmsd/graph.py @@ -5,7 +5,7 @@ from spyrmsd import constants -_supported_backends = ("graph_tool", "networkx") +_supported_backends = ("graph_tool", "networkx", "rustworkx") _available_backends = [] _current_backend = None @@ -13,6 +13,7 @@ _backend_to_alias = { "graph_tool": ["graph_tool", "graphtool", "graph-tool", "graph tool", "gt"], "networkx": ["networkx", "nx"], + "rustworkx": ["rustworkx", "rx"], } _alias_to_backend = {} @@ -141,6 +142,25 @@ def _set_backend(backend): num_vertices = nx_num_vertices vertex_property = nx_vertex_property + elif backend == "rustworkx": + from spyrmsd.graphs.rx import cycle as rx_cycle + from spyrmsd.graphs.rx import ( + graph_from_adjacency_matrix as rx_graph_from_adjacency_matrix, + ) + from spyrmsd.graphs.rx import lattice as rx_lattice + from spyrmsd.graphs.rx import match_graphs as rx_match_graphs + from spyrmsd.graphs.rx import num_edges as rx_num_edges + from spyrmsd.graphs.rx import num_vertices as rx_num_vertices + from spyrmsd.graphs.rx import vertex_property as rx_vertex_property + + cycle = rx_cycle + graph_from_adjacency_matrix = rx_graph_from_adjacency_matrix + lattice = rx_lattice + match_graphs = rx_match_graphs + num_edges = rx_num_edges + num_vertices = rx_num_vertices + vertex_property = rx_vertex_property + _current_backend = backend diff --git a/spyrmsd/graphs/rx.py b/spyrmsd/graphs/rx.py new file mode 100644 index 0000000..673293a --- /dev/null +++ b/spyrmsd/graphs/rx.py @@ -0,0 +1,190 @@ +import warnings +from typing import Any, List, Optional, Tuple, Union + +import numpy as np +import rustworkx as rx + +from spyrmsd.exceptions import NonIsomorphicGraphs +from spyrmsd.graphs._common import ( + error_non_isomorphic_graphs, + warn_disconnected_graph, + warn_no_atomic_properties, +) + + +def graph_from_adjacency_matrix( + adjacency_matrix: Union[np.ndarray, List[List[int]]], + aprops: Optional[Union[np.ndarray, List[Any]]] = None, +) -> rx.PyGraph: + """ + Graph from adjacency matrix. + + Parameters + ---------- + adjacency_matrix: Union[np.ndarray, List[List[int]]] + Adjacency matrix + aprops: Union[np.ndarray, List[Any]], optional + Atomic properties + + Returns + ------- + Graph + Molecular graph + + Notes + ----- + It the atomic numbers are passed, they are used as node attributes. + """ + + G = rx.PyGraph.from_adjacency_matrix(np.asarray(adjacency_matrix, dtype=np.float64)) + + if not rx.is_connected(G): + warnings.warn(warn_disconnected_graph) + + if aprops is not None: + for i in G.node_indices(): + G[i] = aprops[i] + + return G + + +def match_graphs(G1, G2) -> List[Tuple[List[int], List[int]]]: + """ + Compute graph isomorphisms. + + Parameters + ---------- + G1: + Graph 1 + G2: + Graph 2 + + Returns + ------- + List[Tuple[List[int],List[int]]] + All possible mappings between nodes of graph 1 and graph 2 (isomorphisms) + + Raises + ------ + NonIsomorphicGraphs + If the graphs `G1` and `G2` are not isomorphic + """ + + def match_aprops(node1, node2): + """ + Check if atomic properties for two nodes match. + """ + return node1 == node2 + + if G1[0] is None or G2[0] is None: + # Nodes without atomic number information + # No node-matching check + node_match = None + + warnings.warn(warn_no_atomic_properties) + + else: + node_match = match_aprops + + GM = rx.vf2_mapping(G1, G2, node_match) + + isomorphisms = [ + (list(isomorphism.keys()), list(isomorphism.values())) for isomorphism in GM + ] + + # Check if graphs are actually isomorphic + if len(isomorphisms) == 0: + raise NonIsomorphicGraphs(error_non_isomorphic_graphs) + + return isomorphisms + + +def vertex_property(G, vproperty: str, idx: int) -> Any: + """ + Get vertex (node) property from graph + + Parameters + ---------- + G: + Graph + vproperty: str + Vertex property name + idx: int + Vertex index + + Returns + ------- + Any + Vertex property value + """ + return G[idx] + + +def num_vertices(G) -> int: + """ + Number of vertices + + Parameters + ---------- + G: + Graph + + Returns + ------- + int + Number of vertices (nodes) + """ + return G.num_nodes() + + +def num_edges(G) -> int: + """ + Number of edges + + Parameters + ---------- + G: + Graph + + Returns + ------- + int + Number of edges + """ + return G.num_edges() + + +def lattice(n1, n2): + """ + Build 2D lattice graph + + Parameters + ---------- + n1: int + Number of nodes in dimension 1 + n2: int + Number of nodes in dimension 2 + + Returns + ------- + Graph + Lattice graph + """ + return rx.generators.grid_graph(rows=n1, cols=n2, multigraph=False) + + +def cycle(n): + """ + Build cycle graph + + Parameters + ---------- + n: int + Number of nodes + + Returns + ------- + Graph + Cycle graph + """ + return rx.generators.cycle_graph(n, multigraph=False) diff --git a/tests/test_graph.py b/tests/test_graph.py index cbd607f..c25bf5a 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -164,6 +164,7 @@ def test_build_graph_node_features_unsupported() -> None: def test_set_backend() -> None: import graph_tool as gt import networkx as nx + import rustworkx as rx A = np.array([[0, 1, 1], [1, 0, 0], [1, 0, 1]]) @@ -179,5 +180,11 @@ def test_set_backend() -> None: Ggt = graph.graph_from_adjacency_matrix(A) assert isinstance(Ggt, gt.Graph) + spyrmsd.set_backend("rustworkx") + assert spyrmsd.get_backend() == "rustworkx" + + Grx = graph.graph_from_adjacency_matrix(A) + assert isinstance(Grx, rx.PyGraph) + with pytest.raises(ValueError, match="backend is not recognized or supported"): spyrmsd.set_backend("unknown") diff --git a/tests/test_molecule.py b/tests/test_molecule.py index 4e2fcbd..fde466b 100644 --- a/tests/test_molecule.py +++ b/tests/test_molecule.py @@ -250,6 +250,7 @@ def test_from_rdmol(adjacency): def test_molecule_graph_cache(mol) -> None: import graph_tool as gt import networkx as nx + import rustworkx as rx ## Graph cache persists from previous tests, manually reset them mol.G = {} @@ -262,9 +263,13 @@ def test_molecule_graph_cache(mol) -> None: spyrmsd.set_backend("graph-tool") mol.to_graph() - ## Make sure both backends (still) have a cache + spyrmsd.set_backend("rustworkx") + mol.to_graph() + + ## Make sure all backends (still) have a cache assert isinstance(mol.G["networkx"], nx.Graph) assert isinstance(mol.G["graph_tool"], gt.Graph) + assert isinstance(mol.G["rustworkx"], rx.PyGraph) ## Strip the molecule to ensure the cache is reset mol.strip()