pyphyschemtools 0.1.0__py3-none-any.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 (80) hide show
  1. pyphyschemtools/Chem3D.py +831 -0
  2. pyphyschemtools/ML.py +42 -0
  3. pyphyschemtools/PeriodicTable.py +289 -0
  4. pyphyschemtools/__init__.py +43 -0
  5. pyphyschemtools/aithermo.py +350 -0
  6. pyphyschemtools/cheminformatics.py +230 -0
  7. pyphyschemtools/core.py +119 -0
  8. pyphyschemtools/icons-logos-banner/Logo_pyPhysChem_border.svg +1109 -0
  9. pyphyschemtools/icons-logos-banner/__init__.py +0 -0
  10. pyphyschemtools/icons-logos-banner/logo.png +0 -0
  11. pyphyschemtools/icons-logos-banner/tools4pyPC_banner.png +0 -0
  12. pyphyschemtools/icons-logos-banner/tools4pyPC_banner.svg +193 -0
  13. pyphyschemtools/kinetics.py +193 -0
  14. pyphyschemtools/resources/css/BrainHalfHalf-120x139.base64 +1 -0
  15. pyphyschemtools/resources/css/BrainHalfHalf-120x139.png +0 -0
  16. pyphyschemtools/resources/css/BrainHalfHalf.base64 +8231 -0
  17. pyphyschemtools/resources/css/BrainHalfHalf.png +0 -0
  18. pyphyschemtools/resources/css/BrainHalfHalf.svg +289 -0
  19. pyphyschemtools/resources/css/visualID.css +325 -0
  20. pyphyschemtools/resources/img/Tranformative_3.webp +0 -0
  21. pyphyschemtools/resources/img/Tranformative_3_banner.png +0 -0
  22. pyphyschemtools/resources/img/pyPhysChem_1.png +0 -0
  23. pyphyschemtools/resources/svg/BrainHalfHalf.png +0 -0
  24. pyphyschemtools/resources/svg/BrainHalfHalf.svg +289 -0
  25. pyphyschemtools/resources/svg/GitHub-Logo-C.png +0 -0
  26. pyphyschemtools/resources/svg/GitHub-Logo.png +0 -0
  27. pyphyschemtools/resources/svg/Logo-Universite-Toulouse-n-2023.png +0 -0
  28. pyphyschemtools/resources/svg/Logo_pyPhysChem_1-translucentBgd-woName.png +0 -0
  29. pyphyschemtools/resources/svg/Logo_pyPhysChem_1-translucentBgd.png +0 -0
  30. pyphyschemtools/resources/svg/Logo_pyPhysChem_1.png +0 -0
  31. pyphyschemtools/resources/svg/Logo_pyPhysChem_1.svg +622 -0
  32. pyphyschemtools/resources/svg/Logo_pyPhysChem_5.png +0 -0
  33. pyphyschemtools/resources/svg/Logo_pyPhysChem_5.svg +48 -0
  34. pyphyschemtools/resources/svg/Logo_pyPhysChem_border.svg +1109 -0
  35. pyphyschemtools/resources/svg/Python-logo-notext.svg +265 -0
  36. pyphyschemtools/resources/svg/Python_logo_and_wordmark.svg.png +0 -0
  37. pyphyschemtools/resources/svg/UT3_logoQ.jpg +0 -0
  38. pyphyschemtools/resources/svg/UT3_logoQ.png +0 -0
  39. pyphyschemtools/resources/svg/Universite-Toulouse-n-2023.svg +141 -0
  40. pyphyschemtools/resources/svg/X.png +0 -0
  41. pyphyschemtools/resources/svg/logoAnaconda.png +0 -0
  42. pyphyschemtools/resources/svg/logoAnaconda.webp +0 -0
  43. pyphyschemtools/resources/svg/logoCNRS.png +0 -0
  44. pyphyschemtools/resources/svg/logoDebut.svg +316 -0
  45. pyphyschemtools/resources/svg/logoEnd.svg +172 -0
  46. pyphyschemtools/resources/svg/logoFin.svg +172 -0
  47. pyphyschemtools/resources/svg/logoPPCL.svg +359 -0
  48. pyphyschemtools/resources/svg/logoPytChem.png +0 -0
  49. pyphyschemtools/resources/svg/logo_lpcno_300_dpi_notexttransparent.png +0 -0
  50. pyphyschemtools/resources/svg/logo_pyPhysChem.png +0 -0
  51. pyphyschemtools/resources/svg/logo_pyPhysChem_0.png +0 -0
  52. pyphyschemtools/resources/svg/logo_pyPhysChem_0.svg +390 -0
  53. pyphyschemtools/resources/svg/logopyPhyschem.png +0 -0
  54. pyphyschemtools/resources/svg/logopyPhyschem_2.webp +0 -0
  55. pyphyschemtools/resources/svg/logopyPhyschem_3.webp +0 -0
  56. pyphyschemtools/resources/svg/logopyPhyschem_4.webp +0 -0
  57. pyphyschemtools/resources/svg/logopyPhyschem_5.png +0 -0
  58. pyphyschemtools/resources/svg/logopyPhyschem_5.webp +0 -0
  59. pyphyschemtools/resources/svg/logopyPhyschem_6.webp +0 -0
  60. pyphyschemtools/resources/svg/logopyPhyschem_7.webp +0 -0
  61. pyphyschemtools/resources/svg/logos-Anaconda-pyPhysChem.png +0 -0
  62. pyphyschemtools/resources/svg/logos-Anaconda-pyPhysChem.svg +58 -0
  63. pyphyschemtools/resources/svg/pyPCBanner.svg +309 -0
  64. pyphyschemtools/resources/svg/pyPhysChem-GitHubSocialMediaTemplate.png +0 -0
  65. pyphyschemtools/resources/svg/pyPhysChem-GitHubSocialMediaTemplate.svg +295 -0
  66. pyphyschemtools/resources/svg/pyPhysChemBanner.png +0 -0
  67. pyphyschemtools/resources/svg/pyPhysChemBanner.svg +639 -0
  68. pyphyschemtools/resources/svg/qrcode-pyPhysChem.png +0 -0
  69. pyphyschemtools/resources/svg/repository-open-graph-template.png +0 -0
  70. pyphyschemtools/spectra.py +451 -0
  71. pyphyschemtools/survey.py +1048 -0
  72. pyphyschemtools/sympyUtilities.py +51 -0
  73. pyphyschemtools/tools4AS.py +960 -0
  74. pyphyschemtools/visualID.py +101 -0
  75. pyphyschemtools/visualID_Eng.py +175 -0
  76. pyphyschemtools-0.1.0.dist-info/METADATA +38 -0
  77. pyphyschemtools-0.1.0.dist-info/RECORD +80 -0
  78. pyphyschemtools-0.1.0.dist-info/WHEEL +5 -0
  79. pyphyschemtools-0.1.0.dist-info/licenses/LICENSE +674 -0
  80. pyphyschemtools-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,831 @@
1
+ ############################################################
2
+ # 3D Chemistry
3
+ ############################################################
4
+ from .visualID_Eng import fg, bg, hl
5
+ from .core import centerTitle, centertxt
6
+
7
+ import py3Dmol
8
+ import io, os
9
+ from ase import Atoms
10
+ from ase.io import read, write
11
+ from ase.data import vdw_radii, atomic_numbers
12
+ import requests
13
+ import numpy as np
14
+ from ipywidgets import GridspecLayout, VBox, Label, Layout
15
+ import CageCavityCalc as CCC
16
+
17
+ # ============================================================
18
+ # Jmol-like element color palette
19
+ # ============================================================
20
+ JMOL_COLORS = {
21
+ 'H': '#FFFFFF',
22
+ 'C': '#909090',
23
+ 'N': '#3050F8',
24
+ 'O': '#FF0D0D',
25
+ 'F': '#90E050',
26
+ 'Cl': '#1FF01F',
27
+ 'Br': '#A62929',
28
+ 'I': '#940094',
29
+ 'S': '#FFFF30',
30
+ 'P': '#FF8000',
31
+ 'B': '#FFB5B5',
32
+ 'Si': '#F0C8A0',
33
+
34
+ 'Li': '#CC80FF',
35
+ 'Na': '#AB5CF2',
36
+ 'K': '#8F40D4',
37
+ 'Mg': '#8AFF00',
38
+ 'Ca': '#3DFF00',
39
+
40
+ 'Fe': '#E06633',
41
+ 'Co': '#F090A0',
42
+ 'Ni': '#50D050',
43
+ 'Cu': '#C88033',
44
+ 'Zn': '#7D80B0',
45
+
46
+ 'Ru': '#248F8F', # Ruthenium (Jmol faithful)
47
+ 'Rh': '#E000E0',
48
+ 'Pd': '#A0A0C0',
49
+ 'Ag': '#C0C0C0',
50
+ 'Pt': '#D0D0D0',
51
+ 'Au': '#FFD123',
52
+ 'Ir': '#175487',
53
+ 'Os': '#266696',
54
+ }
55
+
56
+ class XYZData:
57
+ """
58
+ Object containing molecular coordinates and symbols extracted by molView.
59
+ Allows for geometric calculations without reloading data.
60
+ """
61
+ def __init__(self, symbols, positions):
62
+ self.symbols = np.array(symbols)
63
+ self.positions = np.array(positions, dtype=float)
64
+
65
+ def get_center_of_mass(self):
66
+ return np.mean(self.positions, axis=0)
67
+
68
+ def get_center_of_geometry(self):
69
+ """
70
+ Calculates the arithmetic mean of the atomic positions (Centroid).
71
+ """
72
+ return np.mean(self.positions, axis=0)
73
+
74
+ def get_bounding_sphere(self, include_vdw=True, scale=1.0):
75
+ """
76
+ Calculates the center and radius of the bounding sphere using ASE.
77
+ scale: multiplication factor (e.g., 0.6 to match a reduced CPK style).
78
+ """
79
+ center = np.mean(self.positions, axis=0)
80
+ distances = np.linalg.norm(self.positions - center, axis=1)
81
+
82
+ if include_vdw:
83
+ z_numbers = [atomic_numbers[s] for s in self.symbols]
84
+ radii = vdw_radii[z_numbers] * scale
85
+ radius = np.max(distances + radii)
86
+ else:
87
+ radius = np.max(distances)
88
+
89
+ return center, radius
90
+
91
+ def get_cage_volume(self, grid_spacing=0.5, return_spheres=False):
92
+ """
93
+ Calculates the internal cavity volume of a molecular cage using CageCavityCalc.
94
+
95
+ This method interfaces with the CageCavityCalc library by generating a
96
+ temporary PDB file of the current structure. It can also retrieve the
97
+ coordinates of the 'dummy atoms' (points) that fill the detected void.
98
+
99
+ Parameters
100
+ ----------
101
+ grid_spacing : float, optional
102
+ The resolution of the grid used for volume integration in Å.
103
+ Smaller values provide higher precision (default: 0.5).
104
+ return_spheres : bool, optional
105
+ If True, returns both the volume and an ase.Atoms object
106
+ containing the dummy atoms representing the cavity (default: False).
107
+
108
+ Returns
109
+ -------
110
+ volume : float or None
111
+ The calculated cavity volume in ų. Returns None if the
112
+ calculation fails.
113
+ cavity_atoms : ase.Atoms, optional
114
+ Returned only if return_spheres is True. An ASE Atoms object
115
+ representing the internal void space.
116
+ """
117
+ import tempfile
118
+ import os
119
+ from ase import Atoms
120
+ from ase.io import read as ase_read, write as ase_write
121
+
122
+ try:
123
+ from CageCavityCalc.CageCavityCalc import cavity
124
+
125
+ # 1. Fichier temporaire pour la cage
126
+ with tempfile.NamedTemporaryFile(suffix=".pdb", delete=False) as tmp:
127
+ cage_tmp = tmp.name
128
+ temp_atoms = Atoms(symbols=self.symbols, positions=self.positions)
129
+ ase_write(cage_tmp, temp_atoms)
130
+
131
+ cav = cavity()
132
+ cav.read_file(cage_tmp)
133
+ cav.grid_spacing = float(grid_spacing)
134
+ cav.dummy_atom_radii = float(grid_spacing)
135
+ volume = cav.calculate_volume()
136
+
137
+ cavity_atoms = None
138
+ if return_spheres:
139
+ with tempfile.NamedTemporaryFile(suffix=".pdb", delete=False) as tmp2:
140
+ cav_tmp = tmp2.name
141
+
142
+ cav.print_to_file(cav_tmp)
143
+
144
+ # --- NOUVEAU : Correction pour ASE (remplace ' D ' par ' H ') ---
145
+ with open(cav_tmp, 'r') as f:
146
+ content = f.read().replace(' D ', ' H ') # On transforme les Dummy en Hydrogène
147
+ with open(cav_tmp, 'w') as f:
148
+ f.write(content)
149
+
150
+ # Maintenant ASE peut lire le fichier sans erreur
151
+ cavity_atoms = ase_read(cav_tmp)
152
+
153
+ if os.path.exists(cav_tmp):
154
+ os.remove(cav_tmp)
155
+
156
+ # ... (fin de la fonction) ...
157
+ if return_spheres:
158
+ return volume, cavity_atoms
159
+ return volume
160
+
161
+ except Exception as e:
162
+ print(f"Erreur CageCavityCalc : {e}")
163
+ return None
164
+
165
+ def get_cavity_dimensions(self, cavity_atoms):
166
+ """
167
+ Calculates the principal dimensions (Length, Width, Height) of the cavity points.
168
+
169
+ This method uses Principal Component Analysis (PCA) to find the natural
170
+ axes of the cavity, making it independent of the molecule's orientation.
171
+ Percentiles are used instead of absolute Max-Min to filter out
172
+ potential outliers or 'leaking' points at the openings.
173
+
174
+ Parameters
175
+ ----------
176
+ cavity_atoms : ase.Atoms
177
+ The Atoms object containing the 'dummy atoms' generated
178
+ by the cavity calculation.
179
+
180
+ Returns
181
+ -------
182
+ tuple (float, float, float)
183
+ The dimensions (L, W, H) sorted from largest to smallest.
184
+ """
185
+ import numpy as np
186
+
187
+ # On récupère les positions des dummy atoms (les points de vide)
188
+ points = cavity_atoms.get_positions()
189
+
190
+ if len(points) < 2:
191
+ return 0, 0, 0
192
+
193
+ # Center the points at the origin (Arithmetic Mean)
194
+ centered_points = points - np.mean(points, axis=0)
195
+
196
+ # Compute the Covariance Matrix to find the spread direction
197
+ cov = np.cov(centered_points, rowvar=False)
198
+
199
+ # Compute Eigenvalues and Eigenvectors
200
+ # Eigenvectors represent the principal axes of the cavity
201
+ evals, evecs = np.linalg.eigh(cov)
202
+
203
+ # Project the points onto the principal axes (PCA transformation)
204
+ projections = np.dot(centered_points, evecs)
205
+
206
+ dims = []
207
+ for i in range(3):
208
+ # Calculate the spread using percentiles (2% to 98%)
209
+ # This is more robust than np.ptp() as it ignores outliers
210
+ p_min = np.percentile(projections[:, i], 2)
211
+ p_max = np.percentile(projections[:, i], 98)
212
+ dims.append(p_max - p_min)
213
+
214
+ # Sort dimensions from largest to smallest
215
+ dims = sorted(dims, reverse=True)
216
+
217
+ return dims[0], dims[1], dims[2]
218
+
219
+ def __repr__(self):
220
+ return f"<XYZData: {len(self.symbols)} atoms>"
221
+
222
+ class molView:
223
+ """
224
+ Initializes a molecular/crystal viewer and coordinate extractor.
225
+
226
+ This class acts as a bridge between various molecular data sources and
227
+ the py3Dmol interactive viewer. It can operate in 'Full' mode (display +
228
+ analysis) or 'Headless' mode (analysis only) by toggling the `viewer` parameter.
229
+
230
+ The class automatically extracts geometric data into the `self.data` attribute
231
+ (an XYZData object), allowing for volume, dimension, and cavity calculations.
232
+
233
+ Display molecular and crystal structures in py3Dmol from various sources:
234
+ - XYZ/PDB/CIF local files
235
+ - XYZ-format string
236
+ - PubChem CID
237
+ - ASE Atoms object
238
+ - COD ID
239
+ - RSCB PDB ID
240
+
241
+ Three visualization styles are available:
242
+ - 'bs' : ball-and-stick (default)
243
+ - 'cpk' : CPK space-filling spheres (with adjustable size)
244
+ - 'cartoon': protein backbone representation
245
+
246
+ Upon creation, an interactive 3D viewer is shown directly in a Jupyter notebook cell, unless the headless viewer parameter is set to False.
247
+
248
+ Parameters
249
+ ----------
250
+ mol : str or ase.Atoms
251
+ The molecular structure to visualize.
252
+ - If `source='file'`, this should be a path to a structure file (XYZ, PDB, etc.)
253
+ - If `source='mol'`, this should be a string containing the structure (XYZ, PDB...)
254
+ - If `source='cif'`, this should be a cif file (string)
255
+ - If `source='cid'`, this should be a PubChem CID (string or int)
256
+ - If `source='rscb'`, this should be a RSCB PDB ID (string)
257
+ - If `source='cod'`, this should be a COD ID (string)
258
+ - If `source='ase'`, this should be an `ase.Atoms` object
259
+ source : {'file', 'mol', 'cif', 'cid', 'rscb', 'ase'}, optional
260
+ The type of the input `mol` (default: 'file').
261
+ style : {'bs', 'cpk', 'cartoon'}, optional
262
+ Visualization style (default: 'bs').
263
+ - 'bs' → ball-and-stick
264
+ - 'cpk' → CPK space-filling spheres
265
+ - 'cartoon' → draws a smooth tube or ribbon through the protein backbone
266
+ (default for pdb structures)
267
+ displayHbonds : plots hydrogen bonds (default: True)
268
+ cpk_scale : float, optional
269
+ Overall scaling factor for sphere size in CPK style (default: 0.5).
270
+ Ignored when `style='bs'`.
271
+ supercell : tuple of int
272
+ Repetition of the unit cell (na, nb, nc). Default is (1, 1, 1).
273
+ w : int, optional
274
+ Width of the viewer in pixels (default: 600).
275
+ h : int, optional
276
+ Height of the viewer in pixels (default: 400).
277
+ detect_BondOrders : bool, optional
278
+ If True (default) and input is XYZ, uses RDKit to perceive connectivity
279
+ and bond orders (detects double/triple bonds).
280
+ Requires the `rdkit` library. If False, fallback to standard 3Dmol
281
+ distance-based single bonds.
282
+ viewer : bool, optional
283
+ If True (default), initializes the py3Dmol viewer and renders the
284
+ molecule. If False, operates in 'headless' mode: only coordinates
285
+ are processed for calculations (default: True).
286
+ zoom : None, optional
287
+ scaling factor
288
+
289
+ Attributes
290
+ ----------
291
+ data : XYZData or None
292
+ Container for atomic symbols and positions, used for geometric analysis.
293
+ v : py3Dmol.view or None
294
+ The 3Dmol.js viewer instance (None if viewer=False).
295
+
296
+ Examples
297
+ --------
298
+ >>> molView("molecule.xyz", source="file")
299
+ >>> molView(xyz_string, source="mol")
300
+ >>> molView(2244, source="cid") # PubChem aspirin
301
+ >>> from ase.build import molecule
302
+ >>> molView(molecule("H2O"), source="ase")
303
+ >>> molView.view_grid([2244, 2519, 702], n_cols=3, source='cid', style='bs')
304
+ >>> molView.view_grid(xyzFiles, n_cols=3, source='file', style='bs', titles=titles, w=500, sync=True)
305
+ >>> # Headless mode for high-throughput volume calculations
306
+ >>> mv = molView("cage.xyz", viewer=False)
307
+ >>> vol = mv.data.get_cage_volume()
308
+ """
309
+
310
+ def __init__(self, mol, source='file', style='bs', displayHbonds=True, cpk_scale=0.6, w=600, h=400,\
311
+ supercell=(1, 1, 1), display_now=True, detect_BondOrders=True, viewer=True, zoom=None):
312
+ self.mol = mol
313
+ self.source = source
314
+ self.style = style
315
+ self.cpk_scale = cpk_scale
316
+ self.displayHbonds = displayHbonds
317
+ self.w = w
318
+ self.h = h
319
+ self.detect_bonds = detect_BondOrders # Store the option
320
+ self.supercell = supercell
321
+ self.viewer = viewer
322
+ self.zoom = zoom
323
+ self.v = py3Dmol.view(width=self.w, height=self.h) # Création du viewer une seule fois
324
+ self._load_and_display(show=display_now)
325
+
326
+ @classmethod
327
+ def view_grid(cls, mol_list, n_cols=3, titles=None, **kwargs):
328
+ """
329
+ Displays a list of molecular structures in an interactive n_rows x n_cols grid.
330
+
331
+ This method uses ipywidgets.GridspecLayout to organize multiple 3D viewers
332
+ into a clean matrix. It automatically calculates the required number of rows
333
+ based on the length of the input list.
334
+
335
+ Parameters
336
+ ----------
337
+ mol_list : list
338
+ A list containing the molecular data to visualize. Elements should
339
+ match the expected 'mol' input for the class (paths, CIDs, strings, etc.).
340
+ n_cols : int, optional
341
+ Number of columns in the grid (default: 3).
342
+ titles : list of str, optional
343
+ Custom labels for each cell. If None, the string representation
344
+ of the 'mol' input is used as the title.
345
+ **kwargs : dict
346
+ Additional arguments passed to the molView constructor:
347
+ - source : {'file', 'mol', 'cif', 'cid', 'rscb', 'ase'}
348
+ - style : {'bs', 'cpk', 'cartoon'}
349
+ - displayHbonds : plots hydrogen bonds (default: True)
350
+ - w : width of each individual viewer in pixels (default: 300)
351
+ - h : height of each individual viewer in pixels (default: 300)
352
+ - supercell : tuple (na, nb, nc) for crystal structures
353
+ - cpk_scale : scaling factor for space-filling spheres
354
+
355
+ Returns
356
+ -------
357
+ ipywidgets.GridspecLayout
358
+ A widget object containing the grid of molecular viewers.
359
+
360
+ Examples
361
+ --------
362
+ >>> files = ["mol1.xyz", "mol2.xyz", "mol3.xyz", "mol4.xyz"]
363
+ >>> labels = ["Reactant", "TS", "Intermediate", "Product"]
364
+ >>> molView.view_grid(files, n_cols=2, titles=labels, source='file', w=400)
365
+ """
366
+ from ipywidgets import GridspecLayout, VBox, Label, Layout, Output
367
+ from IPython.display import display
368
+
369
+ # 1. Gestion des dimensions
370
+ w_cell = kwargs.get('w', 300)
371
+ h_cell = kwargs.get('h', 300)
372
+
373
+ n_mol = len(mol_list)
374
+ n_rows = (n_mol + n_cols - 1) // n_cols # Calcul automatique du nombre de lignes
375
+
376
+ # Largeur totale pour éviter le scroll horizontal
377
+ total_width = n_cols * (w_cell + 25)
378
+ grid = GridspecLayout(n_rows, n_cols, layout=Layout(width=f'{total_width}px'))
379
+
380
+ kwargs['w'] = w_cell
381
+ kwargs['h'] = h_cell
382
+ kwargs['display_now'] = False # Indispensable pour garder le contrôle
383
+
384
+ # 2. Remplissage de la grille
385
+ for i, mol in enumerate(mol_list):
386
+ row, col = i // n_cols, i % n_cols
387
+ t = titles[i] if titles and i < len(titles) else str(mol)
388
+
389
+ # Création de l'instance (charge les données et styles)
390
+ obj = cls(mol, **kwargs)
391
+
392
+ # Widget de sortie pour capturer le rendu JS de py3Dmol
393
+ out = Output(layout=Layout(
394
+ width=f'{w_cell}px',
395
+ height=f'{h_cell}px',
396
+ overflow='hidden'
397
+ ))
398
+
399
+ with out:
400
+ display(obj.v)
401
+
402
+ # Assemblage Titre + Molécule dans la cellule
403
+ grid[row, col] = VBox([
404
+ Label(value=t, layout=Layout(display='flex', justify_content='center', width='100%')),
405
+ out
406
+ ], layout=Layout(
407
+ width=f'{w_cell + 15}px',
408
+ align_items='center',
409
+ overflow='hidden',
410
+ margin='5px'
411
+ ))
412
+
413
+ return grid
414
+
415
+ def _get_ase_atoms(self, content, fmt):
416
+ """Helper to convert string content to ASE Atoms and apply supercell."""
417
+ # Use ASE to parse the structure (more robust for symmetry)
418
+ atoms = read(io.StringIO(content), format=fmt)
419
+ if self.supercell != (1, 1, 1):
420
+ atoms = atoms * self.supercell
421
+ return atoms
422
+
423
+ def _draw_cell_vectors(self, cell, origin=(0, 0, 0),
424
+ radius=0.12, head_radius=0.25, head_length=0.6,
425
+ label_offset=0.15):
426
+ """
427
+ Draw crystallographic vectors a, b, c as colored arrows
428
+ and add labels a, b, c at their tips.
429
+
430
+ a = red, b = blue, c = green
431
+ """
432
+ a, b, c = np.array(cell, dtype=float)
433
+ o = np.array(origin, dtype=float)
434
+
435
+ vectors = {
436
+ "a": (a, "red"),
437
+ "b": (b, "blue"),
438
+ "c": (c, "green")
439
+ }
440
+
441
+ for name, (vec, color) in vectors.items():
442
+ end = o + vec
443
+
444
+ # Arrow
445
+ self.v.addArrow({
446
+ "start": {
447
+ "x": float(o[0]), "y": float(o[1]), "z": float(o[2])
448
+ },
449
+ "end": {
450
+ "x": float(end[0]), "y": float(end[1]), "z": float(end[2])
451
+ },
452
+ "radius": float(radius),
453
+ "radiusRatio": head_radius / radius,
454
+ "mid": 0.85,
455
+ "color": color
456
+ })
457
+
458
+ # Label slightly beyond the arrow tip
459
+ label_pos = end + label_offset * vec / np.linalg.norm(vec)
460
+
461
+ self.v.addLabel(
462
+ name,
463
+ {
464
+ "position": {
465
+ "x": float(label_pos[0]),
466
+ "y": float(label_pos[1]),
467
+ "z": float(label_pos[2])
468
+ },
469
+ "fontColor": color,
470
+ "backgroundColor": "white",
471
+ "backgroundOpacity": 0.,
472
+ "fontSize": 16,
473
+ "borderThickness": 0
474
+ }
475
+ )
476
+ def _draw_lattice_wireframe(self, cell, reps, color="black", radius=0.05):
477
+ """
478
+ Draw all unit cells of a supercell lattice as wireframes.
479
+
480
+ Parameters
481
+ ----------
482
+ cell : ase.Cell
483
+ Primitive cell.
484
+ reps : tuple(int,int,int)
485
+ Supercell repetitions (na, nb, nc).
486
+ """
487
+ a, b, c = np.array(cell, dtype=float)
488
+ na, nb, nc = reps
489
+
490
+ for i in range(na):
491
+ for j in range(nb):
492
+ for k in range(nc):
493
+ origin = i*a + j*b + k*c
494
+ self._draw_cell_wireframe(
495
+ cell,
496
+ color=color,
497
+ radius=radius,
498
+ origin=origin
499
+ )
500
+
501
+ def _draw_cell_wireframe(self, cell, color="black", radius=0.05, origin=(0, 0, 0)):
502
+ """
503
+ Draw a unit cell as a wireframe using py3Dmol lines.
504
+ Works with XYZ or CIF models.
505
+ """
506
+ a, b, c = np.array(cell)
507
+ o = np.array(origin)
508
+
509
+ corners = [
510
+ o,
511
+ o + a,
512
+ o + b,
513
+ o + c,
514
+ o + a + b,
515
+ o + a + c,
516
+ o + b + c,
517
+ o + a + b + c
518
+ ]
519
+
520
+ edges = [
521
+ (0,1), (0,2), (0,3),
522
+ (1,4), (1,5),
523
+ (2,4), (2,6),
524
+ (3,5), (3,6),
525
+ (4,7), (5,7), (6,7)
526
+ ]
527
+
528
+ for i, j in edges:
529
+ self.v.addCylinder({
530
+ "start": {
531
+ "x": float(corners[i][0]),
532
+ "y": float(corners[i][1]),
533
+ "z": float(corners[i][2]),
534
+ },
535
+ "end": {
536
+ "x": float(corners[j][0]),
537
+ "y": float(corners[j][1]),
538
+ "z": float(corners[j][2]),
539
+ },
540
+ "color": color,
541
+ "radius": float(radius),
542
+ "fromCap": True,
543
+ "toCap": True
544
+ })
545
+
546
+ def _add_h_bonds(self, atoms, dist_max=2.5, angle_min=120):
547
+ """
548
+ Detects and renders realistic H-bonds using ASE neighbor list.
549
+ Criteria: d(H...A) < dist_max & Angle(Donor-H...Acceptor) > angle_min
550
+ """
551
+ from ase.neighborlist import neighbor_list
552
+ import numpy as np
553
+
554
+ # 1. Identify donors: Find Hydrogens covalently bonded to N or O
555
+ # i_cov: indices of H, j_cov: indices of parent atoms (N, O)
556
+ i_cov, j_cov = neighbor_list('ij', atoms, cutoff=1.2)
557
+ donors = {
558
+ idx_h: idx_d for idx_h, idx_d in zip(i_cov, j_cov)
559
+ if atoms[idx_h].symbol == 'H' and atoms[idx_d].symbol in ['N', 'O']
560
+ }
561
+
562
+ # 2. Search for potential acceptors near these Hydrogens
563
+ # i_h: indices of H, j_acc: indices of potential acceptors (N, O)
564
+ i_h, j_acc, d_ha = neighbor_list('ijd', atoms, cutoff=dist_max)
565
+
566
+ for idx_h, idx_acc, dist in zip(i_h, j_acc, d_ha):
567
+ # Validate: H is a known donor, Target is N or O, and not its own parent
568
+ if idx_h in donors and atoms[idx_acc].symbol in ['N', 'O']:
569
+ idx_d = donors[idx_h]
570
+ if idx_acc == idx_d:
571
+ continue
572
+
573
+ # 3. Angle check: Donor-H...Acceptor
574
+ try:
575
+ # ASE get_angle returns the angle in degrees
576
+ angle = atoms.get_angle(idx_d, idx_h, idx_acc)
577
+
578
+ if angle >= angle_min:
579
+ p_h = atoms[idx_h].position
580
+ p_a = atoms[idx_acc].position
581
+
582
+ self.v.addCylinder({
583
+ 'start': {'x': float(p_h[0]), 'y': float(p_h[1]), 'z': float(p_h[2])},
584
+ 'end': {'x': float(p_a[0]), 'y': float(p_a[1]), 'z': float(p_a[2])},
585
+ 'radius': 0.06,
586
+ 'color': '#00FFFF', # Cyan
587
+ 'dashed': True,
588
+ 'fromCap': 1,
589
+ 'toCap': 1
590
+ })
591
+ except Exception:
592
+ continue
593
+
594
+ def _load_and_display(self, show):
595
+
596
+ content = ""
597
+ fmt = "xyz"
598
+
599
+ # --- 1. Handle External API Sources ---
600
+ if self.source == 'cid':
601
+ if self.viewer: self.v = py3Dmol.view(query=f'cid:{self.mol}', width=self.w, height=self.h)
602
+ url = f"https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/{self.mol}/SDF?record_type=3d"
603
+ response = requests.get(url)
604
+ if response.status_code == 200:
605
+ content = response.text
606
+ fmt = "sdf"
607
+
608
+ elif self.source == 'rscb':
609
+ if self.viewer: self.v = py3Dmol.view(query=f'pdb:{self.mol}', width=self.w, height=self.h)
610
+ url = f"https://files.rcsb.org/view/{self.mol}.pdb"
611
+ response = requests.get(url)
612
+ if response.status_code == 200:
613
+ content = response.text
614
+ fmt = "pdb"
615
+
616
+ elif self.source == 'cod':
617
+ url = f"https://www.crystallography.net/cod/{self.mol}.cif"
618
+ response = requests.get(url)
619
+ if response.status_code == 200:
620
+ self.mol = response.text
621
+ self.source = 'cif'
622
+ else:
623
+ raise ValueError(f"Could not find COD ID: {self.mol}")
624
+
625
+ # --- FIX 1: Initialisation par défaut pour les sources non-API ---
626
+ if self.viewer and self.v is None:
627
+ self.v = py3Dmol.view(width=self.w, height=self.h)
628
+
629
+ # --- 2. Handle Logic for Files and Data ---
630
+ if self.source == 'file':
631
+ if not os.path.exists(self.mol):
632
+ raise FileNotFoundError(f"File not found: {self.mol}")
633
+ ext = os.path.splitext(self.mol)[1].lower().replace('.', '')
634
+ fmt = 'cif' if ext == 'cif' else ext
635
+ with open(self.mol, 'r') as f:
636
+ content = f.read()
637
+
638
+ elif self.source == 'cif':
639
+ content = self.mol
640
+ fmt = 'cif'
641
+
642
+ elif self.source == 'mol':
643
+ content = self.mol
644
+ fmt = 'xyz'
645
+
646
+ # --- EXTRACTION XYZData (Interne) ---
647
+ # On extrait les données ici avant toute modification (RDKit ou Supercell)
648
+ try:
649
+ if self.source == 'ase':
650
+ temp_atoms = self.mol
651
+ else:
652
+ temp_atoms = read(io.StringIO(content), format=fmt)
653
+
654
+ self.data = XYZData(
655
+ symbols=temp_atoms.get_chemical_symbols(),
656
+ positions=temp_atoms.get_positions()
657
+ )
658
+ except Exception as e:
659
+ print(f"Note: Extraction des coordonnées impossible ({e})")
660
+ self.data = None
661
+
662
+ # --- Modern Bond Perception with RDKit ---
663
+ if self.detect_bonds and self.source in ['file', 'mol', 'xyz'] and fmt == 'xyz':
664
+ try:
665
+ from rdkit import Chem
666
+ from rdkit.Chem import rdDetermineBonds
667
+
668
+ raw_mol = Chem.MolFromXYZBlock(content)
669
+ rdDetermineBonds.DetermineConnectivity(raw_mol)
670
+ rdDetermineBonds.DetermineBondOrders(raw_mol, charge=0)
671
+
672
+ content = Chem.MolToMolBlock(raw_mol)
673
+ fmt = "sdf"
674
+ except ImportError:
675
+ # Silent skip if RDKit is missing
676
+ pass
677
+ except Exception as e:
678
+ # Small warning if the geometry is the problem
679
+ print(f"Note: Bond perception failed for {self.mol}. Falling back to standard XYZ.")
680
+
681
+ # --- 3. Rendering Logic ---
682
+ if fmt == 'cif' or self.supercell != (1, 1, 1) or self.source == 'ase':
683
+ # Create ASE atoms object
684
+ if self.source == 'ase':
685
+ atoms = self.mol
686
+ else:
687
+ atoms = read(io.StringIO(content), format=fmt)
688
+
689
+ # --- CRYSTAL LOGIC (Jmol packed-like) ---
690
+
691
+ # 1. Read primitive cell (before supercell)
692
+ atoms0 = atoms.copy()
693
+
694
+ # 2. Apply supercell if requested
695
+ if self.supercell != (1, 1, 1):
696
+ atoms = atoms * self.supercell
697
+
698
+ # 3. Send atoms to py3Dmol (XYZ, robust)
699
+ xyz_buf = io.StringIO()
700
+ write(xyz_buf, atoms, format="xyz")
701
+
702
+ if self.viewer:
703
+ self.v.addModel(xyz_buf.getvalue(), "xyz")
704
+
705
+ # 4. Draw supercell (optional, thick & gray)
706
+ if self.supercell != (1, 1, 1):
707
+ self._draw_lattice_wireframe(
708
+ atoms0.cell,
709
+ self.supercell,
710
+ color="gray",
711
+ radius=0.015
712
+ )
713
+ self._draw_cell_wireframe(
714
+ atoms.cell,
715
+ color="gray",
716
+ radius=0.015
717
+ )
718
+
719
+ # 5. Draw primitive cell (Jmol packed equivalent)
720
+ self._draw_cell_wireframe(
721
+ atoms0.cell,
722
+ color="black",
723
+ radius=0.03
724
+ )
725
+ # Vecteurs a, b, c
726
+ self._draw_cell_vectors(
727
+ atoms0.cell,
728
+ radius=0.04
729
+ )
730
+
731
+ else:
732
+ # Standard molecule (non-crystal)
733
+ if self.viewer:
734
+ self.v.addModel(content, fmt)
735
+ # FIX: Create the atoms object for standard molecules here
736
+ atoms = read(io.StringIO(content), format=fmt)
737
+
738
+
739
+ # Finalize
740
+ if self.viewer:
741
+ self._apply_style()
742
+ self._add_interactions()
743
+ # Detect and add H-bonds if hydrogens are present
744
+ symbols = atoms.get_chemical_symbols()
745
+ if 'H' in symbols and self.displayHbonds:
746
+ self._add_h_bonds(atoms)
747
+ self.v.zoomTo()
748
+ if self.zoom is not None:
749
+ self.v.zoom(self.zoom)
750
+ elif self.source != 'cif':
751
+ self.v.zoom(0.9) # Zoom par défaut pour ne pas coller aux bords
752
+ if show: self.v.show()
753
+
754
+ def _apply_element_colors(self, color_table):
755
+ """
756
+ Override element colors without breaking the current style (bs / cpk).
757
+ """
758
+ for elem, color in color_table.items():
759
+ if self.style == 'bs':
760
+ self.v.setStyle(
761
+ {'elem': elem},
762
+ {
763
+ 'sphere': {'color': color, 'scale': 0.25},
764
+ 'stick': {'color': color, 'radius': 0.15}
765
+ }
766
+ )
767
+
768
+ elif self.style == 'cpk':
769
+ self.v.setStyle(
770
+ {'elem': elem},
771
+ {
772
+ 'sphere': {'color': color, 'scale': self.cpk_scale}
773
+ }
774
+ )
775
+
776
+ def _apply_style(self):
777
+ """Apply either ball-and-stick, cartoon or CPK style."""
778
+
779
+ if self.style == 'bs':
780
+ self.v.setStyle({'sphere': {'scale': 0.25, 'colorscheme': 'element'},
781
+ 'stick': {'radius': 0.15, 'multibond': True}})
782
+ self._apply_element_colors(JMOL_COLORS)
783
+ elif self.style == 'cpk':
784
+ self.v.setStyle({'sphere': {'scale': self.cpk_scale,
785
+ 'colorscheme': 'element'}})
786
+ self._apply_element_colors(JMOL_COLORS)
787
+ elif self.style == 'cartoon':
788
+ self.v.setStyle({'cartoon': {'color': 'spectrum', 'style': 'rectangle', 'arrows': True}})
789
+ else:
790
+ raise ValueError("style must be 'bs', 'cpk' or 'cartoon'")
791
+
792
+ def _add_interactions(self):
793
+ """Add basic JavaScript Hover labels for atom identification."""
794
+ label_js = "function(atom,viewer) { viewer.addLabel(atom.elem+atom.serial,{position:atom, backgroundColor:'black'}); }"
795
+ reset_js = "function(atom,viewer) { viewer.removeAllLabels(); }"
796
+ self.v.setHoverable({}, True, label_js, reset_js)
797
+
798
+ def show_bounding_sphere(self, color='gray', opacity=0.2, scale=1.0):
799
+ """Calculates and displays the VdW bounding sphere in one go."""
800
+ if self.data:
801
+ center, radius = self.data.get_bounding_sphere(include_vdw=True, scale=scale)
802
+ self.v.addSphere({
803
+ 'center': {'x': float(center[0]), 'y': float(center[1]), 'z': float(center[2])},
804
+ 'radius': float(radius),
805
+ 'color': color,
806
+ 'opacity': opacity
807
+ })
808
+ print(f"Bounding Sphere: Radius = {radius:.2f} Š| Volume = {(4/3)*np.pi*radius**3:.2f} ų")
809
+ return self.v.show()
810
+
811
+ def show_cage_cavity(self, grid_spacing=0.5, color='cyan', opacity=0.5):
812
+ """Calculates cavity with CageCavityCalc and displays it as a single model."""
813
+ if self.data:
814
+ result = self.data.get_cage_volume(grid_spacing=grid_spacing, return_spheres=True)
815
+ if result:
816
+ volume, spheres = result
817
+ L, W, H = self.data.get_cavity_dimensions(spheres)
818
+ # Création du modèle optimisé pour éviter le gel du navigateur
819
+ xyz_cavity = f"{len(spheres)}\nCavity points\n"
820
+ for pos in spheres.get_positions():
821
+ xyz_cavity += f"He {pos[0]:.3f} {pos[1]:.3f} {pos[2]:.3f}\n"
822
+
823
+ self.v.addModel(xyz_cavity, "xyz")
824
+ # On applique le style au dernier modèle ajouté
825
+ self.v.setStyle({'model': -1}, {
826
+ 'sphere': {'radius': grid_spacing/2, 'color': color, 'opacity': opacity}
827
+ })
828
+ print(f"Cavity Volume (CageCavityCalc): {volume:.2f} ų")
829
+ print(f"Dimensions: {L:.2f} x {W:.2f} x {H:.2f} Å")
830
+ print(f"Aspect Ratio (L/W): {L/W:.2f}")
831
+ return self.v.show()