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.
Files changed (57) hide show
  1. molify-0.2.4/.github/workflows/build-wheels.yaml +42 -0
  2. {molify-0.2.2 → molify-0.2.4}/.gitignore +5 -0
  3. {molify-0.2.2 → molify-0.2.4}/.pre-commit-config.yaml +1 -1
  4. {molify-0.2.2 → molify-0.2.4}/PKG-INFO +1 -1
  5. {molify-0.2.2 → molify-0.2.4}/paper/bibliography.bib +17 -18
  6. {molify-0.2.2 → molify-0.2.4}/paper/paper.md +16 -14
  7. {molify-0.2.2 → molify-0.2.4}/pyproject.toml +8 -2
  8. molify-0.2.4/src/molify/_version.py +24 -0
  9. {molify-0.2.2 → molify-0.2.4}/src/molify/ase2x.py +27 -17
  10. {molify-0.2.2 → molify-0.2.4}/src/molify/compress.py +2 -1
  11. molify-0.2.4/src/molify/constants.py +35 -0
  12. {molify-0.2.2 → molify-0.2.4}/src/molify/networkx2x.py +28 -14
  13. {molify-0.2.2 → molify-0.2.4}/src/molify/pack.py +7 -3
  14. {molify-0.2.2 → molify-0.2.4}/src/molify/rdkit2x.py +24 -7
  15. {molify-0.2.2 → molify-0.2.4}/src/molify/smiles2x.py +6 -4
  16. {molify-0.2.2 → molify-0.2.4}/src/molify/substructure.py +3 -2
  17. {molify-0.2.2 → molify-0.2.4}/tests/integration/test_ase2x.py +46 -0
  18. {molify-0.2.2 → molify-0.2.4}/tests/integration/test_networkx2x.py +92 -0
  19. {molify-0.2.2 → molify-0.2.4}/tests/test_rdkit2ase.py +57 -1
  20. {molify-0.2.2 → molify-0.2.4}/tests/test_solvate.py +10 -0
  21. {molify-0.2.2 → molify-0.2.4}/uv.lock +0 -1
  22. molify-0.2.2/.github/workflows/build-wheels.yaml +0 -25
  23. {molify-0.2.2 → molify-0.2.4}/.github/workflows/pages.yaml +0 -0
  24. {molify-0.2.2 → molify-0.2.4}/.github/workflows/pytest.yaml +0 -0
  25. {molify-0.2.2 → molify-0.2.4}/.python-version +0 -0
  26. {molify-0.2.2 → molify-0.2.4}/CITATION.cff +0 -0
  27. {molify-0.2.2 → molify-0.2.4}/CONTRIBUTING.md +0 -0
  28. {molify-0.2.2 → molify-0.2.4}/LICENSE +0 -0
  29. {molify-0.2.2 → molify-0.2.4}/README.md +0 -0
  30. {molify-0.2.2 → molify-0.2.4}/docs/Makefile +0 -0
  31. {molify-0.2.2 → molify-0.2.4}/docs/make.bat +0 -0
  32. {molify-0.2.2 → molify-0.2.4}/docs/source/ase_tools.ipynb +0 -0
  33. {molify-0.2.2 → molify-0.2.4}/docs/source/atom_selection.ipynb +0 -0
  34. {molify-0.2.2 → molify-0.2.4}/docs/source/conf.py +0 -0
  35. {molify-0.2.2 → molify-0.2.4}/docs/source/index.rst +0 -0
  36. {molify-0.2.2 → molify-0.2.4}/docs/source/modules.rst +0 -0
  37. {molify-0.2.2 → molify-0.2.4}/docs/source/networkx_tools.ipynb +0 -0
  38. {molify-0.2.2 → molify-0.2.4}/docs/source/packmol_tools.ipynb +0 -0
  39. {molify-0.2.2 → molify-0.2.4}/docs/source/rdkit_tools.ipynb +0 -0
  40. {molify-0.2.2 → molify-0.2.4}/paper/.gitignore +0 -0
  41. {molify-0.2.2 → molify-0.2.4}/paper/build.sh +0 -0
  42. {molify-0.2.2 → molify-0.2.4}/paper/zndraw_rdkit.svg +0 -0
  43. {molify-0.2.2 → molify-0.2.4}/src/molify/__init__.py +0 -0
  44. {molify-0.2.2 → molify-0.2.4}/src/molify/bond_order.py +0 -0
  45. {molify-0.2.2 → molify-0.2.4}/src/molify/com.py +0 -0
  46. {molify-0.2.2 → molify-0.2.4}/src/molify/utils.py +0 -0
  47. {molify-0.2.2 → molify-0.2.4}/tests/conftest.py +0 -0
  48. {molify-0.2.2 → molify-0.2.4}/tests/data/ec_emc.xyz +0 -0
  49. {molify-0.2.2 → molify-0.2.4}/tests/integration/test_rdkit2x.py +0 -0
  50. {molify-0.2.2 → molify-0.2.4}/tests/test_com.py +0 -0
  51. {molify-0.2.2 → molify-0.2.4}/tests/test_compress.py +0 -0
  52. {molify-0.2.2 → molify-0.2.4}/tests/test_connectivity.py +0 -0
  53. {molify-0.2.2 → molify-0.2.4}/tests/test_edgecases.py +0 -0
  54. {molify-0.2.2 → molify-0.2.4}/tests/test_examples.py +0 -0
  55. {molify-0.2.2 → molify-0.2.4}/tests/test_smiles2conformers.py +0 -0
  56. {molify-0.2.2 → molify-0.2.4}/tests/test_substructure.py +0 -0
  57. {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
@@ -160,3 +160,8 @@ cython_debug/
160
160
  #.idea/
161
161
  tmp/
162
162
  .DS_Store
163
+
164
+ # Git worktrees
165
+ .worktrees/
166
+ # hatch-vcs generated version file
167
+ src/molify/_version.py
@@ -26,7 +26,7 @@ repos:
26
26
  additional_dependencies: ["tomli"]
27
27
  - repo: https://github.com/astral-sh/ruff-pre-commit
28
28
  # Ruff version.
29
- rev: v0.14.7
29
+ rev: v0.14.14
30
30
  hooks:
31
31
  # Run the linter.
32
32
  - id: ruff
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: molify
3
- Version: 0.2.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 = {Aric A. Hagberg and Daniel A. Schult and Pieter J. Swart},
206
- title = {Exploring Network Structure, Dynamics, and Function using NetworkX},
207
- booktitle = {Proceedings of the 7th Python in Science Conference},
208
- pages = {11 - 15},
209
- address = {Pasadena, CA USA},
210
- year = {2008},
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 = {Open {{Babel}}: {{An}} Open Chemical Toolbox},
213
+ title = {Open {{Babel}}: {{An}} Open Chemical Toolbox},
215
214
  shorttitle = {Open {{Babel}}},
216
- author = {O'Boyle, Noel M. and Banck, Michael and James, Craig A. and Morley, Chris and Vandermeersch, Tim and Hutchison, Geoffrey R.},
217
- year = 2011,
218
- month = oct,
219
- journal = {Journal of Cheminformatics},
220
- volume = {3},
221
- number = {1},
222
- pages = {33},
223
- issn = {1758-2946},
224
- doi = {10.1186/1758-2946-3-33},
225
- urldate = {2025-11-15},
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
- version = "0.2.2"
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["pbc"] = atoms.pbc
19
- graph.graph["cell"] = atoms.cell
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
- position=atom.position,
25
- atomic_number=int(atom.number),
26
- original_index=atom.index,
27
- charge=charges[i],
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=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
- graph.nodes[i]["position"] = atom.position
90
- graph.nodes[i]["atomic_number"] = int(atom.number)
91
- graph.nodes[i]["original_index"] = atom.index
92
- graph.nodes[i]["charge"] = float(charges[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]["charge"] = 1.0
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 "connectivity" in atoms.info:
162
- connectivity = atoms.info["connectivity"]
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]["bond_order"] = None
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["pbc"] = atoms.pbc
182
- graph.graph["cell"] = atoms.cell
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("connectivity")
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]["position"] for n in graph.nodes])
50
- numbers = np.array([graph.nodes[n]["atomic_number"] for n in graph.nodes])
51
- charges = np.array([graph.nodes[n]["charge"] for n in graph.nodes])
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("pbc", False),
58
- cell=graph.graph.get("cell", None),
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["bond_order"]
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["connectivity"] = connectivity
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("bond_order") is None for u, v, data in graph.edges(data=True)
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("bond_order")
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("atomic_number")
163
- charge = attributes.get("charge", 0)
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(f"Node {node_id} is missing 'atomic_number' attribute.")
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("bond_order")
186
+ bond_order = data.get(EdgeAttr.BOND_ORDER)
175
187
 
176
188
  if bond_order is None:
177
- raise ValueError(f"Edge ({u}, {v}) is missing 'bond_order' attribute.")
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("connectivity" in atoms.info for atoms in selected_images):
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["connectivity"]:
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["connectivity"] = bonds
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["smiles"] = smiles
49
- atoms.info["connectivity"] = []
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["connectivity"].append((a1, a2, order))
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
- atomic_number=atom.GetAtomicNum(),
106
- original_index=atom.GetIdx(),
107
- charge=atom.GetFormalCharge(),
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(), bond.GetEndAtomIdx(), bond_order=bond_order
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["connectivity"] = [(0, i, 1.0) for i in range(1, 7)]
27
- atoms.info["smiles"] = "F[P-](F)(F)(F)(F)F"
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["smiles"] = smiles
144
- atoms.info["connectivity"] = bonds
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 "connectivity" in atoms.info:
307
+ if GraphAttr.CONNECTIVITY in atoms.info:
307
308
  # connectivity is a list of tuples (i, j, bond_type)
308
- connectivity = atoms.info["connectivity"]
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
@@ -1231,7 +1231,6 @@ wheels = [
1231
1231
 
1232
1232
  [[package]]
1233
1233
  name = "molify"
1234
- version = "0.2.1"
1235
1234
  source = { editable = "." }
1236
1235
  dependencies = [
1237
1236
  { name = "ase" },
@@ -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