molify 0.2.5__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.5 → molify-0.2.7}/.github/workflows/pytest.yaml +2 -2
  2. {molify-0.2.5 → molify-0.2.7}/.pre-commit-config.yaml +2 -2
  3. {molify-0.2.5 → molify-0.2.7}/PKG-INFO +13 -2
  4. {molify-0.2.5 → molify-0.2.7}/README.md +9 -0
  5. {molify-0.2.5 → molify-0.2.7}/docs/source/index.rst +9 -0
  6. {molify-0.2.5 → molify-0.2.7}/docs/source/packmol_tools.ipynb +39 -0
  7. {molify-0.2.5 → molify-0.2.7}/paper/paper.md +1 -4
  8. {molify-0.2.5 → molify-0.2.7}/pyproject.toml +5 -0
  9. {molify-0.2.5 → molify-0.2.7}/src/molify/__init__.py +2 -0
  10. molify-0.2.7/src/molify/__main__.py +6 -0
  11. {molify-0.2.5 → molify-0.2.7}/src/molify/_version.py +2 -2
  12. {molify-0.2.5 → molify-0.2.7}/src/molify/ase2x.py +50 -48
  13. molify-0.2.7/src/molify/cli.py +100 -0
  14. molify-0.2.7/src/molify/compress.py +52 -0
  15. {molify-0.2.5 → molify-0.2.7}/src/molify/pack.py +29 -12
  16. molify-0.2.7/src/molify/repeat.py +117 -0
  17. {molify-0.2.5 → molify-0.2.7}/src/molify/smiles2x.py +19 -1
  18. {molify-0.2.5 → molify-0.2.7}/src/molify/substructure.py +15 -15
  19. {molify-0.2.5 → molify-0.2.7}/src/molify/utils.py +94 -31
  20. {molify-0.2.5 → molify-0.2.7}/tests/conftest.py +14 -0
  21. {molify-0.2.5 → molify-0.2.7}/tests/integration/test_ase2x.py +192 -0
  22. molify-0.2.7/tests/test_cli.py +78 -0
  23. molify-0.2.7/tests/test_connectivity.py +156 -0
  24. molify-0.2.7/tests/test_repeat.py +295 -0
  25. {molify-0.2.5 → molify-0.2.7}/tests/test_smiles2conformers.py +32 -1
  26. {molify-0.2.5 → molify-0.2.7}/tests/test_solvate.py +34 -0
  27. {molify-0.2.5 → molify-0.2.7}/tests/test_substructure.py +20 -0
  28. {molify-0.2.5 → molify-0.2.7}/tests/test_utils.py +6 -13
  29. molify-0.2.7/uv.lock +3631 -0
  30. molify-0.2.5/src/molify/compress.py +0 -74
  31. molify-0.2.5/tests/test_connectivity.py +0 -68
  32. molify-0.2.5/uv.lock +0 -2675
  33. {molify-0.2.5 → molify-0.2.7}/.github/workflows/build-wheels.yaml +0 -0
  34. {molify-0.2.5 → molify-0.2.7}/.github/workflows/pages.yaml +0 -0
  35. {molify-0.2.5 → molify-0.2.7}/.gitignore +0 -0
  36. {molify-0.2.5 → molify-0.2.7}/.python-version +0 -0
  37. {molify-0.2.5 → molify-0.2.7}/CITATION.cff +0 -0
  38. {molify-0.2.5 → molify-0.2.7}/CONTRIBUTING.md +0 -0
  39. {molify-0.2.5 → molify-0.2.7}/LICENSE +0 -0
  40. {molify-0.2.5 → molify-0.2.7}/docs/Makefile +0 -0
  41. {molify-0.2.5 → molify-0.2.7}/docs/make.bat +0 -0
  42. {molify-0.2.5 → molify-0.2.7}/docs/source/ase_tools.ipynb +0 -0
  43. {molify-0.2.5 → molify-0.2.7}/docs/source/atom_selection.ipynb +0 -0
  44. {molify-0.2.5 → molify-0.2.7}/docs/source/conf.py +0 -0
  45. {molify-0.2.5 → molify-0.2.7}/docs/source/modules.rst +0 -0
  46. {molify-0.2.5 → molify-0.2.7}/docs/source/networkx_tools.ipynb +0 -0
  47. {molify-0.2.5 → molify-0.2.7}/docs/source/rdkit_tools.ipynb +0 -0
  48. {molify-0.2.5 → molify-0.2.7}/paper/.gitignore +0 -0
  49. {molify-0.2.5 → molify-0.2.7}/paper/bibliography.bib +0 -0
  50. {molify-0.2.5 → molify-0.2.7}/paper/build.sh +0 -0
  51. {molify-0.2.5 → molify-0.2.7}/paper/zndraw_rdkit.svg +0 -0
  52. {molify-0.2.5 → molify-0.2.7}/src/molify/bond_order.py +0 -0
  53. {molify-0.2.5 → molify-0.2.7}/src/molify/com.py +0 -0
  54. {molify-0.2.5 → molify-0.2.7}/src/molify/constants.py +0 -0
  55. {molify-0.2.5 → molify-0.2.7}/src/molify/networkx2x.py +0 -0
  56. {molify-0.2.5 → molify-0.2.7}/src/molify/rdkit2x.py +0 -0
  57. {molify-0.2.5 → molify-0.2.7}/tests/data/ec_emc.xyz +0 -0
  58. {molify-0.2.5 → molify-0.2.7}/tests/integration/test_networkx2x.py +0 -0
  59. {molify-0.2.5 → molify-0.2.7}/tests/integration/test_rdkit2x.py +0 -0
  60. {molify-0.2.5 → molify-0.2.7}/tests/test_com.py +0 -0
  61. {molify-0.2.5 → molify-0.2.7}/tests/test_compress.py +0 -0
  62. {molify-0.2.5 → molify-0.2.7}/tests/test_edgecases.py +0 -0
  63. {molify-0.2.5 → molify-0.2.7}/tests/test_examples.py +0 -0
  64. {molify-0.2.5 → 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.5
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
@@ -15,6 +15,8 @@ Requires-Dist: matplotlib>=3.10.1
15
15
  Requires-Dist: networkx>=3.4.2
16
16
  Requires-Dist: packmol>=21.1.2
17
17
  Requires-Dist: rdkit>=2024
18
+ Requires-Dist: typer>=0.27.1
19
+ Requires-Dist: typing-extensions>=4.15.0
18
20
  Provides-Extra: vesin
19
21
  Requires-Dist: vesin>=0.3.7; extra == 'vesin'
20
22
  Description-Content-Type: text/markdown
@@ -58,6 +60,15 @@ print(atoms)
58
60
  >>> Atoms(symbols='OH2', pbc=False)
59
61
  ```
60
62
 
63
+ ## Command Line Interface
64
+
65
+ Commands mirror the molify functions and write to standard output. `--format`
66
+ takes any ASE format name or extension, defaulting to extxyz.
67
+
68
+ ```bash
69
+ molify smiles2atoms CCO > etoh.xyz
70
+ ```
71
+
61
72
  ## Packmol Interface
62
73
 
63
74
  Given the molecular units, you can build periodic boxes with a given density
@@ -37,6 +37,15 @@ print(atoms)
37
37
  >>> Atoms(symbols='OH2', pbc=False)
38
38
  ```
39
39
 
40
+ ## Command Line Interface
41
+
42
+ Commands mirror the molify functions and write to standard output. `--format`
43
+ takes any ASE format name or extension, defaulting to extxyz.
44
+
45
+ ```bash
46
+ molify smiles2atoms CCO > etoh.xyz
47
+ ```
48
+
40
49
  ## Packmol Interface
41
50
 
42
51
  Given the molecular units, you can build periodic boxes with a given density
@@ -100,6 +100,15 @@ The networkx.Graph representation can be obtained from either RDKit or ASE objec
100
100
  nh3_graph = molify.ase2networkx(nh3_atoms)
101
101
 
102
102
 
103
+ **Command Line Interface**
104
+
105
+ Commands mirror the molify functions and write to standard output. ``--format``
106
+ takes any ASE format name or extension, defaulting to extxyz.
107
+
108
+ .. code-block:: console
109
+
110
+ $ molify smiles2atoms CCO > etoh.xyz
111
+
103
112
  Advanced Features
104
113
  -----------------
105
114
 
@@ -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
@@ -14,6 +14,8 @@ dependencies = [
14
14
  "networkx>=3.4.2",
15
15
  "packmol>=21.1.2",
16
16
  "rdkit>=2024",
17
+ "typer>=0.27.1",
18
+ "typing-extensions>=4.15.0",
17
19
  ]
18
20
 
19
21
  [dependency-groups]
@@ -32,6 +34,9 @@ docs = [
32
34
  "sphinxcontrib-mermaid>=1.0.0",
33
35
  ]
34
36
 
37
+ [project.scripts]
38
+ molify = "molify.cli:app"
39
+
35
40
  [project.optional-dependencies]
36
41
 
37
42
  vesin = [
@@ -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",
@@ -0,0 +1,6 @@
1
+ """Entry point for ``python -m molify``."""
2
+
3
+ from molify.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -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.5'
22
- __version_tuple__ = version_tuple = (0, 2, 5)
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
@@ -0,0 +1,100 @@
1
+ """Command line interface for molify."""
2
+
3
+ import io
4
+ import sys
5
+ from typing import Annotated
6
+
7
+ import ase.io
8
+ import typer
9
+ from ase.io.formats import extension2format
10
+
11
+ from molify import __version__
12
+ from molify import smiles2atoms as _smiles2atoms
13
+
14
+ app = typer.Typer(
15
+ no_args_is_help=True,
16
+ add_completion=False,
17
+ pretty_exceptions_show_locals=False,
18
+ )
19
+
20
+
21
+ def _resolve_format(value: str) -> str:
22
+ """Map a format name or file extension onto an ASE format name.
23
+
24
+ Extensions resolve first, so ``XYZ`` selects ASE's ``extxyz`` writer, which
25
+ keeps the cell and the ``atoms.info`` payload such as the SMILES string and
26
+ the connectivity.
27
+
28
+ Parameters
29
+ ----------
30
+ value : str
31
+ Format name or file extension, in any capitalisation.
32
+
33
+ Returns
34
+ -------
35
+ str
36
+ The format name passed on to :func:`ase.io.write`.
37
+ """
38
+ key = value.strip().lower()
39
+ fmt = extension2format.get(key)
40
+ return fmt.name if fmt is not None else key
41
+
42
+
43
+ def _version(value: bool) -> None:
44
+ """Print the molify version and exit."""
45
+ if value:
46
+ typer.echo(__version__)
47
+ raise typer.Exit
48
+
49
+
50
+ @app.callback()
51
+ def main(
52
+ version: Annotated[
53
+ bool,
54
+ typer.Option(
55
+ "--version",
56
+ callback=_version,
57
+ is_eager=True,
58
+ help="Show the molify version and exit.",
59
+ ),
60
+ ] = False,
61
+ ) -> None:
62
+ """Build molecular structures from the command line.
63
+
64
+ Each command mirrors the molify function of the same name and writes the
65
+ structure to standard output.
66
+ """
67
+
68
+
69
+ @app.command()
70
+ def smiles2atoms(
71
+ smiles: Annotated[
72
+ str, typer.Argument(help="SMILES string of the molecule, such as 'CCO'.")
73
+ ],
74
+ fmt: Annotated[
75
+ str,
76
+ typer.Option(
77
+ "--format",
78
+ "-f",
79
+ help=(
80
+ "Output format, given as an ASE format name or file extension."
81
+ " 'XYZ' selects extxyz, which keeps the SMILES string and the"
82
+ " connectivity."
83
+ ),
84
+ ),
85
+ ] = "extxyz",
86
+ seed: Annotated[
87
+ int, typer.Option(help="Random seed for conformer generation.")
88
+ ] = 42,
89
+ ) -> None:
90
+ """Convert a SMILES string into a single 3D structure on standard output.
91
+
92
+ Example: molify smiles2atoms CCO --format XYZ > etoh.xyz
93
+ """
94
+ try:
95
+ atoms = _smiles2atoms(smiles, seed=seed)
96
+ except ValueError as err:
97
+ raise typer.BadParameter(str(err), param_hint=["smiles"]) from err
98
+ with io.StringIO() as handle:
99
+ ase.io.write(handle, atoms, format=_resolve_format(fmt))
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