molify 0.2.6__tar.gz → 0.2.8__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 (64) hide show
  1. {molify-0.2.6 → molify-0.2.8}/.github/workflows/pytest.yaml +2 -2
  2. {molify-0.2.6 → molify-0.2.8}/.pre-commit-config.yaml +2 -2
  3. {molify-0.2.6 → molify-0.2.8}/PKG-INFO +3 -2
  4. {molify-0.2.6 → molify-0.2.8}/docs/source/index.rst +0 -1
  5. {molify-0.2.6 → molify-0.2.8}/docs/source/packmol_tools.ipynb +39 -0
  6. {molify-0.2.6 → molify-0.2.8}/paper/paper.md +1 -4
  7. {molify-0.2.6 → molify-0.2.8}/pyproject.toml +1 -0
  8. {molify-0.2.6 → molify-0.2.8}/src/molify/__init__.py +2 -0
  9. {molify-0.2.6 → molify-0.2.8}/src/molify/_version.py +2 -2
  10. {molify-0.2.6 → molify-0.2.8}/src/molify/ase2x.py +50 -48
  11. {molify-0.2.6 → molify-0.2.8}/src/molify/cli.py +14 -1
  12. molify-0.2.8/src/molify/compress.py +52 -0
  13. {molify-0.2.6 → molify-0.2.8}/src/molify/pack.py +29 -12
  14. {molify-0.2.6 → molify-0.2.8}/src/molify/rdkit2x.py +16 -3
  15. molify-0.2.8/src/molify/repeat.py +117 -0
  16. {molify-0.2.6 → molify-0.2.8}/src/molify/smiles2x.py +30 -9
  17. {molify-0.2.6 → molify-0.2.8}/src/molify/substructure.py +15 -15
  18. {molify-0.2.6 → molify-0.2.8}/src/molify/utils.py +143 -31
  19. {molify-0.2.6 → molify-0.2.8}/tests/conftest.py +14 -0
  20. {molify-0.2.6 → molify-0.2.8}/tests/integration/test_ase2x.py +192 -0
  21. {molify-0.2.6 → molify-0.2.8}/tests/test_cli.py +15 -0
  22. molify-0.2.8/tests/test_connectivity.py +156 -0
  23. {molify-0.2.6 → molify-0.2.8}/tests/test_rdkit2ase.py +14 -0
  24. molify-0.2.8/tests/test_repeat.py +295 -0
  25. {molify-0.2.6 → molify-0.2.8}/tests/test_smiles2conformers.py +56 -1
  26. {molify-0.2.6 → molify-0.2.8}/tests/test_solvate.py +34 -0
  27. {molify-0.2.6 → molify-0.2.8}/tests/test_substructure.py +20 -0
  28. {molify-0.2.6 → molify-0.2.8}/tests/test_utils.py +6 -13
  29. molify-0.2.8/uv.lock +3631 -0
  30. molify-0.2.6/src/molify/compress.py +0 -74
  31. molify-0.2.6/tests/test_connectivity.py +0 -68
  32. molify-0.2.6/uv.lock +0 -2744
  33. {molify-0.2.6 → molify-0.2.8}/.github/workflows/build-wheels.yaml +0 -0
  34. {molify-0.2.6 → molify-0.2.8}/.github/workflows/pages.yaml +0 -0
  35. {molify-0.2.6 → molify-0.2.8}/.gitignore +0 -0
  36. {molify-0.2.6 → molify-0.2.8}/.python-version +0 -0
  37. {molify-0.2.6 → molify-0.2.8}/CITATION.cff +0 -0
  38. {molify-0.2.6 → molify-0.2.8}/CONTRIBUTING.md +0 -0
  39. {molify-0.2.6 → molify-0.2.8}/LICENSE +0 -0
  40. {molify-0.2.6 → molify-0.2.8}/README.md +0 -0
  41. {molify-0.2.6 → molify-0.2.8}/docs/Makefile +0 -0
  42. {molify-0.2.6 → molify-0.2.8}/docs/make.bat +0 -0
  43. {molify-0.2.6 → molify-0.2.8}/docs/source/ase_tools.ipynb +0 -0
  44. {molify-0.2.6 → molify-0.2.8}/docs/source/atom_selection.ipynb +0 -0
  45. {molify-0.2.6 → molify-0.2.8}/docs/source/conf.py +0 -0
  46. {molify-0.2.6 → molify-0.2.8}/docs/source/modules.rst +0 -0
  47. {molify-0.2.6 → molify-0.2.8}/docs/source/networkx_tools.ipynb +0 -0
  48. {molify-0.2.6 → molify-0.2.8}/docs/source/rdkit_tools.ipynb +0 -0
  49. {molify-0.2.6 → molify-0.2.8}/paper/.gitignore +0 -0
  50. {molify-0.2.6 → molify-0.2.8}/paper/bibliography.bib +0 -0
  51. {molify-0.2.6 → molify-0.2.8}/paper/build.sh +0 -0
  52. {molify-0.2.6 → molify-0.2.8}/paper/zndraw_rdkit.svg +0 -0
  53. {molify-0.2.6 → molify-0.2.8}/src/molify/__main__.py +0 -0
  54. {molify-0.2.6 → molify-0.2.8}/src/molify/bond_order.py +0 -0
  55. {molify-0.2.6 → molify-0.2.8}/src/molify/com.py +0 -0
  56. {molify-0.2.6 → molify-0.2.8}/src/molify/constants.py +0 -0
  57. {molify-0.2.6 → molify-0.2.8}/src/molify/networkx2x.py +0 -0
  58. {molify-0.2.6 → molify-0.2.8}/tests/data/ec_emc.xyz +0 -0
  59. {molify-0.2.6 → molify-0.2.8}/tests/integration/test_networkx2x.py +0 -0
  60. {molify-0.2.6 → molify-0.2.8}/tests/integration/test_rdkit2x.py +0 -0
  61. {molify-0.2.6 → molify-0.2.8}/tests/test_com.py +0 -0
  62. {molify-0.2.6 → molify-0.2.8}/tests/test_compress.py +0 -0
  63. {molify-0.2.6 → molify-0.2.8}/tests/test_edgecases.py +0 -0
  64. {molify-0.2.6 → molify-0.2.8}/tests/test_examples.py +0 -0
@@ -21,8 +21,8 @@ jobs:
21
21
  - "3.12"
22
22
  - "3.11"
23
23
  - "3.10"
24
- os: [ubuntu-latest, ubuntu-24.04-arm, windows-latest, macos-15-intel, macos-latest]
25
- # windows-11-arm -> rdkit support missing
24
+ os: [ubuntu-latest, ubuntu-24.04-arm, windows-latest, macos-latest]
25
+ # windows-11-arm, macos-15-intel -> rdkit support missing
26
26
 
27
27
  steps:
28
28
  - uses: actions/checkout@v5
@@ -20,13 +20,13 @@ repos:
20
20
  - id: sort-simple-yaml
21
21
  - id: trailing-whitespace
22
22
  - repo: https://github.com/codespell-project/codespell
23
- rev: v2.4.1
23
+ rev: v2.4.3
24
24
  hooks:
25
25
  - id: codespell
26
26
  additional_dependencies: ["tomli"]
27
27
  - repo: https://github.com/astral-sh/ruff-pre-commit
28
28
  # Ruff version.
29
- rev: v0.14.14
29
+ rev: v0.16.10
30
30
  hooks:
31
31
  # Run the linter.
32
32
  - id: ruff
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: molify
3
- Version: 0.2.6
3
+ Version: 0.2.8
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
@@ -16,6 +16,7 @@ Requires-Dist: networkx>=3.4.2
16
16
  Requires-Dist: packmol>=21.1.2
17
17
  Requires-Dist: rdkit>=2024
18
18
  Requires-Dist: typer>=0.27.1
19
+ Requires-Dist: typing-extensions>=4.15.0
19
20
  Provides-Extra: vesin
20
21
  Requires-Dist: vesin>=0.3.7; extra == 'vesin'
21
22
  Description-Content-Type: text/markdown
@@ -124,7 +124,6 @@ For example, molify provides helper functions to generate 3D conformers directly
124
124
  # Generate 10 different 3D conformers of water
125
125
  water = molify.smiles2conformers("O", numConfs=10)
126
126
 
127
-
128
127
  **Molecular System Building**
129
128
 
130
129
  For many applications, it is useful to solvate molecules or create mixtures.
@@ -284,6 +284,45 @@
284
284
  "print(f\"Density: {actual_density:.1f} kg/m³\")"
285
285
  ]
286
286
  },
287
+ {
288
+ "cell_type": "markdown",
289
+ "metadata": {},
290
+ "source": [
291
+ "## Repeating a Box\n",
292
+ "\n",
293
+ "`molify.repeat()` builds a supercell of a periodic box and tiles `info['connectivity']` with it. Every copy carries its own bonds, bonds across the periodic boundary link neighbouring copies, and bond orders are kept. Use it to repeat any structure that carries connectivity."
294
+ ]
295
+ },
296
+ {
297
+ "cell_type": "code",
298
+ "execution_count": 9,
299
+ "metadata": {},
300
+ "outputs": [
301
+ {
302
+ "name": "stdout",
303
+ "output_type": "stream",
304
+ "text": [
305
+ "Atoms: 60 -> 120\n",
306
+ "Bonds: 50 -> 100\n",
307
+ "Molecules: 10 -> 20\n"
308
+ ]
309
+ }
310
+ ],
311
+ "source": [
312
+ "# Tile the mixture twice along the first cell vector\n",
313
+ "supercell = molify.repeat(mixture, (2, 1, 1))\n",
314
+ "\n",
315
+ "print(f\"Atoms: {len(mixture)} -> {len(supercell)}\")\n",
316
+ "print(\n",
317
+ " f\"Bonds: {len(mixture.info['connectivity'])} \"\n",
318
+ " f\"-> {len(supercell.info['connectivity'])}\"\n",
319
+ ")\n",
320
+ "print(\n",
321
+ " f\"Molecules: {len(list(molify.iter_fragments(mixture)))} \"\n",
322
+ " f\"-> {len(list(molify.iter_fragments(supercell)))}\"\n",
323
+ ")"
324
+ ]
325
+ },
287
326
  {
288
327
  "cell_type": "markdown",
289
328
  "metadata": {},
@@ -110,10 +110,7 @@ For example, `molify` streamlines the extraction of the CH$_3$ alkyl group from
110
110
  ```python
111
111
  from molify import get_substructures
112
112
 
113
- frames: list[ase.Atoms] = get_substructures(
114
- atoms=box,
115
- smiles="[C]([H])([H])[H]"
116
- )
113
+ frames: list[ase.Atoms] = get_substructures(atoms=box, smiles="[C]([H])([H])[H]")
117
114
  ```
118
115
 
119
116
  # Related software
@@ -15,6 +15,7 @@ dependencies = [
15
15
  "packmol>=21.1.2",
16
16
  "rdkit>=2024",
17
17
  "typer>=0.27.1",
18
+ "typing-extensions>=4.15.0",
18
19
  ]
19
20
 
20
21
  [dependency-groups]
@@ -6,6 +6,7 @@ from molify.compress import compress
6
6
  from molify.networkx2x import networkx2ase, networkx2rdkit
7
7
  from molify.pack import pack
8
8
  from molify.rdkit2x import rdkit2ase, rdkit2networkx
9
+ from molify.repeat import repeat
9
10
  from molify.smiles2x import smiles2atoms, smiles2conformers
10
11
  from molify.substructure import (
11
12
  get_substructures,
@@ -27,6 +28,7 @@ __all__ = [
27
28
  "pack",
28
29
  "smiles2conformers",
29
30
  "compress",
31
+ "repeat",
30
32
  "match_substructure",
31
33
  "group_matches_by_fragment",
32
34
  "get_substructures",
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.6'
22
- __version_tuple__ = version_tuple = (0, 2, 6)
21
+ __version__ = version = '0.2.8'
22
+ __version_tuple__ = version_tuple = (0, 2, 8)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -1,16 +1,21 @@
1
1
  import ase
2
2
  import networkx as nx
3
3
  import numpy as np
4
- from ase.neighborlist import natural_cutoffs, neighbor_list
4
+ from ase.data import covalent_radii
5
+ from ase.neighborlist import neighbor_list
5
6
  from rdkit import Chem
6
7
 
7
8
  from molify.constants import EdgeAttr, GraphAttr, NodeAttr
9
+ from molify.utils import read_connectivity
8
10
 
9
11
  try:
10
12
  import vesin
11
13
  except ImportError:
12
14
  vesin = None
13
15
 
16
+ # Li, Na, K, Rb, Cs, Fr
17
+ _NON_BONDING_ATOMIC_NUMBERS = (3, 11, 19, 37, 55, 87)
18
+
14
19
 
15
20
  def _create_graph_from_connectivity(
16
21
  atoms: ase.Atoms, connectivity, charges
@@ -39,17 +44,30 @@ def _create_graph_from_connectivity(
39
44
  return graph
40
45
 
41
46
 
42
- def _compute_connectivity_matrix(atoms: ase.Atoms, scale: float, pbc: bool):
43
- """Compute connectivity matrix from distance-based cutoffs."""
44
- # non-bonding positive charged atoms / ions.
45
- non_bonding_atomic_numbers = {3, 11, 19, 37, 55, 87}
47
+ def _compute_bonded_pairs(atoms: ase.Atoms, scale: float, pbc: bool) -> np.ndarray:
48
+ """Compute bonded atom pairs from distance-based cutoffs.
46
49
 
47
- atomic_numbers = atoms.get_atomic_numbers()
48
- excluded_mask = np.isin(atomic_numbers, list(non_bonding_atomic_numbers))
50
+ Parameters
51
+ ----------
52
+ atoms : ase.Atoms
53
+ Structure to search for bonds.
54
+ scale : float
55
+ Factor applied to the covalent radii.
56
+ pbc : bool
57
+ Whether bonds may cross periodic boundaries.
49
58
 
50
- atom_radii = np.array(natural_cutoffs(atoms, mult=scale))
51
- pairwise_cutoffs = atom_radii[:, None] + atom_radii[None, :]
52
- max_cutoff = np.max(pairwise_cutoffs)
59
+ Returns
60
+ -------
61
+ numpy.ndarray
62
+ Shape ``(M, 2)``; unique ``(i, j)`` rows with ``i < j``, sorted
63
+ lexicographically.
64
+ """
65
+ radii = covalent_radii[atoms.numbers] * scale
66
+ bonding = ~np.isin(atoms.numbers, _NON_BONDING_ATOMIC_NUMBERS)
67
+ if not bonding.any():
68
+ return np.empty((0, 2), dtype=np.intp)
69
+ # Neighbor lists keep d < cutoff; one ulp more keeps pairs at exactly the cutoff.
70
+ max_cutoff = np.nextafter(2 * radii[bonding].max(), np.inf)
53
71
 
54
72
  if vesin is not None:
55
73
  try:
@@ -66,31 +84,13 @@ def _compute_connectivity_matrix(atoms: ase.Atoms, scale: float, pbc: bool):
66
84
  "ijdS", atoms, cutoff=max_cutoff, self_interaction=False
67
85
  )
68
86
 
69
- # If pbc=False, filter out bonds that cross periodic boundaries
87
+ keep = (i < j) & bonding[i] & bonding[j] & (d <= radii[i] + radii[j])
70
88
  if not pbc:
71
- non_periodic_mask = np.all(s == 0, axis=1)
72
- i = i[non_periodic_mask]
73
- j = j[non_periodic_mask]
74
- d = d[non_periodic_mask]
89
+ keep &= ~s.any(axis=1)
90
+ return np.unique(np.stack([i[keep], j[keep]], axis=1), axis=0)
75
91
 
76
- d_ij = np.full((len(atoms), len(atoms)), np.inf)
77
- d_ij[i, j] = d
78
- np.fill_diagonal(d_ij, 0.0)
79
92
 
80
- # mask out non-bonding atoms
81
- d_ij[excluded_mask, :] = np.inf
82
- d_ij[:, excluded_mask] = np.inf
83
-
84
- connectivity_matrix = np.zeros((len(atoms), len(atoms)), dtype=int)
85
- np.fill_diagonal(d_ij, np.inf)
86
- connectivity_matrix[d_ij <= pairwise_cutoffs] = 1
87
-
88
- return connectivity_matrix, non_bonding_atomic_numbers
89
-
90
-
91
- def _add_node_properties(
92
- graph: nx.Graph, atoms: ase.Atoms, charges, non_bonding_atomic_numbers
93
- ):
93
+ def _add_node_properties(graph: nx.Graph, atoms: ase.Atoms, charges):
94
94
  """Add node properties to the graph."""
95
95
  stored_indices = atoms.info.get(NodeAttr.ORIGINAL_INDEX)
96
96
 
@@ -100,7 +100,7 @@ def _add_node_properties(
100
100
  graph.nodes[i][NodeAttr.ATOMIC_NUMBER] = int(atom.number)
101
101
  graph.nodes[i][NodeAttr.ORIGINAL_INDEX] = original_index
102
102
  graph.nodes[i][NodeAttr.CHARGE] = float(charges[i])
103
- if atom.number in non_bonding_atomic_numbers:
103
+ if atom.number in _NON_BONDING_ATOMIC_NUMBERS:
104
104
  graph.nodes[i][NodeAttr.CHARGE] = 1.0
105
105
 
106
106
 
@@ -132,6 +132,12 @@ def ase2networkx(
132
132
  networkx.Graph
133
133
  An undirected NetworkX graph with connectivity information.
134
134
 
135
+ Raises
136
+ ------
137
+ ValueError
138
+ For an invalid bond in ``atoms.info['connectivity']``, see
139
+ :func:`molify.utils.read_connectivity`.
140
+
135
141
  Notes
136
142
  -----
137
143
  The graph contains the following information:
@@ -150,7 +156,11 @@ def ase2networkx(
150
156
  Connectivity is determined by:
151
157
 
152
158
  1. Using explicit connectivity if present in atoms.info
153
- 2. Otherwise using distance-based cutoffs (edges will have bond_order=None)
159
+ 2. Otherwise using distance-based cutoffs: atoms *i* and *j* are bonded
160
+ when any periodic image of *j* lies within ``scale * (r_i + r_j)`` of
161
+ *i*, with *r* the covalent radius. Li, Na, K, Rb, Cs and Fr are
162
+ non-bonding ions. Edges have ``bond_order=None``; memory scales
163
+ linearly with the number of atoms.
154
164
 
155
165
  To get bond orders, pass the graph to networkx2rdkit().
156
166
 
@@ -169,24 +179,16 @@ def ase2networkx(
169
179
  charges = atoms.get_initial_charges()
170
180
 
171
181
  if GraphAttr.CONNECTIVITY in atoms.info:
172
- connectivity = atoms.info[GraphAttr.CONNECTIVITY]
173
- # ensure connectivity is list[tuple[int, int, float|None]] and
174
- # does not contain np.generic
175
- connectivity = [
176
- (int(i), int(j), float(bond_order) if bond_order is not None else None)
177
- for i, j, bond_order in connectivity
178
- ]
182
+ connectivity = read_connectivity(atoms)
179
183
  return _create_graph_from_connectivity(atoms, connectivity, charges)
180
184
 
181
- connectivity_matrix, non_bonding_atomic_numbers = _compute_connectivity_matrix(
182
- atoms, scale, pbc
183
- )
185
+ pairs = _compute_bonded_pairs(atoms, scale, pbc)
184
186
 
185
- graph = nx.from_numpy_array(connectivity_matrix, edge_attr=None)
186
- for u, v in graph.edges():
187
- graph.edges[u, v][EdgeAttr.BOND_ORDER] = None
187
+ graph = nx.Graph()
188
+ graph.add_nodes_from(range(len(atoms)))
189
+ graph.add_edges_from(pairs.tolist(), **{EdgeAttr.BOND_ORDER: None})
188
190
 
189
- _add_node_properties(graph, atoms, charges, non_bonding_atomic_numbers)
191
+ _add_node_properties(graph, atoms, charges)
190
192
 
191
193
  graph.graph[GraphAttr.PBC] = atoms.pbc
192
194
  graph.graph[GraphAttr.CELL] = atoms.cell
@@ -86,12 +86,25 @@ def smiles2atoms(
86
86
  seed: Annotated[
87
87
  int, typer.Option(help="Random seed for conformer generation.")
88
88
  ] = 42,
89
+ random_coords: Annotated[
90
+ bool,
91
+ typer.Option(
92
+ "--random-coords",
93
+ help=(
94
+ "Start the embedding from random coordinates, which long chains"
95
+ " such as n-alkanes from about C56 on often need."
96
+ ),
97
+ ),
98
+ ] = False,
89
99
  ) -> None:
90
100
  """Convert a SMILES string into a single 3D structure on standard output.
91
101
 
92
102
  Example: molify smiles2atoms CCO --format XYZ > etoh.xyz
93
103
  """
94
- atoms = _smiles2atoms(smiles, seed=seed)
104
+ try:
105
+ atoms = _smiles2atoms(smiles, seed=seed, useRandomCoords=random_coords)
106
+ except ValueError as err:
107
+ raise typer.BadParameter(str(err), param_hint=["smiles"]) from err
95
108
  with io.StringIO() as handle:
96
109
  ase.io.write(handle, atoms, format=_resolve_format(fmt))
97
110
  sys.stdout.write(handle.getvalue())
@@ -0,0 +1,52 @@
1
+ import ase
2
+ import numpy as np
3
+ from ase.cell import Cell
4
+
5
+ from molify.constants import GraphAttr
6
+ from molify.utils import calculate_box_dimensions, fragment_indices
7
+
8
+
9
+ def compress(
10
+ atoms: ase.Atoms,
11
+ density: float,
12
+ freeze_molecules: bool = False,
13
+ ) -> ase.Atoms:
14
+ """Compress an ASE Atoms object to a target density.
15
+
16
+ Parameters
17
+ ----------
18
+ atoms : ase.Atoms
19
+ The Atoms object to compress.
20
+ density : float
21
+ The target density in kg/m^3.
22
+ freeze_molecules : bool
23
+ If True, freeze the internal degrees of freedom of the molecules
24
+ during compression, to prevent bond compression.
25
+
26
+ Raises
27
+ ------
28
+ ValueError
29
+ With ``freeze_molecules=True``, for a missing ``info['connectivity']``
30
+ or an invalid bond in it, see :func:`molify.utils.read_connectivity`.
31
+ """
32
+ atoms = atoms.copy()
33
+ new_dimensions = np.array(calculate_box_dimensions([atoms], density))
34
+
35
+ if freeze_molecules:
36
+ if atoms.info.get(GraphAttr.CONNECTIVITY) is None:
37
+ raise ValueError("No connectivity info found for freeze_molecules=True")
38
+ fragments = fragment_indices(atoms)
39
+ new_cell = Cell.new(new_dimensions)
40
+ t_mat = np.linalg.solve(atoms.get_cell().T, new_cell.T)
41
+
42
+ positions = atoms.get_positions()
43
+ for fragment in fragments:
44
+ com = atoms[fragment].get_center_of_mass()
45
+ positions[fragment] += com @ t_mat - com
46
+
47
+ atoms.set_positions(positions)
48
+ atoms.set_cell(new_cell, scale_atoms=False)
49
+ else:
50
+ atoms.set_cell(new_dimensions, scale_atoms=True)
51
+
52
+ return atoms
@@ -10,7 +10,7 @@ from ase.io.proteindatabank import write_proteindatabank
10
10
  from rdkit import Chem
11
11
 
12
12
  from molify.constants import GraphAttr
13
- from molify.utils import calculate_box_dimensions
13
+ from molify.utils import calculate_box_dimensions, read_connectivity
14
14
 
15
15
  log = logging.getLogger(__name__)
16
16
 
@@ -90,13 +90,29 @@ def _write_molecule_files(
90
90
  ase.io.write(filepath, atoms)
91
91
 
92
92
 
93
+ def _combine_bonds(
94
+ selected_images: list[ase.Atoms],
95
+ ) -> list[tuple[int, int, float | None]] | None:
96
+ """Offset each molecule's bonds into the packed box.
97
+
98
+ Returns None when a molecule lacks ``info['connectivity']``.
99
+ """
100
+ if not all(GraphAttr.CONNECTIVITY in atoms.info for atoms in selected_images):
101
+ return None
102
+ bonds = []
103
+ offset = 0
104
+ for atoms in selected_images:
105
+ for i, j, order in read_connectivity(atoms):
106
+ bonds.append((i + offset, j + offset, order))
107
+ offset += len(atoms)
108
+ return bonds
109
+
110
+
93
111
  def _extract_atom_arrays(
94
112
  selected_images: list[ase.Atoms], packed_atoms: ase.Atoms
95
113
  ) -> ase.Atoms:
96
114
  """Extracts and adds relevant atom arrays (if present)
97
115
 
98
- Add bonds from the input structures to the packed structure if available.
99
-
100
116
  Parameters
101
117
  ----------
102
118
  selected_images : list[ase.Atoms]
@@ -107,7 +123,7 @@ def _extract_atom_arrays(
107
123
  Returns
108
124
  -------
109
125
  ase.Atoms
110
- The packed ASE Atoms object with the copied arrays and bonds.
126
+ The packed ASE Atoms object with the copied arrays.
111
127
  """
112
128
  array_keys = [
113
129
  "occupancy",
@@ -126,14 +142,6 @@ def _extract_atom_arrays(
126
142
  )
127
143
  packed_atoms.arrays[key] = concatenated_array
128
144
 
129
- if all(GraphAttr.CONNECTIVITY in atoms.info for atoms in selected_images):
130
- bonds = []
131
- offset = 0
132
- for atoms in selected_images:
133
- for bond in atoms.info[GraphAttr.CONNECTIVITY]:
134
- bonds.append((bond[0] + offset, bond[1] + offset, bond[2]))
135
- offset += len(atoms)
136
- packed_atoms.info[GraphAttr.CONNECTIVITY] = bonds
137
145
  charges = np.concatenate([atom.get_initial_charges() for atom in selected_images])
138
146
  if any(charge != 0 for charge in charges):
139
147
  packed_atoms.set_initial_charges(charges)
@@ -189,6 +197,12 @@ def pack(
189
197
  ase.Atoms
190
198
  An ASE Atoms object representing the packed system.
191
199
 
200
+ Raises
201
+ ------
202
+ ValueError
203
+ For an invalid bond in a molecule's ``info['connectivity']``, see
204
+ :func:`molify.utils.read_connectivity`.
205
+
192
206
  Example
193
207
  -------
194
208
  >>> from molify import pack, smiles2conformers
@@ -200,6 +214,7 @@ def pack(
200
214
  Atoms(symbols='C10H44O12', pbc=True, cell=[8.4, 8.4, 8.4])
201
215
  """
202
216
  selected_images = _select_conformers(data, counts, seed)
217
+ bonds = _combine_bonds(selected_images)
203
218
  cell = calculate_box_dimensions(images=selected_images, density=density)
204
219
 
205
220
  # Adjust cell dimensions according to ratio while keeping volume unchanged
@@ -240,4 +255,6 @@ def pack(
240
255
  packed_atoms.cell = cell
241
256
  packed_atoms.pbc = True
242
257
  packed_atoms = _extract_atom_arrays(selected_images, packed_atoms)
258
+ if bonds is not None:
259
+ packed_atoms.info[GraphAttr.CONNECTIVITY] = bonds
243
260
  return packed_atoms
@@ -1,12 +1,16 @@
1
1
  import ase
2
2
  import networkx as nx
3
3
  from rdkit import Chem
4
- from rdkit.Chem import AllChem
5
4
 
6
5
  from molify.constants import EdgeAttr, GraphAttr, NodeAttr
6
+ from molify.utils import embed_conformers
7
7
 
8
8
 
9
- def rdkit2ase(mol: Chem.Mol, seed: int = 42) -> ase.Atoms:
9
+ def rdkit2ase(
10
+ mol: Chem.Mol,
11
+ seed: int = 42,
12
+ useRandomCoords: bool = False, # noqa N803
13
+ ) -> ase.Atoms:
10
14
  """Convert an RDKit molecule to an ASE Atoms object.
11
15
 
12
16
  Parameters
@@ -15,6 +19,10 @@ def rdkit2ase(mol: Chem.Mol, seed: int = 42) -> ase.Atoms:
15
19
  RDKit molecule to convert
16
20
  seed : int, optional
17
21
  Random seed for conformer generation (default is 42)
22
+ useRandomCoords : bool, optional
23
+ Start the embedding from random coordinates instead of the eigenvectors
24
+ of the distance matrix (default is False). Long chains, such as
25
+ n-alkanes from about C56 on, often embed only this way.
18
26
 
19
27
  Returns
20
28
  -------
@@ -26,6 +34,11 @@ def rdkit2ase(mol: Chem.Mol, seed: int = 42) -> ase.Atoms:
26
34
  - Formal charges if present
27
35
  - SMILES string in atoms.info
28
36
 
37
+ Raises
38
+ ------
39
+ ValueError
40
+ If RDKit fails to embed ``mol``.
41
+
29
42
  Examples
30
43
  --------
31
44
  >>> from rdkit import Chem
@@ -41,7 +54,7 @@ def rdkit2ase(mol: Chem.Mol, seed: int = 42) -> ase.Atoms:
41
54
  smiles = Chem.MolToSmiles(mol)
42
55
  mol = Chem.AddHs(mol)
43
56
  charges = [atom.GetFormalCharge() for atom in mol.GetAtoms()]
44
- AllChem.EmbedMolecule(mol, randomSeed=seed)
57
+ embed_conformers(mol, smiles, 1, seed, 0, useRandomCoords)
45
58
 
46
59
  atoms = ase.Atoms(
47
60
  positions=mol.GetConformer().GetPositions(),
@@ -0,0 +1,117 @@
1
+ from collections.abc import Sequence
2
+
3
+ import ase
4
+ import numpy as np
5
+ from ase.geometry import find_mic, minkowski_reduce
6
+
7
+ from molify.constants import GraphAttr, NodeAttr
8
+ from molify.utils import read_connectivity
9
+
10
+
11
+ def _normalize_rep(rep: int | Sequence[int]) -> tuple[int, ...]:
12
+ try:
13
+ reps = np.asarray(rep)
14
+ except ValueError:
15
+ reps = np.empty(0)
16
+ if reps.ndim == 0:
17
+ reps = np.broadcast_to(reps, 3)
18
+ if reps.shape != (3,) or reps.dtype.kind not in "iu" or (reps < 1).any():
19
+ raise ValueError(
20
+ "rep must be a positive integer or a sequence of three positive integers, "
21
+ f"got {rep!r}"
22
+ )
23
+ return tuple(reps.tolist())
24
+
25
+
26
+ def _tile_bonds(
27
+ atoms: ase.Atoms, reps: tuple[int, ...]
28
+ ) -> list[tuple[int, int, float | None]]:
29
+ n_atoms = len(atoms)
30
+ bonds = read_connectivity(atoms)
31
+ i = np.array([bond[0] for bond in bonds], dtype=int)
32
+ j = np.array([bond[1] for bond in bonds], dtype=int)
33
+ orders = [bond[2] for bond in bonds]
34
+
35
+ periodic = atoms.pbc & atoms.cell.any(1)
36
+ d = atoms.positions[j] - atoms.positions[i]
37
+ d_mic, lengths = find_mic(d, atoms.cell, periodic)
38
+ lattice, _ = minkowski_reduce(atoms.cell, periodic)
39
+ limit = 0.5 * np.linalg.norm(lattice[periodic], axis=1).min(initial=np.inf)
40
+ too_long = np.flatnonzero(lengths >= limit)
41
+ if too_long.size:
42
+ k = too_long[0]
43
+ raise ValueError(
44
+ f"bond ({i[k]}, {j[k]}) in atoms.info['connectivity'] is "
45
+ f"{lengths[k]:.3f} Å long; repeat needs every bond shorter than "
46
+ f"{limit:.3f} Å, half the shortest periodic lattice vector, so that "
47
+ "each bond links a unique periodic image"
48
+ )
49
+ shift = np.rint(atoms.cell.scaled_positions(d_mic - d)).astype(int)
50
+
51
+ images = np.array(list(np.ndindex(*reps)))
52
+ targets = (images[:, None, :] + shift[None, :, :]) % np.array(reps)
53
+ target_image = np.ravel_multi_index(np.moveaxis(targets, -1, 0), reps)
54
+ new_i = np.arange(len(images))[:, None] * n_atoms + i[None, :]
55
+ new_j = target_image * n_atoms + j[None, :]
56
+ return [
57
+ (int(a), int(b), order)
58
+ for a, b, order in zip(
59
+ new_i.ravel(), new_j.ravel(), orders * len(images), strict=True
60
+ )
61
+ ]
62
+
63
+
64
+ def repeat(atoms: ase.Atoms, rep: int | Sequence[int]) -> ase.Atoms:
65
+ """Repeat a periodic structure together with its connectivity.
66
+
67
+ Atom ``k`` of the result is a copy of atom ``k % len(atoms)``, as in
68
+ :meth:`ase.Atoms.repeat`. Each bond ``(i, j, order)`` links atom ``i`` to
69
+ the nearest periodic image of atom ``j``. Bond orders are kept.
70
+
71
+ Parameters
72
+ ----------
73
+ atoms : ase.Atoms
74
+ Periodic structure whose bonds are each shorter than half the shortest
75
+ vector of its periodic lattice.
76
+ rep : int or Sequence[int]
77
+ Copies along each cell vector: one positive integer for all three, or
78
+ three positive integers.
79
+
80
+ Returns
81
+ -------
82
+ ase.Atoms
83
+ Repeated structure with ``info['connectivity']`` and
84
+ ``info['original_index']`` tiled and ``info['smiles']`` removed.
85
+
86
+ Raises
87
+ ------
88
+ ValueError
89
+ For an invalid ``rep``, an ``info['original_index']`` of the wrong
90
+ length, an invalid bond in ``info['connectivity']``, or a repeat along
91
+ an undefined cell vector.
92
+
93
+ Examples
94
+ --------
95
+ >>> import molify
96
+ >>> water = molify.smiles2conformers("O", numConfs=1)
97
+ >>> box = molify.pack([water], counts=[4], density=1000)
98
+ >>> big = molify.repeat(box, (2, 1, 1))
99
+ >>> len(big.info["connectivity"]) == 2 * len(box.info["connectivity"])
100
+ True
101
+ """
102
+ reps = _normalize_rep(rep)
103
+ original_index = atoms.info.get(NodeAttr.ORIGINAL_INDEX)
104
+ if original_index is not None and len(original_index) != len(atoms):
105
+ raise ValueError(
106
+ f"atoms.info['original_index'] holds {len(original_index)} entries "
107
+ f"for {len(atoms)} atoms; it needs one entry per atom"
108
+ )
109
+
110
+ result = atoms.repeat(reps)
111
+ result.info.pop(GraphAttr.SMILES, None)
112
+ if GraphAttr.CONNECTIVITY in atoms.info:
113
+ result.info[GraphAttr.CONNECTIVITY] = _tile_bonds(atoms, reps)
114
+ if original_index is not None:
115
+ tiled = [int(k) for k in original_index] * int(np.prod(reps))
116
+ result.info[NodeAttr.ORIGINAL_INDEX] = tiled
117
+ return result