molify 0.2.7__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 (61) hide show
  1. {molify-0.2.7 → molify-0.2.8}/PKG-INFO +1 -1
  2. {molify-0.2.7 → molify-0.2.8}/docs/source/index.rst +0 -1
  3. {molify-0.2.7 → molify-0.2.8}/src/molify/_version.py +2 -2
  4. {molify-0.2.7 → molify-0.2.8}/src/molify/cli.py +11 -1
  5. {molify-0.2.7 → molify-0.2.8}/src/molify/rdkit2x.py +16 -3
  6. {molify-0.2.7 → molify-0.2.8}/src/molify/smiles2x.py +17 -14
  7. {molify-0.2.7 → molify-0.2.8}/src/molify/utils.py +49 -0
  8. {molify-0.2.7 → molify-0.2.8}/tests/test_cli.py +7 -0
  9. {molify-0.2.7 → molify-0.2.8}/tests/test_rdkit2ase.py +14 -0
  10. {molify-0.2.7 → molify-0.2.8}/tests/test_smiles2conformers.py +29 -5
  11. {molify-0.2.7 → molify-0.2.8}/.github/workflows/build-wheels.yaml +0 -0
  12. {molify-0.2.7 → molify-0.2.8}/.github/workflows/pages.yaml +0 -0
  13. {molify-0.2.7 → molify-0.2.8}/.github/workflows/pytest.yaml +0 -0
  14. {molify-0.2.7 → molify-0.2.8}/.gitignore +0 -0
  15. {molify-0.2.7 → molify-0.2.8}/.pre-commit-config.yaml +0 -0
  16. {molify-0.2.7 → molify-0.2.8}/.python-version +0 -0
  17. {molify-0.2.7 → molify-0.2.8}/CITATION.cff +0 -0
  18. {molify-0.2.7 → molify-0.2.8}/CONTRIBUTING.md +0 -0
  19. {molify-0.2.7 → molify-0.2.8}/LICENSE +0 -0
  20. {molify-0.2.7 → molify-0.2.8}/README.md +0 -0
  21. {molify-0.2.7 → molify-0.2.8}/docs/Makefile +0 -0
  22. {molify-0.2.7 → molify-0.2.8}/docs/make.bat +0 -0
  23. {molify-0.2.7 → molify-0.2.8}/docs/source/ase_tools.ipynb +0 -0
  24. {molify-0.2.7 → molify-0.2.8}/docs/source/atom_selection.ipynb +0 -0
  25. {molify-0.2.7 → molify-0.2.8}/docs/source/conf.py +0 -0
  26. {molify-0.2.7 → molify-0.2.8}/docs/source/modules.rst +0 -0
  27. {molify-0.2.7 → molify-0.2.8}/docs/source/networkx_tools.ipynb +0 -0
  28. {molify-0.2.7 → molify-0.2.8}/docs/source/packmol_tools.ipynb +0 -0
  29. {molify-0.2.7 → molify-0.2.8}/docs/source/rdkit_tools.ipynb +0 -0
  30. {molify-0.2.7 → molify-0.2.8}/paper/.gitignore +0 -0
  31. {molify-0.2.7 → molify-0.2.8}/paper/bibliography.bib +0 -0
  32. {molify-0.2.7 → molify-0.2.8}/paper/build.sh +0 -0
  33. {molify-0.2.7 → molify-0.2.8}/paper/paper.md +0 -0
  34. {molify-0.2.7 → molify-0.2.8}/paper/zndraw_rdkit.svg +0 -0
  35. {molify-0.2.7 → molify-0.2.8}/pyproject.toml +0 -0
  36. {molify-0.2.7 → molify-0.2.8}/src/molify/__init__.py +0 -0
  37. {molify-0.2.7 → molify-0.2.8}/src/molify/__main__.py +0 -0
  38. {molify-0.2.7 → molify-0.2.8}/src/molify/ase2x.py +0 -0
  39. {molify-0.2.7 → molify-0.2.8}/src/molify/bond_order.py +0 -0
  40. {molify-0.2.7 → molify-0.2.8}/src/molify/com.py +0 -0
  41. {molify-0.2.7 → molify-0.2.8}/src/molify/compress.py +0 -0
  42. {molify-0.2.7 → molify-0.2.8}/src/molify/constants.py +0 -0
  43. {molify-0.2.7 → molify-0.2.8}/src/molify/networkx2x.py +0 -0
  44. {molify-0.2.7 → molify-0.2.8}/src/molify/pack.py +0 -0
  45. {molify-0.2.7 → molify-0.2.8}/src/molify/repeat.py +0 -0
  46. {molify-0.2.7 → molify-0.2.8}/src/molify/substructure.py +0 -0
  47. {molify-0.2.7 → molify-0.2.8}/tests/conftest.py +0 -0
  48. {molify-0.2.7 → molify-0.2.8}/tests/data/ec_emc.xyz +0 -0
  49. {molify-0.2.7 → molify-0.2.8}/tests/integration/test_ase2x.py +0 -0
  50. {molify-0.2.7 → molify-0.2.8}/tests/integration/test_networkx2x.py +0 -0
  51. {molify-0.2.7 → molify-0.2.8}/tests/integration/test_rdkit2x.py +0 -0
  52. {molify-0.2.7 → molify-0.2.8}/tests/test_com.py +0 -0
  53. {molify-0.2.7 → molify-0.2.8}/tests/test_compress.py +0 -0
  54. {molify-0.2.7 → molify-0.2.8}/tests/test_connectivity.py +0 -0
  55. {molify-0.2.7 → molify-0.2.8}/tests/test_edgecases.py +0 -0
  56. {molify-0.2.7 → molify-0.2.8}/tests/test_examples.py +0 -0
  57. {molify-0.2.7 → molify-0.2.8}/tests/test_repeat.py +0 -0
  58. {molify-0.2.7 → molify-0.2.8}/tests/test_solvate.py +0 -0
  59. {molify-0.2.7 → molify-0.2.8}/tests/test_substructure.py +0 -0
  60. {molify-0.2.7 → molify-0.2.8}/tests/test_utils.py +0 -0
  61. {molify-0.2.7 → molify-0.2.8}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: molify
3
- Version: 0.2.7
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
@@ -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.
@@ -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.7'
22
- __version_tuple__ = version_tuple = (0, 2, 7)
21
+ __version__ = version = '0.2.8'
22
+ __version_tuple__ = version_tuple = (0, 2, 8)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -86,13 +86,23 @@ 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
104
  try:
95
- atoms = _smiles2atoms(smiles, seed=seed)
105
+ atoms = _smiles2atoms(smiles, seed=seed, useRandomCoords=random_coords)
96
106
  except ValueError as err:
97
107
  raise typer.BadParameter(str(err), param_hint=["smiles"]) from err
98
108
  with io.StringIO() as handle:
@@ -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(),
@@ -1,9 +1,9 @@
1
1
  import ase
2
2
  import numpy as np
3
3
  from rdkit import Chem
4
- from rdkit.Chem import rdDistGeom
5
4
 
6
5
  from molify.constants import GraphAttr
6
+ from molify.utils import embed_conformers
7
7
 
8
8
 
9
9
  def get_pf6() -> ase.Atoms:
@@ -32,7 +32,11 @@ def get_pf6() -> ase.Atoms:
32
32
  return atoms
33
33
 
34
34
 
35
- def smiles2atoms(smiles: str, seed: int = 42) -> ase.Atoms:
35
+ def smiles2atoms(
36
+ smiles: str,
37
+ seed: int = 42,
38
+ useRandomCoords: bool = False, # noqa N803
39
+ ) -> ase.Atoms:
36
40
  """Convert a SMILES string to an ASE Atoms object.
37
41
 
38
42
  Parameters
@@ -41,6 +45,10 @@ def smiles2atoms(smiles: str, seed: int = 42) -> ase.Atoms:
41
45
  The SMILES string to convert.
42
46
  seed : int, optional
43
47
  Random seed for conformer generation (default is 42).
48
+ useRandomCoords : bool, optional
49
+ Start the embedding from random coordinates instead of the eigenvectors
50
+ of the distance matrix (default is False). Long chains, such as
51
+ n-alkanes from about C56 on, often embed only this way.
44
52
 
45
53
  Returns
46
54
  -------
@@ -65,7 +73,7 @@ def smiles2atoms(smiles: str, seed: int = 42) -> ase.Atoms:
65
73
  >>> isinstance(atoms, ase.Atoms)
66
74
  True
67
75
  """
68
- return smiles2conformers(smiles, 1, seed)[0]
76
+ return smiles2conformers(smiles, 1, seed, useRandomCoords=useRandomCoords)[0]
69
77
 
70
78
 
71
79
  def smiles2conformers(
@@ -73,6 +81,7 @@ def smiles2conformers(
73
81
  numConfs: int, # noqa N803
74
82
  randomSeed: int = 42, # noqa N803
75
83
  maxAttempts: int = 1000, # noqa N803
84
+ useRandomCoords: bool = False, # noqa N803
76
85
  ) -> list[ase.Atoms]:
77
86
  """Generate multiple molecular conformers from a SMILES string.
78
87
 
@@ -86,6 +95,10 @@ def smiles2conformers(
86
95
  Random seed for conformer generation (default is 42).
87
96
  maxAttempts : int, optional
88
97
  Maximum number of embedding attempts (default is 1000).
98
+ useRandomCoords : bool, optional
99
+ Start each embedding from random coordinates instead of the
100
+ eigenvectors of the distance matrix (default is False). Long chains,
101
+ such as n-alkanes from about C56 on, often embed only this way.
89
102
 
90
103
  Returns
91
104
  -------
@@ -127,17 +140,7 @@ def smiles2conformers(
127
140
  return [get_pf6()] * numConfs
128
141
 
129
142
  mol = Chem.AddHs(mol)
130
- conformer_ids = rdDistGeom.EmbedMultipleConfs(
131
- mol,
132
- numConfs=numConfs,
133
- randomSeed=randomSeed,
134
- maxAttempts=maxAttempts,
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
- )
143
+ embed_conformers(mol, smiles, numConfs, randomSeed, maxAttempts, useRandomCoords)
141
144
 
142
145
  images: list[ase.Atoms] = []
143
146
  charges = [atom.GetFormalCharge() for atom in mol.GetAtoms()]
@@ -14,11 +14,60 @@ from ase.data.colors import jmol_colors
14
14
  from matplotlib.axes import Axes
15
15
  from matplotlib.figure import Figure
16
16
  from rdkit import Chem
17
+ from rdkit.Chem import rdDistGeom
17
18
  from typing_extensions import deprecated
18
19
 
19
20
  from molify.constants import GraphAttr
20
21
 
21
22
 
23
+ def embed_conformers(
24
+ mol: Chem.Mol,
25
+ smiles: str,
26
+ numConfs: int, # noqa N803
27
+ randomSeed: int, # noqa N803
28
+ maxAttempts: int, # noqa N803
29
+ useRandomCoords: bool, # noqa N803
30
+ ) -> None:
31
+ """Embed conformers into ``mol`` in place.
32
+
33
+ Parameters
34
+ ----------
35
+ mol : rdkit.Chem.Mol
36
+ Molecule with explicit hydrogens.
37
+ smiles : str
38
+ SMILES string named in the error message.
39
+ numConfs : int
40
+ Number of conformers to embed.
41
+ randomSeed : int
42
+ Random seed for the embedding.
43
+ maxAttempts : int
44
+ Maximum number of embedding attempts per conformer, 0 for RDKit's
45
+ default.
46
+ useRandomCoords : bool
47
+ Start each embedding from random coordinates instead of the
48
+ eigenvectors of the distance matrix.
49
+
50
+ Raises
51
+ ------
52
+ ValueError
53
+ If RDKit embeds fewer than ``numConfs`` conformers.
54
+ """
55
+ embedded = len(
56
+ rdDistGeom.EmbedMultipleConfs(
57
+ mol,
58
+ numConfs=numConfs,
59
+ randomSeed=randomSeed,
60
+ maxAttempts=maxAttempts,
61
+ useRandomCoords=useRandomCoords,
62
+ )
63
+ )
64
+ if embedded < numConfs:
65
+ message = f"rdkit embedded {embedded} of {numConfs} conformers of {smiles!r}"
66
+ if not useRandomCoords:
67
+ message += "; random starting coordinates often embed large molecules"
68
+ raise ValueError(message)
69
+
70
+
22
71
  def _is_atom_index(value) -> bool:
23
72
  return (
24
73
  isinstance(value, numbers.Real)
@@ -76,3 +76,10 @@ def test_smiles2atoms_reports_unparseable_smiles():
76
76
  assert (
77
77
  "Invalid value for 'smiles': rdkit cannot parse SMILES 'C1CC'" in result.output
78
78
  )
79
+
80
+
81
+ def test_smiles2atoms_random_coords_embeds_long_chains():
82
+ result = runner.invoke(app, ["smiles2atoms", "C" * 56, "--random-coords"])
83
+ assert result.exit_code == 0, result.output
84
+ atoms = ase.io.read(io.StringIO(result.output), format="extxyz")
85
+ assert atoms.get_chemical_formula() == "C56H114"
@@ -1,3 +1,5 @@
1
+ import re
2
+
1
3
  import ase
2
4
  import numpy as np
3
5
  import numpy.testing as npt
@@ -203,3 +205,15 @@ class TestRdkit2NetworkxOriginalIndex:
203
205
 
204
206
  for node_id, attributes in graph.nodes(data=True):
205
207
  assert attributes[NodeAttr.ORIGINAL_INDEX] == node_id
208
+
209
+
210
+ def test_rdkit2ase_embeds_long_chains_from_random_coords():
211
+ atoms = rdkit2ase(Chem.MolFromSmiles("C" * 56), seed=42, useRandomCoords=True)
212
+ assert atoms.get_chemical_formula() == "C56H114"
213
+
214
+
215
+ def test_rdkit2ase_rejects_unembeddable_mol():
216
+ mol = Chem.MolFromSmiles("[Fe](C)(C)(C)(C)(C)(C)(C)(C)")
217
+ message = f"rdkit embedded 0 of 1 conformers of {Chem.MolToSmiles(mol)!r}"
218
+ with pytest.raises(ValueError, match=re.escape(message)):
219
+ rdkit2ase(mol)
@@ -81,22 +81,46 @@ def test_smiles2conformers_rejects_unparseable_smiles(smiles):
81
81
  smiles2conformers(smiles, numConfs=3)
82
82
 
83
83
 
84
+ RANDOM_COORDS_HINT = "; random starting coordinates often embed large molecules"
85
+
86
+
87
+ @pytest.mark.parametrize(
88
+ ("use_random_coords", "hint"),
89
+ [(False, RANDOM_COORDS_HINT), (True, "")],
90
+ ids=["eigenvector-coords", "random-coords"],
91
+ )
84
92
  @pytest.mark.parametrize(
85
93
  ("convert", "num_confs"),
86
94
  [(partial(smiles2conformers, numConfs=3), 3), (smiles2atoms, 1)],
87
95
  ids=["smiles2conformers", "smiles2atoms"],
88
96
  )
89
- def test_rejects_unembeddable_smiles(convert, num_confs):
97
+ def test_rejects_unembeddable_smiles(convert, num_confs, use_random_coords, hint):
90
98
  smiles = "[Fe](C)(C)(C)(C)(C)(C)(C)(C)"
91
- message = f"rdkit embedded 0 of {num_confs} conformers of {smiles!r}"
92
- with pytest.raises(ValueError, match=re.escape(message)):
93
- convert(smiles)
99
+ message = f"rdkit embedded 0 of {num_confs} conformers of {smiles!r}{hint}"
100
+ with pytest.raises(ValueError, match=f"^{re.escape(message)}$"):
101
+ convert(smiles, useRandomCoords=use_random_coords)
94
102
 
95
103
 
96
104
  def test_smiles2conformers_rejects_partial_embedding():
97
105
  smiles = "C1CC2CCC1C2"
98
106
  assert len(smiles2conformers(smiles, numConfs=10)) == 10
99
107
  with pytest.raises(
100
- ValueError, match=r"^rdkit embedded [1-9] of 10 conformers of 'C1CC2CCC1C2'$"
108
+ ValueError,
109
+ match=(
110
+ r"^rdkit embedded [1-9] of 10 conformers of 'C1CC2CCC1C2'"
111
+ f"{re.escape(RANDOM_COORDS_HINT)}$"
112
+ ),
101
113
  ):
102
114
  smiles2conformers(smiles, numConfs=10, maxAttempts=1)
115
+
116
+
117
+ @pytest.mark.parametrize("n_carbons", [56, 64])
118
+ def test_smiles2atoms_embeds_long_chains_from_random_coords(n_carbons):
119
+ atoms = smiles2atoms("C" * n_carbons, seed=42, useRandomCoords=True)
120
+ assert atoms.get_chemical_formula() == f"C{n_carbons}H{2 * n_carbons + 2}"
121
+
122
+
123
+ def test_smiles2conformers_embeds_long_chains_from_random_coords():
124
+ frames = smiles2conformers("C" * 56, numConfs=2, useRandomCoords=True)
125
+ assert [atoms.get_chemical_formula() for atoms in frames] == ["C56H114"] * 2
126
+ assert not np.allclose(frames[0].positions, frames[1].positions)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes