chython 2.19__cp313-cp313-win_amd64.whl

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 (175) hide show
  1. chython/__init__.py +35 -0
  2. chython/_functions.py +95 -0
  3. chython/_java.py +57 -0
  4. chython/algorithms/__init__.py +18 -0
  5. chython/algorithms/_isomorphism.cp313-win_amd64.pyd +0 -0
  6. chython/algorithms/_isomorphism.pyx +189 -0
  7. chython/algorithms/_rings.cp313-win_amd64.pyd +0 -0
  8. chython/algorithms/_rings.pyx +478 -0
  9. chython/algorithms/aromatics/__init__.py +27 -0
  10. chython/algorithms/aromatics/_rules.py +124 -0
  11. chython/algorithms/aromatics/kekule.py +532 -0
  12. chython/algorithms/aromatics/test/__init__.py +18 -0
  13. chython/algorithms/aromatics/test/test_kekule.py +160 -0
  14. chython/algorithms/aromatics/test/test_thiele.py +263 -0
  15. chython/algorithms/aromatics/thiele.py +222 -0
  16. chython/algorithms/calculate2d/__init__.py +23 -0
  17. chython/algorithms/calculate2d/clean2d.js +1 -0
  18. chython/algorithms/calculate2d/molecule.py +191 -0
  19. chython/algorithms/calculate2d/reaction.py +75 -0
  20. chython/algorithms/conformers.py +106 -0
  21. chython/algorithms/depict.py +596 -0
  22. chython/algorithms/fingerprints/__init__.py +43 -0
  23. chython/algorithms/fingerprints/linear.py +202 -0
  24. chython/algorithms/fingerprints/morgan.py +132 -0
  25. chython/algorithms/fingerprints/test/__init__.py +18 -0
  26. chython/algorithms/fingerprints/test/test_linear.py +127 -0
  27. chython/algorithms/fingerprints/test/test_morgan.py +79 -0
  28. chython/algorithms/groups/__init__.py +212 -0
  29. chython/algorithms/groups/_functional.py +290 -0
  30. chython/algorithms/groups/_oxidations.py +70 -0
  31. chython/algorithms/groups/_protective.py +964 -0
  32. chython/algorithms/groups/_reactions.py +809 -0
  33. chython/algorithms/groups/_reductions.py +88 -0
  34. chython/algorithms/groups/_transformations.py +278 -0
  35. chython/algorithms/groups/test/__init__.py +18 -0
  36. chython/algorithms/groups/test/test_deprotection.py +108 -0
  37. chython/algorithms/groups/test/test_reactions.py +377 -0
  38. chython/algorithms/isomorphism.py +664 -0
  39. chython/algorithms/mapping/__init__.py +188 -0
  40. chython/algorithms/mapping/_groups.py +94 -0
  41. chython/algorithms/mapping/_reactions.py +72 -0
  42. chython/algorithms/mapping/attention.py +266 -0
  43. chython/algorithms/mapping/reconstruct.py +346 -0
  44. chython/algorithms/mcs.py +201 -0
  45. chython/algorithms/morgan.py +77 -0
  46. chython/algorithms/rings.py +178 -0
  47. chython/algorithms/smiles.py +597 -0
  48. chython/algorithms/standardize/__init__.py +30 -0
  49. chython/algorithms/standardize/_groups.py +958 -0
  50. chython/algorithms/standardize/_isomers.py +330 -0
  51. chython/algorithms/standardize/_metal_organics.py +189 -0
  52. chython/algorithms/standardize/_reagents.py +44 -0
  53. chython/algorithms/standardize/_salts.py +68 -0
  54. chython/algorithms/standardize/molecule.py +475 -0
  55. chython/algorithms/standardize/reaction.py +384 -0
  56. chython/algorithms/standardize/resonance.py +191 -0
  57. chython/algorithms/standardize/salts.py +128 -0
  58. chython/algorithms/standardize/saturation.py +373 -0
  59. chython/algorithms/standardize/test/__init__.py +18 -0
  60. chython/algorithms/standardize/test/test_canonicalize.py +66 -0
  61. chython/algorithms/standardize/test/test_groups.py +146 -0
  62. chython/algorithms/standardize/test/test_isomers.py +198 -0
  63. chython/algorithms/stereo.py +1215 -0
  64. chython/algorithms/tautomers/__init__.py +167 -0
  65. chython/algorithms/tautomers/_acid.py +59 -0
  66. chython/algorithms/tautomers/_base.py +116 -0
  67. chython/algorithms/tautomers/_keto_enol.py +107 -0
  68. chython/algorithms/tautomers/acid_base.py +208 -0
  69. chython/algorithms/tautomers/heteroarenes.py +103 -0
  70. chython/algorithms/tautomers/keto_enol.py +143 -0
  71. chython/algorithms/tautomers/test/__init__.py +18 -0
  72. chython/algorithms/tautomers/test/test_tautomers.py +80 -0
  73. chython/algorithms/test/__init__.py +18 -0
  74. chython/algorithms/test/test_isomorphism.py +58 -0
  75. chython/algorithms/test/test_smiles.py +76 -0
  76. chython/algorithms/x3dom.py +350 -0
  77. chython/containers/__init__.py +48 -0
  78. chython/containers/_pack_v2.cp313-win_amd64.pyd +0 -0
  79. chython/containers/_pack_v2.pyx +271 -0
  80. chython/containers/_unpack_v0v2.cp313-win_amd64.pyd +0 -0
  81. chython/containers/_unpack_v0v2.pyx +302 -0
  82. chython/containers/bonds.py +255 -0
  83. chython/containers/cgr.py +115 -0
  84. chython/containers/chimera.py +63 -0
  85. chython/containers/graph.py +199 -0
  86. chython/containers/molecule.py +817 -0
  87. chython/containers/query.py +71 -0
  88. chython/containers/rdkit.py +183 -0
  89. chython/containers/reaction.py +324 -0
  90. chython/containers/test/__init__.py +18 -0
  91. chython/containers/test/test_molecule.py +117 -0
  92. chython/containers/test/test_pack.py +60 -0
  93. chython/containers/test/test_rdkit.py +70 -0
  94. chython/containers/test/test_reaction.py +40 -0
  95. chython/exceptions.py +114 -0
  96. chython/files/MRVrw.py +532 -0
  97. chython/files/PDBrw.py +255 -0
  98. chython/files/RDFrw.py +309 -0
  99. chython/files/SDFrw.py +221 -0
  100. chython/files/__init__.py +30 -0
  101. chython/files/_convert.py +207 -0
  102. chython/files/_mapping.py +117 -0
  103. chython/files/_xyz.cp313-win_amd64.pyd +0 -0
  104. chython/files/_xyz.pyx +74 -0
  105. chython/files/daylight/__init__.py +24 -0
  106. chython/files/daylight/parser.py +157 -0
  107. chython/files/daylight/smarts.py +107 -0
  108. chython/files/daylight/smiles.py +260 -0
  109. chython/files/daylight/test/__init__.py +18 -0
  110. chython/files/daylight/test/test_daylight_smarts.py +289 -0
  111. chython/files/daylight/test/test_daylight_smiles.py +101 -0
  112. chython/files/daylight/test/test_parser.py +118 -0
  113. chython/files/daylight/test/test_tokenize.py +77 -0
  114. chython/files/daylight/tokenize.py +376 -0
  115. chython/files/libinchi/__init__.py +22 -0
  116. chython/files/libinchi/libinchi.dll +0 -0
  117. chython/files/libinchi/wrapper.py +554 -0
  118. chython/files/mdl/__init__.py +25 -0
  119. chython/files/mdl/emol.py +251 -0
  120. chython/files/mdl/erxn.py +67 -0
  121. chython/files/mdl/mol.py +186 -0
  122. chython/files/mdl/read.py +223 -0
  123. chython/files/mdl/rxn.py +67 -0
  124. chython/files/mdl/stereo.py +63 -0
  125. chython/files/mdl/write.py +201 -0
  126. chython/files/opsin.py +35 -0
  127. chython/files/test/__init__.py +0 -0
  128. chython/files/test/test_inchi.py +27 -0
  129. chython/files/test/test_mrv.py +47 -0
  130. chython/files/test/test_sdf_rdf.py +64 -0
  131. chython/files/test/test_xyz.py +20 -0
  132. chython/files/xyz.py +78 -0
  133. chython/periodictable/__init__.py +69 -0
  134. chython/periodictable/base/__init__.py +25 -0
  135. chython/periodictable/base/dynamic.py +149 -0
  136. chython/periodictable/base/element.py +451 -0
  137. chython/periodictable/base/groups.py +90 -0
  138. chython/periodictable/base/periods.py +46 -0
  139. chython/periodictable/base/query.py +473 -0
  140. chython/periodictable/base/vector.py +119 -0
  141. chython/periodictable/groupI.py +252 -0
  142. chython/periodictable/groupII.py +223 -0
  143. chython/periodictable/groupIII.py +1154 -0
  144. chython/periodictable/groupIV.py +210 -0
  145. chython/periodictable/groupIX.py +196 -0
  146. chython/periodictable/groupV.py +192 -0
  147. chython/periodictable/groupVI.py +183 -0
  148. chython/periodictable/groupVII.py +162 -0
  149. chython/periodictable/groupVIII.py +161 -0
  150. chython/periodictable/groupX.py +166 -0
  151. chython/periodictable/groupXI.py +164 -0
  152. chython/periodictable/groupXII.py +158 -0
  153. chython/periodictable/groupXIII.py +239 -0
  154. chython/periodictable/groupXIV.py +262 -0
  155. chython/periodictable/groupXV.py +276 -0
  156. chython/periodictable/groupXVI.py +457 -0
  157. chython/periodictable/groupXVII.py +324 -0
  158. chython/periodictable/groupXVIII.py +260 -0
  159. chython/periodictable/test/__init__.py +0 -0
  160. chython/periodictable/test/test_element.py +100 -0
  161. chython/reactor/__init__.py +24 -0
  162. chython/reactor/base.py +222 -0
  163. chython/reactor/reactor.py +177 -0
  164. chython/reactor/test/__init__.py +18 -0
  165. chython/reactor/test/test_reactor.py +40 -0
  166. chython/reactor/test/test_transformer.py +78 -0
  167. chython/reactor/transformer.py +72 -0
  168. chython/utils/__init__.py +50 -0
  169. chython/utils/free_wilson.py +114 -0
  170. chython/utils/grid.py +130 -0
  171. chython/utils/retro.py +139 -0
  172. chython-2.19.dist-info/METADATA +105 -0
  173. chython-2.19.dist-info/RECORD +175 -0
  174. chython-2.19.dist-info/WHEEL +4 -0
  175. chython-2.19.dist-info/licenses/LICENSE +165 -0
@@ -0,0 +1,373 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright 2021-2026 Ramil Nugmanov <nougmanoff@protonmail.com>
4
+ # This file is part of chython.
5
+ #
6
+ # chython is free software; you can redistribute it and/or modify
7
+ # it under the terms of the GNU Lesser General Public License as published by
8
+ # the Free Software Foundation; either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU Lesser General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU Lesser General Public License
17
+ # along with this program; if not, see <https://www.gnu.org/licenses/>.
18
+ #
19
+ from collections import defaultdict
20
+ from itertools import product
21
+ from operator import itemgetter
22
+ from random import shuffle
23
+ from ...containers.bonds import Bond
24
+ from ...exceptions import ValenceError
25
+
26
+ # atom, charge, unsaturation
27
+ tuned_priority = {(7, 0, 0): -3, # amine
28
+ (7, 0, 1): -3, # X=N-X
29
+ (7, 0, 2): -3, # X#N
30
+ (7, 1, 1): -2, # X=[N+](-X)-X
31
+ (7, 1, 2): -2, # X=[N+]=X
32
+ (7, -1, 0): -1, # X-[N-]-X
33
+ (7, -1, 1): -1, # X=[N-]
34
+ (8, 0, 0): -2, # X-O-X
35
+ (8, 0, 1): -2, # X=O
36
+ (8, -1, 0): -1, # X-[O-]
37
+ (8, 1, 1): -1, # X=[O+]-X
38
+ (16, 0, 0): -3, # X-S-X
39
+ (16, 0, 2): -2, # X-S(-X)(=X)=X
40
+ (16, 0, 1): -2, # X-S(-X)(=X)
41
+ (16, 0, 2): -2, # X=S=X
42
+ (16, 0, 0): -1, # X-S(-X)(-X)-X
43
+ (16, 0, 1): -1, # X-[S+](-X)(-X)
44
+ (16, 1, 1): -1, # X=[S+]-X
45
+ }
46
+ charge_priority = {0: 0, -1: 1, 1: 2, 2: 3, 3: 4, -2: 5, 4: 6, -3: 7, -4: 8}
47
+
48
+
49
+ class Saturation:
50
+ __slots__ = ()
51
+
52
+ def saturate(self, neighbors_distances: dict[int, dict[int, float]] | None = None,
53
+ reset_electrons: bool = True, expected_charge: int = 0, expected_radicals_count: int = 0,
54
+ allow_errors: bool = True, logging: bool = False) -> bool | list:
55
+ """
56
+ Saturate molecules with double and triple bonds and charges and radical states to correct valences of atoms.
57
+ Note: works only with fully explicit hydrogens!
58
+
59
+ :param neighbors_distances: If given longest bonds can be removed if need.
60
+ :param reset_electrons: Can change charges and radicals if need.
61
+ :param expected_charge: Reset charge to given. Works only with reset_electrons=True.
62
+ :param expected_radicals_count: Reset radical atoms count to given. Works only with reset_electrons=True.
63
+ :param allow_errors: allow unbalanced result.
64
+ :param logging: return log.
65
+ """
66
+ if any(b != 1 for _, _, b in self.bonds()):
67
+ raise ValenceError('only single bonded skeleton can be saturated')
68
+ atoms = self._atoms
69
+ if not reset_electrons:
70
+ expected_radicals_count = sum(a.is_radical for a in atoms.values())
71
+ expected_charge = int(self)
72
+
73
+ if reset_electrons:
74
+ charges = {x: None for x in self}
75
+ radicals = {x: None for x in self}
76
+ else:
77
+ charges = {n: a.charge for n, a in self.atoms()}
78
+ radicals = {n: a.is_radical for n, a in self.atoms()}
79
+ sat, adjacency = _find_possible_valences(atoms, neighbors_distances or self._bonds,
80
+ charges, radicals, neighbors_distances is not None)
81
+ charges = {} # new charge states
82
+ radicals = {} # new radical states
83
+ bonds = {n: {} for n in atoms} # new bonds
84
+
85
+ seen = set()
86
+ unsaturated = {}
87
+ for n, env in adjacency.items(): # set single bonds in molecule. collect unsaturated atoms
88
+ s = sat[n]
89
+ if len(s) == 1:
90
+ c, r, h = s.pop()
91
+ if not h:
92
+ seen.add(n)
93
+ charges[n] = c
94
+ radicals[n] = r
95
+ for m in env:
96
+ if m not in seen:
97
+ bonds[n][m] = bonds[m][n] = Bond(1)
98
+ else:
99
+ unsaturated[n] = [(c, r, h)]
100
+ else:
101
+ # radicals have the lowest priority
102
+ # tuned priority
103
+ # multiple bonds have higher priority
104
+ # charges priority: 0>-1>1>2>3>-2>4>-3>-4
105
+ unsaturated[n] = sorted(s, key=lambda x: (x[1],
106
+ tuned_priority.get((atoms[n].atomic_number, x[0], x[2]), 0),
107
+ -x[2], charge_priority[x[0]]))
108
+
109
+ log = []
110
+ if (need_radicals := expected_radicals_count - sum(radicals.values())) < 0:
111
+ log.append('Radical state not balanced')
112
+ if not allow_errors:
113
+ if logging:
114
+ return log
115
+ return False
116
+ need_radicals = 0 # reset to zero
117
+
118
+ if unsaturated:
119
+ # create graph of unsaturated atoms
120
+ bonds_graph = {n: {m for m in adjacency[n] if m in unsaturated} for n in unsaturated}
121
+ order = list(unsaturated)
122
+ # try to saturate with different random states
123
+ for _ in range(len(unsaturated)):
124
+ shuffle(order)
125
+ sb, sa, log_ = _saturate({n: bonds_graph[n].copy() for n in order}, unsaturated, need_radicals,
126
+ expected_charge - sum(charges.values()))
127
+ if not log_: # success
128
+ break
129
+ else: # failed
130
+ if log_ == 1:
131
+ log.append('Charge state not balanced')
132
+ elif log_ == 2:
133
+ log.append('Radical state not balanced')
134
+ else:
135
+ log.append('Charge state not balanced')
136
+ log.append('Radical state not balanced')
137
+ if not allow_errors: # all attempts failed
138
+ if logging:
139
+ return log
140
+ return False
141
+
142
+ for n, m, b in sb:
143
+ bonds[n][m] = bonds[m][n] = Bond(b)
144
+ for n, c, r in sa:
145
+ charges[n] = c
146
+ radicals[n] = r
147
+ elif expected_charge != sum(charges.values()): # check charge for saturated case
148
+ log.append('Charge state not balanced')
149
+ if not allow_errors:
150
+ if logging:
151
+ return log
152
+ return False
153
+ # reset molecule
154
+ self._bonds = bonds
155
+ for n, r in radicals.items():
156
+ atoms[n]._is_radical = r
157
+ for n, c in charges.items():
158
+ atoms[n]._charge = c
159
+ for a in atoms.values():
160
+ a._implicit_hydrogens = 0 # reset invalid hydrogens counts.
161
+ self.flush_cache()
162
+ self.calc_labels()
163
+ if logging:
164
+ if not log: # check for errors
165
+ log.append('Saturated successfully')
166
+ else:
167
+ log.append('Saturated with errors')
168
+ return log
169
+ return True
170
+
171
+
172
+ def _find_possible_valences(atoms, neighbors_distances, charges, radicals, allow_deleting=True):
173
+ if allow_deleting:
174
+ possible_bonds = {n: md.copy() for n, md in neighbors_distances.items()}
175
+ else:
176
+ possible_bonds = {n: list(md) for n, md in neighbors_distances.items()}
177
+ while True:
178
+ saturation = defaultdict(set)
179
+ for n, env in possible_bonds.items():
180
+ env_atoms = None
181
+ el = len(env)
182
+ dc = charges[n]
183
+ dr = radicals[n]
184
+ for charge, is_radical, valence, implicit, explicit_dict in atoms[n].saturation_rules:
185
+ if valence < el or dc is not None and dc != charge or dr is not None and dr != is_radical:
186
+ continue # skip impossible rules
187
+ if explicit_dict:
188
+ if env_atoms is None: # lazy caching
189
+ env_atoms = defaultdict(int)
190
+ for m in env:
191
+ env_atoms[atoms[m].atomic_number] += 1
192
+ env_atoms_copy = env_atoms.copy()
193
+ for (b, a), c in explicit_dict.items(): # stage 1. find explicit valence
194
+ if env_atoms_copy[a] < c: # `c` always > 0
195
+ break # rule not matched
196
+ env_atoms_copy[a] -= c
197
+ else: # stage 2. find possible valence
198
+ if unmatched := sum(env_atoms_copy.values()): # number of atoms outside rule
199
+ if implicit >= unmatched:
200
+ # number of implicit H should be greater or equal to number of neighbors
201
+ saturation[n].add((charge, is_radical, valence - el))
202
+ else: # pattern fully matched. difference bw valence and connectivity is unsaturation.
203
+ saturation[n].add((charge, is_radical, valence - el))
204
+ else: # unspecific rule. found possible valence
205
+ saturation[n].add((charge, is_radical, valence - el))
206
+ if n not in saturation: # valence not found
207
+ break
208
+ else: # all atoms passed
209
+ break
210
+ if allow_deleting:
211
+ out = max(env.items(), key=itemgetter(1))[0]
212
+ del possible_bonds[out][n]
213
+ del possible_bonds[n][out]
214
+ else:
215
+ raise ValenceError('Structure has invalid atoms neighbors count and electron states')
216
+ return saturation, possible_bonds
217
+
218
+
219
+ def _saturate(bonds, atoms, expected_radicals_count, expected_charge):
220
+ atoms = {k: v.copy() for k, v in atoms.items()}
221
+ dots = []
222
+ saturation = []
223
+ electrons = []
224
+ while True:
225
+ # get isolated atoms. atoms should be charged or radical
226
+ to_del = []
227
+ for n, env in bonds.items():
228
+ if not env:
229
+ es = [(n, c, r) for c, r, h in atoms[n] if not h]
230
+ if not es:
231
+ raise ValenceError('Saturation impossible. '
232
+ f"Isolated atom ({n}) doesn't have appropriate charge-radical state")
233
+ to_del.append(n)
234
+ dots.append(es)
235
+ for n in to_del:
236
+ del bonds[n]
237
+ if not bonds:
238
+ break
239
+
240
+ try: # get terminal atom
241
+ n = next(n for n, ms in bonds.items() if len(ms) == 1)
242
+ except StopIteration:
243
+ # get ring or linker atom
244
+ n, _ = min(bonds.items(), key=lambda x: len(x[1]))
245
+ m = bonds[n].pop()
246
+ bonds[m].discard(n)
247
+
248
+ for (nc, nr, nh), (i, (mc, mr, mh)) in product(atoms[n], enumerate(atoms[m])):
249
+ if nh == mh:
250
+ saturation.append((n, m, nh + 1))
251
+ electrons.append((n, nc, nr))
252
+ electrons.append((m, mc, mr))
253
+
254
+ for x in bonds.pop(n):
255
+ saturation.append((n, x, 1))
256
+ bonds[x].discard(n)
257
+ for x in bonds.pop(m):
258
+ saturation.append((m, x, 1))
259
+ bonds[x].discard(m)
260
+ break
261
+ elif nh < mh:
262
+ electrons.append((n, nc, nr))
263
+ saturation.append((n, m, nh + 1))
264
+ atoms[m].pop(i)
265
+ atoms[m].insert(i, (mc, mr, mh - nh))
266
+
267
+ for x in bonds.pop(n):
268
+ saturation.append((n, x, 1))
269
+ bonds[x].discard(n)
270
+ break
271
+ elif nh > mh:
272
+ electrons.append((m, mc, mr))
273
+ saturation.append((n, m, mh + 1))
274
+ atoms[n].pop(i)
275
+ atoms[n].insert(i, (nc, nr, nh - mh))
276
+
277
+ for x in bonds.pop(m):
278
+ saturation.append((m, x, 1))
279
+ bonds[x].discard(m)
280
+ break
281
+ else:
282
+ m = bonds.pop(n).pop()
283
+ bonds[m].discard(n)
284
+
285
+ for (nc, nr, nh), (i, (mc, mr, mh)) in product(atoms[n], enumerate(atoms[m])):
286
+ if nh == mh:
287
+ saturation.append((n, m, nh + 1))
288
+ electrons.append((n, nc, nr))
289
+ electrons.append((m, mc, mr))
290
+ for x in bonds.pop(m):
291
+ saturation.append((m, x, 1))
292
+ bonds[x].discard(m)
293
+ break
294
+ elif nh < mh and bonds[m]:
295
+ electrons.append((n, nc, nr))
296
+ saturation.append((n, m, nh + 1))
297
+ atoms[m].pop(i)
298
+ atoms[m].insert(i, (mc, mr, mh - nh))
299
+ break
300
+ else:
301
+ saturation.append((n, m, 1))
302
+ if not bonds[m]:
303
+ del bonds[m]
304
+
305
+ combo_ua = [] # possible single atoms electron states
306
+ for s in dots:
307
+ if len(s) == 1:
308
+ electrons.extend(s)
309
+ elif s:
310
+ combo_ua.append(s)
311
+
312
+ # if < 0 - we already in bad situation
313
+ # if > 0 - we need more radicals
314
+ need_radical = expected_radicals_count - sum(x for _, _, x in electrons)
315
+ need_charge = expected_charge - sum(x for _, x, _ in electrons)
316
+ if combo_ua:
317
+ # try randomly set charges and radicals.
318
+ # first pick required radical states.
319
+ # second try to minimize charge delta.
320
+ for attempt in range(1, len(combo_ua) + 1):
321
+ shuffle(combo_ua)
322
+ charges_radicals = []
323
+ rad = []
324
+ chg = []
325
+ for atom in combo_ua:
326
+ if len(rad) < need_radical: # pick radicals
327
+ r = next((x for x in atom if x[2]), None)
328
+ if r: # pick random radical states
329
+ rad.append(r)
330
+ else: # not radical
331
+ chg.append(atom)
332
+ else: # pick not radical states
333
+ c = [x for x in atom if not x[2]]
334
+ if len(c) > 1:
335
+ chg.append(c)
336
+ elif c:
337
+ charges_radicals.extend(c)
338
+ elif attempt == len(combo_ua): # all states has radical. balancing impossible
339
+ chg.append(atom) # fuck it horse. we in last attempt
340
+ else: # do next attempt
341
+ break
342
+ else:
343
+ charges_radicals.extend(rad)
344
+ current_charge = need_charge - sum(x for _, x, _ in charges_radicals)
345
+ current_radical = need_radical - len(rad)
346
+ for x in chg:
347
+ n, c, r = min(x, key=lambda x: abs(current_charge - x[1]))
348
+ charges_radicals.append((n, c, r))
349
+ current_charge -= c
350
+ current_radical -= r
351
+
352
+ if current_radical: # radical unbalanced
353
+ if current_charge:
354
+ log = 3
355
+ else:
356
+ log = 2
357
+ elif current_charge:
358
+ log = 1
359
+ else: # balanced!
360
+ log = 0
361
+ break
362
+
363
+ electrons.extend(charges_radicals)
364
+ elif need_radical:
365
+ log = 3 if need_charge else 2
366
+ elif need_charge:
367
+ log = 1
368
+ else:
369
+ log = 0
370
+ return saturation, electrons, log
371
+
372
+
373
+ __all__ = ['Saturation']
@@ -0,0 +1,18 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright 2023-2026 Ramil Nugmanov <nougmanoff@protonmail.com>
4
+ # This file is part of chython.
5
+ #
6
+ # chython is free software; you can redistribute it and/or modify
7
+ # it under the terms of the GNU Lesser General Public License as published by
8
+ # the Free Software Foundation; either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU Lesser General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU Lesser General Public License
17
+ # along with this program; if not, see <https://www.gnu.org/licenses/>.
18
+ #
@@ -0,0 +1,66 @@
1
+ # -*- coding: utf-8 -*-
2
+ from chython import smiles
3
+
4
+
5
+ def test_canonicalize_nitro():
6
+ mol = smiles('c1ccccc1N(=O)=O')
7
+ mol.canonicalize()
8
+ s = str(mol)
9
+ assert '[N+]' in s and '[O-]' in s
10
+
11
+
12
+ def test_canonicalize_benzene():
13
+ mol = smiles('C1=CC=CC=C1')
14
+ mol.canonicalize()
15
+ assert str(mol) == 'c1ccccc1'
16
+
17
+
18
+ def test_kekule_thiele():
19
+ mol = smiles('c1ccccc1')
20
+ mol.kekule()
21
+ s = str(mol)
22
+ assert 'c' not in s
23
+ assert '=' in s
24
+ mol.thiele()
25
+ assert str(mol) == 'c1ccccc1'
26
+
27
+
28
+ def test_standardize():
29
+ mol = smiles('c1ccccc1N(=O)=O')
30
+ mol.standardize()
31
+ s = str(mol)
32
+ assert '[N+]' in s
33
+
34
+
35
+ def test_neutralize():
36
+ mol = smiles('[NH3+]CC(=O)[O-]')
37
+ mol.neutralize()
38
+ s = str(mol)
39
+ assert '+' not in s or '-' not in s
40
+
41
+
42
+ def test_check_valence_clean():
43
+ mol = smiles('CCO')
44
+ errors = mol.check_valence()
45
+ assert errors == []
46
+
47
+
48
+ def test_canonicalize_idempotent():
49
+ mol = smiles('OCC')
50
+ mol.canonicalize()
51
+ s1 = str(mol)
52
+ mol.canonicalize()
53
+ s2 = str(mol)
54
+ assert s1 == s2
55
+
56
+
57
+ def test_keep_kekule_after_charge_isomer_standardization():
58
+ mol = smiles('C=1N(C)C=[N+](CC)C=1')
59
+ mol.canonicalize(keep_kekule=True)
60
+
61
+ aromatic = mol.copy()
62
+ aromatic.thiele()
63
+
64
+ expected = smiles('c1[n+](C)ccn1CC')
65
+ expected.canonicalize()
66
+ assert aromatic == expected
@@ -0,0 +1,146 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright 2023-2026 Ramil Nugmanov <nougmanoff@protonmail.com>
4
+ # This file is part of chython.
5
+ #
6
+ # chython is free software; you can redistribute it and/or modify
7
+ # it under the terms of the GNU Lesser General Public License as published by
8
+ # the Free Software Foundation; either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU Lesser General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU Lesser General Public License
17
+ # along with this program; if not, see <https://www.gnu.org/licenses/>.
18
+ #
19
+ from chython import smiles
20
+ from pytest import mark
21
+
22
+
23
+ data = [
24
+ ('CP(C)(C)C', 'C[P+](C)(C)C'),
25
+ ('CB1(C)[H]B(C)(C)[H]1', 'CB1(C)~[H]B(C)(C)~[H]1'),
26
+ ('[O]N(C)[NH]', '[O-][N+](C)=N'), ('[O]N(C)[CH2]', '[O-][N+](C)=C'),
27
+ ('[O]S(C)(C)[O]', 'O=S(C)(C)=O'), ('[O]S(C)(C)[S]', 'O=S(C)(C)=S'),
28
+ ('BN(C)=C', 'B~N(C)=C'),
29
+ ('B=N(C)(C)C', 'B~N(C)(C)C'),
30
+ ('BS(C)C', 'B~S(C)C'), ('BO(C)C', 'B~O(C)C'),
31
+ ('[B-]=[N+](C)C', 'BN(C)C'), ('C[B-]=[N+]C', 'CBNC'), ('[B-]=[N+]', 'BN'),
32
+ ('[O-][B+3]([O-])([O-])[O-]', 'O[B-](O)(O)O'),
33
+ ('[O-]B(O)(O)O', 'O[B-](O)(O)O'),
34
+ ('OB(O)(O)O', 'O[B-](O)(O)O'),
35
+ ('CN(C)(C)C', 'C[N+](C)(C)C'),
36
+ ('C=N(=O)O', 'C[N+](=O)[O-]'),
37
+ ('C=N(=O)C', 'C=[N+]([O-])C'), ('O=N(=O)C', 'O=[N+]([O-])C'), ('N=N(=O)C', 'N=[N+]([O-])C'),
38
+ ('C=[N+]([O-])O', 'C[N+](=O)[O-]'),
39
+ ('CN(=O)=N(=O)C', 'C[N+]([O-])=[N+]([O-])C'),
40
+ ('CN(=O)=N(=N)C', 'C[N+]([O-])=[N+]([NH-])C'), ('CN(=O)=N(=NC)C', 'C[N+]([O-])=[N+]([N-]C)C'),
41
+ ('C=N(=N)C', 'C=[N+]([N-])C'), ('C=N(=NC)C', 'C=[N+]([N-]C)C'), ('N=N(=N)C', 'N=[N+]([N-])C'),
42
+ ('[N-][N+](=O)C', 'N=[N+]([O-])C'), ('C[N-][N+](=O)C', 'CN=[N+]([O-])C'),
43
+ ('[O-]N(=O)=O', '[O-][N+](=O)[O-]'),
44
+ ('CN(:O):O', 'C[N+](=O)[O-]'),
45
+ ('O=[N-]=O', '[O-]N=O'),
46
+ ('O=N#N', 'O=[N+]=[N-]'), ('C=N#N', 'C=[N+]=[N-]'), ('N=N#N', 'N=[N+]=[N-]'),
47
+ ('[O-][N+]#N', 'O=[N+]=[N-]'), ('C[CH-][N+]#N', 'CC=[N+]=[N-]'), ('[NH-][N+]#N', 'N=[N+]=[N-]'),
48
+ ('C[N+]#N=[N-]', 'CN=[N+]=[N-]'),
49
+ ('CN=N=N', 'CN=[N+]=[N-]'),
50
+ ('CNN#N', 'CN=[N+]=[N-]'),
51
+ ('[N-]#N=NC', '[N-]=[N+]=NC'),
52
+ ('[N-]=N#N', '[N-]=[N+]=[N-]'),
53
+ ('CC#N=N', 'CC=[N+]=[N-]'),
54
+ ('CC#N=NC', 'CC#[N+][N-]C'), ('CC#N=O', 'CC#[N+][O-]'),
55
+ ('NN#C', '[NH-][N+]#C'), ('ON#CC', '[O-][N+]#CC'), ('SN#CC', '[S-][N+]#CC'),
56
+ ('CNN#C', 'C[N-][N+]#C'),
57
+ ('N[N+]#[C-]', '[NH-][N+]#C'), ('O[N+]#[C-]', '[O-][N+]#C'), ('S[N+]#[C-]', '[S-][N+]#C'),
58
+ ('CN[N+]#[C-]', 'C[N-][N+]#C'),
59
+ ('CN#C', 'C[N+]#[C-]'),
60
+ ('C[C-]=[N+]=N', 'CC=[N+]=[N-]'),
61
+ ('CN(C)(C)=O', 'C[N+](C)(C)[O-]'), ('CN(C)(C)=NC', 'C[N+](C)(C)[N-]C'),
62
+ ('C[N](C)=O |^1:1|', 'CN(C)[O] |^1:3|'),
63
+ ('C=N(C)[O] |^1:3|', 'C=[N+](C)[O-]'),
64
+ ('CN(C)#N', 'C[N+](C)=[N-]'),
65
+ ('CN=[N+]', 'C[N+]#N'),
66
+ ('[NH2+][C-]=O', 'N=C=O'), ('C[NH+][C-]=O', 'CN=C=O'),
67
+ ('N#CO', 'N=C=O'),
68
+ ('N#C[O-]', '[N-]=C=O'),
69
+ ('CC(C)(C)[N+][O-]', 'CC(C)(C)N=O'),
70
+ ('CN=O', 'C=NO'),
71
+ ('NC=[O+]C', '[NH2+]=COC'), ('CNC=[O+]C', 'C[NH+]=COC'), ('CN(C)C=[O+]C', 'C[N+](C)=COC'),
72
+ # amide rule: N=C-OH >> NH-C=O
73
+ ('N=CO', 'NC=O'), ('N=CS', 'NC=S'),
74
+ # ring amidation (6-membered): OH-C=N in ring >> O=C-NH in ring
75
+ ('OC1=CC=CC=N1', 'O=C1NC=CC=C1'),
76
+ ('OC1=CC=NC=N1', 'O=C1NC=NC=C1'),
77
+ ('OC1=NC=CC=N1', 'O=C1N=CC=CN1'),
78
+ ('OC1=C(O)N=CC=N1', 'O=C1NC=CNC1=O'),
79
+ ('OC1=NC=CN=C1O', 'O=C1NC=CNC1=O'),
80
+ ('OC1=CC=NC(=O)N1', 'O=C1NC=CC(=O)N1'),
81
+ ('OC1=CC=NC(O)=N1', 'O=C1NC=CC(=O)N1'),
82
+ ('OC1=NC(O)=NC=C1', 'O=C1NC=CC(=O)N1'),
83
+ # 5-membered ring amidation (short flip)
84
+ ('CN1C=CC(O)=N1', 'CN1NC(=O)C=C1'),
85
+ # 5-membered ring amidation (long flip)
86
+ ('CN1N=CC=C1O', 'CN1NC=CC1=O'),
87
+ # hydroxypyridine to pyridone
88
+ ('OC1=CC=NC=C1', 'O=C1C=CNC=C1'),
89
+ # 6-membered ring N=C-CH adjacent to C=O (short flip)
90
+ ('O=C1C=CCC=N1', 'O=C1NC=CC=C1'),
91
+ # 6-membered ring N=C-C=C-CH adjacent to C=O (long flip)
92
+ ('O=C1CC=CC=N1', 'O=C1NC=CC=C1'),
93
+ # 6-membered ring N=C-CH adjacent to C=O (C=O between CH and ring end)
94
+ ('O=C1CC=NC=C1', 'O=C1C=CNC=C1'),
95
+ # 5-membered ring N=C-CH >> NH-C=C
96
+ ('C1C=NC=N1', 'N1C=CN=C1'),
97
+ # 6-membered ring exocyclic C=N to C-NH (cytosine-like)
98
+ ('N=C1NC=CC(=O)N1', 'NC1=NC=CC(=O)N1'),
99
+ # 5-membered ring N=C-CH with sp3 N,O closure
100
+ ('CN1N=CCC1=O', 'CN1NC=CC1=O'),
101
+ # acyclic enol (51)
102
+ ('OC=C', 'O=CC'), ('OC(C)=C', 'O=C(C)C'),
103
+ # P rules
104
+ ('[O-][P+](C)(C)C', 'O=P(C)(C)C'),
105
+ ('[CH2+][P-](C)(C)C', 'C=P(C)(C)C'),
106
+ ('FP(F)(F)(F)(F)F', 'F[P-](F)(F)(F)(F)F'),
107
+ ('C[P-](C)(=O)=O', 'CP(C)(=O)[O-]'),
108
+ ('CP(C)(=O)[S-]', 'CP(C)(=S)[O-]'),
109
+ ('CP(C)(=O)S', 'CP(C)(=S)O'),
110
+ # S rules
111
+ ('CS(=O)(=O)[S-]', 'CS(=O)(=S)[O-]'),
112
+ ('CS(=O)(=O)S', 'CS(=O)(=S)O'),
113
+ ('C[S+](C)[O-]', 'CS(C)=O'),
114
+ ('O=[S+][O-]', 'O=S=O'), ('O=[S+](C)(C)[O-]', 'O=S(C)(C)=O'),
115
+ ('O=[S+2]([O-])[O-]', 'O=S(=O)=O'),
116
+ ('C[S+2](C)([O-])[O-]', 'CS(C)(=O)=O'),
117
+ ('C[S-](C)[CH2+]', 'CS(C)=C'),
118
+ ('O=[S-](C)=O', 'O=S(C)[O-]'),
119
+ ('O=[S-](C)(=O)=O', 'O=S(C)(=O)[O-]'),
120
+ ('O=[S-](=O)[S-]', 'S=S([O-])[O-]'),
121
+ ('CS(C)(=O)O', 'CS(C)(=O)=O'),
122
+ ('CS(C)(=O)N', 'CS(C)(=O)=N'),
123
+ ('N=S(C)O', 'NS(C)=O'), ('N=S(C)(C)(C)O', 'NS(C)(C)(C)=O'),
124
+ # C rules
125
+ ('C#CO', 'C=C=O'),
126
+ ('C#CNC', 'C=C=NC'),
127
+ ('C=O |^1:0|', '[C-]#[O+]'),
128
+ ('[O]O[O] |^1:0,2|', 'O=[O+][O-]'),
129
+ ('[CH2+]N(C)C', 'C=[N+](C)C'),
130
+ ('[CH2+]N(C)O', 'C=[N+](C)O'),
131
+ ('[CH2+]=NC', 'C#[N+]C'),
132
+ # Cl rules
133
+ ('O[Cl+][O-]', 'OCl=O'),
134
+ ('O[Cl+2]([O-])[O-]', 'OCl(=O)=O'),
135
+ ('O[Cl+3]([O-])([O-])[O-]', 'OCl(=O)(=O)=O'),
136
+ ('[Cl-]=O', 'Cl[O-]'),
137
+ # S=N double rules
138
+ ('OS(=N)(=N)O', 'O=S(N)(N)=O'), ('OS(=N)(=N)C', 'O=S(N)(=N)C')
139
+ ]
140
+
141
+
142
+ @mark.parametrize('raw,result', data)
143
+ def test_group(raw, result):
144
+ tmp = smiles(raw)
145
+ tmp.standardize()
146
+ assert tmp == smiles(result), f'{raw} > {tmp} != {result}'