molify 0.2.6__tar.gz → 0.2.7__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.7}/.github/workflows/pytest.yaml +2 -2
  2. {molify-0.2.6 → molify-0.2.7}/.pre-commit-config.yaml +2 -2
  3. {molify-0.2.6 → molify-0.2.7}/PKG-INFO +3 -2
  4. {molify-0.2.6 → molify-0.2.7}/docs/source/packmol_tools.ipynb +39 -0
  5. {molify-0.2.6 → molify-0.2.7}/paper/paper.md +1 -4
  6. {molify-0.2.6 → molify-0.2.7}/pyproject.toml +1 -0
  7. {molify-0.2.6 → molify-0.2.7}/src/molify/__init__.py +2 -0
  8. {molify-0.2.6 → molify-0.2.7}/src/molify/_version.py +2 -2
  9. {molify-0.2.6 → molify-0.2.7}/src/molify/ase2x.py +50 -48
  10. {molify-0.2.6 → molify-0.2.7}/src/molify/cli.py +4 -1
  11. molify-0.2.7/src/molify/compress.py +52 -0
  12. {molify-0.2.6 → molify-0.2.7}/src/molify/pack.py +29 -12
  13. molify-0.2.7/src/molify/repeat.py +117 -0
  14. {molify-0.2.6 → molify-0.2.7}/src/molify/smiles2x.py +19 -1
  15. {molify-0.2.6 → molify-0.2.7}/src/molify/substructure.py +15 -15
  16. {molify-0.2.6 → molify-0.2.7}/src/molify/utils.py +94 -31
  17. {molify-0.2.6 → molify-0.2.7}/tests/conftest.py +14 -0
  18. {molify-0.2.6 → molify-0.2.7}/tests/integration/test_ase2x.py +192 -0
  19. {molify-0.2.6 → molify-0.2.7}/tests/test_cli.py +8 -0
  20. molify-0.2.7/tests/test_connectivity.py +156 -0
  21. molify-0.2.7/tests/test_repeat.py +295 -0
  22. {molify-0.2.6 → molify-0.2.7}/tests/test_smiles2conformers.py +32 -1
  23. {molify-0.2.6 → molify-0.2.7}/tests/test_solvate.py +34 -0
  24. {molify-0.2.6 → molify-0.2.7}/tests/test_substructure.py +20 -0
  25. {molify-0.2.6 → molify-0.2.7}/tests/test_utils.py +6 -13
  26. molify-0.2.7/uv.lock +3631 -0
  27. molify-0.2.6/src/molify/compress.py +0 -74
  28. molify-0.2.6/tests/test_connectivity.py +0 -68
  29. molify-0.2.6/uv.lock +0 -2744
  30. {molify-0.2.6 → molify-0.2.7}/.github/workflows/build-wheels.yaml +0 -0
  31. {molify-0.2.6 → molify-0.2.7}/.github/workflows/pages.yaml +0 -0
  32. {molify-0.2.6 → molify-0.2.7}/.gitignore +0 -0
  33. {molify-0.2.6 → molify-0.2.7}/.python-version +0 -0
  34. {molify-0.2.6 → molify-0.2.7}/CITATION.cff +0 -0
  35. {molify-0.2.6 → molify-0.2.7}/CONTRIBUTING.md +0 -0
  36. {molify-0.2.6 → molify-0.2.7}/LICENSE +0 -0
  37. {molify-0.2.6 → molify-0.2.7}/README.md +0 -0
  38. {molify-0.2.6 → molify-0.2.7}/docs/Makefile +0 -0
  39. {molify-0.2.6 → molify-0.2.7}/docs/make.bat +0 -0
  40. {molify-0.2.6 → molify-0.2.7}/docs/source/ase_tools.ipynb +0 -0
  41. {molify-0.2.6 → molify-0.2.7}/docs/source/atom_selection.ipynb +0 -0
  42. {molify-0.2.6 → molify-0.2.7}/docs/source/conf.py +0 -0
  43. {molify-0.2.6 → molify-0.2.7}/docs/source/index.rst +0 -0
  44. {molify-0.2.6 → molify-0.2.7}/docs/source/modules.rst +0 -0
  45. {molify-0.2.6 → molify-0.2.7}/docs/source/networkx_tools.ipynb +0 -0
  46. {molify-0.2.6 → molify-0.2.7}/docs/source/rdkit_tools.ipynb +0 -0
  47. {molify-0.2.6 → molify-0.2.7}/paper/.gitignore +0 -0
  48. {molify-0.2.6 → molify-0.2.7}/paper/bibliography.bib +0 -0
  49. {molify-0.2.6 → molify-0.2.7}/paper/build.sh +0 -0
  50. {molify-0.2.6 → molify-0.2.7}/paper/zndraw_rdkit.svg +0 -0
  51. {molify-0.2.6 → molify-0.2.7}/src/molify/__main__.py +0 -0
  52. {molify-0.2.6 → molify-0.2.7}/src/molify/bond_order.py +0 -0
  53. {molify-0.2.6 → molify-0.2.7}/src/molify/com.py +0 -0
  54. {molify-0.2.6 → molify-0.2.7}/src/molify/constants.py +0 -0
  55. {molify-0.2.6 → molify-0.2.7}/src/molify/networkx2x.py +0 -0
  56. {molify-0.2.6 → molify-0.2.7}/src/molify/rdkit2x.py +0 -0
  57. {molify-0.2.6 → molify-0.2.7}/tests/data/ec_emc.xyz +0 -0
  58. {molify-0.2.6 → molify-0.2.7}/tests/integration/test_networkx2x.py +0 -0
  59. {molify-0.2.6 → molify-0.2.7}/tests/integration/test_rdkit2x.py +0 -0
  60. {molify-0.2.6 → molify-0.2.7}/tests/test_com.py +0 -0
  61. {molify-0.2.6 → molify-0.2.7}/tests/test_compress.py +0 -0
  62. {molify-0.2.6 → molify-0.2.7}/tests/test_edgecases.py +0 -0
  63. {molify-0.2.6 → molify-0.2.7}/tests/test_examples.py +0 -0
  64. {molify-0.2.6 → molify-0.2.7}/tests/test_rdkit2ase.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.7
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
@@ -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.7'
22
+ __version_tuple__ = version_tuple = (0, 2, 7)
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
@@ -91,7 +91,10 @@ def smiles2atoms(
91
91
 
92
92
  Example: molify smiles2atoms CCO --format XYZ > etoh.xyz
93
93
  """
94
- atoms = _smiles2atoms(smiles, seed=seed)
94
+ try:
95
+ atoms = _smiles2atoms(smiles, seed=seed)
96
+ except ValueError as err:
97
+ raise typer.BadParameter(str(err), param_hint=["smiles"]) from err
95
98
  with io.StringIO() as handle:
96
99
  ase.io.write(handle, atoms, format=_resolve_format(fmt))
97
100
  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
@@ -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
@@ -47,6 +47,11 @@ def smiles2atoms(smiles: str, seed: int = 42) -> ase.Atoms:
47
47
  ase.Atoms
48
48
  The generated Atoms object (first conformer).
49
49
 
50
+ Raises
51
+ ------
52
+ ValueError
53
+ If RDKit fails to parse or embed ``smiles``.
54
+
50
55
  Notes
51
56
  -----
52
57
  This is a convenience wrapper around smiles2conformers that returns
@@ -87,6 +92,12 @@ def smiles2conformers(
87
92
  list[ase.Atoms]
88
93
  List of generated conformers as ASE Atoms objects.
89
94
 
95
+ Raises
96
+ ------
97
+ ValueError
98
+ If RDKit fails to parse ``smiles`` or embeds fewer than ``numConfs``
99
+ conformers.
100
+
90
101
  Notes
91
102
  -----
92
103
  Special handling is included for PF6- (hexafluorophosphate) which
@@ -110,16 +121,23 @@ def smiles2conformers(
110
121
  True
111
122
  """
112
123
  mol = Chem.MolFromSmiles(smiles)
124
+ if mol is None:
125
+ raise ValueError(f"rdkit cannot parse SMILES {smiles!r}")
113
126
  if Chem.MolToSmiles(mol, canonical=True) == "F[P-](F)(F)(F)(F)F":
114
127
  return [get_pf6()] * numConfs
115
128
 
116
129
  mol = Chem.AddHs(mol)
117
- rdDistGeom.EmbedMultipleConfs(
130
+ conformer_ids = rdDistGeom.EmbedMultipleConfs(
118
131
  mol,
119
132
  numConfs=numConfs,
120
133
  randomSeed=randomSeed,
121
134
  maxAttempts=maxAttempts,
122
135
  )
136
+ if len(conformer_ids) < numConfs:
137
+ raise ValueError(
138
+ f"rdkit embedded {len(conformer_ids)} of {numConfs} conformers"
139
+ f" of {smiles!r}"
140
+ )
123
141
 
124
142
  images: list[ase.Atoms] = []
125
143
  charges = [atom.GetFormalCharge() for atom in mol.GetAtoms()]
@@ -8,7 +8,7 @@ from rdkit.Chem import Draw
8
8
 
9
9
  from molify.ase2x import ase2rdkit
10
10
  from molify.constants import GraphAttr
11
- from molify.utils import find_connected_components
11
+ from molify.utils import fragment_indices
12
12
 
13
13
 
14
14
  def match_substructure( # noqa: C901
@@ -289,26 +289,26 @@ def iter_fragments(atoms: ase.Atoms) -> list[ase.Atoms]:
289
289
  ase.Atoms
290
290
  Each connected component (fragment) in the input structure.
291
291
 
292
+ Raises
293
+ ------
294
+ ValueError
295
+ For an invalid bond in ``atoms.info['connectivity']``, see
296
+ :func:`molify.utils.read_connectivity`.
297
+
292
298
  Examples
293
299
  --------
294
- >>> from molify import smiles2atoms
295
- >>> from rdkit.Chem import CombineMols
300
+ >>> from molify import iter_fragments, pack, smiles2conformers
296
301
  >>>
297
- >>> # Create multi-fragment system
298
- >>> ethanol = smiles2atoms("CCO")
299
- >>> methanol = smiles2atoms("CO")
300
- >>> combined = ethanol + methanol
302
+ >>> water = smiles2conformers("O", 1)
303
+ >>> ethanol = smiles2conformers("CCO", 1)
304
+ >>> box = pack([water, ethanol], [2, 1], density=800)
301
305
  >>>
302
- >>> # Iterate over fragments
303
- >>> fragments = list(iter_fragments(combined))
304
- >>> len(fragments)
305
- 2
306
+ >>> [fragment.get_chemical_formula() for fragment in iter_fragments(box)]
307
+ ['H2O', 'H2O', 'C2H6O']
306
308
  """
307
309
  if GraphAttr.CONNECTIVITY in atoms.info:
308
- # connectivity is a list of tuples (i, j, bond_type)
309
- connectivity = atoms.info[GraphAttr.CONNECTIVITY]
310
- for component in find_connected_components(connectivity):
311
- yield atoms[list(component)]
310
+ for indices in fragment_indices(atoms):
311
+ yield atoms[indices]
312
312
  else:
313
313
  for molecule in separate(atoms):
314
314
  yield molecule