molify 0.2.2__tar.gz → 0.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- molify-0.2.4/.github/workflows/build-wheels.yaml +42 -0
- {molify-0.2.2 → molify-0.2.4}/.gitignore +5 -0
- {molify-0.2.2 → molify-0.2.4}/.pre-commit-config.yaml +1 -1
- {molify-0.2.2 → molify-0.2.4}/PKG-INFO +1 -1
- {molify-0.2.2 → molify-0.2.4}/paper/bibliography.bib +17 -18
- {molify-0.2.2 → molify-0.2.4}/paper/paper.md +16 -14
- {molify-0.2.2 → molify-0.2.4}/pyproject.toml +8 -2
- molify-0.2.4/src/molify/_version.py +24 -0
- {molify-0.2.2 → molify-0.2.4}/src/molify/ase2x.py +27 -17
- {molify-0.2.2 → molify-0.2.4}/src/molify/compress.py +2 -1
- molify-0.2.4/src/molify/constants.py +35 -0
- {molify-0.2.2 → molify-0.2.4}/src/molify/networkx2x.py +28 -14
- {molify-0.2.2 → molify-0.2.4}/src/molify/pack.py +7 -3
- {molify-0.2.2 → molify-0.2.4}/src/molify/rdkit2x.py +24 -7
- {molify-0.2.2 → molify-0.2.4}/src/molify/smiles2x.py +6 -4
- {molify-0.2.2 → molify-0.2.4}/src/molify/substructure.py +3 -2
- {molify-0.2.2 → molify-0.2.4}/tests/integration/test_ase2x.py +46 -0
- {molify-0.2.2 → molify-0.2.4}/tests/integration/test_networkx2x.py +92 -0
- {molify-0.2.2 → molify-0.2.4}/tests/test_rdkit2ase.py +57 -1
- {molify-0.2.2 → molify-0.2.4}/tests/test_solvate.py +10 -0
- {molify-0.2.2 → molify-0.2.4}/uv.lock +0 -1
- molify-0.2.2/.github/workflows/build-wheels.yaml +0 -25
- {molify-0.2.2 → molify-0.2.4}/.github/workflows/pages.yaml +0 -0
- {molify-0.2.2 → molify-0.2.4}/.github/workflows/pytest.yaml +0 -0
- {molify-0.2.2 → molify-0.2.4}/.python-version +0 -0
- {molify-0.2.2 → molify-0.2.4}/CITATION.cff +0 -0
- {molify-0.2.2 → molify-0.2.4}/CONTRIBUTING.md +0 -0
- {molify-0.2.2 → molify-0.2.4}/LICENSE +0 -0
- {molify-0.2.2 → molify-0.2.4}/README.md +0 -0
- {molify-0.2.2 → molify-0.2.4}/docs/Makefile +0 -0
- {molify-0.2.2 → molify-0.2.4}/docs/make.bat +0 -0
- {molify-0.2.2 → molify-0.2.4}/docs/source/ase_tools.ipynb +0 -0
- {molify-0.2.2 → molify-0.2.4}/docs/source/atom_selection.ipynb +0 -0
- {molify-0.2.2 → molify-0.2.4}/docs/source/conf.py +0 -0
- {molify-0.2.2 → molify-0.2.4}/docs/source/index.rst +0 -0
- {molify-0.2.2 → molify-0.2.4}/docs/source/modules.rst +0 -0
- {molify-0.2.2 → molify-0.2.4}/docs/source/networkx_tools.ipynb +0 -0
- {molify-0.2.2 → molify-0.2.4}/docs/source/packmol_tools.ipynb +0 -0
- {molify-0.2.2 → molify-0.2.4}/docs/source/rdkit_tools.ipynb +0 -0
- {molify-0.2.2 → molify-0.2.4}/paper/.gitignore +0 -0
- {molify-0.2.2 → molify-0.2.4}/paper/build.sh +0 -0
- {molify-0.2.2 → molify-0.2.4}/paper/zndraw_rdkit.svg +0 -0
- {molify-0.2.2 → molify-0.2.4}/src/molify/__init__.py +0 -0
- {molify-0.2.2 → molify-0.2.4}/src/molify/bond_order.py +0 -0
- {molify-0.2.2 → molify-0.2.4}/src/molify/com.py +0 -0
- {molify-0.2.2 → molify-0.2.4}/src/molify/utils.py +0 -0
- {molify-0.2.2 → molify-0.2.4}/tests/conftest.py +0 -0
- {molify-0.2.2 → molify-0.2.4}/tests/data/ec_emc.xyz +0 -0
- {molify-0.2.2 → molify-0.2.4}/tests/integration/test_rdkit2x.py +0 -0
- {molify-0.2.2 → molify-0.2.4}/tests/test_com.py +0 -0
- {molify-0.2.2 → molify-0.2.4}/tests/test_compress.py +0 -0
- {molify-0.2.2 → molify-0.2.4}/tests/test_connectivity.py +0 -0
- {molify-0.2.2 → molify-0.2.4}/tests/test_edgecases.py +0 -0
- {molify-0.2.2 → molify-0.2.4}/tests/test_examples.py +0 -0
- {molify-0.2.2 → molify-0.2.4}/tests/test_smiles2conformers.py +0 -0
- {molify-0.2.2 → molify-0.2.4}/tests/test_substructure.py +0 -0
- {molify-0.2.2 → molify-0.2.4}/tests/test_utils.py +0 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
name: Build and upload to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
release:
|
|
6
|
+
types:
|
|
7
|
+
- published
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build:
|
|
11
|
+
name: Build distribution
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v5
|
|
15
|
+
with:
|
|
16
|
+
fetch-depth: 0
|
|
17
|
+
- uses: astral-sh/setup-uv@v5
|
|
18
|
+
- name: Build sdist and wheel
|
|
19
|
+
run: uv build
|
|
20
|
+
- name: Upload artifacts
|
|
21
|
+
uses: actions/upload-artifact@v4
|
|
22
|
+
with:
|
|
23
|
+
name: dist
|
|
24
|
+
path: dist/*
|
|
25
|
+
|
|
26
|
+
upload_pypi:
|
|
27
|
+
needs: [build]
|
|
28
|
+
environment:
|
|
29
|
+
name: pypi
|
|
30
|
+
url: https://pypi.org/p/molify
|
|
31
|
+
permissions:
|
|
32
|
+
id-token: write
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.action == 'published')
|
|
35
|
+
steps:
|
|
36
|
+
- name: Download all artifacts
|
|
37
|
+
uses: actions/download-artifact@v4
|
|
38
|
+
with:
|
|
39
|
+
name: dist
|
|
40
|
+
path: dist
|
|
41
|
+
- name: Publish to PyPI
|
|
42
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: molify
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Molecular Structure Interface with RDKit, ASE, Packmol and NetworkX
|
|
5
5
|
Project-URL: Repository, https://github.com/zincware/molify
|
|
6
6
|
Project-URL: Releases, https://github.com/zincware/molify/releases
|
|
@@ -202,25 +202,24 @@
|
|
|
202
202
|
urldate = {2025-05-24}
|
|
203
203
|
}
|
|
204
204
|
@inproceedings{hagbergExploringNetworkStructure2008,
|
|
205
|
-
author
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
editor = {Ga\"el Varoquaux and Travis Vaught and Jarrod Millman}
|
|
205
|
+
author = {Aric A. Hagberg and Daniel A. Schult and Pieter J. Swart},
|
|
206
|
+
year = {2008},
|
|
207
|
+
month = {06},
|
|
208
|
+
title = {Exploring Network Structure, Dynamics, and Function Using NetworkX},
|
|
209
|
+
journal = {Proceedings of the 7th Python in Science Conference},
|
|
210
|
+
doi = {10.25080/TCWV9851}
|
|
212
211
|
}
|
|
213
212
|
@article{oboyleOpenBabelOpen2011,
|
|
214
|
-
title
|
|
213
|
+
title = {Open {{Babel}}: {{An}} Open Chemical Toolbox},
|
|
215
214
|
shorttitle = {Open {{Babel}}},
|
|
216
|
-
author
|
|
217
|
-
year
|
|
218
|
-
month
|
|
219
|
-
journal
|
|
220
|
-
volume
|
|
221
|
-
number
|
|
222
|
-
pages
|
|
223
|
-
issn
|
|
224
|
-
doi
|
|
225
|
-
urldate
|
|
215
|
+
author = {O'Boyle, Noel M. and Banck, Michael and James, Craig A. and Morley, Chris and Vandermeersch, Tim and Hutchison, Geoffrey R.},
|
|
216
|
+
year = 2011,
|
|
217
|
+
month = oct,
|
|
218
|
+
journal = {Journal of Cheminformatics},
|
|
219
|
+
volume = {3},
|
|
220
|
+
number = {1},
|
|
221
|
+
pages = {33},
|
|
222
|
+
issn = {1758-2946},
|
|
223
|
+
doi = {10.1186/1758-2946-3-33},
|
|
224
|
+
urldate = {2025-11-15}
|
|
226
225
|
}
|
|
@@ -21,17 +21,17 @@ bibliography: bibliography.bib
|
|
|
21
21
|
# Summary
|
|
22
22
|
|
|
23
23
|
The increasing prevalence of Machine-Learned Interatomic Potentials (MLIPs) has shifted requirements for setting up atomistic simulations.
|
|
24
|
-
Unlike classical force fields, MLIPs primarily require atomic positions and species, thereby removing the need for predefined topology files used for classical force fields in molecular dynamics software like GROMACS[@abrahamGROMACSHighPerformance2015], LAMMPS[@LAMMPS], ESPResSo[@weikESPResSo40Extensible2019], or OpenMM[@eastmanOpenMM8Molecular2024].
|
|
24
|
+
Unlike classical force fields, MLIPs primarily require atomic positions and species, thereby removing the need for predefined topology files used for classical force fields in molecular dynamics software like GROMACS [@abrahamGROMACSHighPerformance2015], LAMMPS [@LAMMPS], ESPResSo [@weikESPResSo40Extensible2019], or OpenMM [@eastmanOpenMM8Molecular2024].
|
|
25
25
|
Consequently, the Atomic Simulation Environment (ASE) [@larsenAtomicSimulationEnvironment2017] has become a popular Python toolkit for handling atomic structures and interfacing with MLIPs, particularly within the material science and soft matter communities.
|
|
26
26
|
ASE originates from the electronic structure community, which shares the same setup as MLIP-driven studies.
|
|
27
27
|
In contrast to _ab initio_, MLIPs are much faster and can run on much larger systems, making high-throughput simulations of more complex systems feasible and increasing the need for efficient initial structure generation.
|
|
28
28
|
|
|
29
|
-
Concurrently, RDKit[@landrumRdkitRdkit2023_03_22023] offers extensive functionality for cheminformatics and manipulating chemical structures.
|
|
29
|
+
Concurrently, RDKit [@landrumRdkitRdkit2023_03_22023] offers extensive functionality for cheminformatics and manipulating chemical structures.
|
|
30
30
|
However, standard RDKit workflows are not designed for MLIP-driven simulation, while typical ASE-MLIP workflows may lack rich explicit chemical information such as bond orders or molecular identities, as well as capabilities for generating different conformations or searching substructures.
|
|
31
31
|
|
|
32
32
|
The `molify` package bridges this gap, providing an interface between RDKit's chemical structure generation and cheminformatics capabilities and ASE's handling of 3D atomic structures.
|
|
33
|
-
Furthermore, `molify` integrates with PACKMOL[@martinezPACKMOLPackageBuilding2009] to facilitate the creation of complex, periodic simulation cells with diverse chemical compositions, all while preserving crucial chemical connectivity information.
|
|
34
|
-
In addition, `molify` simplifies the representation of molecular structures as graphs using NetworkX[@hagbergExploringNetworkStructure2008], e.g., enabling traversing or comparing them.
|
|
33
|
+
Furthermore, `molify` integrates with PACKMOL [@martinezPACKMOLPackageBuilding2009] to facilitate the creation of complex, periodic simulation cells with diverse chemical compositions, all while preserving crucial chemical connectivity information.
|
|
34
|
+
In addition, `molify` simplifies the representation of molecular structures as graphs using NetworkX [@hagbergExploringNetworkStructure2008], e.g., enabling traversing or comparing them.
|
|
35
35
|
Lastly, the combination of these packages enables selection and manipulation of atomistic structures based on chemical knowledge rather than manual index handling.
|
|
36
36
|
While designed for MLIP data, the usage of `molify` is not limited and can be expanded, e.g., by utilizing the bond order information in other ASE-based workflows for classical MD simulations or integrating with machine-learning driven bond order predictions.
|
|
37
37
|
|
|
@@ -41,17 +41,17 @@ While designed for MLIP data, the usage of `molify` is not limited and can be ex
|
|
|
41
41
|
While its core function is to interface these tools, it thereby unlocks new capabilities and significantly reduces the manual coding and data wrangling typically required for preparing and analyzing molecular simulations.
|
|
42
42
|
For example, ASE has no tools for handling topological information such as bonds or molecular identities, while RDKit cannot natively interface with MLIPs.
|
|
43
43
|
|
|
44
|
-
`molify` simplifies workflows that previously involved laborious tasks such as sourcing individual structure files from various databases (e.g., the Materials Project[@jainCommentaryMaterialsProject2013] or the ZINC database[@tingleZINC22AFreeMultiBillionScale2023]) and custom setups of simulation cells.
|
|
44
|
+
`molify` simplifies workflows that previously involved laborious tasks such as sourcing individual structure files from various databases (e.g., the Materials Project [@jainCommentaryMaterialsProject2013] or the ZINC database [@tingleZINC22AFreeMultiBillionScale2023]) and custom setups of simulation cells.
|
|
45
45
|
With `molify`, more complex and chemically diverse simulation cells are easier to set up and process.
|
|
46
46
|
|
|
47
47
|
One challenge in MLIP-driven simulations is the post-simulation identification and analysis of molecular fragments or chemical changes, as explicit topological information is not available and changes in connectivity can occur.
|
|
48
|
-
`molify` addresses this by enabling the use of RDKit's powerful SMILES[@weiningerSMILESChemicalLanguage1988]/SMARTS-based substructure searching on ASE structures.
|
|
49
|
-
In addition, the resulting molecular graph can be exported to a NetworkX[@hagbergExploringNetworkStructure2008] object for further analysis.
|
|
50
|
-
This selection and handling allows for similar functionality as is provided by the MDAnalysis[@gowersMDAnalysisPythonPackage2016] atom selection language, designed for simulations with a fixed topology.
|
|
48
|
+
`molify` addresses this by enabling the use of RDKit's powerful SMILES [@weiningerSMILESChemicalLanguage1988]/SMARTS-based substructure searching on ASE structures.
|
|
49
|
+
In addition, the resulting molecular graph can be exported to a NetworkX [@hagbergExploringNetworkStructure2008] object for further analysis.
|
|
50
|
+
This selection and handling allows for similar functionality as is provided by the MDAnalysis [@gowersMDAnalysisPythonPackage2016] atom selection language, designed for simulations with a fixed topology.
|
|
51
51
|
|
|
52
52
|
# Features and Implementation
|
|
53
53
|
|
|
54
|
-
![Visualization of a 3D structure from ASE, visualized with ZnDraw[@elijosiusZeroShotMolecular2024] (left) and its corresponding RDKit 2D chemical structure representation (right).\label{fig:zndraw-rdkit}](zndraw_rdkit.svg)
|
|
54
|
+
![Visualization of a 3D structure from ASE, visualized with ZnDraw [@elijosiusZeroShotMolecular2024] (left) and its corresponding RDKit 2D chemical structure representation (right).\label{fig:zndraw-rdkit}](zndraw_rdkit.svg)
|
|
55
55
|
|
|
56
56
|
The generation of atomic configurations in `molify` is centered around SMILES for defining molecular species.
|
|
57
57
|
A typical workflow often follows these steps:
|
|
@@ -81,7 +81,7 @@ opt.run(fmax=0.01)
|
|
|
81
81
|
```
|
|
82
82
|
All ASE Atoms objects generated or processed by `molify` store `connectivity` information (bonds and their orders) within the `ase.Atoms.info` dictionary.
|
|
83
83
|
If this information is available, `molify` uses it to convert between ASE, NetworkX and RDKit.
|
|
84
|
-
If an ASE structure is converted to an RDKit molecule without pre-existing connectivity, `molify` leverages RDKit's bond perception algorithms[@kimUniversalStructureConversion2015] to estimate this information.
|
|
84
|
+
If an ASE structure is converted to an RDKit molecule without pre-existing connectivity, `molify` leverages RDKit's bond perception algorithms [@kimUniversalStructureConversion2015] to estimate this information.
|
|
85
85
|
|
|
86
86
|
A visualisation of the 2D and 3D structure from the simulation is shown in \autoref{fig:zndraw-rdkit}.
|
|
87
87
|
|
|
@@ -116,9 +116,6 @@ frames: list[ase.Atoms] = get_substructures(
|
|
|
116
116
|
)
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
-
# Acknowledgements
|
|
120
|
-
F.Z. acknowledges support by the Deutsche Forschungsgemeinschaft (DFG, German Research Foundation) in the framework of the priority program SPP 2363, "Utilization and Development of Machine Learning for Molecular Applications – Molecular Machine Learning" Project No. 497249646. Further funding through the DFG under Germany's Excellence Strategy – EXC 2075 – 390740016 and the Stuttgart Center for Simulation Science (SimTech) was provided.
|
|
121
|
-
|
|
122
119
|
# Related software
|
|
123
120
|
The functionality of `molify` relies critically on the following packages:
|
|
124
121
|
|
|
@@ -133,7 +130,12 @@ The `molify` package is currently a crucial part of the following software packa
|
|
|
133
130
|
- [ZnDraw](https://github.com/zincware/zndraw): Interactive generation of simulation boxes and selection of substructures through a graphical user interface inside a web-based visualization package.
|
|
134
131
|
- [mlipx](https://github.com/basf/mlipx): Creating initial structures for benchmarking different MLIPs on real-world test scenarios.
|
|
135
132
|
|
|
136
|
-
The OpenBabel[@oboyleOpenBabelOpen2011] package provides similar cheminformatics functionality to RDKit along with extensive file format support.
|
|
133
|
+
The OpenBabel [@oboyleOpenBabelOpen2011] package provides similar cheminformatics functionality to RDKit along with extensive file format support.
|
|
137
134
|
However, OpenBabel is primarily designed as a format conversion tool with a focus on command-line usage and file I/O, while `molify` is designed for Python-native workflows with in-memory object conversions.
|
|
138
135
|
Currently, OpenBabel does not provide direct support for ASE Atoms objects, ASE calculators (including MLIPs), or seamless RDKit-ASE interconversion within Python.
|
|
139
136
|
Furthermore, `molify`'s integration with PACKMOL and NetworkX provides capabilities beyond OpenBabel's core focus on format conversion.
|
|
137
|
+
|
|
138
|
+
# Acknowledgements
|
|
139
|
+
F.Z. acknowledges support by the Deutsche Forschungsgemeinschaft (DFG, German Research Foundation) in the framework of the priority program SPP 2363, "Utilization and Development of Machine Learning for Molecular Applications – Molecular Machine Learning" Project No. 497249646. Further funding through the DFG under Germany's Excellence Strategy – EXC 2075 – 390740016 and the Stuttgart Center for Simulation Science (SimTech) was provided.
|
|
140
|
+
|
|
141
|
+
# References
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "molify"
|
|
3
|
-
|
|
3
|
+
dynamic = ["version"]
|
|
4
4
|
description = "Molecular Structure Interface with RDKit, ASE, Packmol and NetworkX"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -52,9 +52,15 @@ extend-ignore = [
|
|
|
52
52
|
]
|
|
53
53
|
|
|
54
54
|
[build-system]
|
|
55
|
-
requires = ["hatchling"]
|
|
55
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
56
56
|
build-backend = "hatchling.build"
|
|
57
57
|
|
|
58
|
+
[tool.hatch.version]
|
|
59
|
+
source = "vcs"
|
|
60
|
+
|
|
61
|
+
[tool.hatch.build.hooks.vcs]
|
|
62
|
+
version-file = "src/molify/_version.py"
|
|
63
|
+
|
|
58
64
|
[tool.codespell]
|
|
59
65
|
ignore-words-list = "basf"
|
|
60
66
|
skip = "*.svg,paper/bibliography.bib,*.ipynb"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.2.4'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 4)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -4,6 +4,8 @@ import numpy as np
|
|
|
4
4
|
from ase.neighborlist import natural_cutoffs, neighbor_list
|
|
5
5
|
from rdkit import Chem
|
|
6
6
|
|
|
7
|
+
from molify.constants import EdgeAttr, GraphAttr, NodeAttr
|
|
8
|
+
|
|
7
9
|
try:
|
|
8
10
|
import vesin
|
|
9
11
|
except ImportError:
|
|
@@ -15,20 +17,25 @@ def _create_graph_from_connectivity(
|
|
|
15
17
|
) -> nx.Graph:
|
|
16
18
|
"""Create NetworkX graph from explicit connectivity information."""
|
|
17
19
|
graph = nx.Graph()
|
|
18
|
-
graph.graph[
|
|
19
|
-
graph.graph[
|
|
20
|
+
graph.graph[GraphAttr.PBC] = atoms.pbc
|
|
21
|
+
graph.graph[GraphAttr.CELL] = atoms.cell
|
|
22
|
+
|
|
23
|
+
stored_indices = atoms.info.get(NodeAttr.ORIGINAL_INDEX)
|
|
20
24
|
|
|
21
25
|
for i, atom in enumerate(atoms):
|
|
26
|
+
original_index = stored_indices[i] if stored_indices is not None else atom.index
|
|
22
27
|
graph.add_node(
|
|
23
28
|
i,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
**{
|
|
30
|
+
NodeAttr.POSITION: atom.position,
|
|
31
|
+
NodeAttr.ATOMIC_NUMBER: int(atom.number),
|
|
32
|
+
NodeAttr.ORIGINAL_INDEX: original_index,
|
|
33
|
+
NodeAttr.CHARGE: charges[i],
|
|
34
|
+
},
|
|
28
35
|
)
|
|
29
36
|
|
|
30
37
|
for i, j, bond_order in connectivity:
|
|
31
|
-
graph.add_edge(i, j, bond_order
|
|
38
|
+
graph.add_edge(i, j, **{EdgeAttr.BOND_ORDER: bond_order})
|
|
32
39
|
return graph
|
|
33
40
|
|
|
34
41
|
|
|
@@ -85,13 +92,16 @@ def _add_node_properties(
|
|
|
85
92
|
graph: nx.Graph, atoms: ase.Atoms, charges, non_bonding_atomic_numbers
|
|
86
93
|
):
|
|
87
94
|
"""Add node properties to the graph."""
|
|
95
|
+
stored_indices = atoms.info.get(NodeAttr.ORIGINAL_INDEX)
|
|
96
|
+
|
|
88
97
|
for i, atom in enumerate(atoms):
|
|
89
|
-
|
|
90
|
-
graph.nodes[i][
|
|
91
|
-
graph.nodes[i][
|
|
92
|
-
graph.nodes[i][
|
|
98
|
+
original_index = stored_indices[i] if stored_indices is not None else atom.index
|
|
99
|
+
graph.nodes[i][NodeAttr.POSITION] = atom.position
|
|
100
|
+
graph.nodes[i][NodeAttr.ATOMIC_NUMBER] = int(atom.number)
|
|
101
|
+
graph.nodes[i][NodeAttr.ORIGINAL_INDEX] = original_index
|
|
102
|
+
graph.nodes[i][NodeAttr.CHARGE] = float(charges[i])
|
|
93
103
|
if atom.number in non_bonding_atomic_numbers:
|
|
94
|
-
graph.nodes[i][
|
|
104
|
+
graph.nodes[i][NodeAttr.CHARGE] = 1.0
|
|
95
105
|
|
|
96
106
|
|
|
97
107
|
def ase2networkx(
|
|
@@ -158,8 +168,8 @@ def ase2networkx(
|
|
|
158
168
|
return nx.Graph()
|
|
159
169
|
charges = atoms.get_initial_charges()
|
|
160
170
|
|
|
161
|
-
if
|
|
162
|
-
connectivity = atoms.info[
|
|
171
|
+
if GraphAttr.CONNECTIVITY in atoms.info:
|
|
172
|
+
connectivity = atoms.info[GraphAttr.CONNECTIVITY]
|
|
163
173
|
# ensure connectivity is list[tuple[int, int, float|None]] and
|
|
164
174
|
# does not contain np.generic
|
|
165
175
|
connectivity = [
|
|
@@ -174,12 +184,12 @@ def ase2networkx(
|
|
|
174
184
|
|
|
175
185
|
graph = nx.from_numpy_array(connectivity_matrix, edge_attr=None)
|
|
176
186
|
for u, v in graph.edges():
|
|
177
|
-
graph.edges[u, v][
|
|
187
|
+
graph.edges[u, v][EdgeAttr.BOND_ORDER] = None
|
|
178
188
|
|
|
179
189
|
_add_node_properties(graph, atoms, charges, non_bonding_atomic_numbers)
|
|
180
190
|
|
|
181
|
-
graph.graph[
|
|
182
|
-
graph.graph[
|
|
191
|
+
graph.graph[GraphAttr.PBC] = atoms.pbc
|
|
192
|
+
graph.graph[GraphAttr.CELL] = atoms.cell
|
|
183
193
|
|
|
184
194
|
return graph
|
|
185
195
|
|
|
@@ -2,6 +2,7 @@ import ase
|
|
|
2
2
|
import numpy as np
|
|
3
3
|
from ase.cell import Cell
|
|
4
4
|
|
|
5
|
+
from molify.constants import GraphAttr
|
|
5
6
|
from molify.utils import calculate_box_dimensions
|
|
6
7
|
|
|
7
8
|
|
|
@@ -28,7 +29,7 @@ def compress(
|
|
|
28
29
|
if freeze_molecules:
|
|
29
30
|
new_cell = Cell.new(new_dimensions)
|
|
30
31
|
old_cell = atoms.get_cell()
|
|
31
|
-
connectivity = atoms.info.get(
|
|
32
|
+
connectivity = atoms.info.get(GraphAttr.CONNECTIVITY)
|
|
32
33
|
if connectivity is None:
|
|
33
34
|
raise ValueError("No connectivity info found for freeze_molecules=True")
|
|
34
35
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""String constants used as attribute keys across molify conversions."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
if sys.version_info >= (3, 11):
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
else:
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
class StrEnum(str, Enum):
|
|
11
|
+
"""Backport of StrEnum for Python 3.10."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NodeAttr(StrEnum):
|
|
15
|
+
"""Attribute keys stored on networkx graph nodes."""
|
|
16
|
+
|
|
17
|
+
POSITION = "position"
|
|
18
|
+
ATOMIC_NUMBER = "atomic_number"
|
|
19
|
+
ORIGINAL_INDEX = "original_index"
|
|
20
|
+
CHARGE = "charge"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EdgeAttr(StrEnum):
|
|
24
|
+
"""Attribute keys stored on networkx graph edges."""
|
|
25
|
+
|
|
26
|
+
BOND_ORDER = "bond_order"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GraphAttr(StrEnum):
|
|
30
|
+
"""Attribute keys stored on networkx graph-level or ASE atoms.info dicts."""
|
|
31
|
+
|
|
32
|
+
PBC = "pbc"
|
|
33
|
+
CELL = "cell"
|
|
34
|
+
CONNECTIVITY = "connectivity"
|
|
35
|
+
SMILES = "smiles"
|
|
@@ -3,6 +3,7 @@ import networkx as nx
|
|
|
3
3
|
import numpy as np
|
|
4
4
|
from rdkit import Chem
|
|
5
5
|
|
|
6
|
+
from molify.constants import EdgeAttr, GraphAttr, NodeAttr
|
|
6
7
|
from molify.utils import bond_type_from_order
|
|
7
8
|
|
|
8
9
|
|
|
@@ -46,27 +47,33 @@ def networkx2ase(graph: nx.Graph) -> ase.Atoms:
|
|
|
46
47
|
# Create mapping from original node indices to new sequential indices
|
|
47
48
|
node_mapping = {node: i for i, node in enumerate(graph.nodes)}
|
|
48
49
|
|
|
49
|
-
positions = np.array([graph.nodes[n][
|
|
50
|
-
numbers = np.array([graph.nodes[n][
|
|
51
|
-
charges = np.array([graph.nodes[n][
|
|
50
|
+
positions = np.array([graph.nodes[n][NodeAttr.POSITION] for n in graph.nodes])
|
|
51
|
+
numbers = np.array([graph.nodes[n][NodeAttr.ATOMIC_NUMBER] for n in graph.nodes])
|
|
52
|
+
charges = np.array([graph.nodes[n][NodeAttr.CHARGE] for n in graph.nodes])
|
|
52
53
|
|
|
53
54
|
atoms = ase.Atoms(
|
|
54
55
|
positions=positions,
|
|
55
56
|
numbers=numbers,
|
|
56
57
|
charges=charges,
|
|
57
|
-
pbc=graph.graph.get(
|
|
58
|
-
cell=graph.graph.get(
|
|
58
|
+
pbc=graph.graph.get(GraphAttr.PBC, False),
|
|
59
|
+
cell=graph.graph.get(GraphAttr.CELL, None),
|
|
59
60
|
)
|
|
60
61
|
|
|
61
62
|
connectivity = []
|
|
62
63
|
for u, v, data in graph.edges(data=True):
|
|
63
|
-
bond_order = data[
|
|
64
|
+
bond_order = data[EdgeAttr.BOND_ORDER]
|
|
64
65
|
# Map original node indices to new sequential indices
|
|
65
66
|
new_u = node_mapping[u]
|
|
66
67
|
new_v = node_mapping[v]
|
|
67
68
|
connectivity.append((new_u, new_v, bond_order))
|
|
68
69
|
|
|
69
|
-
atoms.info[
|
|
70
|
+
atoms.info[GraphAttr.CONNECTIVITY] = connectivity
|
|
71
|
+
|
|
72
|
+
original_indices = [
|
|
73
|
+
graph.nodes[n].get(NodeAttr.ORIGINAL_INDEX) for n in graph.nodes
|
|
74
|
+
]
|
|
75
|
+
if all(idx is not None for idx in original_indices):
|
|
76
|
+
atoms.info[NodeAttr.ORIGINAL_INDEX] = original_indices
|
|
70
77
|
|
|
71
78
|
return atoms
|
|
72
79
|
|
|
@@ -131,7 +138,7 @@ def networkx2rdkit(graph: nx.Graph, suggestions: list[str] | None = None) -> Che
|
|
|
131
138
|
|
|
132
139
|
# Check if any edges have bond_order=None and determine them if needed
|
|
133
140
|
has_none_bond_orders = any(
|
|
134
|
-
data.get(
|
|
141
|
+
data.get(EdgeAttr.BOND_ORDER) is None for u, v, data in graph.edges(data=True)
|
|
135
142
|
)
|
|
136
143
|
|
|
137
144
|
if has_none_bond_orders:
|
|
@@ -141,7 +148,7 @@ def networkx2rdkit(graph: nx.Graph, suggestions: list[str] | None = None) -> Che
|
|
|
141
148
|
|
|
142
149
|
# Verify all bond orders are now determined
|
|
143
150
|
for u, v, data in graph.edges(data=True):
|
|
144
|
-
bond_order = data.get(
|
|
151
|
+
bond_order = data.get(EdgeAttr.BOND_ORDER)
|
|
145
152
|
if bond_order is None:
|
|
146
153
|
raise ValueError(
|
|
147
154
|
f"Failed to determine bond order for edge ({u}, {v}).\n"
|
|
@@ -159,22 +166,29 @@ def networkx2rdkit(graph: nx.Graph, suggestions: list[str] | None = None) -> Che
|
|
|
159
166
|
nx_to_rdkit_atom_map = {}
|
|
160
167
|
|
|
161
168
|
for node_id, attributes in graph.nodes(data=True):
|
|
162
|
-
atomic_number = attributes.get(
|
|
163
|
-
charge = attributes.get(
|
|
169
|
+
atomic_number = attributes.get(NodeAttr.ATOMIC_NUMBER)
|
|
170
|
+
charge = attributes.get(NodeAttr.CHARGE, 0)
|
|
164
171
|
|
|
165
172
|
if atomic_number is None:
|
|
166
|
-
raise ValueError(
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f"Node {node_id} is missing '{NodeAttr.ATOMIC_NUMBER}' attribute."
|
|
175
|
+
)
|
|
167
176
|
|
|
168
177
|
atom = Chem.Atom(int(atomic_number))
|
|
169
178
|
atom.SetFormalCharge(int(charge))
|
|
179
|
+
original_index = attributes.get(NodeAttr.ORIGINAL_INDEX)
|
|
180
|
+
if original_index is not None:
|
|
181
|
+
atom.SetIntProp(NodeAttr.ORIGINAL_INDEX, int(original_index))
|
|
170
182
|
idx = mol.AddAtom(atom)
|
|
171
183
|
nx_to_rdkit_atom_map[node_id] = idx
|
|
172
184
|
|
|
173
185
|
for u, v, data in graph.edges(data=True):
|
|
174
|
-
bond_order = data.get(
|
|
186
|
+
bond_order = data.get(EdgeAttr.BOND_ORDER)
|
|
175
187
|
|
|
176
188
|
if bond_order is None:
|
|
177
|
-
raise ValueError(
|
|
189
|
+
raise ValueError(
|
|
190
|
+
f"Edge ({u}, {v}) is missing '{EdgeAttr.BOND_ORDER}' attribute."
|
|
191
|
+
)
|
|
178
192
|
|
|
179
193
|
mol.AddBond(
|
|
180
194
|
nx_to_rdkit_atom_map[u],
|
|
@@ -9,6 +9,7 @@ import numpy as np
|
|
|
9
9
|
from ase.io.proteindatabank import write_proteindatabank
|
|
10
10
|
from rdkit import Chem
|
|
11
11
|
|
|
12
|
+
from molify.constants import GraphAttr
|
|
12
13
|
from molify.utils import calculate_box_dimensions
|
|
13
14
|
|
|
14
15
|
log = logging.getLogger(__name__)
|
|
@@ -125,17 +126,20 @@ def _extract_atom_arrays(
|
|
|
125
126
|
)
|
|
126
127
|
packed_atoms.arrays[key] = concatenated_array
|
|
127
128
|
|
|
128
|
-
if all(
|
|
129
|
+
if all(GraphAttr.CONNECTIVITY in atoms.info for atoms in selected_images):
|
|
129
130
|
bonds = []
|
|
130
131
|
offset = 0
|
|
131
132
|
for atoms in selected_images:
|
|
132
|
-
for bond in atoms.info[
|
|
133
|
+
for bond in atoms.info[GraphAttr.CONNECTIVITY]:
|
|
133
134
|
bonds.append((bond[0] + offset, bond[1] + offset, bond[2]))
|
|
134
135
|
offset += len(atoms)
|
|
135
|
-
packed_atoms.info[
|
|
136
|
+
packed_atoms.info[GraphAttr.CONNECTIVITY] = bonds
|
|
136
137
|
charges = np.concatenate([atom.get_initial_charges() for atom in selected_images])
|
|
137
138
|
if any(charge != 0 for charge in charges):
|
|
138
139
|
packed_atoms.set_initial_charges(charges)
|
|
140
|
+
tags = np.concatenate([atom.get_tags() for atom in selected_images])
|
|
141
|
+
if any(tag != 0 for tag in tags):
|
|
142
|
+
packed_atoms.set_tags(tags)
|
|
139
143
|
return packed_atoms
|
|
140
144
|
|
|
141
145
|
|
|
@@ -3,6 +3,8 @@ import networkx as nx
|
|
|
3
3
|
from rdkit import Chem
|
|
4
4
|
from rdkit.Chem import AllChem
|
|
5
5
|
|
|
6
|
+
from molify.constants import EdgeAttr, GraphAttr, NodeAttr
|
|
7
|
+
|
|
6
8
|
|
|
7
9
|
def rdkit2ase(mol: Chem.Mol, seed: int = 42) -> ase.Atoms:
|
|
8
10
|
"""Convert an RDKit molecule to an ASE Atoms object.
|
|
@@ -45,16 +47,22 @@ def rdkit2ase(mol: Chem.Mol, seed: int = 42) -> ase.Atoms:
|
|
|
45
47
|
positions=mol.GetConformer().GetPositions(),
|
|
46
48
|
numbers=[atom.GetAtomicNum() for atom in mol.GetAtoms()],
|
|
47
49
|
)
|
|
48
|
-
atoms.info[
|
|
49
|
-
atoms.info[
|
|
50
|
+
atoms.info[GraphAttr.SMILES] = smiles
|
|
51
|
+
atoms.info[GraphAttr.CONNECTIVITY] = []
|
|
50
52
|
for bond in mol.GetBonds():
|
|
51
53
|
a1 = bond.GetBeginAtomIdx()
|
|
52
54
|
a2 = bond.GetEndAtomIdx()
|
|
53
55
|
order = bond.GetBondTypeAsDouble()
|
|
54
|
-
atoms.info[
|
|
56
|
+
atoms.info[GraphAttr.CONNECTIVITY].append((a1, a2, order))
|
|
55
57
|
|
|
56
58
|
if any(charge != 0 for charge in charges):
|
|
57
59
|
atoms.set_initial_charges(charges)
|
|
60
|
+
|
|
61
|
+
if all(atom.HasProp(NodeAttr.ORIGINAL_INDEX) for atom in mol.GetAtoms()):
|
|
62
|
+
atoms.info[NodeAttr.ORIGINAL_INDEX] = [
|
|
63
|
+
atom.GetIntProp(NodeAttr.ORIGINAL_INDEX) for atom in mol.GetAtoms()
|
|
64
|
+
]
|
|
65
|
+
|
|
58
66
|
return atoms
|
|
59
67
|
|
|
60
68
|
|
|
@@ -100,11 +108,18 @@ def rdkit2networkx(mol: Chem.Mol) -> nx.Graph:
|
|
|
100
108
|
mol = Chem.AddHs(mol)
|
|
101
109
|
graph = nx.Graph()
|
|
102
110
|
for atom in mol.GetAtoms():
|
|
111
|
+
original_index = (
|
|
112
|
+
atom.GetIntProp(NodeAttr.ORIGINAL_INDEX)
|
|
113
|
+
if atom.HasProp(NodeAttr.ORIGINAL_INDEX)
|
|
114
|
+
else atom.GetIdx()
|
|
115
|
+
)
|
|
103
116
|
graph.add_node(
|
|
104
117
|
atom.GetIdx(),
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
118
|
+
**{
|
|
119
|
+
NodeAttr.ATOMIC_NUMBER: atom.GetAtomicNum(),
|
|
120
|
+
NodeAttr.ORIGINAL_INDEX: original_index,
|
|
121
|
+
NodeAttr.CHARGE: atom.GetFormalCharge(),
|
|
122
|
+
},
|
|
108
123
|
)
|
|
109
124
|
|
|
110
125
|
for bond in mol.GetBonds():
|
|
@@ -120,6 +135,8 @@ def rdkit2networkx(mol: Chem.Mol) -> nx.Graph:
|
|
|
120
135
|
bond_order = 1.5
|
|
121
136
|
|
|
122
137
|
graph.add_edge(
|
|
123
|
-
bond.GetBeginAtomIdx(),
|
|
138
|
+
bond.GetBeginAtomIdx(),
|
|
139
|
+
bond.GetEndAtomIdx(),
|
|
140
|
+
**{EdgeAttr.BOND_ORDER: bond_order},
|
|
124
141
|
)
|
|
125
142
|
return graph
|
|
@@ -3,6 +3,8 @@ import numpy as np
|
|
|
3
3
|
from rdkit import Chem
|
|
4
4
|
from rdkit.Chem import rdDistGeom
|
|
5
5
|
|
|
6
|
+
from molify.constants import GraphAttr
|
|
7
|
+
|
|
6
8
|
|
|
7
9
|
def get_pf6() -> ase.Atoms:
|
|
8
10
|
directions = np.array(
|
|
@@ -23,8 +25,8 @@ def get_pf6() -> ase.Atoms:
|
|
|
23
25
|
|
|
24
26
|
atoms = ase.Atoms(symbols=symbols, positions=positions)
|
|
25
27
|
|
|
26
|
-
atoms.info[
|
|
27
|
-
atoms.info[
|
|
28
|
+
atoms.info[GraphAttr.CONNECTIVITY] = [(0, i, 1.0) for i in range(1, 7)]
|
|
29
|
+
atoms.info[GraphAttr.SMILES] = "F[P-](F)(F)(F)(F)F"
|
|
28
30
|
atoms.set_initial_charges([-1] + [0] * 6)
|
|
29
31
|
|
|
30
32
|
return atoms
|
|
@@ -140,8 +142,8 @@ def smiles2conformers(
|
|
|
140
142
|
positions=conf.GetPositions(),
|
|
141
143
|
numbers=[atom.GetAtomicNum() for atom in mol.GetAtoms()],
|
|
142
144
|
)
|
|
143
|
-
atoms.info[
|
|
144
|
-
atoms.info[
|
|
145
|
+
atoms.info[GraphAttr.SMILES] = smiles
|
|
146
|
+
atoms.info[GraphAttr.CONNECTIVITY] = bonds
|
|
145
147
|
if charges is not None:
|
|
146
148
|
atoms.set_initial_charges(charges)
|
|
147
149
|
images.append(atoms)
|
|
@@ -7,6 +7,7 @@ from rdkit import Chem
|
|
|
7
7
|
from rdkit.Chem import Draw
|
|
8
8
|
|
|
9
9
|
from molify.ase2x import ase2rdkit
|
|
10
|
+
from molify.constants import GraphAttr
|
|
10
11
|
from molify.utils import find_connected_components
|
|
11
12
|
|
|
12
13
|
|
|
@@ -303,9 +304,9 @@ def iter_fragments(atoms: ase.Atoms) -> list[ase.Atoms]:
|
|
|
303
304
|
>>> len(fragments)
|
|
304
305
|
2
|
|
305
306
|
"""
|
|
306
|
-
if
|
|
307
|
+
if GraphAttr.CONNECTIVITY in atoms.info:
|
|
307
308
|
# connectivity is a list of tuples (i, j, bond_type)
|
|
308
|
-
connectivity = atoms.info[
|
|
309
|
+
connectivity = atoms.info[GraphAttr.CONNECTIVITY]
|
|
309
310
|
for component in find_connected_components(connectivity):
|
|
310
311
|
yield atoms[list(component)]
|
|
311
312
|
else:
|
|
@@ -5,6 +5,7 @@ import pytest
|
|
|
5
5
|
from rdkit import Chem
|
|
6
6
|
|
|
7
7
|
import molify
|
|
8
|
+
from molify.constants import GraphAttr, NodeAttr
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
class SMILES:
|
|
@@ -343,3 +344,48 @@ def test_ase2networkx_preserves_none_bond_orders_in_connectivity():
|
|
|
343
344
|
for i, j, bond_order in connectivity_without_orders:
|
|
344
345
|
assert graph.has_edge(i, j)
|
|
345
346
|
assert graph.edges[i, j]["bond_order"] is None
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class TestAse2NetworkxOriginalIndex:
|
|
350
|
+
"""Test that ase2networkx reads back original_index from atoms.info."""
|
|
351
|
+
|
|
352
|
+
def test_reads_stored_original_index_with_connectivity(self):
|
|
353
|
+
"""ase2networkx should use atoms.info['original_index'] when present.
|
|
354
|
+
|
|
355
|
+
This tests the _create_graph_from_connectivity path.
|
|
356
|
+
"""
|
|
357
|
+
atoms = molify.smiles2atoms("CCO")
|
|
358
|
+
# Manually set non-trivial original_index so the test isn't trivially true
|
|
359
|
+
atoms.info[NodeAttr.ORIGINAL_INDEX] = list(range(10, 10 + len(atoms)))
|
|
360
|
+
|
|
361
|
+
graph = molify.ase2networkx(atoms)
|
|
362
|
+
|
|
363
|
+
expected = list(range(10, 10 + len(atoms)))
|
|
364
|
+
actual = [graph.nodes[n][NodeAttr.ORIGINAL_INDEX] for n in graph.nodes]
|
|
365
|
+
assert actual == expected
|
|
366
|
+
|
|
367
|
+
def test_reads_stored_original_index_without_connectivity(self):
|
|
368
|
+
"""ase2networkx should use atoms.info['original_index'] when present.
|
|
369
|
+
|
|
370
|
+
This tests the _add_node_properties path (no connectivity in atoms.info).
|
|
371
|
+
"""
|
|
372
|
+
atoms = molify.smiles2atoms("CCO")
|
|
373
|
+
# Store non-trivial original_index, then remove connectivity
|
|
374
|
+
atoms.info[NodeAttr.ORIGINAL_INDEX] = list(range(10, 10 + len(atoms)))
|
|
375
|
+
del atoms.info[GraphAttr.CONNECTIVITY]
|
|
376
|
+
|
|
377
|
+
graph = molify.ase2networkx(atoms)
|
|
378
|
+
|
|
379
|
+
expected = list(range(10, 10 + len(atoms)))
|
|
380
|
+
actual = [graph.nodes[n][NodeAttr.ORIGINAL_INDEX] for n in graph.nodes]
|
|
381
|
+
assert actual == expected
|
|
382
|
+
|
|
383
|
+
def test_defaults_to_atom_index_without_stored_indices(self):
|
|
384
|
+
"""Without atoms.info['original_index'], use sequential atom.index."""
|
|
385
|
+
atoms = molify.smiles2atoms("CCO")
|
|
386
|
+
assert NodeAttr.ORIGINAL_INDEX not in atoms.info
|
|
387
|
+
|
|
388
|
+
graph = molify.ase2networkx(atoms)
|
|
389
|
+
|
|
390
|
+
for n in graph.nodes:
|
|
391
|
+
assert graph.nodes[n][NodeAttr.ORIGINAL_INDEX] == n
|
|
@@ -3,6 +3,7 @@ import pytest
|
|
|
3
3
|
from rdkit import Chem
|
|
4
4
|
|
|
5
5
|
import molify
|
|
6
|
+
from molify.constants import EdgeAttr, NodeAttr
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
class SMILES:
|
|
@@ -601,3 +602,94 @@ def test_modify_graph_combined_add_remove_to_rdkit():
|
|
|
601
602
|
assert 9 in atom_nums # Fluorine should be present
|
|
602
603
|
assert 6 in atom_nums # Carbon should be present
|
|
603
604
|
assert 8 in atom_nums # Oxygen should be present
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# ============================================================================
|
|
608
|
+
# Tests for original_index preservation (GitHub issue #104)
|
|
609
|
+
# ============================================================================
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
class TestNetworkx2RdkitOriginalIndex:
|
|
613
|
+
"""Test that networkx2rdkit preserves original_index from graph nodes."""
|
|
614
|
+
|
|
615
|
+
def test_original_index_stored_on_rdkit_atoms(self):
|
|
616
|
+
"""original_index should be an RDKit atom int property."""
|
|
617
|
+
atoms = molify.smiles2atoms("CCO")
|
|
618
|
+
graph = molify.ase2networkx(atoms)
|
|
619
|
+
|
|
620
|
+
mol = molify.networkx2rdkit(graph)
|
|
621
|
+
|
|
622
|
+
for i, (_node_id, attributes) in enumerate(graph.nodes(data=True)):
|
|
623
|
+
rdkit_atom = mol.GetAtomWithIdx(i)
|
|
624
|
+
assert rdkit_atom.HasProp(NodeAttr.ORIGINAL_INDEX)
|
|
625
|
+
assert (
|
|
626
|
+
rdkit_atom.GetIntProp(NodeAttr.ORIGINAL_INDEX)
|
|
627
|
+
== attributes[NodeAttr.ORIGINAL_INDEX]
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
def test_original_index_preserved_with_subgraph(self):
|
|
631
|
+
"""original_index should be preserved even with non-sequential node IDs."""
|
|
632
|
+
atoms = molify.smiles2atoms("CCO")
|
|
633
|
+
graph = molify.ase2networkx(atoms)
|
|
634
|
+
|
|
635
|
+
heavy_atom_nodes = [
|
|
636
|
+
n for n, d in graph.nodes(data=True) if d[NodeAttr.ATOMIC_NUMBER] != 1
|
|
637
|
+
]
|
|
638
|
+
subgraph = graph.subgraph(heavy_atom_nodes).copy()
|
|
639
|
+
|
|
640
|
+
mol = molify.networkx2rdkit(subgraph)
|
|
641
|
+
|
|
642
|
+
for i, (_node_id, attributes) in enumerate(subgraph.nodes(data=True)):
|
|
643
|
+
rdkit_atom = mol.GetAtomWithIdx(i)
|
|
644
|
+
assert (
|
|
645
|
+
rdkit_atom.GetIntProp(NodeAttr.ORIGINAL_INDEX)
|
|
646
|
+
== attributes[NodeAttr.ORIGINAL_INDEX]
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
def test_original_index_preserved_with_none_bond_orders(self):
|
|
650
|
+
"""original_index preserved with auto bond orders."""
|
|
651
|
+
atoms = molify.smiles2atoms("CC")
|
|
652
|
+
graph = molify.ase2networkx(atoms)
|
|
653
|
+
|
|
654
|
+
for u, v in graph.edges():
|
|
655
|
+
graph.edges[u, v][EdgeAttr.BOND_ORDER] = None
|
|
656
|
+
|
|
657
|
+
mol = molify.networkx2rdkit(graph, suggestions=[])
|
|
658
|
+
|
|
659
|
+
for i, (_node_id, attributes) in enumerate(graph.nodes(data=True)):
|
|
660
|
+
rdkit_atom = mol.GetAtomWithIdx(i)
|
|
661
|
+
assert (
|
|
662
|
+
rdkit_atom.GetIntProp(NodeAttr.ORIGINAL_INDEX)
|
|
663
|
+
== attributes[NodeAttr.ORIGINAL_INDEX]
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
class TestNetworkx2AseOriginalIndex:
|
|
668
|
+
"""Test that networkx2ase preserves original_index from graph nodes."""
|
|
669
|
+
|
|
670
|
+
def test_original_index_stored_in_atoms_info(self):
|
|
671
|
+
"""original_index from networkx nodes should be stored in atoms.info."""
|
|
672
|
+
atoms = molify.smiles2atoms("CCO")
|
|
673
|
+
graph = molify.ase2networkx(atoms)
|
|
674
|
+
|
|
675
|
+
new_atoms = molify.networkx2ase(graph)
|
|
676
|
+
|
|
677
|
+
assert NodeAttr.ORIGINAL_INDEX in new_atoms.info
|
|
678
|
+
expected = [graph.nodes[n][NodeAttr.ORIGINAL_INDEX] for n in graph.nodes]
|
|
679
|
+
assert new_atoms.info[NodeAttr.ORIGINAL_INDEX] == expected
|
|
680
|
+
|
|
681
|
+
def test_original_index_preserved_with_subgraph(self):
|
|
682
|
+
"""original_index should reflect original atom indices, not remapped ones."""
|
|
683
|
+
atoms = molify.smiles2atoms("CCO")
|
|
684
|
+
graph = molify.ase2networkx(atoms)
|
|
685
|
+
|
|
686
|
+
heavy_atom_nodes = [
|
|
687
|
+
n for n, d in graph.nodes(data=True) if d[NodeAttr.ATOMIC_NUMBER] != 1
|
|
688
|
+
]
|
|
689
|
+
subgraph = graph.subgraph(heavy_atom_nodes).copy()
|
|
690
|
+
|
|
691
|
+
new_atoms = molify.networkx2ase(subgraph)
|
|
692
|
+
|
|
693
|
+
assert NodeAttr.ORIGINAL_INDEX in new_atoms.info
|
|
694
|
+
expected = [subgraph.nodes[n][NodeAttr.ORIGINAL_INDEX] for n in subgraph.nodes]
|
|
695
|
+
assert new_atoms.info[NodeAttr.ORIGINAL_INDEX] == expected
|
|
@@ -5,7 +5,8 @@ import pytest
|
|
|
5
5
|
import rdkit
|
|
6
6
|
from rdkit import Chem
|
|
7
7
|
|
|
8
|
-
from molify import ase2rdkit, rdkit2ase, smiles2atoms
|
|
8
|
+
from molify import ase2rdkit, rdkit2ase, rdkit2networkx, smiles2atoms
|
|
9
|
+
from molify.constants import NodeAttr
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
@pytest.fixture
|
|
@@ -147,3 +148,58 @@ def test_rdkit2ase_nacn():
|
|
|
147
148
|
]
|
|
148
149
|
assert atoms.get_initial_charges().tolist() == [-1, 0, 1]
|
|
149
150
|
assert atoms.get_atomic_numbers().tolist() == [6, 7, 11]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ============================================================================
|
|
154
|
+
# Tests for original_index preservation (GitHub issue #104)
|
|
155
|
+
# ============================================================================
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class TestRdkit2AseOriginalIndex:
|
|
159
|
+
"""Test that rdkit2ase preserves original_index from RDKit atom properties."""
|
|
160
|
+
|
|
161
|
+
def test_original_index_carried_from_rdkit_to_ase(self):
|
|
162
|
+
"""If RDKit atoms have original_index, store it."""
|
|
163
|
+
mol = Chem.RWMol(Chem.MolFromSmiles("CO"))
|
|
164
|
+
mol = Chem.AddHs(mol)
|
|
165
|
+
# Set original_index on each atom (simulating a prior networkx2rdkit conversion)
|
|
166
|
+
for atom in mol.GetAtoms():
|
|
167
|
+
atom.SetIntProp(NodeAttr.ORIGINAL_INDEX, atom.GetIdx() + 10)
|
|
168
|
+
|
|
169
|
+
atoms = rdkit2ase(mol)
|
|
170
|
+
|
|
171
|
+
assert NodeAttr.ORIGINAL_INDEX in atoms.info
|
|
172
|
+
expected = [atom.GetIdx() + 10 for atom in mol.GetAtoms()]
|
|
173
|
+
assert atoms.info[NodeAttr.ORIGINAL_INDEX] == expected
|
|
174
|
+
|
|
175
|
+
def test_no_original_index_when_rdkit_atoms_lack_property(self):
|
|
176
|
+
"""No original_index added when absent from RDKit atoms."""
|
|
177
|
+
mol = Chem.MolFromSmiles("CO")
|
|
178
|
+
atoms = rdkit2ase(mol)
|
|
179
|
+
|
|
180
|
+
assert NodeAttr.ORIGINAL_INDEX not in atoms.info
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class TestRdkit2NetworkxOriginalIndex:
|
|
184
|
+
"""Test that rdkit2networkx preserves original_index from RDKit atom properties."""
|
|
185
|
+
|
|
186
|
+
def test_original_index_carried_from_rdkit_to_networkx(self):
|
|
187
|
+
"""If RDKit atoms have original_index, use it."""
|
|
188
|
+
mol = Chem.RWMol(Chem.MolFromSmiles("CO"))
|
|
189
|
+
mol = Chem.AddHs(mol)
|
|
190
|
+
for atom in mol.GetAtoms():
|
|
191
|
+
atom.SetIntProp(NodeAttr.ORIGINAL_INDEX, atom.GetIdx() + 20)
|
|
192
|
+
|
|
193
|
+
graph = rdkit2networkx(mol)
|
|
194
|
+
|
|
195
|
+
for node_id, attributes in graph.nodes(data=True):
|
|
196
|
+
assert attributes[NodeAttr.ORIGINAL_INDEX] == node_id + 20
|
|
197
|
+
|
|
198
|
+
def test_falls_back_to_getidx_without_property(self):
|
|
199
|
+
"""Falls back to GetIdx() without original_index."""
|
|
200
|
+
mol = Chem.MolFromSmiles("CO")
|
|
201
|
+
|
|
202
|
+
graph = rdkit2networkx(mol)
|
|
203
|
+
|
|
204
|
+
for node_id, attributes in graph.nodes(data=True):
|
|
205
|
+
assert attributes[NodeAttr.ORIGINAL_INDEX] == node_id
|
|
@@ -109,6 +109,16 @@ def test_pack_charges():
|
|
|
109
109
|
assert charges == [0, 0, 0, -1, 0, 1] + [1, 0, 0, -1, 0, 0, 0, 0, 0, 0]
|
|
110
110
|
|
|
111
111
|
|
|
112
|
+
def test_pack_tags():
|
|
113
|
+
water = smiles2conformers("O", 1)
|
|
114
|
+
methane = smiles2conformers("C", 1)
|
|
115
|
+
water[0].set_tags(range(1, 4)) # O, H, H -> 1, 2, 3
|
|
116
|
+
methane[0].set_tags(range(4, 9)) # C, H, H, H, H -> 4, 5, 6, 7, 8
|
|
117
|
+
|
|
118
|
+
atoms = pack([water, methane], [2, 1], density=1000)
|
|
119
|
+
npt.assert_array_equal(atoms.get_tags(), [1, 2, 3, 1, 2, 3, 4, 5, 6, 7, 8])
|
|
120
|
+
|
|
121
|
+
|
|
112
122
|
def test_pack_ratio():
|
|
113
123
|
water = smiles2conformers("O", 1)
|
|
114
124
|
# pack a cubic box
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
name: Build and upload to PyPI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
workflow_dispatch:
|
|
5
|
-
release:
|
|
6
|
-
types:
|
|
7
|
-
- published
|
|
8
|
-
jobs:
|
|
9
|
-
pypi-publish:
|
|
10
|
-
name: Upload release to PyPI
|
|
11
|
-
runs-on: ubuntu-latest
|
|
12
|
-
environment:
|
|
13
|
-
name: pypi
|
|
14
|
-
url: https://pypi.org/p/molify
|
|
15
|
-
permissions:
|
|
16
|
-
id-token: write
|
|
17
|
-
steps:
|
|
18
|
-
- uses: actions/checkout@v5
|
|
19
|
-
- name: Install uv
|
|
20
|
-
uses: astral-sh/setup-uv@v5
|
|
21
|
-
- name: Build
|
|
22
|
-
run: |
|
|
23
|
-
uv build
|
|
24
|
-
- name: Publish package distributions to PyPI
|
|
25
|
-
uses: pypa/gh-action-pypi-publish@release/v1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|