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,1215 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright 2019-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 functools import cached_property
21
+ from itertools import combinations, product
22
+ from logging import getLogger, INFO
23
+ from .morgan import _morgan
24
+ from ..exceptions import AtomNotFound, IsChiral, NotChiral
25
+
26
+
27
+ logger = getLogger('chython.stereo')
28
+ logger.setLevel(INFO)
29
+
30
+
31
+ # atomic number constants
32
+ H = 1
33
+ C = 6
34
+
35
+ # 1 2
36
+ # \ |
37
+ # \|
38
+ # n---3
39
+ # /
40
+ # /
41
+ # 0
42
+ _tetrahedron_translate = {(0, 1, 2): False, (1, 2, 0): False, (2, 0, 1): False,
43
+ (0, 2, 1): True, (1, 0, 2): True, (2, 1, 0): True,
44
+ (0, 3, 1): False, (3, 1, 0): False, (1, 0, 3): False,
45
+ (0, 1, 3): True, (1, 3, 0): True, (3, 0, 1): True,
46
+ (0, 2, 3): False, (2, 3, 0): False, (3, 0, 2): False,
47
+ (0, 3, 2): True, (3, 2, 0): True, (2, 0, 3): True,
48
+ (1, 3, 2): False, (3, 2, 1): False, (2, 1, 3): False,
49
+ (1, 2, 3): True, (2, 3, 1): True, (3, 1, 2): True}
50
+ # 2 1
51
+ # \ /
52
+ # n---m
53
+ # / \
54
+ # 0 3
55
+ _alkene_translate = {(0, 1): False, (1, 0): False, (0, 3): True, (3, 0): True,
56
+ (2, 3): False, (3, 2): False, (2, 1): True, (1, 2): True}
57
+
58
+
59
+ def _pyramid_sign(n, u, v, w):
60
+ #
61
+ # | n /
62
+ # | |\
63
+ # | | \
64
+ # | /| \
65
+ # | / u---v
66
+ # |/___\_/___
67
+ # w
68
+ #
69
+ nx, ny, nz = n
70
+ ux, uy, uz = u
71
+ vx, vy, vz = v
72
+ wx, wy, wz = w
73
+
74
+ q1x = ux - nx
75
+ q1y = uy - ny
76
+ q1z = uz - nz
77
+ q2x = vx - nx
78
+ q2y = vy - ny
79
+ q2z = vz - nz
80
+ q3x = wx - nx
81
+ q3y = wy - ny
82
+ q3z = wz - nz
83
+
84
+ vol = q1x * (q2y * q3z - q2z * q3y) + q1y * (q2z * q3x - q2x * q3z) + q1z * (q2x * q3y - q2y * q3x)
85
+ if vol > 0:
86
+ return 1
87
+ elif vol < 0:
88
+ return -1
89
+ return 0
90
+
91
+
92
+ def _cis_trans_sign(n, u, v, w):
93
+ # n w
94
+ # \ /
95
+ # u--v
96
+ # / \
97
+ # x x
98
+ nx, ny = n
99
+ ux, uy = u
100
+ vx, vy = v
101
+ wx, wy = w
102
+
103
+ q1x = ux - nx
104
+ q1y = uy - ny
105
+ q2x = vx - ux
106
+ q2y = vy - uy
107
+ q3x = wx - vx
108
+ q3y = wy - vy
109
+
110
+ # cross vectors
111
+ q1q2z = q1x * q2y - q1y * q2x
112
+ q2q3z = q2x * q3y - q2y * q3x
113
+
114
+ dot = q1q2z * q2q3z
115
+ if dot > 0:
116
+ return 1
117
+ elif dot < 0:
118
+ return -1
119
+ return 0
120
+
121
+
122
+ def _allene_sign(mark, u, v, w):
123
+ # n w
124
+ # | /
125
+ # u--v
126
+ ux, uy = u
127
+ vx, vy = v
128
+ wx, wy = w
129
+
130
+ q2x = vx - ux
131
+ q2y = vy - uy
132
+ q3x = wx - vx
133
+ q3y = wy - vy
134
+
135
+ # cross vectors
136
+ q2q3z = q2x * q3y - q2y * q3x
137
+
138
+ dot = -mark * q2q3z
139
+ if dot > 0:
140
+ return 1
141
+ elif dot < 0:
142
+ return -1
143
+ return 0
144
+
145
+
146
+ class MoleculeStereo:
147
+ __slots__ = ()
148
+
149
+ def clean_stereo(self):
150
+ """
151
+ Remove stereo data.
152
+ """
153
+ for _, a in self.atoms():
154
+ a._stereo = None
155
+ for *_, b in self.bonds():
156
+ b._stereo = None
157
+ self.flush_cache(keep_sssr=True, keep_components=True, keep_special_connectivity=True)
158
+
159
+ @cached_property
160
+ def tetrahedrons(self) -> tuple[int, ...]:
161
+ """
162
+ Carbon sp3 atom numbers.
163
+ """
164
+ tetra = []
165
+ for n, atom in self.atoms():
166
+ if atom == C and not atom.charge and not atom.is_radical:
167
+ env = self._bonds[n]
168
+ if all(b == 1 for b in env.values()):
169
+ if sum(int(b) for b in env.values()) > 4:
170
+ continue
171
+ tetra.append(n)
172
+ return tuple(tetra)
173
+
174
+ @cached_property
175
+ def cumulenes(self) -> list[tuple[int, ...]]:
176
+ """
177
+ All double-bonds chains (e.g. alkenes, allenes, cumulenes).
178
+ """
179
+ atoms = self._atoms
180
+ bonds = self._bonds
181
+
182
+ adj = defaultdict(set) # double bonds adjacency matrix
183
+ for n, atom in atoms.items():
184
+ if atom.is_forming_double_bonds:
185
+ adj_n = adj[n].add
186
+ for m, bond in bonds[n].items():
187
+ if bond == 2 and atoms[m].is_forming_double_bonds:
188
+ adj_n(m)
189
+ if not adj:
190
+ return []
191
+
192
+ terminals = [x for x, y in adj.items() if len(y) == 1] # list to keep atoms order!
193
+ cumulenes = []
194
+ while terminals:
195
+ n = terminals.pop(0)
196
+ m = adj[n].pop()
197
+ path = [n, m]
198
+ while m not in terminals:
199
+ if len(bonds[m]) > 2: # not cumulene. SO3, SO4- etc.
200
+ cumulenes.extend(zip(path, path[1:])) # keep single double bonds instead of cumulene chain.
201
+ break
202
+ adj_m = adj[m]
203
+ adj_m.discard(n)
204
+ n, m = m, adj_m.pop()
205
+ path.append(m)
206
+ else:
207
+ terminals.remove(m)
208
+ adj[m].pop()
209
+ cumulenes.append(tuple(path))
210
+ return cumulenes
211
+
212
+ @cached_property
213
+ def stereogenic_tetrahedrons(self) -> dict[int, tuple[int, int, int] | tuple[int, int, int, int]]:
214
+ """
215
+ Tetrahedrons which contains at least 3 non-hydrogen neighbors and corresponding neighbors order.
216
+ """
217
+ # 2
218
+ # |
219
+ # 1--K--3
220
+ # |
221
+ # 4?
222
+ atoms = self._atoms
223
+ bonds = self._bonds
224
+ tetrahedrons = {}
225
+ for n in self.tetrahedrons:
226
+ if any(not atoms[x].is_forming_single_bonds for x in bonds[n]):
227
+ continue # skip metal-carbon complexes
228
+ env = tuple(x for x in bonds[n] if atoms[x] != H)
229
+ if len(env) in (3, 4):
230
+ tetrahedrons[n] = env
231
+ return tetrahedrons
232
+
233
+ @cached_property
234
+ def stereogenic_cumulenes(self) -> dict[tuple[int, ...], tuple[int, int, int | None, int | None]]:
235
+ """
236
+ Cumulenes which contains at least one non-hydrogen neighbor on both ends and corresponding neighbors order.
237
+ """
238
+ # 5 4
239
+ # \ /
240
+ # 2---3
241
+ # / \
242
+ # 1 6
243
+ bonds = self._bonds
244
+ atoms = self._atoms
245
+ cumulenes = {}
246
+ for path in self.cumulenes:
247
+ nf = bonds[path[0]]
248
+ nl = bonds[path[-1]]
249
+ n1, m1 = path[1], path[-2]
250
+ if any(b == 3 or not atoms[m].is_forming_single_bonds and b != 8
251
+ for m, b in nf.items() if m != n1):
252
+ continue # skip X=C=C structures and metal-carbon complexes
253
+ if any(b == 3 or not atoms[m].is_forming_single_bonds and b != 8
254
+ for m, b in nl.items() if m != m1):
255
+ continue # skip X=C=C structures and metal-carbon complexes
256
+ nn = [x for x, b in nf.items() if x != n1 and atoms[x] != H and b != 8]
257
+ mn = [x for x, b in nl.items() if x != m1 and atoms[x] != H and b != 8]
258
+ if nn and mn:
259
+ sn = nn[1] if len(nn) == 2 else None
260
+ sm = mn[1] if len(mn) == 2 else None
261
+ cumulenes[path] = (nn[0], mn[0], sn, sm)
262
+ return cumulenes
263
+
264
+ @cached_property
265
+ def stereogenic_allenes(self) -> dict[int, tuple[int, int, int | None, int | None]]:
266
+ """
267
+ Allenes which contains at least one non-hydrogen neighbor on both ends and corresponding neighbors order.
268
+ """
269
+ return {path[len(path) // 2]: env for path, env in self.stereogenic_cumulenes.items() if len(path) % 2}
270
+
271
+ @cached_property
272
+ def stereogenic_cis_trans(self) -> dict[tuple[int, int], tuple[int, int, int | None, int | None]]:
273
+ """
274
+ Cis-trans bonds which contains at least one non-hydrogen neighbor on both ends and corresponding neighbors order.
275
+ """
276
+ stereo = {}
277
+ for path, env in self.stereogenic_cumulenes.items():
278
+ if len(path) % 2:
279
+ continue
280
+ stereo[(path[0], path[-1])] = env
281
+ return stereo
282
+
283
+ @cached_property
284
+ def ring_tetrahedrons(self) -> dict[int, tuple[int, int] | tuple[int] | tuple]:
285
+ """
286
+ Tetrahedrons in rings, except ring-linkers. Values are non-ring atoms.
287
+ """
288
+ out = {}
289
+ atoms_rings = self.atoms_rings
290
+ tetrahedrons = self.stereogenic_tetrahedrons
291
+ points = self.rings_linker_tetrahedrons
292
+ environment = self.not_special_connectivity
293
+ for n, r in atoms_rings.items():
294
+ if n in tetrahedrons and n not in points:
295
+ out[n] = tuple(environment[n].difference(atoms_rings))
296
+ return out
297
+
298
+ @cached_property
299
+ def rings_linker_tetrahedrons(self) -> dict[int, tuple[int, int, int, int]]:
300
+ """
301
+ A dictionary where the keys are tetrahedron atoms shared between two rings (not condensed rings) and the values
302
+ are tuples representing their neighbors in the first and second rings respectively.
303
+ """
304
+ out = {}
305
+ tetrahedrons = self.stereogenic_tetrahedrons
306
+ for n, r in self.atoms_rings.items():
307
+ if n in tetrahedrons:
308
+ for nr, mr in combinations(r, 2):
309
+ if len(set(nr).intersection(mr)) == 1:
310
+ ni = nr.index(n)
311
+ mi = mr.index(n)
312
+ out[n] = (nr[ni - 1], nr[ni - len(nr) + 1], mr[mi - 1], mr[mi - len(mr) + 1])
313
+ break
314
+ return out
315
+
316
+ @cached_property
317
+ def ring_cumulenes_terminals(self) -> set[tuple[int, int]]:
318
+ """
319
+ Terminal atoms of inside ring cumulenes.
320
+ """
321
+ out = set()
322
+ ar = self.atoms_rings
323
+ for n, *_, m in self.stereogenic_cumulenes:
324
+ if n in ar and m in ar and not set(ar[n]).isdisjoint(ar[m]):
325
+ out.add((n, m))
326
+ return out
327
+
328
+ @cached_property
329
+ def rings_linker_cumulenes_terminals(self) -> dict[tuple[int, int], tuple[int, int, int, int]]:
330
+ """
331
+ Terminal atoms of cumulenes connecting two rings. Values are neighbors in first and second rings.
332
+ """
333
+ out = {}
334
+ ar = self.atoms_rings
335
+ chord = self.ring_cumulenes_terminals
336
+ for (n, *_, m), (n1, m1, n2, m2) in self.stereogenic_cumulenes.items():
337
+ if n in ar and m in ar and (n, m) not in chord:
338
+ out[(n, m)] = (n1, n2, m1, m2)
339
+ return out
340
+
341
+ @cached_property
342
+ def ring_attached_cumulenes(self) -> dict[tuple[int, int], tuple[int, int] | tuple[int]]:
343
+ """
344
+ Cumulenes attached to rings from one side. Values are out of ring neighbor atoms.
345
+ """
346
+ ar = self.atoms_rings
347
+ out = {}
348
+ for (n, *_, m), (n1, m1, n2, m2) in self.stereogenic_cumulenes.items():
349
+ if n in ar:
350
+ if m in ar:
351
+ continue
352
+ if m2:
353
+ out[(n, m)] = (m1, m2)
354
+ else:
355
+ out[(n, m)] = (m1,)
356
+ elif m in ar:
357
+ if n2:
358
+ out[(n, m)] = (n1, n2)
359
+ else:
360
+ out[(n, m)] = (n1,)
361
+ return out
362
+
363
+ @property
364
+ def chiral_tetrahedrons(self) -> set[int]:
365
+ """
366
+ Chiral tetrahedrons except already labeled ones.
367
+ """
368
+ return self.__chiral_centers[0]
369
+
370
+ @property
371
+ def chiral_cis_trans(self) -> set[tuple[int, int]]:
372
+ """
373
+ Chiral cis-trans bonds except already labeled ones.
374
+ """
375
+ return self.__chiral_centers[1]
376
+
377
+ @property
378
+ def chiral_allenes(self) -> set[int]:
379
+ """
380
+ Chiral allenes except already labeled ones.
381
+ """
382
+ return self.__chiral_centers[2]
383
+
384
+ def add_wedge(self, n: int, m: int, mark: int, *, clean_cache=True):
385
+ """
386
+ Add stereo data by wedge notation of bonds. Use it for tetrahedrons of allenes.
387
+
388
+ :param n: number of atom from which wedge bond started
389
+ :param m: number of atom to which wedge bond coming
390
+ :param mark: up bond is 1, down is -1
391
+ """
392
+ atoms = self._atoms
393
+ if n not in atoms or m not in atoms or m not in self._bonds[n]:
394
+ raise AtomNotFound
395
+ elif atoms[n].stereo is not None:
396
+ raise IsChiral
397
+ elif c := self._stereo_allenes_centers.get(n):
398
+ # allenes
399
+ if atoms[c].stereo is not None:
400
+ raise IsChiral
401
+ elif c not in self.chiral_allenes:
402
+ raise NotChiral
403
+
404
+ t1, t2 = self._stereo_allenes_terminals[c]
405
+ order = self.stereogenic_allenes[c]
406
+ if atoms[m] == H:
407
+ if t1 == n:
408
+ m1 = order[1]
409
+ else:
410
+ t1, t2 = t2, t1
411
+ m1 = order[0]
412
+ r = True
413
+ else:
414
+ w = order.index(m)
415
+ if w == 0:
416
+ m1 = order[1]
417
+ r = False
418
+ elif w == 1:
419
+ m1 = order[0]
420
+ t1, t2 = t2, t1
421
+ r = False
422
+ elif w == 2:
423
+ m1 = order[1]
424
+ r = True
425
+ else:
426
+ m1 = order[0]
427
+ t1, t2 = t2, t1
428
+ r = True
429
+ if s := _allene_sign(mark, atoms[t1].xy, atoms[t2].xy, atoms[m1].xy):
430
+ atoms[c]._stereo = s < 0 if r else s > 0
431
+ if clean_cache:
432
+ self.flush_cache(keep_sssr=True, keep_components=True, keep_special_connectivity=True)
433
+ # tetrahedrons
434
+ elif n in self.chiral_tetrahedrons:
435
+ th = self.stereogenic_tetrahedrons[n]
436
+ am = atoms[m]
437
+ if am == H:
438
+ order = []
439
+ for x in th:
440
+ ax = atoms[x]
441
+ order.append((ax.x, ax.y, 0))
442
+ s = _pyramid_sign((am.x, am.y, mark), *order)
443
+ else:
444
+ order = []
445
+ for x in th:
446
+ ax = atoms[x]
447
+ order.append((ax.x, ax.y, mark if x == m else 0))
448
+ if len(order) == 3:
449
+ if len(self._bonds[n]) == 4: # explicit hydrogen
450
+ x = next(x for x in self._bonds[n] if x not in th)
451
+ ax = atoms[x]
452
+ s = _pyramid_sign((ax.x, ax.y, 0), *order)
453
+ else:
454
+ an = atoms[n]
455
+ s = _pyramid_sign((an.x, an.y, 0), *order)
456
+ else:
457
+ s = _pyramid_sign(order[-1], *order[:3])
458
+ if s:
459
+ atoms[n]._stereo = s > 0
460
+ if clean_cache:
461
+ self.flush_cache(keep_components=True, keep_sssr=True, keep_special_connectivity=True)
462
+ else:
463
+ raise NotChiral
464
+
465
+ def calculate_cis_trans_from_2d(self, *, clean_cache=True):
466
+ """
467
+ Calculate cis-trans stereo bonds from given 2d coordinates. Unusable for SMILES and INCHI.
468
+ """
469
+ atoms = self._atoms
470
+ flag = False
471
+ while self.chiral_cis_trans:
472
+ stereo = False
473
+ for nm in self.chiral_cis_trans:
474
+ n, m = nm
475
+ n1, m1, *_ = self.stereogenic_cis_trans[nm]
476
+ s = _cis_trans_sign(atoms[n1].xy, atoms[n].xy, atoms[m].xy, atoms[m1].xy)
477
+ if s:
478
+ stereo = True
479
+ i, j = self._stereo_cis_trans_centers[n]
480
+ self._bonds[i][j]._stereo = s > 0
481
+ if stereo:
482
+ flag = True
483
+ self.flush_stereo_cache()
484
+ else:
485
+ break
486
+ if flag and clean_cache:
487
+ self.flush_cache(keep_components=True, keep_sssr=True, keep_special_connectivity=True)
488
+
489
+ def add_atom_stereo(self, n: int, env: tuple[int, ...], mark: bool, *, clean_cache=True):
490
+ """
491
+ Add stereo data for specified neighbors bypass. Use it for tetrahedrons or allenes.
492
+
493
+ :param n: number of tetrahedron atom or central atom of allene.
494
+ :param env: numbers of atoms with specified bypass
495
+ :param mark: clockwise or anti bypass.
496
+
497
+ See <https://www.daylight.com/dayhtml/doc/theory/theory.smiles.html> and <http://opensmiles.org/opensmiles.html>
498
+ """
499
+ try:
500
+ atom = self._atoms[n]
501
+ except KeyError:
502
+ raise AtomNotFound
503
+ if atom.stereo is not None:
504
+ raise IsChiral
505
+ if not isinstance(mark, bool):
506
+ raise TypeError('stereo mark should be bool')
507
+
508
+ if n in self.chiral_tetrahedrons:
509
+ atom._stereo = self._translate_tetrahedron_sign(n, env, mark)
510
+ if clean_cache:
511
+ self.flush_cache(keep_components=True, keep_sssr=True, keep_special_connectivity=True)
512
+ elif n in self.chiral_allenes:
513
+ atom._stereo = self._translate_allene_sign(n, *env, mark)
514
+ if clean_cache:
515
+ self.flush_cache(keep_components=True, keep_sssr=True, keep_special_connectivity=True)
516
+ else: # only tetrahedrons supported
517
+ raise NotChiral
518
+
519
+ def add_cis_trans_stereo(self, n: int, m: int, n1: int, n2: int, mark: bool, *, clean_cache=True):
520
+ """
521
+ Add stereo data to cis-trans double bonds (not allenes).
522
+
523
+ n1/n=m/n2
524
+
525
+ :param n: number of starting atom of double bonds chain (alkenes of cumulenes)
526
+ :param m: number of ending atom of double bonds chain (alkenes of cumulenes)
527
+ :param n1: number of neighboring atom of starting atom
528
+ :param n2: number of neighboring atom of ending atom
529
+ :param mark: cis or trans
530
+
531
+ See <https://www.daylight.com/dayhtml/doc/theory/theory.smiles.html> and <http://opensmiles.org/opensmiles.html
532
+ """
533
+ atoms = self._atoms
534
+ if n not in atoms or m not in atoms or n1 not in atoms or n2 not in atoms:
535
+ raise AtomNotFound
536
+ if not isinstance(mark, bool):
537
+ raise TypeError('stereo mark should be bool')
538
+
539
+ if n not in self._stereo_cis_trans_counterpart or self._stereo_cis_trans_counterpart[n] != m:
540
+ raise NotChiral
541
+ i, j = self._stereo_cis_trans_centers[n]
542
+ if self._bonds[i][j].stereo is not None:
543
+ raise IsChiral
544
+
545
+ if (n, m) in self.chiral_cis_trans:
546
+ self._bonds[i][j]._stereo = self._translate_cis_trans_sign(n, m, n1, n2, mark)
547
+ if clean_cache:
548
+ self.flush_cache(keep_components=True, keep_sssr=True, keep_special_connectivity=True)
549
+ elif (m, n) in self.chiral_cis_trans:
550
+ self._bonds[i][j]._stereo = self._translate_cis_trans_sign(m, n, n2, n1, mark)
551
+ if clean_cache:
552
+ self.flush_cache(keep_components=True, keep_sssr=True, keep_special_connectivity=True)
553
+ else:
554
+ raise NotChiral
555
+
556
+ def flush_stereo_cache(self):
557
+ """
558
+ Flush chiral morgan and chiral centers cache.
559
+ """
560
+ self.__dict__.pop('_chiral_morgan', None)
561
+ self.__dict__.pop('_MoleculeStereo__chiral_centers', None)
562
+
563
+ def fix_stereo(self):
564
+ """
565
+ Reset stereo marks.
566
+ """
567
+ stereo_tetrahedrons = self.stereogenic_tetrahedrons
568
+ stereo_allenes = self.stereogenic_allenes
569
+ stereo_cis_trans = self._stereo_cis_trans_terminals
570
+ atoms_stereo = []
571
+ allenes_stereo = []
572
+ cis_trans_stereo = []
573
+ for n, a in self.atoms():
574
+ if a.stereo is None:
575
+ continue
576
+ elif n in stereo_tetrahedrons:
577
+ atoms_stereo.append((n, a, a.stereo))
578
+ elif n in stereo_allenes:
579
+ allenes_stereo.append((n, a, a.stereo))
580
+ a._stereo = None # flush stereo label
581
+
582
+ for n, m, b in self.bonds():
583
+ if b.stereo is None:
584
+ continue
585
+ elif (ta := stereo_cis_trans.get(n)) and ta == stereo_cis_trans.get(m):
586
+ cis_trans_stereo.append((ta, b, b.stereo))
587
+ b._stereo = None # flush stereo label
588
+ self.flush_stereo_cache()
589
+
590
+ old_stereo = len(atoms_stereo) + len(allenes_stereo) + len(cis_trans_stereo)
591
+ while old_stereo:
592
+ chiral_tetrahedrons = self.chiral_tetrahedrons
593
+ chiral_allenes = self.chiral_allenes
594
+ chiral_cis_trans = self.chiral_cis_trans
595
+
596
+ # filter out resolved
597
+ tmp = []
598
+ for n, a, s in atoms_stereo:
599
+ if n in chiral_tetrahedrons:
600
+ a._stereo = s # restore stereo
601
+ else:
602
+ tmp.append((n, a, s))
603
+ atoms_stereo = tmp
604
+
605
+ tmp = []
606
+ for n, a, s in allenes_stereo:
607
+ if n in chiral_allenes:
608
+ a._stereo = s # restore stereo
609
+ else:
610
+ tmp.append((n, a, s))
611
+ allenes_stereo = tmp
612
+
613
+ tmp = []
614
+ for ta, b, s in cis_trans_stereo:
615
+ if ta in chiral_cis_trans:
616
+ b._stereo = s
617
+ else:
618
+ tmp.append((ta, b, s))
619
+ cis_trans_stereo = tmp
620
+
621
+ fail_stereo = len(atoms_stereo) + len(allenes_stereo) + len(cis_trans_stereo)
622
+ if fail_stereo == old_stereo:
623
+ break
624
+ old_stereo = fail_stereo
625
+ self.flush_stereo_cache()
626
+
627
+ @cached_property
628
+ def _cis_trans_count(self) -> int:
629
+ return sum(b.stereo is not None for *_, b in self.bonds())
630
+
631
+ @cached_property
632
+ def _stereo_cis_trans_centers(self) -> dict[int, tuple[int, int]]:
633
+ """
634
+ Cis-Trans terminal atoms to cis-trans key mapping. Key is central double bond in a cumulene chain.
635
+ """
636
+ terminals = {}
637
+ for path in self.stereogenic_cumulenes:
638
+ if len(path) % 2:
639
+ continue
640
+ n, m = path[0], path[-1]
641
+ i = len(path) // 2
642
+ terminals[n] = terminals[m] = (path[i - 1], path[i])
643
+ return terminals
644
+
645
+ @cached_property
646
+ def _stereo_cis_trans_terminals(self) -> dict[int, tuple[int, int]]:
647
+ """
648
+ Cis-Trans terminal and central atoms to terminal pair mapping.
649
+ """
650
+ terminals = {}
651
+ for path in self.stereogenic_cumulenes:
652
+ if len(path) % 2:
653
+ continue
654
+ n, m = path[0], path[-1]
655
+ i = len(path) // 2
656
+ terminals[n] = terminals[m] = terminals[path[i]] = terminals[path[i - 1]] = (n, m)
657
+ return terminals
658
+
659
+ @cached_property
660
+ def _stereo_cis_trans_counterpart(self) -> dict[int, int]:
661
+ """
662
+ Cis-Trans terminal atoms counterparts
663
+ """
664
+ counterpart = {}
665
+ for path in self.stereogenic_cumulenes:
666
+ if len(path) % 2:
667
+ continue
668
+ n, m = path[0], path[-1]
669
+ counterpart[n] = m
670
+ counterpart[m] = n
671
+ return counterpart
672
+
673
+ @cached_property
674
+ def _stereo_allenes_centers(self) -> dict[int, int]:
675
+ """
676
+ Allene terminal atom to center mapping
677
+ """
678
+ terminals = {}
679
+ for c, (n, m) in self._stereo_allenes_terminals.items():
680
+ terminals[n] = terminals[m] = c
681
+ return terminals
682
+
683
+ @cached_property
684
+ def _stereo_allenes_terminals(self) -> dict[int, tuple[int, int]]:
685
+ """
686
+ Allene center atom to terminals mapping
687
+ """
688
+ return {path[len(path) // 2]: (path[0], path[-1]) for path in self.stereogenic_cumulenes if len(path) % 2}
689
+
690
+ def _translate_tetrahedron_sign(self, n, env, s=None):
691
+ """
692
+ Get sign of chiral tetrahedron atom for specified neighbors order
693
+
694
+ :param n: stereo atom
695
+ :param env: neighbors order
696
+ :param s: if None, use existing sign else translate given to molecule
697
+ """
698
+ if s is None:
699
+ s = self._atoms[n].stereo
700
+ if s is None:
701
+ raise KeyError
702
+
703
+ order = self.stereogenic_tetrahedrons[n]
704
+ if len(order) == 3:
705
+ if len(env) == 4: # hydrogen atom passed to env
706
+ # hydrogen always last in order
707
+ try:
708
+ order = (*order, next(x for x in env if self._atoms[x] == H)) # see translate scheme
709
+ except StopIteration:
710
+ raise KeyError
711
+ elif len(env) != 3: # pyramid or tetrahedron expected
712
+ raise ValueError('invalid atoms list')
713
+ elif len(env) not in (3, 4): # pyramid or tetrahedron expected
714
+ raise ValueError('invalid atoms list')
715
+
716
+ translate = tuple(order.index(x) for x in env[:3])
717
+ if _tetrahedron_translate[translate]:
718
+ return not s
719
+ return s
720
+
721
+ def _translate_cis_trans_sign(self, n, m, nn, nm, s=None):
722
+ """
723
+ Get sign for specified opposite neighbors
724
+
725
+ :param n: first double bonded atom
726
+ :param m: last double bonded atom
727
+ :param nn: neighbor of first atom
728
+ :param nm: neighbor of last atom
729
+ :param s: if None, use existing sign else translate given to molecule
730
+ """
731
+ try:
732
+ n0, n1, n2, n3 = self.stereogenic_cis_trans[(n, m)]
733
+ except KeyError:
734
+ n0, n1, n2, n3 = self.stereogenic_cis_trans[(m, n)]
735
+ n, m = m, n # in alkenes sign not order depended
736
+ nn, nm = nm, nn
737
+
738
+ if s is None:
739
+ i, j = self._stereo_cis_trans_centers[n]
740
+ s = self._bonds[i][j].stereo
741
+ if s is None:
742
+ raise KeyError
743
+
744
+ if nn == n0: # same start
745
+ t0 = 0
746
+ if nm == n1:
747
+ t1 = 1
748
+ elif nm == n3 or n3 is None and self._atoms[nm] == H:
749
+ t1 = 3
750
+ else:
751
+ raise KeyError
752
+ elif nn == n1:
753
+ t0 = 1
754
+ if nm == n0:
755
+ t1 = 0
756
+ elif nm == n2 or n2 is None and self._atoms[nm] == H:
757
+ t1 = 2
758
+ else:
759
+ raise KeyError
760
+ elif nn == n2 or n2 is None and self._atoms[nn] == H:
761
+ t0 = 2
762
+ if nm == n1:
763
+ t1 = 1
764
+ elif nm == n3 or n3 is None and self._atoms[nm] == H:
765
+ t1 = 3
766
+ else:
767
+ raise KeyError
768
+ elif nn == n3 or n3 is None and self._atoms[nn] == H:
769
+ t0 = 3
770
+ if nm == n0:
771
+ t1 = 0
772
+ elif nm == n2 or n2 is None and self._atoms[nm] == H:
773
+ t1 = 2
774
+ else:
775
+ raise KeyError
776
+ else:
777
+ raise KeyError
778
+
779
+ if _alkene_translate[(t0, t1)]:
780
+ return not s
781
+ return s
782
+
783
+ def _translate_allene_sign(self, c, nn, nm, s=None):
784
+ """
785
+ get sign for specified opposite neighbors
786
+
787
+ :param c: central double bonded atom
788
+ :param nn: neighbor of first double bonded atom
789
+ :param nm: neighbor of last double bonded atom
790
+ :param s: if None, use existing sign else translate given to molecule
791
+ """
792
+ if s is None:
793
+ s = self._atoms[c].stereo
794
+ if s is None:
795
+ raise KeyError
796
+
797
+ n0, n1, n2, n3 = self.stereogenic_allenes[c]
798
+ if nn == n0: # same start
799
+ t0 = 0
800
+ if nm == n1:
801
+ t1 = 1
802
+ elif nm == n3 or n3 is None and self._atoms[nm] == H:
803
+ t1 = 3
804
+ else:
805
+ raise KeyError
806
+ elif nn == n1:
807
+ t0 = 1
808
+ if nm == n0:
809
+ t1 = 0
810
+ elif nm == n2 or n2 is None and self._atoms[nm] == H:
811
+ t1 = 2
812
+ else:
813
+ raise KeyError
814
+ elif nn == n2 or n2 is None and self._atoms[nn] == H:
815
+ t0 = 2
816
+ if nm == n1:
817
+ t1 = 1
818
+ elif nm == n3 or n3 is None and self._atoms[nm] == H:
819
+ t1 = 3
820
+ else:
821
+ raise KeyError
822
+ elif nn == n3 or n3 is None and self._atoms[nn] == H:
823
+ t0 = 3
824
+ if nm == n0:
825
+ t1 = 0
826
+ elif nm == n2 or n2 is None and self._atoms[nm] == H:
827
+ t1 = 2
828
+ else:
829
+ raise KeyError
830
+ else:
831
+ raise KeyError
832
+
833
+ if _alkene_translate[(t0, t1)]:
834
+ return not s
835
+ return s
836
+
837
+ @cached_property
838
+ def _wedge_map(self):
839
+ atoms = self._atoms
840
+
841
+ overlap = set()
842
+ space = []
843
+ solved = []
844
+ seen = set()
845
+ for n, env in self.stereogenic_allenes.items():
846
+ if atoms[n].stereo is None:
847
+ continue
848
+ term = self._stereo_allenes_terminals[n]
849
+ overlap.update(term) # don't allow incoming wedge to allenes terminals
850
+ orders = [(*env[:2], *term, n, True), (*env[1::-1], *term[::-1], n, True)]
851
+ if env[2]:
852
+ orders.append((env[2], env[1], *term, n, True))
853
+ if env[3]:
854
+ orders.append((env[3], env[0], *term[::-1], n, True))
855
+ space.append(orders)
856
+ for n, env in self.stereogenic_tetrahedrons.items():
857
+ if atoms[n].stereo is None:
858
+ continue
859
+ overlap.add(n) # don't allow incoming wedge to stereo tetrahedrons
860
+ order = list(env)
861
+ orders = [(*order, n, False)]
862
+ for _ in range(1, len(order)):
863
+ order = order.copy()
864
+ order.append(order.pop(0))
865
+ orders.append((*order, n, False))
866
+ space.append(orders)
867
+
868
+ while space:
869
+ ls = len(space)
870
+ unsolved = []
871
+ for orders in space:
872
+ good = []
873
+ if orders[0][-1]:
874
+ for x in orders:
875
+ x0 = x[0]
876
+ if x0 in seen or x0 not in overlap:
877
+ good.append(x)
878
+ seen.add(x[2])
879
+ if good:
880
+ solved.append(max(good, key=lambda x: (not atoms[x[0]].in_ring, atoms[x[0]].atomic_number)))
881
+ else:
882
+ unsolved.append(orders)
883
+ else:
884
+ for x in orders:
885
+ x0 = x[0]
886
+ if x0 in seen or x0 not in overlap:
887
+ good.append(x)
888
+ if good:
889
+ seen.add(x[-2])
890
+ solved.append(max(good, key=lambda x: (not atoms[x[0]].in_ring, atoms[x[0]].atomic_number)))
891
+ else:
892
+ unsolved.append(orders)
893
+ space = unsolved
894
+ if len(unsolved) == ls:
895
+ break
896
+
897
+ solved = [y for x in solved if (y := self.__wedge_sign(x))]
898
+ if not space:
899
+ return solved
900
+
901
+ for orders in product(*space):
902
+ used = set()
903
+ wedge = []
904
+ for order in orders:
905
+ if order[-1]: # allene
906
+ if (order[0], order[2]) in used:
907
+ break
908
+ used.add((order[2], order[0]))
909
+ wedge.append(self.__wedge_sign(order))
910
+ else: # TH
911
+ n = order[-2]
912
+ if (order[0], n) in used:
913
+ break
914
+ used.add((n, order[0]))
915
+ wedge.append(self.__wedge_sign(order))
916
+ else: # found
917
+ solved.extend(wedge)
918
+ return solved
919
+ logger.info('wedge stereo mapping failed')
920
+ return solved
921
+
922
+ def __wedge_sign(self, order):
923
+ if order[-1]: # allene
924
+ s = self._translate_allene_sign(order[-2], order[0], order[1])
925
+ v = _allene_sign(1, self._atoms[order[2]].xy, self._atoms[order[3]].xy, self._atoms[order[1]].xy)
926
+ if not v:
927
+ logger.info(f'need 2d clean. allenes wedge stereo ambiguous for atom {order[-2]}')
928
+ if s:
929
+ return order[2], order[0], v
930
+ else:
931
+ return order[2], order[0], -v
932
+ else: # TH
933
+ n = order[-2]
934
+ s = self._translate_tetrahedron_sign(n, order[:-2])
935
+ # need recalculation if XY changed
936
+ ao0 = self._atoms[order[0]]
937
+ ao1 = self._atoms[order[1]]
938
+ ao2 = self._atoms[order[2]]
939
+ if len(order) == 5:
940
+ an = self._atoms[n]
941
+ v = _pyramid_sign((an.x, an.y, 0),
942
+ (ao0.x, ao0.y, 1),
943
+ (ao1.x, ao1.y, 0),
944
+ (ao2.x, ao2.y, 0))
945
+ else:
946
+ ao3 = self._atoms[order[3]]
947
+ v = _pyramid_sign((ao3.x, ao3.y, 0),
948
+ (ao0.x, ao0.y, 1),
949
+ (ao1.x, ao1.y, 0),
950
+ (ao2.x, ao2.y, 0))
951
+ if not v:
952
+ logger.info(f'need 2d clean. tetrahedron wedge stereo ambiguous for atom {n}')
953
+ if s:
954
+ return n, order[0], v
955
+ else:
956
+ return n, order[0], -v
957
+
958
+ @cached_property
959
+ def _chiral_morgan(self) -> dict[int, int]:
960
+ stereo_atoms = {n for n, a in self.atoms() if a.stereo is not None}
961
+ stereo_bonds = {n for n, mb in self._bonds.items() if any(b.stereo is not None for m, b in mb.items())}
962
+ if not stereo_atoms and not stereo_bonds:
963
+ return self.atoms_order
964
+
965
+ morgan = self.atoms_order.copy()
966
+ atoms_stereo = stereo_atoms.intersection(self.tetrahedrons)
967
+ allenes_stereo = stereo_atoms - atoms_stereo
968
+
969
+ cis_trans_terminals = self._stereo_cis_trans_terminals
970
+ cis_trans_stereo = {cis_trans_terminals[n] for n in stereo_bonds}
971
+
972
+ while True:
973
+ # try iteratively differentiate stereo atoms.
974
+ morgan, atoms_stereo, cis_trans_stereo, allenes_stereo, atoms_groups, cis_trans_groups, allenes_groups = \
975
+ self.__differentiation(morgan, atoms_stereo, cis_trans_stereo, allenes_stereo)
976
+ if not atoms_groups and not cis_trans_groups and not allenes_groups:
977
+ break
978
+ # for some rings differentiation by morgan impossible. try randomly set new weights.
979
+ # sometimes this will lead to pseudo chiral centers and non-unique morgan.
980
+ for group in atoms_groups:
981
+ for n in group[:len(group) // 2]: # set new weight in half of group randomly.
982
+ morgan[n] = -morgan[n]
983
+ for group in cis_trans_groups:
984
+ for n, _ in group[:len(group) // 2]: # set new weight in half of group randomly.
985
+ morgan[n] = -morgan[n]
986
+ for group in allenes_groups:
987
+ for n in group[:len(group) // 2]: # set new weight in half of group randomly.
988
+ morgan[n] = -morgan[n]
989
+ morgan = _morgan(morgan, self.int_adjacency)
990
+ return morgan
991
+
992
+ @cached_property
993
+ def __chiral_centers(self):
994
+ atoms_rings = self.atoms_rings
995
+ tetrahedrons = self.stereogenic_tetrahedrons
996
+ cis_trans = self.stereogenic_cis_trans
997
+ allenes_centers = self._stereo_allenes_centers
998
+ cis_trans_terminals = self._stereo_cis_trans_terminals
999
+ cis_trans_centers = self._stereo_cis_trans_centers
1000
+ morgan = self._chiral_morgan
1001
+
1002
+ # find new chiral atoms and bonds.
1003
+ # tetrahedron is chiral if all its neighbors are unique.
1004
+ chiral_t = {n for n, env in tetrahedrons.items() if len({morgan[x] for x in env}) == len(env)}
1005
+ # tetrahedrons-linkers is chiral if in each rings neighbors are unique.
1006
+ chiral_t.update(n for n, (n1, n2, m1, m2) in self.rings_linker_tetrahedrons.items()
1007
+ if morgan[n1] != morgan[n2] and morgan[m1] != morgan[m2])
1008
+
1009
+ # required for axes detection.
1010
+ graph = {}
1011
+ stereogenic = set()
1012
+ pseudo = {}
1013
+
1014
+ # double bond is chiral if neighbors of each terminal atom is unique.
1015
+ # ring-linkers and rings-attached also takes into account.
1016
+ chiral_c = set()
1017
+ chiral_a = set()
1018
+ for path, (n1, m1, n2, m2) in self.stereogenic_cumulenes.items():
1019
+ if morgan[n1] != morgan.get(n2, 0) and morgan[m1] != morgan.get(m2, 0):
1020
+ n, m = path[0], path[-1]
1021
+ if len(path) % 2:
1022
+ chiral_a.add(path[len(path) // 2])
1023
+ else:
1024
+ chiral_c.add(n)
1025
+ stereogenic.add(n)
1026
+ stereogenic.add(m)
1027
+ # ring cumulenes always chiral. can be already added.
1028
+ for nm in self.ring_cumulenes_terminals:
1029
+ n, m = nm
1030
+ if any(len(x) < 8 for x in atoms_rings[n]): # skip small rings.
1031
+ if n in chiral_c: # remove already added small rings cumulenes.
1032
+ chiral_c.discard(n)
1033
+ if m in chiral_c:
1034
+ chiral_c.discard(m)
1035
+ elif n in allenes_centers and (c := allenes_centers[n]) in chiral_a:
1036
+ chiral_a.discard(c)
1037
+ continue
1038
+ elif nm in cis_trans:
1039
+ chiral_c.add(n)
1040
+ else:
1041
+ chiral_a.add(allenes_centers[n])
1042
+ pseudo[m] = n
1043
+ graph[n] = set()
1044
+ stereogenic.add(n)
1045
+
1046
+ # find chiral axes. build graph of stereogenic atoms in rings.
1047
+ # atoms connected then located in same ring or cumulene.
1048
+ for n, env in self.ring_tetrahedrons.items():
1049
+ if len(env) == 2: # one or zero non-ring neighbors stereogenic.
1050
+ n1, n2 = env
1051
+ if morgan[n1] == morgan[n2]: # only unique non-ring members required.
1052
+ continue
1053
+ graph[n] = set()
1054
+ stereogenic.add(n) # non-linker tetrahedrons in rings - stereogenic.
1055
+ for n, (n1, n2, m1, m2) in self.rings_linker_tetrahedrons.items():
1056
+ graph[n] = set()
1057
+ if morgan[n1] != morgan[n2] or morgan[m1] != morgan[m2]:
1058
+ stereogenic.add(n) # linkers with at least one unsymmetric ring.
1059
+ for n, m in self.rings_linker_cumulenes_terminals:
1060
+ graph[n] = {m}
1061
+ graph[m] = {n}
1062
+ # stereogenic atoms already found.
1063
+ for (n, m), env in self.ring_attached_cumulenes.items():
1064
+ if len(env) == 2:
1065
+ n1, n2 = env
1066
+ if morgan[n1] == morgan[n2]: # only unique non-ring members required.
1067
+ continue
1068
+ if n in atoms_rings:
1069
+ graph[n] = set() # non ring endpoints not required.
1070
+ stereogenic.add(n) # mark as stereogenic
1071
+ else:
1072
+ graph[m] = set()
1073
+ stereogenic.add(m)
1074
+
1075
+ if len(graph) > 1: # add bonds to graph. bonds connects atoms in same rings and terminal atoms of cumulenes.
1076
+ for n, ms in graph.items():
1077
+ for r in atoms_rings[n]:
1078
+ for m in r:
1079
+ if n == m:
1080
+ continue
1081
+ elif m in graph:
1082
+ ms.add(m)
1083
+ elif m in pseudo and (m := pseudo[m]) != n:
1084
+ ms.add(m)
1085
+ # remove not stereogenic terminals.
1086
+ while True:
1087
+ try:
1088
+ n = next(n for n, ms in graph.items() if not ms or len(ms) == 1 and n not in stereogenic)
1089
+ except StopIteration:
1090
+ break
1091
+ for m in graph.pop(n):
1092
+ graph[m].discard(n)
1093
+ # update chiral atoms.
1094
+ for n in graph:
1095
+ if n in tetrahedrons:
1096
+ chiral_t.add(n)
1097
+ elif n in allenes_centers:
1098
+ chiral_a.add(allenes_centers[n])
1099
+ else:
1100
+ chiral_c.add(n)
1101
+
1102
+ # skip already marked.
1103
+ stereo_atoms = {n for n, a in self.atoms() if a.stereo is not None}
1104
+ chiral_t.difference_update(stereo_atoms)
1105
+ chiral_a.difference_update(stereo_atoms)
1106
+ diff = set()
1107
+ for n in chiral_c:
1108
+ i, j = cis_trans_centers[n]
1109
+ if self._bonds[i][j].stereo is None:
1110
+ diff.add(cis_trans_terminals[n])
1111
+ return chiral_t, diff, chiral_a
1112
+
1113
+ def __differentiation(self, morgan, atoms_stereo, cis_trans_stereo, allenes_stereo):
1114
+ bonds = self.int_adjacency
1115
+
1116
+ tetrahedrons = self.stereogenic_tetrahedrons
1117
+ cis_trans = self.stereogenic_cis_trans
1118
+ allenes = self.stereogenic_allenes
1119
+
1120
+ translate_tetrahedron = self._translate_tetrahedron_sign
1121
+ translate_cis_trans = self._translate_cis_trans_sign
1122
+ translate_allene = self._translate_allene_sign
1123
+
1124
+ while True:
1125
+ morgan_update = {}
1126
+ atoms_groups = []
1127
+ cis_trans_groups = []
1128
+ allenes_groups = []
1129
+ # recalculate morgan weights with taking into account existing stereo marks.
1130
+ if atoms_stereo:
1131
+ grouped_stereo = defaultdict(list)
1132
+ for n in atoms_stereo:
1133
+ grouped_stereo[morgan[n]].append(n) # collect equal stereo atoms.
1134
+ for group in grouped_stereo.values():
1135
+ if not len(group) % 2: # only even number of equal stereo atoms give new stereo center.
1136
+ # process only truly stereogenic.
1137
+ if len(env := tetrahedrons[group[0]]) == len({morgan[x] for x in env}):
1138
+ s = [n for n in group if translate_tetrahedron(n, sorted(tetrahedrons[n], key=morgan.get))]
1139
+ if 0 < len(s) < len(group): # RS pair required.
1140
+ for m in s:
1141
+ morgan_update[m] = -morgan[m]
1142
+ for n in group: # prevent checks repeating.
1143
+ atoms_stereo.discard(n)
1144
+ else: # stereo group in rings. unambiguous environment order impossible.
1145
+ atoms_groups.append(group)
1146
+
1147
+ if cis_trans_stereo:
1148
+ grouped_stereo = defaultdict(list)
1149
+ for nm in cis_trans_stereo:
1150
+ n, m = nm
1151
+ if (mn := morgan[n]) <= (mm := morgan[m]):
1152
+ grouped_stereo[mn].append((n, nm))
1153
+ else:
1154
+ grouped_stereo[mm].append((m, nm))
1155
+ for group in grouped_stereo.values():
1156
+ if not len(group) % 2: # only even number of equal stereo bonds give new stereo center.
1157
+ n1, m1, n2, m2 = cis_trans[group[0][1]]
1158
+ if morgan[n1] != morgan.get(n2, 0) and morgan[m1] != morgan.get(m2, 0):
1159
+ s = []
1160
+ for x, nm in group:
1161
+ n, m = nm
1162
+ n1, m1, n2, m2 = cis_trans[nm]
1163
+ if n2 is None:
1164
+ a = n1
1165
+ else:
1166
+ a = min(n1, n2, key=morgan.get)
1167
+ if m2 is None:
1168
+ b = m1
1169
+ else:
1170
+ b = min(m1, m2, key=morgan.get)
1171
+ if translate_cis_trans(n, m, a, b):
1172
+ s.append(x)
1173
+ if 0 < len(s) < len(group): # RS pair required.
1174
+ for n in s:
1175
+ morgan_update[n] = -morgan[n]
1176
+ for _, nm in group:
1177
+ cis_trans_stereo.discard(nm)
1178
+ else:
1179
+ cis_trans_groups.append(group)
1180
+
1181
+ if allenes_stereo:
1182
+ grouped_stereo = defaultdict(list)
1183
+ for c in allenes_stereo:
1184
+ grouped_stereo[morgan[c]].append(c)
1185
+ for group in grouped_stereo.values():
1186
+ if not len(group) % 2: # only even number of equal stereo bonds give new stereo center.
1187
+ n1, m1, n2, m2 = allenes[group[0]]
1188
+ if morgan[n1] != morgan.get(n2, 0) and morgan[m1] != morgan.get(m2, 0):
1189
+ s = []
1190
+ for c in group:
1191
+ n1, m1, n2, m2 = allenes[c]
1192
+ if n2 is None:
1193
+ a = n1
1194
+ else:
1195
+ a = min(n1, n2, key=morgan.get)
1196
+ if m2 is None:
1197
+ b = m1
1198
+ else:
1199
+ b = min(m1, m2, key=morgan.get)
1200
+ if translate_allene(c, a, b):
1201
+ s.append(c)
1202
+ if 0 < len(s) < len(group): # RS pair required.
1203
+ for c in s:
1204
+ morgan_update[c] = -morgan[c]
1205
+ for c in group:
1206
+ allenes_stereo.discard(c)
1207
+ else:
1208
+ allenes_groups.append(group)
1209
+ if not morgan_update:
1210
+ break
1211
+ morgan = _morgan({**morgan, **morgan_update}, bonds)
1212
+ return morgan, atoms_stereo, cis_trans_stereo, allenes_stereo, atoms_groups, cis_trans_groups, allenes_groups
1213
+
1214
+
1215
+ __all__ = ['MoleculeStereo']