ePDFsuite 0.1.4__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.
@@ -0,0 +1,2616 @@
1
+ import subprocess
2
+ import os
3
+ import numpy as np
4
+ from ase.io import read,write
5
+ from ase.spacegroup import get_spacegroup,Spacegroup
6
+ from ase.cluster import Icosahedron, Octahedron, Decahedron
7
+ from diffpy.srfit.fitbase import FitContribution, FitRecipe
8
+ from diffpy.srfit.fitbase import FitResults
9
+ from diffpy.srfit.fitbase import Profile
10
+ from diffpy.srfit.pdf import PDFParser, DebyePDFGenerator
11
+ from diffpy.structure import Structure
12
+ from scipy.optimize import least_squares
13
+ import matplotlib as mpl
14
+ from matplotlib import pyplot as plt
15
+ from pathlib import Path
16
+ import glob
17
+ import math
18
+ import re
19
+ import random
20
+ from ase.calculators.emt import EMT
21
+ from ase.optimize import BFGS,GPMin,FIRE,MDMin
22
+ from ase.io.trajectory import Trajectory
23
+ from scipy.spatial import ConvexHull, cKDTree
24
+ from ase import Atoms
25
+
26
+
27
+ class PDFExtractor:
28
+ def __init__(self,
29
+ datafilelist,
30
+ composition,
31
+ qmin,
32
+ qmax,
33
+ qmaxinst,
34
+ wavelength=0.7107,
35
+ dataformat='QA',
36
+ rmin=0,
37
+ rmax=50,
38
+ rstep=0.01,
39
+ bgscale=1,
40
+ rpoly=0.9,
41
+ emptyfile=None):
42
+ self.datafilelist=datafilelist
43
+ self.emptyfile=emptyfile
44
+ self.composition=composition
45
+ self.qmin=qmin
46
+ self.qmax=qmax
47
+ self.qmaxinst=qmaxinst
48
+ self.wl=wavelength
49
+ self.dataformat=dataformat
50
+ self.rmin=rmin
51
+ self.rmax=rmax
52
+ self.rstep=rstep
53
+ self.bgscale=bgscale
54
+ self.rpoly=rpoly
55
+
56
+
57
+ def writecfg(self):
58
+ """
59
+ datafilelist: list of paths to data files from wich PDF should be extracted
60
+ """
61
+
62
+ self.datapath=os.path.dirname(self.datafilelist[0])
63
+ self.pdfpath=self.datapath+'/extracted_PDF'
64
+
65
+ os.makedirs(self.pdfpath,exist_ok=True)
66
+
67
+ cfg=open(self.pdfpath+'/pdfgetX3_GUI.cfg','w')
68
+ cfg.write('[DEFAULT] \n')
69
+ cfg.write('dataformat = %s' %self.dataformat +' \n')
70
+
71
+
72
+
73
+ cfg.write('inputfile='+''.join(os.path.basename(i) +'\n' +'\t'
74
+ for i in self.datafilelist[:-1]))
75
+ cfg.write('\t %s' %os.path.basename(self.datafilelist[-1])+'\n')
76
+ cfg.write('datapath = %s' % os.path.dirname(self.datafilelist[0])+'/' +'\n')
77
+ if self.emptyfile is not None:
78
+ cfg.write('\t %s' %os.path.dirname(self.emptyfile)+'\n')
79
+
80
+ cfg.write('bgscale=%f \n' %self.bgscale)
81
+ cfg.write('backgroundfile=%s' % os.path.basename(self.emptyfile)+'\n')
82
+
83
+
84
+ cfg.write('composition= %s \n'%str(self.composition))
85
+ cfg.write('qmin=%f \n' %self.qmin)
86
+ cfg.write('qmax=%f \n' %self.qmax)
87
+ cfg.write('qmaxinst=%f \n' %self.qmaxinst)
88
+ cfg.write('wavelength=%f \n' %self.wl)
89
+ cfg.write('mode = xray \n')
90
+ cfg.write('rpoly=%f \n' %self.rpoly)
91
+ cfg.write('rmin=%f \n' %self.rmin)
92
+ cfg.write('rstep=%f \n' %self.rstep)
93
+ cfg.write('rmax=%f \n' %self.rmax)
94
+ cfg.write('output=%s' %self.pdfpath +'/@b.@o \n')
95
+ cfg.write('outputtype = sq,gr \n')
96
+ #cfg.write('plot = iq,fq,gr \n' )
97
+ cfg.write('force = yes \n')
98
+
99
+ cfg.close()
100
+ return
101
+
102
+
103
+ def extractpdf(self):
104
+ self.writecfg()
105
+ command = 'conda run -n py36 pdfgetx3 -c' +self.pdfpath+'/pdfgetX3_GUI.cfg'
106
+
107
+ # Use subprocess to execute the command
108
+ subprocess.run(command, shell=True)
109
+ print(f'PDF file(s) extracted in {self.pdfpath}')
110
+ # Plot pdf
111
+
112
+ fig,ax=plt.subplots()
113
+ for file in self.datafilelist:
114
+ rootname=(os.path.basename(file).split('/')[-1]).split('.')[0]
115
+ pdffile=self.pdfpath+f'/{rootname}.gr'
116
+ r,g=np.loadtxt(pdffile,skiprows=27,unpack=True)
117
+ ax.plot(r,g,label=rootname)
118
+ ax.set_xlabel('r ($\\AA$)')
119
+ ax.set_ylabel('G(r)')
120
+ fig.legend()
121
+ fig.tight_layout()
122
+
123
+ return self.pdfpath
124
+
125
+
126
+ class StructureGenerator():
127
+ def __init__(self,pdfpath,cif_file:str,size_array:tuple=None, min_params:tuple=[1,1],max_params:tuple=[10,10],sphere_only: bool=False,
128
+ auto_mode: bool=False, pdf_file: str=None, r_coh: float=None, n_sizes: int=2, tolerance: float=0.1,
129
+ max_search_param: int=20, derivative_sigma: float=5.0, amplitude_sigma: float=3.0,
130
+ window_size: int=10, derivative_weight: float=0.0, noise_window_start: float=0.85,
131
+ score_threshold: float=0.001, n_jobs: int=-1):
132
+ """
133
+ pdfpath: directory where pdf are stored
134
+ cif_file: path to cif file (provide Fm-3m SG if N.A. (e.g. icosahedra))
135
+ size_array: tuple array of diameters of envelopping sphere (if None and auto_mode=True, will be auto-determined)
136
+ min_params: tuple array of parameters used to define ase clusters (min values) - ignored in auto_mode
137
+ max_params: tuple array of parameters used to define ase clusters (max values) - ignored in auto_mode
138
+ sphere_only: bool Make Spherical particles only
139
+ auto_mode: bool If True, automatically determine sizes from PDF analysis or r_coh
140
+ pdf_file: str Path to PDF file to analyze (required if auto_mode=True and r_coh=None)
141
+ r_coh: float Coherence length / max particle diameter (Å). If provided, bypasses automatic detection
142
+ n_sizes: int Number of different sizes to generate for spheres in auto mode (default=2)
143
+ tolerance: float Absolute tolerance around r_coh in Angströms (±tolerance Å)
144
+ max_search_param: int Maximum value for p and q parameters when searching in auto mode
145
+ derivative_sigma: float Sigma multiplier for derivative threshold (for auto detection only)
146
+ amplitude_sigma: float Sigma multiplier for amplitude threshold (for auto detection only)
147
+ window_size: int Window size for local statistics in PDF analysis (for auto detection only)
148
+ derivative_weight: float Weight for derivative terms (default=0.0, set >0 to use derivative; if 0, only amplitude is used)
149
+ noise_window_start: float Fraction of r-range where noise reference window starts (default=0.85 = last 15%)
150
+ score_threshold: float Score threshold for r_max detection (default=0.001). Lower values = stricter detection (larger r_max)
151
+ n_jobs: int Number of parallel jobs for structure generation (-1 = all CPU cores, 1 = sequential)
152
+ """
153
+ self.pdfpath=pdfpath
154
+ self.cif_file=cif_file
155
+ self.auto_mode=auto_mode
156
+ self.pdf_file=pdf_file
157
+ self.r_coh=r_coh
158
+ self.n_sizes=n_sizes
159
+ self.tolerance=tolerance
160
+ self.max_search_param=max_search_param
161
+ self.derivative_sigma=derivative_sigma
162
+ self.amplitude_sigma=amplitude_sigma
163
+ self.window_size=window_size
164
+ self.derivative_weight=derivative_weight
165
+ self.noise_window_start=noise_window_start
166
+ self.score_threshold=score_threshold
167
+ self.n_jobs=n_jobs
168
+
169
+ # Auto mode: determine parameters from r_coh or PDF analysis
170
+ if self.auto_mode:
171
+ if self.r_coh is not None:
172
+ # Manual specification of coherence length
173
+ print(f"Using user-specified r_coh: {self.r_coh:.2f} Å")
174
+ self.r_max = self.r_coh
175
+ else:
176
+ # Automatic detection from PDF (fallback if no r_coh)
177
+ if self.pdf_file is not None:
178
+ print("No r_coh specified, analyzing PDF to auto-detect r_max...")
179
+ try:
180
+ self.r_max = self.analyze_pdf_and_get_rmax()
181
+ except Exception as e:
182
+ print(f"⚠️ Auto-detection failed: {e}")
183
+ print("Using default r_max = 30.0 Å")
184
+ self.r_max = 30.0
185
+ else:
186
+ # No r_coh and no pdf_file: use default
187
+ print("⚠️ No r_coh or pdf_file provided, using default r_max = 30.0 Å")
188
+ self.r_max = 30.0
189
+
190
+ self.size_array = self.auto_size_array_from_rmax()
191
+ else:
192
+ if size_array is None:
193
+ raise ValueError("size_array must be provided when auto_mode=False")
194
+ self.size_array=size_array
195
+ self.r_max = None
196
+
197
+ self.structure=read(self.cif_file)
198
+ #SG=Spacegroup(structure)
199
+ SG=Spacegroup(get_spacegroup(self.structure))
200
+
201
+ self.SGNo=SG.no
202
+ self.lattice_parameters=self.structure.get_cell()
203
+ self.a,self.b,self.c=self.lattice_parameters.lengths()
204
+ self.alpha,self.beta,self.gamma=self.lattice_parameters.angles()
205
+ self.atoms=self.structure.get_chemical_symbols()
206
+ self.atom_positions=self.structure.get_scaled_positions()
207
+ self.bravais=self.get_crystal_type()
208
+ print('Crystal structure loaded from cif:')
209
+ print(f'Cell edges: a={self.a:4f}, b={self.b:4f}, c={self.c:4f}')
210
+ print(f'Cell angles: $\\alpha$={self.alpha:.2f},$\\beta$={self.beta:.2f}, $\\gamma$={self.gamma:.2f} ')
211
+ print(f'Bravais unit cell:{self.bravais}')
212
+ print('Atomic Positions:')
213
+ i=0
214
+ for frac_coord in enumerate(self.atom_positions):
215
+ print(f"Atom {self.atoms[i]}: {frac_coord}")
216
+ i+=1
217
+ pass
218
+ self.min_params=min_params
219
+ self.max_params=max_params
220
+ self.sphere_only=sphere_only
221
+
222
+ def analyze_pdf_and_get_rmax(self):
223
+ """
224
+ Analyzes PDF to find r_max where G(r) becomes noise
225
+ Uses strong smoothing then derivative to detect signal->noise transition
226
+
227
+ Returns:
228
+ r_max: value of r where PDF becomes quasi-null
229
+ """
230
+ from scipy.signal import savgol_filter
231
+
232
+ # Read PDF (assuming standard .gr format: r, G(r))
233
+ try:
234
+ data = np.loadtxt(self.pdf_file, skiprows=27)
235
+ r = data[:, 0]
236
+ gr = data[:, 1]
237
+ except:
238
+ raise ValueError(f"Cannot read PDF file: {self.pdf_file}")
239
+
240
+ # STRONG SMOOTHING to eliminate FFT truncation oscillations
241
+ # Use Savitzky-Golay with wide window
242
+ window_length = min(101, len(gr) - 1) # Must be odd
243
+ if window_length % 2 == 0:
244
+ window_length -= 1
245
+
246
+ gr_smooth = savgol_filter(gr, window_length=window_length, polyorder=3)
247
+
248
+ # Find position of main G(r) peak
249
+ idx_max = np.argmax(np.abs(gr_smooth))
250
+ r_max_peak = r[idx_max]
251
+
252
+ # Calculate derivative of SMOOTHED signal
253
+ dgr_smooth = np.gradient(gr_smooth, r)
254
+
255
+ # Also smooth the derivative
256
+ dgr_smooth = savgol_filter(dgr_smooth, window_length=window_length, polyorder=2)
257
+
258
+ # Calculate threshold based on user-defined noise window
259
+ noise_region_start = int(self.noise_window_start * len(r))
260
+ dgr_noise = dgr_smooth[noise_region_start:]
261
+ gr_noise = gr_smooth[noise_region_start:]
262
+
263
+ # Noise statistics
264
+ noise_deriv_std = np.std(dgr_noise)
265
+ noise_gr_std = np.std(gr_noise)
266
+ noise_gr_mean = np.mean(np.abs(gr_noise))
267
+
268
+ # Reference values for scoring
269
+ derivative_ref = noise_deriv_std * self.derivative_sigma
270
+ gr_ref = noise_gr_mean + self.amplitude_sigma * noise_gr_std
271
+
272
+ # Start search AFTER main peak
273
+ # Use CONTINUOUS scoring instead of binary thresholds
274
+ search_window = max(self.window_size, 20)
275
+
276
+ # Define minimum search radius to avoid early local minima
277
+ # Set to at least 15 Å to avoid detecting artifacts in the first coordination shells
278
+ rmin_search = max(15.0, r_max_peak + 5.0)
279
+ idx_min_search = np.argmin(np.abs(r - rmin_search))
280
+
281
+ best_candidate = None
282
+ all_scores = []
283
+
284
+ # Start search from idx_min_search instead of idx_max
285
+ for i in range(max(idx_max, idx_min_search), len(r) - search_window):
286
+ window_deriv = dgr_smooth[i:i+search_window]
287
+ window_gr = gr_smooth[i:i+search_window]
288
+
289
+ # Window statistics
290
+ std_deriv = np.std(window_deriv)
291
+ mean_abs_gr = np.mean(np.abs(window_gr))
292
+ max_abs_deriv = np.max(np.abs(window_deriv))
293
+ max_abs_gr = np.max(np.abs(window_gr))
294
+
295
+ # CONTINUOUS score: combination of normalized deviations from noise level
296
+ # Lower score = closer to noise = better candidate
297
+ if self.derivative_weight == 0:
298
+ # Simplified: only use amplitude (most effective according to user tests)
299
+ score = (mean_abs_gr / (gr_ref + 1e-10))**2
300
+ else:
301
+ # Full score with derivative terms weighted
302
+ score = (
303
+ self.derivative_weight * (max_abs_deriv / (derivative_ref + 1e-10))**2 +
304
+ (mean_abs_gr / (gr_ref + 1e-10))**2 +
305
+ self.derivative_weight * ((std_deriv - noise_deriv_std) / (noise_deriv_std + 1e-10))**2
306
+ )
307
+
308
+ all_scores.append((i, r[i], score))
309
+
310
+ # NEW STRATEGY: Find FIRST position where score < threshold (not absolute minimum)
311
+ # This makes the detection sensitive to gr_ref value
312
+ if best_candidate is None and score < self.score_threshold:
313
+ best_candidate = i
314
+ detected_score = score
315
+ break # Stop at first match
316
+
317
+ # Fallback: if no position meets threshold, use minimum score
318
+ if best_candidate is None:
319
+ min_score = float('inf')
320
+ for idx, r_val, score in all_scores:
321
+ if score < min_score:
322
+ min_score = score
323
+ best_candidate = idx
324
+ detected_score = min_score
325
+
326
+ # Always return best candidate found
327
+ if best_candidate is not None:
328
+ print(f" r_max detected: {r[best_candidate]:.2f} Å (score: {detected_score:.3f})")
329
+ print(f" Search started from r = {rmin_search:.1f} Å to avoid early artifacts")
330
+ return r[best_candidate]
331
+
332
+ # Fallback: return 70% of r_max if no clear detection
333
+ print(f" No clear transition detected, using 70% of r_max")
334
+ return r[-1] * 0.7
335
+
336
+
337
+
338
+ def auto_size_array_from_rmax(self):
339
+ """
340
+ Génère automatiquement size_array basé sur r_max et la tolérance
341
+
342
+ Returns:
343
+ size_array: tuple de diamètres pour les sphères
344
+ """
345
+ r_min = self.r_max - self.tolerance
346
+ r_max_tol = self.r_max + self.tolerance
347
+
348
+ size_array = tuple(np.linspace(r_min, r_max_tol, self.n_sizes))
349
+
350
+ return size_array
351
+
352
+ def is_diameter_in_target_range(self, diameter):
353
+ """
354
+ Vérifie si un diamètre tombe dans la fenêtre cible
355
+
356
+ Args:
357
+ diameter: diamètre à vérifier
358
+
359
+ Returns:
360
+ bool: True si le diamètre est dans la fenêtre acceptable
361
+ """
362
+ if self.r_max is None:
363
+ return True # Pas de filtre en mode manuel
364
+
365
+ d_min = self.r_max - self.tolerance
366
+ d_max = self.r_max + self.tolerance
367
+
368
+ return d_min <= diameter <= d_max
369
+
370
+ def get_crystal_type(self):
371
+ """
372
+ Find the Bravais lattice based on the space group number.
373
+ """
374
+ spacegroup_number=self.SGNo
375
+ # bravais lattice based on space group number https://fr.wikipedia.org/wiki/Groupe_d%27espace
376
+ if 195 <= spacegroup_number <= 230: # Cubic
377
+ if spacegroup_number == 225:
378
+ return 'fcc'
379
+ elif spacegroup_number == 229:
380
+ return 'bcc'
381
+ else:
382
+ return 'cubic'
383
+ elif 168 <= spacegroup_number <= 194: # Hexagonal
384
+ return 'hcp'
385
+ elif 75 <= spacegroup_number <= 142: # Tetragonal
386
+ return 'tetragonal'
387
+ elif 16 <= spacegroup_number <= 74: # Orthorhombic
388
+ return 'orthorhombic'
389
+ elif 3 <= spacegroup_number <= 15: # Monoclinic
390
+ return 'monoclinic'
391
+ elif 1 <= spacegroup_number <= 2: # Triclinic
392
+ return 'triclinic'
393
+ else:
394
+ return 'unknown'
395
+
396
+ def diameter_from_Atoms(self,Atoms):
397
+ xyz_coord=Atoms.get_positions()
398
+ x=list(zip(*xyz_coord))[0];y=list(zip(*xyz_coord))[1];z=list(zip(*xyz_coord))[2]
399
+ x_center=np.mean(x);y_center=np.mean(y);z_center=np.mean(z)
400
+ x_ok=x-x_center;y_ok=y-y_center;z_ok=z-z_center
401
+ r=(x_ok**2+y_ok**2+z_ok**2)**(1/2)
402
+ return max(r)
403
+
404
+ def center(self,pos_array):
405
+ output=np.zeros_like(pos_array)
406
+ x=pos_array[:,0];y=pos_array[:,1];z=pos_array[:,2]
407
+ x0=np.mean(x);y0=np.mean(y);z0=np.mean(z)
408
+ i=0
409
+ for pos in pos_array:
410
+ x,y,z=pos
411
+ xok=x-x0;yok=y-y0;zok=z-z0
412
+ output[i]=[xok,yok,zok]
413
+ i+=1
414
+ return output
415
+
416
+ def writexyz(self,filename,atoms):
417
+ """atoms ase Atoms object"""
418
+ cifname=(os.path.basename(self.cif_file).split('/')[-1]).split('.')[0]
419
+ strufile_dir=self.pdfpath+f'/structure_files_{cifname}'
420
+ os.makedirs(strufile_dir,exist_ok=True)
421
+ #write(strufile_dir+f'/{filename}.xyz',atoms)
422
+ element_array=atoms.get_chemical_symbols()
423
+ # extract composition in dict form
424
+ composition={}
425
+ for element in element_array:
426
+ if element in composition:
427
+ composition[element]+=1
428
+ else:
429
+ composition[element]=1
430
+
431
+ coord=atoms.get_positions()
432
+ natoms=len(element_array)
433
+ line2write='%d \n'%natoms
434
+ line2write+='%s\n'%str(composition)
435
+ for i in range(natoms):
436
+ line2write+='%s'%str(element_array[i])+'\t %.8f'%float(coord[i,0])+'\t %.8f'%float(coord[i,1])+'\t %.8f'%float(coord[i,2])+'\n'
437
+ with open(strufile_dir+f'/{filename}.xyz','w') as file:
438
+ file.write(line2write)
439
+
440
+ def makeSphere(self,phi):
441
+ # makesupercell
442
+ nbcell=np.max([math.ceil(phi/self.a),math.ceil(phi/self.b),math.ceil(phi/self.c)])+1
443
+ scaling_factors=[nbcell,nbcell,nbcell]
444
+ supercell = self.structure.repeat(scaling_factors)
445
+
446
+ original_positions = supercell.get_positions()
447
+
448
+ #positions should be centered around 0
449
+ original_positions=self.center(original_positions)
450
+ atom_names=supercell.get_atomic_numbers()
451
+
452
+ # atoms to delete
453
+ delAtoms=[]
454
+ for i in range(len(atom_names)):
455
+ x, y, z = original_positions[i]
456
+ r = np.sqrt(x**2 + y**2+z**2)
457
+ condition=True
458
+ # Ensure the cylinder is maintained
459
+ if r > phi/2:
460
+ condition=False
461
+ if not condition:
462
+ delAtoms.append(i)
463
+ del supercell[delAtoms]
464
+ nbatoms=len(supercell)
465
+ #write xyz file
466
+ cifname=(os.path.basename(self.cif_file).split('/')[-1]).split('.')[0]
467
+ filename=f'Sphere_phi={int(phi)}_{cifname}_{nbatoms}atoms'
468
+ self.writexyz(filename,supercell)
469
+ return filename,phi,nbatoms
470
+
471
+ def makeIcosahedron(self,p):
472
+ ico=Icosahedron(self.atoms[0],p,self.a)
473
+ nbatoms=len(ico)
474
+ cifname=(os.path.basename(self.cif_file).split('/')[-1]).split('.')[0]
475
+ filename=f'Ih_{p}shells_phi={int(2*self.diameter_from_Atoms(ico))}_{cifname}_{nbatoms}atoms'
476
+ self.writexyz(filename,ico)
477
+ return filename,2*self.diameter_from_Atoms(ico),nbatoms
478
+
479
+ def makeDecahedron(self,p,q):
480
+ deca=Decahedron(self.atoms[0],p,q,0,self.a)
481
+ nbatoms=len(deca)
482
+ cifname=(os.path.basename(self.cif_file).split('/')[-1]).split('.')[0]
483
+ filename=f'Dh_{p}_{q}_phi={int(2*self.diameter_from_Atoms(deca))}_{cifname}_{nbatoms}atoms'
484
+ self.writexyz(filename,deca)
485
+ return filename,2*self.diameter_from_Atoms(deca),nbatoms
486
+
487
+ def makeOctahedron(self,p,q):
488
+
489
+ octa=Octahedron(self.atoms[0],p,q,self.a)
490
+ nbatoms=len(octa)
491
+ cifname=(os.path.basename(self.cif_file).split('/')[-1]).split('.')[0]
492
+ if q==0:
493
+ filename=f'RegOh_{p}_0_phi={int(2*self.diameter_from_Atoms(octa))}_{cifname}_{nbatoms}atoms'
494
+ if p==2*q+1:
495
+ filename=f'CubOh_{p}_{q}_phi={int(2*self.diameter_from_Atoms(octa))}_{cifname}_{nbatoms}atoms'
496
+ if p==3*q+1:
497
+ filename=f'RegTrOh_{p}_{q}_phi={int(2*self.diameter_from_Atoms(octa))}_{cifname}_{nbatoms}atoms'
498
+ else:
499
+ filename=f'TrOh_{p}_{q}_phi={int(2*self.diameter_from_Atoms(octa))}_{cifname}_{nbatoms}atoms'
500
+ self.writexyz(filename,octa)
501
+ return filename,2*self.diameter_from_Atoms(octa),nbatoms
502
+
503
+ def returnPointsThatLieInPlanes(self,planes: np.ndarray,
504
+ coords: np.ndarray,
505
+ debug: bool=False,
506
+ threshold: float=1e-3
507
+ ):
508
+ """
509
+ Finds all points (atoms) that lie within the given planes based on a signed distance criterion.
510
+
511
+ Args:
512
+ planes (np.ndarray): A 2D array where each row represents a plane equation [a, b, c, d] for the plane ax + by + cz + d = 0.
513
+ coords (np.ndarray): A 2D array where each row is the coordinates of an atom [x, y, z].
514
+ debug (bool, optional): If True, prints additional debugging information. Defaults to False.
515
+ threshold (float, optional): The tolerance for the distance to the plane to consider a point as lying in the plane. Defaults to 1e-3.
516
+ noOutput (bool, optional): If True, suppresses the output messages. Defaults to False.
517
+
518
+ Returns:
519
+ np.ndarray: A boolean array where True indicates that the atom lies in one of the planes.
520
+ """
521
+ import numpy as np
522
+
523
+ AtomsInPlane = np.zeros(len(coords), dtype=bool)
524
+ for p in planes:
525
+ for i,c in enumerate(coords):
526
+ signedDistance = self.Pt2planeSignedDistance(p,c)
527
+ AtomsInPlane[i] = AtomsInPlane[i] or np.abs(signedDistance) < threshold
528
+ nOfAtomsInPlane = np.count_nonzero(AtomsInPlane)
529
+ if debug:
530
+ print(f"- plane", [f"{x: .2f}" for x in p],f"> {nOfAtomsInPlane} atoms lie in the planes")
531
+ for i,a in enumerate(delAtoms):
532
+ if a: print(f"@{i+1}",end=',')
533
+ print("",end='\n')
534
+ AtomsInPlane = np.array(AtomsInPlane)
535
+ return AtomsInPlane
536
+
537
+ def Pt2planeSignedDistance(self,plane,point):
538
+ '''
539
+ Returns the orthogonal distance of a given point X0 to the plane p in a metric space (projection of X0 on p = P),
540
+ with the sign determined by whether or not X0 is in the interior of p with respect to the center of gravity [0 0 0]
541
+ Args:
542
+ - plane (numpy array): [u v w h] definition of the P plane
543
+ - point (numpy array): [x0 y0 z0] coordinates of the X0 point
544
+ Returns:
545
+ the signed modulus ±||PX0||
546
+ '''
547
+
548
+ sd = (plane[3] + np.dot(plane[0:3],point))/np.sqrt(plane[0]**2+plane[1]**2+plane[2]**2)
549
+ return sd
550
+
551
+ def coreSurface(self,atoms: Atoms,
552
+ threshold=1e-3
553
+ ):
554
+
555
+ from scipy.spatial import ConvexHull
556
+
557
+ coords = atoms.get_positions()
558
+ hull = ConvexHull(coords)
559
+ atoms.trPlanes = hull.equations
560
+ surfaceAtoms = self.returnPointsThatLieInPlanes(atoms.trPlanes,coords,threshold=threshold)
561
+
562
+ return [hull.vertices,hull.simplices,hull.neighbors,hull.equations], surfaceAtoms
563
+
564
+
565
+ def detect_surface_atoms(self,filename,view=False):
566
+ atoms=read(filename+'.xyz')
567
+ _, surfaceAtoms = self.coreSurface(atoms)
568
+ coords = atoms.get_positions()
569
+ hull = ConvexHull(coords)
570
+ surface_indices = hull.vertices
571
+ n_surface_atoms = len(hull.vertices)
572
+ if view:
573
+ from ase.visualize import view
574
+ surface_indices = hull.vertices
575
+
576
+ # Create a copy to modify
577
+ atoms_copy = atoms.copy()
578
+
579
+ # Option 1: Change color by changing chemical symbols
580
+ # For example, make surface atoms 'O' and others 'C'
581
+ # (you can pick other symbols if you like)
582
+ symbols = ['C'] * len(atoms)
583
+ for idx in surface_indices:
584
+ symbols[idx] = 'O' # change to oxygen, so it'll show up red
585
+ atoms_copy.set_chemical_symbols(symbols)
586
+
587
+ view(atoms_copy)
588
+ return surfaceAtoms.sum()
589
+
590
+ def _process_icosahedron(self, p):
591
+ """
592
+ Traite un icosaèdre avec paramètre p (pour parallélisation)
593
+ Retourne None si hors fenêtre, sinon (diameter, filename, size, nbatoms, nbsurfatoms)
594
+ """
595
+ try:
596
+ ico = Icosahedron(self.atoms[0], p, self.a)
597
+ diameter = 2 * self.diameter_from_Atoms(ico)
598
+
599
+ if self.is_diameter_in_target_range(diameter):
600
+ cifname = (os.path.basename(self.cif_file).split('/')[-1]).split('.')[0]
601
+ strufile_dir = self.pdfpath + f'/structure_files_{cifname}/'
602
+ filename, size, nbatoms = self.makeIcosahedron(p)
603
+ nbsurfatoms = self.detect_surface_atoms(strufile_dir + filename)
604
+ return (diameter, filename, size, nbatoms, nbsurfatoms)
605
+ except Exception as e:
606
+ pass
607
+ return None
608
+
609
+ def _process_decahedron(self, p, q):
610
+ """
611
+ Traite un décaèdre avec paramètres p, q (pour parallélisation)
612
+ Retourne None si hors fenêtre, sinon (diameter, filename, size, nbatoms, nbsurfatoms)
613
+ """
614
+ try:
615
+ deca = Decahedron(self.atoms[0], p, q, 0, self.a)
616
+ diameter = 2 * self.diameter_from_Atoms(deca)
617
+
618
+ if self.is_diameter_in_target_range(diameter):
619
+ cifname = (os.path.basename(self.cif_file).split('/')[-1]).split('.')[0]
620
+ strufile_dir = self.pdfpath + f'/structure_files_{cifname}/'
621
+ filename, size, nbatoms = self.makeDecahedron(p, q)
622
+ nbsurfatoms = self.detect_surface_atoms(strufile_dir + filename)
623
+ return (diameter, filename, size, nbatoms, nbsurfatoms)
624
+ except Exception as e:
625
+ pass
626
+ return None
627
+
628
+ def _process_octahedron(self, p, q):
629
+ """
630
+ Traite un octaèdre avec paramètres p, q (pour parallélisation)
631
+ Retourne None si hors fenêtre, sinon (diameter, filename, size, nbatoms, nbsurfatoms)
632
+ """
633
+ try:
634
+ octa = Octahedron(self.atoms[0], p, q, self.a)
635
+ diameter = 2 * self.diameter_from_Atoms(octa)
636
+
637
+ if self.is_diameter_in_target_range(diameter):
638
+ cifname = (os.path.basename(self.cif_file).split('/')[-1]).split('.')[0]
639
+ strufile_dir = self.pdfpath + f'/structure_files_{cifname}/'
640
+ filename, size, nbatoms = self.makeOctahedron(p, q)
641
+ nbsurfatoms = self.detect_surface_atoms(strufile_dir + filename)
642
+ return (diameter, filename, size, nbatoms, nbsurfatoms)
643
+ except Exception as e:
644
+ pass
645
+ return None
646
+
647
+
648
+ def run(self):
649
+ """
650
+ Méthode de génération classique (mode manuel)
651
+ """
652
+ if self.auto_mode:
653
+ return self.run_auto()
654
+
655
+ cifname=(os.path.basename(self.cif_file).split('/')[-1]).split('.')[0]
656
+ strufile_dir=self.pdfpath+f'/structure_files_{cifname}/'
657
+ logfile=strufile_dir+'/structure_generation.log'
658
+ line2write= '*****************************************************\n\n'
659
+ line2write+=' STRUCTURE GENERATION \n\n'
660
+ line2write+='*****************************************************\n\n'
661
+ line2write+='Structure File \tDiameter \tNumber of atoms \tNumber of surface atoms\n'
662
+ print(line2write)
663
+ if not self.sphere_only:
664
+ p_array=np.arange(self.min_params[0],self.max_params[0])
665
+ q_array=np.arange(self.min_params[1],self.max_params[1])
666
+ for p in p_array:
667
+ filename,size,nbatoms=self.makeIcosahedron(p)
668
+ nbsurfatoms=self.detect_surface_atoms(strufile_dir+filename)
669
+
670
+ print(f'{filename:50}\t{size:.4f}\t\t{nbatoms}\t\t\t{nbsurfatoms}')
671
+ line2write+=f'{filename:50}\t{size:.4f}\t\t{nbatoms}\t\t\t{nbsurfatoms}\n'
672
+ for q in q_array:
673
+ if q>=1:
674
+ filename,size,nbatoms=self.makeDecahedron(p,q)
675
+ nbsurfatoms=self.detect_surface_atoms(strufile_dir+filename)
676
+
677
+ print(f'{filename:50}\t{size:.4f}\t\t{nbatoms}\t\t\t{nbsurfatoms}')
678
+ line2write+=f'{filename:50}\t{size:.4f}\t\t{nbatoms}\t\t\t{nbsurfatoms}\n'
679
+ if q<=(p-1)/2:
680
+ filename,size,nbatoms=self.makeOctahedron(p,q)
681
+ nbsurfatoms=self.detect_surface_atoms(strufile_dir+filename)
682
+
683
+ print(f'{filename:50}\t{size:.4f}\t\t{nbatoms}\t\t\t{nbsurfatoms}')
684
+ line2write+=f'{filename:50}\t{size:.4f}\t\t{nbatoms}\t\t\t{nbsurfatoms}\n'
685
+ for size in self.size_array:
686
+ filename,size,nbatoms=self.makeSphere(size)
687
+ nbsurfatoms=self.detect_surface_atoms(strufile_dir+filename)
688
+
689
+ print(f'{filename:50}\t{size:.4f}\t\t{nbatoms}\t\t\t{nbsurfatoms}')
690
+ line2write+=f'{filename:50}\t{size:.4f}\t\t{nbatoms}\t\t\t{nbsurfatoms}\n'
691
+ else:
692
+ for size in self.size_array:
693
+ filename,size,nbatoms=self.makeSphere(size)
694
+ nbsurfatoms=self.detect_surface_atoms(strufile_dir+filename)
695
+
696
+ print(f'{filename:30}\t\t{size:.4f}\t\t{nbatoms}\t\t\t{nbsurfatoms}')
697
+ line2write+=f'{filename:30}\t\t{size:.4f}\t\t{nbatoms}\t\t\t{nbsurfatoms}\n'
698
+ with open(logfile,'w')as f:
699
+ f.write(line2write)
700
+ return strufile_dir
701
+
702
+ def run_auto(self):
703
+ """
704
+ Automatic generation method based on PDF analysis
705
+ Generates candidate structures and keeps only those within diameter window
706
+ Uses multiprocessing for acceleration
707
+ """
708
+ from multiprocessing import Pool, cpu_count
709
+ from tqdm import tqdm
710
+ import os as os_module
711
+
712
+ cifname=(os.path.basename(self.cif_file).split('/')[-1]).split('.')[0]
713
+ strufile_dir=self.pdfpath+f'/structure_files_{cifname}/'
714
+ logfile=strufile_dir+'/structure_generation.log'
715
+
716
+ # Determine number of processes
717
+ if self.n_jobs == -1:
718
+ n_processes = cpu_count()
719
+ else:
720
+ n_processes = max(1, self.n_jobs)
721
+
722
+ print(f"\nStarting structure generation with {n_processes} parallel processes")
723
+ print(f"Target diameter range: [{self.r_max-self.tolerance:.2f}, {self.r_max+self.tolerance:.2f}] Å")
724
+
725
+ line2write= '*****************************************************\n\n'
726
+ line2write+=' STRUCTURE GENERATION (AUTO MODE) \n\n'
727
+ line2write+='*****************************************************\n\n'
728
+ line2write+=f'PDF analyzed: {os.path.basename(self.pdf_file)}\n'
729
+ line2write+=f'r_max detected: {self.r_max:.2f} Å\n'
730
+ line2write+=f'Target diameter window: [{self.r_max-self.tolerance:.2f}, {self.r_max+self.tolerance:.2f}] Å\n'
731
+ line2write+=f'Tolerance: ±{self.tolerance:.2f} Å\n'
732
+ line2write+=f'Parallel processes: {n_processes}\n\n'
733
+ line2write+='Structure File \tDiameter \tNumber of atoms \tNumber of surface atoms\n'
734
+
735
+ structures_generated = []
736
+ structures_kept = []
737
+ kept_filenames = [] # Track filenames of kept structures
738
+ results_to_write = []
739
+
740
+ if not self.sphere_only:
741
+ # Prepare tasks for icosahedra
742
+ ico_tasks = [(p,) for p in range(1, self.max_search_param + 1)]
743
+
744
+ # Process with progress bar
745
+ print("\nSearching icosahedra...")
746
+ if n_processes > 1:
747
+ with Pool(processes=n_processes) as pool:
748
+ ico_results = list(tqdm(pool.starmap(self._process_icosahedron, ico_tasks),
749
+ total=len(ico_tasks), desc="Icosahedra", ncols=80))
750
+ else:
751
+ ico_results = [self._process_icosahedron(p) for p in tqdm(range(1, self.max_search_param + 1),
752
+ desc="Icosahedra", ncols=80)]
753
+
754
+ # Filter and store results
755
+ for result in ico_results:
756
+ if result is not None:
757
+ diameter, filename, size, nbatoms, nbsurfatoms = result
758
+ structures_generated.append(('Icosahedron', None, None, diameter))
759
+ if self.is_diameter_in_target_range(diameter):
760
+ structures_kept.append(('Icosahedron', None, None, diameter))
761
+ kept_filenames.append(strufile_dir + filename + '.xyz')
762
+ results_to_write.append(f'{filename:50}\t{size:.4f}\t\t{nbatoms}\t\t\t{nbsurfatoms}\n')
763
+
764
+ # Prepare tasks for decahedra
765
+ deca_tasks = [(p, q) for p in range(1, self.max_search_param + 1)
766
+ for q in range(1, self.max_search_param + 1)]
767
+
768
+ print("Searching decahedra...")
769
+ if n_processes > 1:
770
+ with Pool(processes=n_processes) as pool:
771
+ deca_results = list(tqdm(pool.starmap(self._process_decahedron, deca_tasks),
772
+ total=len(deca_tasks), desc="Decahedra", ncols=80))
773
+ else:
774
+ deca_results = [self._process_decahedron(p, q) for p, q in tqdm(deca_tasks,
775
+ desc="Decahedra", ncols=80)]
776
+
777
+ # Filter and store results
778
+ for result in deca_results:
779
+ if result is not None:
780
+ diameter, filename, size, nbatoms, nbsurfatoms = result
781
+ structures_generated.append(('Decahedron', None, None, diameter))
782
+ if self.is_diameter_in_target_range(diameter):
783
+ structures_kept.append(('Decahedron', None, None, diameter))
784
+ kept_filenames.append(strufile_dir + filename + '.xyz')
785
+ results_to_write.append(f'{filename:50}\t{size:.4f}\t\t{nbatoms}\t\t\t{nbsurfatoms}\n')
786
+
787
+ # Prepare tasks for octahedra
788
+ octa_tasks = [(p, q) for p in range(1, self.max_search_param + 1)
789
+ for q in range(0, (p-1)//2 + 1)]
790
+
791
+ print("Searching octahedra...")
792
+ if n_processes > 1:
793
+ with Pool(processes=n_processes) as pool:
794
+ octa_results = list(tqdm(pool.starmap(self._process_octahedron, octa_tasks),
795
+ total=len(octa_tasks), desc="Octahedra", ncols=80))
796
+ else:
797
+ octa_results = [self._process_octahedron(p, q) for p, q in tqdm(octa_tasks,
798
+ desc="Octahedra", ncols=80)]
799
+
800
+ # Filter and store results
801
+ for result in octa_results:
802
+ if result is not None:
803
+ diameter, filename, size, nbatoms, nbsurfatoms = result
804
+ structures_generated.append(('Octahedron', None, None, diameter))
805
+ if self.is_diameter_in_target_range(diameter):
806
+ structures_kept.append(('Octahedron', None, None, diameter))
807
+ kept_filenames.append(strufile_dir + filename + '.xyz')
808
+ results_to_write.append(f'{filename:50}\t{size:.4f}\t\t{nbatoms}\t\t\t{nbsurfatoms}\n')
809
+
810
+ # Generate spheres with auto-determined sizes
811
+ print("Generating spheres...")
812
+ for size in tqdm(self.size_array, desc="Spheres", ncols=80):
813
+ filename,size,nbatoms=self.makeSphere(size)
814
+ kept_filenames.append(strufile_dir + filename + '.xyz') # Spheres always kept
815
+ nbsurfatoms=self.detect_surface_atoms(strufile_dir+filename)
816
+ results_to_write.append(f'{filename:50}\t{size:.4f}\t\t{nbatoms}\t\t\t{nbsurfatoms}\n')
817
+
818
+ # Write all results to log
819
+ line2write += ''.join(results_to_write)
820
+
821
+ # Statistics
822
+ line2write+='\n*****************************************************\n'
823
+ line2write+=f'STATISTICS:\n'
824
+ line2write+=f'Candidate structures tested: {len(structures_generated)}\n'
825
+ line2write+=f'Structures kept: {len(structures_kept)} polyhedra + {len(self.size_array)} spheres\n'
826
+ line2write+=f'Selection rate: {len(structures_kept)/max(len(structures_generated),1)*100:.1f}%\n'
827
+ line2write+='*****************************************************\n'
828
+
829
+ print('\n' + '='*60)
830
+ print(f'GENERATION SUMMARY')
831
+ print('='*60)
832
+ print(f'Candidate structures tested: {len(structures_generated)}')
833
+ print(f'Structures kept: {len(structures_kept)} polyhedra + {len(self.size_array)} spheres')
834
+ print(f'Selection rate: {len(structures_kept)/max(len(structures_generated),1)*100:.1f}%')
835
+ print(f'\nLog file: {logfile}')
836
+ print('='*60)
837
+
838
+ with open(logfile,'w')as f:
839
+ f.write(line2write)
840
+
841
+ # Save list of kept structures for screening
842
+ kept_structures_file = strufile_dir + 'kept_structures.txt'
843
+ with open(kept_structures_file, 'w') as f:
844
+ for filename in kept_filenames:
845
+ f.write(filename + '\n')
846
+
847
+ return strufile_dir
848
+
849
+
850
+ class StructureCustom():
851
+ def __init__ (self,
852
+ strufile: str,
853
+ zoomscale:float = 1,
854
+ new_element: str =None,
855
+ fraction :float=0):
856
+ """
857
+ strufile: str, full path to structure file (xyz file)
858
+ zoomscale: float, coefficient to adjust interatomic distance
859
+ new_element: str, element to insert in the structure (randomly)
860
+ fraction: float, fraction of the new element (between 0 and 1)
861
+ """
862
+ self.strufile=strufile
863
+ self.path=os.path.dirname(self.strufile)
864
+ self.zoomscale=zoomscale
865
+ self.new_element=new_element
866
+ self.fraction=fraction
867
+
868
+ def apply_zoomscale(self):
869
+ self.x=[x*self.zoomscale for x in self.x]
870
+ self.y=[y*self.zoomscale for y in self.y]
871
+ self.z=[z*self.zoomscale for z in self.z]
872
+ return self.x, self.y, self.z
873
+
874
+ def parseline(self,line):
875
+ parse=line.split('\t')
876
+ element=parse[0];x=parse[1];y=parse[2];z=parse[3]
877
+ return element,x,y,z
878
+
879
+ def transform_structure(self):
880
+ # extract data (element,x,y,z) from xyz file
881
+ data=np.loadtxt(self.strufile,skiprows=2,dtype=[('element', 'U2'), ('x', 'f4'), ('y', 'f4'), ('z', 'f4')])
882
+ self.element=data['element']
883
+ self.x=data['x'];self.y=data['y'];self.z=data['z']
884
+ # apply zoomscale coefficient
885
+ self.x, self.y, self.z=self.apply_zoomscale()
886
+
887
+ # perform random substitution
888
+ initial_elements=np.unique(self.element)
889
+ initcompo=''
890
+ for el in initial_elements:
891
+ initcompo+=el
892
+ N=len(self.element)
893
+ k=N #number of initial elements
894
+ if self.new_element is not None:
895
+ n=0 #number of new elements inserted in structure
896
+ while n<=(N*self.fraction):
897
+ random_number = random.randint(0, N-1)
898
+ if self.element[random_number] != self.new_element:
899
+ self.element[random_number]=self.new_element
900
+ n+=1
901
+ k-=1
902
+ else:
903
+ pass
904
+ final_content='{%s'%initcompo+':%d'%k+',%s'%self.new_element+':%d}'%n
905
+ outputfile=self.strufile.split('.')[0]+f'_zoomscale={self.zoomscale:.2f}_{initcompo}{100*(1-self.fraction):.0f}{self.new_element}{self.fraction*100:.0f}.xyz'
906
+ else: # no random substitution
907
+ final_content='{%s'%initcompo+':%d'%k+'}'
908
+ outputfile=self.strufile.split('.')[0]+f'_zoomscale={self.zoomscale:.2f}.xyz'
909
+ # write transformed structure to xyz file
910
+ line2write=f'{N}\n{final_content}\n'
911
+ for i in range(N):
912
+ line2write += f"{self.element[i]} \t {self.x[i]:.4f} \t {self.y[i]:.4f} \t {self.z[i]:.4f} \n"
913
+
914
+ with open(outputfile,'w') as f:
915
+ f.write(line2write)
916
+ return outputfile
917
+
918
+ def optimize(self):
919
+ xyzfile=self.strufile
920
+ ico=read(xyzfile)
921
+ ico.calc = EMT()
922
+ basename=os.path.basename(xyzfile).split('/')[-1].split('.')[0]
923
+ opt = FIRE(ico, trajectory=self.path+'/'+basename+'_FIRE.traj')
924
+ opt.run(fmax=0.01)
925
+
926
+ traj=Trajectory(self.path+'/'+basename+'_FIRE.traj')
927
+ ico_opt=traj[-1]
928
+ strufile_dir=self.path+f'/relaxed_structure_files/'
929
+ os.makedirs(strufile_dir,exist_ok=True)
930
+ outfilename=strufile_dir+basename+'_optimized.xyz'
931
+ self.writexyz(outfilename,ico_opt)
932
+ return outfilename
933
+
934
+ def writexyz(self,filename,atoms):
935
+ """atoms ase Atoms object"""
936
+ #cifname=(os.path.basename(self.cif_file).split('/')[-1]).split('.')[0]
937
+
938
+
939
+ #write(strufile_dir+f'/{filename}.xyz',atoms)
940
+ element_array=atoms.get_chemical_symbols()
941
+ # extract composition in dict form
942
+ composition={}
943
+ for element in element_array:
944
+ if element in composition:
945
+ composition[element]+=1
946
+ else:
947
+ composition[element]=1
948
+
949
+ coord=atoms.get_positions()
950
+ natoms=len(element_array)
951
+ line2write='%d \n'%natoms
952
+ line2write+='%s\n'%str(composition)
953
+ for i in range(natoms):
954
+ line2write+='%s'%str(element_array[i])+'\t %.8f'%float(coord[i,0])+'\t %.8f'%float(coord[i,1])+'\t %.8f'%float(coord[i,2])+'\n'
955
+ with open(f'/{filename}','w') as file:
956
+ file.write(line2write)
957
+
958
+ def view_structure(self, style='sphere', width=400, height=400, spin=True):
959
+ """
960
+ Visualize structure in 3D using py3Dmol in Jupyter notebook
961
+
962
+ Parameters:
963
+ -----------
964
+ style: str, default='sphere'
965
+ Visualization style: 'sphere', 'stick', 'cartoon', 'line', 'cross'
966
+ width: int, default=400
967
+ Width of the viewer in pixels
968
+ height: int, default=400
969
+ Height of the viewer in pixels
970
+ spin: bool, default=True
971
+ Enable automatic rotation
972
+
973
+ Returns:
974
+ --------
975
+ view: py3Dmol.view object
976
+ """
977
+ try:
978
+ import py3Dmol
979
+ except ImportError:
980
+ print("py3Dmol not installed. Install with: pip install py3Dmol")
981
+ return None
982
+
983
+ # Read structure data
984
+ data = np.loadtxt(self.strufile, skiprows=2,
985
+ dtype=[('element', 'U2'), ('x', 'f4'), ('y', 'f4'), ('z', 'f4')])
986
+
987
+ elements = data['element']
988
+ coords = np.column_stack([data['x'], data['y'], data['z']]) * self.zoomscale
989
+
990
+ # Create XYZ format string
991
+ xyz_string = f"{len(elements)}\n"
992
+ xyz_string += f"Structure with zoomscale={self.zoomscale}\n"
993
+ for i, elem in enumerate(elements):
994
+ xyz_string += f"{elem} {coords[i,0]:.6f} {coords[i,1]:.6f} {coords[i,2]:.6f}\n"
995
+
996
+ # Create 3D viewer
997
+ view = py3Dmol.view(width=width, height=height)
998
+ view.addModel(xyz_string, 'xyz')
999
+
1000
+ # Apply style
1001
+ view.setStyle({style: {}})
1002
+
1003
+ # Enable spin if requested
1004
+ if spin:
1005
+ view.spin(True)
1006
+
1007
+ view.zoomTo()
1008
+ return view
1009
+
1010
+ def save_structure_image(self, output_path, style='sphere', width=800, height=800):
1011
+ """
1012
+ Save structure visualization as PNG image
1013
+
1014
+ Parameters:
1015
+ -----------
1016
+ output_path: str
1017
+ Path where to save the image (should end with .png)
1018
+ style: str, default='sphere'
1019
+ Visualization style
1020
+ width: int, default=800
1021
+ Image width in pixels
1022
+ height: int, default=800
1023
+ Image height in pixels
1024
+ """
1025
+ view = self.view_structure(style=style, width=width, height=height, spin=False)
1026
+ if view is not None:
1027
+ # Note: py3Dmol PNG export requires selenium/chromium
1028
+ # Alternative: use screenshot in notebook or export to other formats
1029
+ print(f"To save image, use: view.png() in notebook then save manually")
1030
+ print(f"Or use selenium for automated export")
1031
+ return view
1032
+ return None
1033
+
1034
+ def get_structure_info(self):
1035
+ """
1036
+ Extract structure information including atom count and composition
1037
+
1038
+ Returns:
1039
+ --------
1040
+ dict with keys: 'natoms', 'composition', 'elements'
1041
+ """
1042
+ data = np.loadtxt(self.strufile, skiprows=2,
1043
+ dtype=[('element', 'U2'), ('x', 'f4'), ('y', 'f4'), ('z', 'f4')])
1044
+
1045
+ elements = data['element']
1046
+ composition = {}
1047
+ for elem in elements:
1048
+ composition[elem] = composition.get(elem, 0) + 1
1049
+
1050
+ return {
1051
+ 'natoms': len(elements),
1052
+ 'composition': composition,
1053
+ 'elements': list(composition.keys()),
1054
+ 'zoomscale': self.zoomscale
1055
+ }
1056
+
1057
+
1058
+ class StructureReportGenerator():
1059
+ """
1060
+ Generate comprehensive HTML/PDF reports for structure screening results
1061
+ """
1062
+
1063
+ def __init__(self, strufile_dir, best_results, screening_log=None, all_screening_results=None):
1064
+ """
1065
+ Parameters:
1066
+ -----------
1067
+ strufile_dir: str
1068
+ Directory containing structure files and generation log
1069
+ best_results: dict
1070
+ Results from StructureScreener.run()
1071
+ screening_log: str, optional
1072
+ Path to screening log file
1073
+ all_screening_results: dict, optional
1074
+ Complete screening results dictionary {pdf_file: {strufile: {'Rw': float, 'zoomscale': float}}}
1075
+ If provided, will be used instead of parsing the log file
1076
+ """
1077
+ self.strufile_dir = strufile_dir
1078
+ self.best_results = best_results
1079
+ self.generation_log = os.path.join(strufile_dir, 'structure_generation.log')
1080
+ self.screening_log = screening_log or os.path.join(strufile_dir, 'structure_screening.log')
1081
+ self.all_screening_results = all_screening_results
1082
+
1083
+ def parse_generation_log(self):
1084
+ """
1085
+ Parse structure_generation.log to extract diameter, natoms, surface atoms info
1086
+
1087
+ Returns:
1088
+ --------
1089
+ dict: {structure_filename: {'diameter': float, 'natoms': int, 'surface_atoms': int}}
1090
+ """
1091
+ structure_info = {}
1092
+
1093
+ if not os.path.exists(self.generation_log):
1094
+ print(f"Warning: Generation log not found at {self.generation_log}")
1095
+ return structure_info
1096
+
1097
+ with open(self.generation_log, 'r') as f:
1098
+ lines = f.readlines()
1099
+
1100
+ # Skip header lines
1101
+ data_started = False
1102
+ for line in lines:
1103
+ if 'Structure File' in line and 'Diameter' in line:
1104
+ data_started = True
1105
+ continue
1106
+
1107
+ if data_started and line.strip():
1108
+ try:
1109
+ parts = line.split()
1110
+ if len(parts) >= 4:
1111
+ filename = parts[0]
1112
+ diameter = float(parts[1])
1113
+ natoms = int(parts[2])
1114
+ surface_atoms = int(parts[3])
1115
+
1116
+ structure_info[filename] = {
1117
+ 'diameter': diameter,
1118
+ 'natoms': natoms,
1119
+ 'surface_atoms': surface_atoms,
1120
+ 'surface_fraction': surface_atoms / natoms if natoms > 0 else 0
1121
+ }
1122
+ except (ValueError, IndexError):
1123
+ continue
1124
+
1125
+ return structure_info
1126
+
1127
+ def generate_html_report(self, output_path='structure_screening_report.html'):
1128
+ """
1129
+ Generate comprehensive HTML report with structure visualizations
1130
+
1131
+ Parameters:
1132
+ -----------
1133
+ output_path: str
1134
+ Path for output HTML file
1135
+ """
1136
+ structure_info = self.parse_generation_log()
1137
+
1138
+ html_content = """
1139
+ <!DOCTYPE html>
1140
+ <html>
1141
+ <head>
1142
+ <meta charset="UTF-8">
1143
+ <title>Structure Screening Report</title>
1144
+ <style>
1145
+ body {
1146
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1147
+ margin: 40px;
1148
+ background-color: #f5f5f5;
1149
+ }
1150
+ .container {
1151
+ max-width: 1200px;
1152
+ margin: 0 auto;
1153
+ background-color: white;
1154
+ padding: 30px;
1155
+ box-shadow: 0 0 10px rgba(0,0,0,0.1);
1156
+ }
1157
+ h1 {
1158
+ color: #2c3e50;
1159
+ border-bottom: 3px solid #3498db;
1160
+ padding-bottom: 10px;
1161
+ }
1162
+ h2 {
1163
+ color: #34495e;
1164
+ margin-top: 30px;
1165
+ border-left: 4px solid #3498db;
1166
+ padding-left: 10px;
1167
+ }
1168
+ table {
1169
+ width: 100%;
1170
+ border-collapse: collapse;
1171
+ margin: 20px 0;
1172
+ }
1173
+ th, td {
1174
+ padding: 12px;
1175
+ text-align: left;
1176
+ border-bottom: 1px solid #ddd;
1177
+ }
1178
+ th {
1179
+ background-color: #3498db;
1180
+ color: white;
1181
+ }
1182
+ tr:hover {
1183
+ background-color: #f5f5f5;
1184
+ }
1185
+ .best-result {
1186
+ background-color: #e8f5e9;
1187
+ padding: 20px;
1188
+ border-radius: 5px;
1189
+ margin: 20px 0;
1190
+ }
1191
+ .metric {
1192
+ display: inline-block;
1193
+ margin: 10px 20px 10px 0;
1194
+ }
1195
+ .metric-label {
1196
+ font-weight: bold;
1197
+ color: #555;
1198
+ }
1199
+ .metric-value {
1200
+ color: #2c3e50;
1201
+ font-size: 1.2em;
1202
+ }
1203
+ .summary-box {
1204
+ background-color: #ecf0f1;
1205
+ padding: 15px;
1206
+ border-radius: 5px;
1207
+ margin: 15px 0;
1208
+ }
1209
+ </style>
1210
+ </head>
1211
+ <body>
1212
+ <div class="container">
1213
+ <h1>📊 Structure Screening Report</h1>
1214
+ <p><strong>Generated:</strong> """ + str(np.datetime64('now')) + """</p>
1215
+ <p><strong>Structure Directory:</strong> """ + self.strufile_dir + """</p>
1216
+
1217
+ <h2>🎯 Best Results Summary</h2>
1218
+ """
1219
+
1220
+ # Add best results for each PDF
1221
+ for pdf_file, result in self.best_results.items():
1222
+ pdf_name = os.path.basename(pdf_file)
1223
+ strufile = result['strufile']
1224
+ strufile_basename = os.path.basename(strufile)
1225
+ rw = result['Rw']
1226
+ zoomscale = result['zoomscale']
1227
+
1228
+ # Get structure info from generation log
1229
+ stru_info = structure_info.get(strufile_basename, {})
1230
+
1231
+ html_content += f"""
1232
+ <div class="best-result">
1233
+ <h3>📄 {pdf_name}</h3>
1234
+ <div class="metric">
1235
+ <span class="metric-label">Structure:</span>
1236
+ <span class="metric-value">{strufile_basename}</span>
1237
+ </div>
1238
+ <div class="metric">
1239
+ <span class="metric-label">Rw:</span>
1240
+ <span class="metric-value">{rw:.4f}</span>
1241
+ </div>
1242
+ <div class="metric">
1243
+ <span class="metric-label">Zoomscale:</span>
1244
+ <span class="metric-value">{zoomscale:.6f}</span>
1245
+ </div>
1246
+ """
1247
+
1248
+ if stru_info:
1249
+ html_content += f"""
1250
+ <div class="metric">
1251
+ <span class="metric-label">Diameter:</span>
1252
+ <span class="metric-value">{stru_info.get('diameter', 'N/A'):.2f} Å</span>
1253
+ </div>
1254
+ <div class="metric">
1255
+ <span class="metric-label">Total Atoms:</span>
1256
+ <span class="metric-value">{stru_info.get('natoms', 'N/A')}</span>
1257
+ </div>
1258
+ <div class="metric">
1259
+ <span class="metric-label">Surface Atoms:</span>
1260
+ <span class="metric-value">{stru_info.get('surface_atoms', 'N/A')} ({stru_info.get('surface_fraction', 0)*100:.1f}%)</span>
1261
+ </div>
1262
+ """
1263
+
1264
+ html_content += """
1265
+ </div>
1266
+ """
1267
+
1268
+ # Add structure info table
1269
+ if structure_info:
1270
+ html_content += """
1271
+ <h2>📋 All Generated Structures</h2>
1272
+ <table>
1273
+ <thead>
1274
+ <tr>
1275
+ <th>Structure</th>
1276
+ <th>Diameter (Å)</th>
1277
+ <th>Total Atoms</th>
1278
+ <th>Surface Atoms</th>
1279
+ <th>Surface %</th>
1280
+ </tr>
1281
+ </thead>
1282
+ <tbody>
1283
+ """
1284
+ for filename, info in sorted(structure_info.items()):
1285
+ html_content += f"""
1286
+ <tr>
1287
+ <td>{filename}</td>
1288
+ <td>{info['diameter']:.2f}</td>
1289
+ <td>{info['natoms']}</td>
1290
+ <td>{info['surface_atoms']}</td>
1291
+ <td>{info['surface_fraction']*100:.1f}%</td>
1292
+ </tr>
1293
+ """
1294
+
1295
+ html_content += """
1296
+ </tbody>
1297
+ </table>
1298
+ """
1299
+
1300
+ html_content += """
1301
+ </div>
1302
+ </body>
1303
+ </html>
1304
+ """
1305
+
1306
+ # Write HTML file
1307
+ output_full_path = os.path.join(self.strufile_dir, output_path)
1308
+ with open(output_full_path, 'w', encoding='utf-8') as f:
1309
+ f.write(html_content)
1310
+
1311
+ print(f"✓ Report generated: {output_full_path}")
1312
+ return output_full_path
1313
+
1314
+ def generate_summary_dict(self):
1315
+ """
1316
+ Generate a comprehensive summary dictionary for programmatic access
1317
+
1318
+ Returns:
1319
+ --------
1320
+ dict with complete information about screening results
1321
+ """
1322
+ structure_info = self.parse_generation_log()
1323
+
1324
+ summary = {
1325
+ 'strufile_dir': self.strufile_dir,
1326
+ 'num_pdfs': len(self.best_results),
1327
+ 'results': []
1328
+ }
1329
+
1330
+ for pdf_file, result in self.best_results.items():
1331
+ strufile_basename = os.path.basename(result['strufile'])
1332
+ stru_info = structure_info.get(strufile_basename, {})
1333
+
1334
+ result_dict = {
1335
+ 'pdf_file': pdf_file,
1336
+ 'pdf_name': os.path.basename(pdf_file),
1337
+ 'strufile': result['strufile'],
1338
+ 'strufile_name': strufile_basename,
1339
+ 'Rw': result['Rw'],
1340
+ 'zoomscale': result['zoomscale'],
1341
+ 'diameter': stru_info.get('diameter'),
1342
+ 'natoms': stru_info.get('natoms'),
1343
+ 'surface_atoms': stru_info.get('surface_atoms'),
1344
+ 'surface_fraction': stru_info.get('surface_fraction')
1345
+ }
1346
+
1347
+ summary['results'].append(result_dict)
1348
+
1349
+ return summary
1350
+
1351
+ def send_report_by_email(self, report_path, recipient_email,
1352
+ smtp_server='smtp.gmail.com', smtp_port=587, subject=None, message=None):
1353
+ """
1354
+ Envoyer le rapport PDF par email
1355
+
1356
+ Parameters:
1357
+ -----------
1358
+ report_path: str
1359
+ Chemin du fichier PDF à envoyer
1360
+ recipient_email: str or list
1361
+ Adresse(s) email du/des destinataire(s)
1362
+ Peut être une chaîne avec plusieurs emails séparés par des virgules
1363
+ ou une liste d'emails: ['email1@example.com', 'email2@example.com']
1364
+ sender_email: str
1365
+ Adresse email de l'expéditeur
1366
+ sender_password: str
1367
+ Mot de passe de l'email expéditeur (ou mot de passe d'application)
1368
+ smtp_server: str
1369
+ Serveur SMTP (défaut: smtp.gmail.com pour Gmail)
1370
+ smtp_port: int
1371
+ Port SMTP (défaut: 587 pour TLS)
1372
+ subject: str, optional
1373
+ Sujet de l'email (généré automatiquement si None)
1374
+ message: str, optional
1375
+ Corps du message (généré automatiquement si None)
1376
+
1377
+ Returns:
1378
+ --------
1379
+ bool: True si envoyé avec succès, False sinon
1380
+
1381
+ Notes:
1382
+ ------
1383
+ Pour Gmail, vous devez :
1384
+ 1. Activer l'authentification à deux facteurs
1385
+ 2. Générer un "mot de passe d'application" dans les paramètres de sécurité Google
1386
+ 3. Utiliser ce mot de passe d'application comme sender_password
1387
+
1388
+ Autres serveurs SMTP courants :
1389
+ - Outlook: smtp.office365.com, port 587
1390
+ - Yahoo: smtp.mail.yahoo.com, port 587
1391
+ - INSA: smtp.insa-rennes.fr, port 587 (ou selon config)
1392
+ """
1393
+ import smtplib
1394
+ from email.mime.multipart import MIMEMultipart
1395
+ from email.mime.text import MIMEText
1396
+ from email.mime.base import MIMEBase
1397
+ from email import encoders
1398
+
1399
+ try:
1400
+ # Vérifier que le fichier existe
1401
+ if not os.path.exists(report_path):
1402
+ print(f"❌ Erreur: Le fichier {report_path} n'existe pas")
1403
+ # Gérer plusieurs destinataires
1404
+ if isinstance(recipient_email, str):
1405
+ # Si c'est une chaîne, séparer par les virgules et nettoyer les espaces
1406
+ recipients = [email.strip() for email in recipient_email.split(',')]
1407
+ else:
1408
+ # Si c'est déjà une liste
1409
+ recipients = recipient_email
1410
+
1411
+ # Créer le message
1412
+ msg = MIMEMultipart()
1413
+ msg['From'] = 'nicolas.ratel-ramond@insa-toulouse.fr'
1414
+ msg['To'] = ', '.join(recipients) # Joindre les emails pour l'en-tête
1415
+ msg['From'] = 'nicolas.ratel-ramond@insa-toulouse.fr'
1416
+ msg['To'] = recipient_email
1417
+
1418
+ # Sujet par défaut
1419
+ if subject is None:
1420
+ report_name = os.path.basename(report_path)
1421
+ subject = f"Structure Screening Report - {report_name}"
1422
+ msg['Subject'] = subject
1423
+
1424
+ # Corps du message par défaut
1425
+ if message is None:
1426
+ summary = self.generate_summary_dict()
1427
+ message = f"""
1428
+ Bonjour,
1429
+
1430
+ Veuillez trouver ci-joint le rapport d'analyse de structure.
1431
+
1432
+ Résumé des résultats :
1433
+ - Nombre de PDF analysés : {summary['num_pdfs']}
1434
+ - Répertoire : {summary['strufile_dir']}
1435
+
1436
+ """
1437
+ for result in summary['results']:
1438
+ message += f"\n{result['pdf_name']}:\n"
1439
+ message += f" • Best structure : {result['strufile_name']}\n"
1440
+ message += f" • Rw : {result['Rw']:.4f}\n"
1441
+ message += f" • Zoomscale : {result['zoomscale']:.6f}\n"
1442
+ if result['natoms']:
1443
+ message += f" • Number of atoms : {result['natoms']}\n"
1444
+ message += f" • diameter : {result['diameter']:.2f} Å\n"
1445
+
1446
+
1447
+
1448
+ msg.attach(MIMEText(message, 'plain'))
1449
+
1450
+ # Attacher le fichier PDF
1451
+ attachment = open(report_path, 'rb')
1452
+ part = MIMEBase('application', 'pdf')
1453
+ part.set_payload(attachment.read())
1454
+ encoders.encode_base64(part)
1455
+ part.add_header('Content-Disposition', f"attachment; filename= {os.path.basename(report_path)}")
1456
+ msg.attach(part)
1457
+ attachment.close()
1458
+
1459
+ # Se connecter au serveur SMTP et envoyer
1460
+ print(f"📧 Connexion au serveur SMTP {smtp_server}:{smtp_port}...")
1461
+ server = smtplib.SMTP(smtp_server, smtp_port)
1462
+ server.starttls() # Sécuriser la connexion
1463
+
1464
+ print(f"🔐 Authentification...")
1465
+ server.login('nicolas.ratel-ramond@insa-toulouse.fr', 'MNm12102012!')
1466
+
1467
+ print(f"📤 Envoi du rapport à {', '.join(recipients)}...")
1468
+ text = msg.as_string()
1469
+ server.sendmail('nicolas.ratel-ramond@insa-toulouse.fr', recipients, text) # recipients est une liste
1470
+ server.quit()
1471
+
1472
+ print(f"✅ Email envoyé avec succès à {len(recipients)} destinataire(s)!")
1473
+ return True
1474
+
1475
+ except Exception as e:
1476
+ print(f"❌ Erreur lors de l'envoi de l'email: {e}")
1477
+ print(f"\nVérifiez:")
1478
+ print(f" • Vos identifiants email")
1479
+ print(f" • Que vous utilisez un mot de passe d'application (Gmail)")
1480
+ print(f" • Votre connexion internet")
1481
+ print(f" • Les paramètres SMTP de votre fournisseur")
1482
+ return False
1483
+
1484
+ def parse_screening_log(self):
1485
+ """
1486
+ Parse structure_screening.log to extract all refinement results
1487
+
1488
+ Returns:
1489
+ --------
1490
+ dict: {pdf_file: {strufile: {'Rw': float, 'zoomscale': float}}}
1491
+ """
1492
+ all_results = {}
1493
+
1494
+ if not os.path.exists(self.screening_log):
1495
+ print(f"Warning: Screening log not found at {self.screening_log}")
1496
+ return all_results
1497
+
1498
+ with open(self.screening_log, 'r') as f:
1499
+ lines = f.readlines()
1500
+
1501
+ # Skip header lines
1502
+ data_started = False
1503
+ for line in lines:
1504
+ # Skip header and separator lines
1505
+ if 'STRUCTURE SCREENING' in line or '*****' in line or 'PDF file' in line:
1506
+ if 'PDF file' in line and 'Structure file' in line:
1507
+ data_started = True
1508
+ continue
1509
+
1510
+ if not data_started:
1511
+ continue
1512
+
1513
+ line = line.strip()
1514
+ if not line or line.startswith('Liste des meilleures') or line.startswith('Fichier PDF'):
1515
+ continue
1516
+
1517
+ # Parse result lines: PDF_name \t Structure_name \t Rw \t zoomscale=value
1518
+ # or: PDF_name \t Structure_name \t Rw
1519
+ parts = [p.strip() for p in line.split('\t') if p.strip()]
1520
+
1521
+ if len(parts) >= 3:
1522
+ try:
1523
+ pdf_name = parts[0]
1524
+ stru_name = parts[1]
1525
+
1526
+ # Extract Rw and zoomscale from the third part
1527
+ rw_zoomscale_str = parts[2]
1528
+
1529
+ # Check if zoomscale is in a separate column or in the same
1530
+ if len(parts) >= 4 and 'zoomscale=' in parts[3]:
1531
+ # Format: PDF \t Structure \t Rw \t zoomscale=value
1532
+ rw = float(rw_zoomscale_str)
1533
+ zoomscale_str = parts[3].replace('zoomscale=', '').strip()
1534
+ zoomscale = float(zoomscale_str)
1535
+ elif 'zoomscale=' in rw_zoomscale_str:
1536
+ # Format: PDF \t Structure \t Rw\tzoomscale=value
1537
+ rw_part = rw_zoomscale_str.split('zoomscale=')[0].strip()
1538
+ zoom_part = rw_zoomscale_str.split('zoomscale=')[1].strip()
1539
+ rw = float(rw_part)
1540
+ zoomscale = float(zoom_part)
1541
+ else:
1542
+ # Format: PDF \t Structure \t Rw (no zoomscale)
1543
+ rw = float(rw_zoomscale_str)
1544
+ zoomscale = None
1545
+
1546
+ # Store results - use PDF basename if it's a full path
1547
+ if '/' in pdf_name:
1548
+ pdf_name = os.path.basename(pdf_name)
1549
+
1550
+ if pdf_name and stru_name:
1551
+ if pdf_name not in all_results:
1552
+ all_results[pdf_name] = {}
1553
+ all_results[pdf_name][stru_name] = {
1554
+ 'Rw': rw,
1555
+ 'zoomscale': zoomscale
1556
+ }
1557
+ except (ValueError, IndexError) as e:
1558
+ # Debug: print problematic line
1559
+ # print(f"Could not parse line: {line} - Error: {e}")
1560
+ continue
1561
+
1562
+ return all_results
1563
+
1564
+ def get_top_n_results(self, n=10, pdf_file=None):
1565
+ """
1566
+ Get top N refinement results sorted by Rw
1567
+
1568
+ Parameters:
1569
+ -----------
1570
+ n: int
1571
+ Number of top results to return
1572
+ pdf_file: str, optional
1573
+ Specific PDF file to analyze. If None, uses first PDF in best_results
1574
+
1575
+ Returns:
1576
+ --------
1577
+ list of dicts with structure info and Rw values
1578
+ """
1579
+ # Use provided all_screening_results or parse log
1580
+ if self.all_screening_results:
1581
+ all_results = self.all_screening_results
1582
+ else:
1583
+ all_results = self.parse_screening_log()
1584
+
1585
+ if not all_results:
1586
+ print("No results found. Try providing all_screening_results when creating StructureReportGenerator.")
1587
+ return []
1588
+
1589
+ # Select PDF file
1590
+ if pdf_file is None:
1591
+ # Get first PDF from best_results
1592
+ first_pdf = list(self.best_results.keys())[0]
1593
+ pdf_file = os.path.basename(first_pdf)
1594
+ else:
1595
+ pdf_file = os.path.basename(pdf_file)
1596
+
1597
+ # Try different PDF name formats
1598
+ pdf_results = None
1599
+ if pdf_file in all_results:
1600
+ pdf_results = all_results[pdf_file]
1601
+ else:
1602
+ # Try without extension
1603
+ pdf_base = os.path.splitext(pdf_file)[0]
1604
+ for key in all_results.keys():
1605
+ if pdf_base in key or key in pdf_base:
1606
+ pdf_results = all_results[key]
1607
+ break
1608
+
1609
+ if not pdf_results:
1610
+ print(f"PDF file {pdf_file} not found in results")
1611
+ print(f"Available PDFs: {list(all_results.keys())}")
1612
+ return []
1613
+
1614
+ # Get structure info
1615
+ structure_info = self.parse_generation_log()
1616
+
1617
+ # Collect and sort results
1618
+ results_list = []
1619
+ for stru_name, refinement in pdf_results.items():
1620
+ stru_info = structure_info.get(stru_name, {})
1621
+
1622
+ result = {
1623
+ 'structure_name': stru_name,
1624
+ 'structure_path': os.path.join(self.strufile_dir, stru_name),
1625
+ 'Rw': refinement['Rw'],
1626
+ 'zoomscale': refinement.get('zoomscale', 1.0),
1627
+ 'diameter': stru_info.get('diameter'),
1628
+ 'natoms': stru_info.get('natoms'),
1629
+ 'surface_atoms': stru_info.get('surface_atoms'),
1630
+ 'surface_fraction': stru_info.get('surface_fraction')
1631
+ }
1632
+ results_list.append(result)
1633
+
1634
+ # Sort by Rw and return top N
1635
+ results_list.sort(key=lambda x: x['Rw'])
1636
+ return results_list[:n]
1637
+
1638
+ def generate_structure_thumbnail(self, strufile, zoomscale, output_path, size=(400, 400)):
1639
+ """
1640
+ Generate a thumbnail image of a structure using matplotlib
1641
+
1642
+ Parameters:
1643
+ -----------
1644
+ strufile: str
1645
+ Path to structure file
1646
+ zoomscale: float
1647
+ Zoomscale to apply
1648
+ output_path: str
1649
+ Path where to save the thumbnail
1650
+ size: tuple
1651
+ Size of the image (width, height) in pixels
1652
+ """
1653
+ # Check if structure file exists
1654
+ if not os.path.exists(strufile):
1655
+ return None
1656
+
1657
+ try:
1658
+
1659
+ # Fallback to matplotlib if py3Dmol fails
1660
+ from matplotlib import pyplot as plt
1661
+ from mpl_toolkits.mplot3d import Axes3D
1662
+ from scipy.spatial import distance_matrix
1663
+
1664
+ # Dictionnaires des propriétés atomiques
1665
+ atomic_radii = {
1666
+ 'Au': 1.44, 'Ag': 1.45, 'Cu': 1.28, 'Pt': 1.39, 'Pd': 1.37,
1667
+ 'Fe': 1.26, 'Ni': 1.24, 'Co': 1.25, 'Cr': 1.28, 'Mn': 1.27,
1668
+ 'Ti': 1.47, 'V': 1.35, 'Zn': 1.34, 'Al': 1.43, 'Si': 1.18,
1669
+ 'C': 0.77, 'O': 0.73, 'N': 0.71, 'H': 0.53, 'S': 1.04
1670
+ }
1671
+
1672
+ element_colors = {
1673
+ 'Au': 'gold', 'Ag': 'silver', 'Cu': 'orange', 'Pt': 'lightgray', 'Pd': 'lightblue',
1674
+ 'Fe': 'orangered', 'Ni': 'lightgreen', 'Co': 'blue', 'Cr': 'gray', 'Mn': 'violet',
1675
+ 'Ti': 'silver', 'V': 'darkgray', 'Zn': 'steelblue', 'Al': 'lightgray', 'Si': 'tan',
1676
+ 'C': 'dimgray', 'O': 'red', 'N': 'blue', 'H': 'white', 'S': 'yellow'
1677
+ }
1678
+
1679
+ data = np.loadtxt(strufile, skiprows=2,
1680
+ dtype=[('element', 'U2'), ('x', 'f4'), ('y', 'f4'), ('z', 'f4')])
1681
+ coords = np.column_stack([data['x'], data['y'], data['z']]) * zoomscale
1682
+
1683
+ # Détecter l'élément principal (le plus fréquent)
1684
+ elements, counts = np.unique(data['element'], return_counts=True)
1685
+ main_element = elements[np.argmax(counts)]
1686
+ main_element = main_element.strip()
1687
+
1688
+ # Récupérer les propriétés de l'élément
1689
+ atom_radius = atomic_radii.get(main_element, 1.4) # défaut si élément inconnu
1690
+ atom_color = element_colors.get(main_element, 'gray')
1691
+ edge_color = 'darkgray' if main_element not in ['Au', 'Ag'] else f'dark{atom_color}'
1692
+
1693
+ # Calculer la distance minimale entre atomes voisins pour dimensionner les sphères
1694
+ if len(coords) > 1:
1695
+ dist_mat = distance_matrix(coords, coords)
1696
+ # Mettre la diagonale à inf pour ignorer la distance d'un atome à lui-même
1697
+ np.fill_diagonal(dist_mat, np.inf)
1698
+ min_dist = np.min(dist_mat)
1699
+ # Utiliser 85% de la distance minimale comme rayon pour un léger overlap
1700
+ sphere_radius = min_dist * 0.85 / 2.0
1701
+ else:
1702
+ sphere_radius = atom_radius
1703
+
1704
+ fig = plt.figure(figsize=(size[0]/100, size[1]/100), dpi=100)
1705
+ ax = fig.add_subplot(111, projection='3d')
1706
+
1707
+ # Calculer la plage des axes
1708
+ max_range = np.array([coords[:, 0].max()-coords[:, 0].min(),
1709
+ coords[:, 1].max()-coords[:, 1].min(),
1710
+ coords[:, 2].max()-coords[:, 2].min()]).max() / 2.0
1711
+
1712
+ mid_x = (coords[:, 0].max()+coords[:, 0].min()) * 0.5
1713
+ mid_y = (coords[:, 1].max()+coords[:, 1].min()) * 0.5
1714
+ mid_z = (coords[:, 2].max()+coords[:, 2].min()) * 0.5
1715
+
1716
+ ax.set_xlim(mid_x - max_range, mid_x + max_range)
1717
+ ax.set_ylim(mid_y - max_range, mid_y + max_range)
1718
+ ax.set_zlim(mid_z - max_range, mid_z + max_range)
1719
+
1720
+ # Convertir le rayon physique en taille de point pour scatter
1721
+ # La taille s dans scatter est en points^2
1722
+ # On calcule la taille en fonction de la plage de l'axe et de la résolution
1723
+ fig_size_inches = size[0] / 100 # taille de la figure en inches
1724
+ points_per_unit = (fig_size_inches * 72) / (2 * max_range) # points par unité Å
1725
+ sphere_size_points = (sphere_radius * points_per_unit) ** 2
1726
+
1727
+ ax.scatter(coords[:, 0], coords[:, 1], coords[:, 2],
1728
+ c='gold', s=sphere_size_points, alpha=0.9,
1729
+ edgecolors='darkgoldenrod', linewidths=0.5)
1730
+
1731
+ ax.set_xlabel('X (Å)', fontsize=8)
1732
+ ax.set_ylabel('Y (Å)', fontsize=8)
1733
+ ax.set_zlabel('Z (Å)', fontsize=8)
1734
+ ax.grid(False)
1735
+ ax.set_facecolor('white')
1736
+ # Masquer les axes pour un rendu plus propre
1737
+ ax.set_xticks([])
1738
+ ax.set_yticks([])
1739
+ ax.set_zticks([])
1740
+
1741
+ plt.tight_layout()
1742
+ plt.savefig(output_path, dpi=100, bbox_inches='tight', facecolor='white')
1743
+ plt.close()
1744
+ return output_path
1745
+ except Exception:
1746
+ return None
1747
+
1748
+ def generate_pdf_report(self, output_path='structure_screening_report.pdf', n_top=10, pdf_file=None):
1749
+ """
1750
+ Generate comprehensive PDF report with top N refinement results
1751
+
1752
+ Parameters:
1753
+ -----------
1754
+ output_path: str
1755
+ Path for output PDF file
1756
+ n_top: int
1757
+ Number of top results to include (default: 10)
1758
+ pdf_file: str, optional
1759
+ Specific PDF file to analyze. If None, uses first PDF in best_results
1760
+ """
1761
+ try:
1762
+ from matplotlib.backends.backend_pdf import PdfPages
1763
+ from matplotlib import pyplot as plt
1764
+ import matplotlib.patches as mpatches
1765
+ except ImportError:
1766
+ print("Matplotlib required for PDF generation")
1767
+ return None
1768
+
1769
+ # Get top N results
1770
+ top_results = self.get_top_n_results(n=n_top, pdf_file=pdf_file)
1771
+
1772
+ if not top_results:
1773
+ print("No results to generate report")
1774
+ return None
1775
+
1776
+ # Select PDF file name
1777
+ if pdf_file is None:
1778
+ pdf_file = list(self.best_results.keys())[0]
1779
+ pdf_name = os.path.basename(pdf_file)
1780
+
1781
+ # Create output directory for thumbnails
1782
+ thumb_dir = os.path.join(self.strufile_dir, 'thumbnails')
1783
+ os.makedirs(thumb_dir, exist_ok=True)
1784
+
1785
+ # Full output path
1786
+ output_full_path = os.path.join(self.strufile_dir, output_path)
1787
+
1788
+ with PdfPages(output_full_path) as pdf:
1789
+ # Page 1: Complete Overview - Fit Curve + Thumbnail + Details
1790
+ best_result = top_results[0]
1791
+ best_strufile_name = best_result['structure_name']
1792
+
1793
+ # Get best structure file path
1794
+ if 'structure_path' in best_result and os.path.exists(best_result['structure_path']):
1795
+ best_strufile_path = best_result['structure_path']
1796
+ else:
1797
+ best_strufile_path = os.path.join(self.strufile_dir, best_strufile_name + '.xyz')
1798
+
1799
+ # Generate thumbnail for best structure and save it
1800
+ best_thumb_path = os.path.join(thumb_dir, f"best_structure_{best_strufile_name}.png")
1801
+
1802
+ if os.path.exists(best_strufile_path):
1803
+ self.generate_structure_thumbnail(
1804
+ best_strufile_path,
1805
+ best_result['zoomscale'] if best_result['zoomscale'] else 1.0,
1806
+ best_thumb_path,
1807
+ size=(500, 500)
1808
+ )
1809
+
1810
+ # Create comprehensive first page
1811
+ fig = plt.figure(figsize=(8.5, 11))
1812
+ fig.suptitle('Structure Screening Report - Best Result', fontsize=18, fontweight='bold', y=0.98)
1813
+
1814
+ # Create grid for layout: [fit curve (top), thumbnail + info (bottom)]
1815
+ gs = fig.add_gridspec(2, 2, height_ratios=[1.2, 1], width_ratios=[1.2, 1],
1816
+ hspace=0.3, wspace=0.3, left=0.08, right=0.95, top=0.93, bottom=0.05)
1817
+
1818
+ # Top: Fit curve (spans both columns)
1819
+ ax_fit = fig.add_subplot(gs[0, :])
1820
+
1821
+ # Find fit data
1822
+ pdf_basename = os.path.basename(pdf_file).replace('.gr', '')
1823
+ png_locations = [
1824
+ os.path.join(self.strufile_dir, 'fig', f"{pdf_basename}_{best_strufile_name}.png"),
1825
+ os.path.join(self.strufile_dir, f"{pdf_basename}_{best_strufile_name}.png")
1826
+ ]
1827
+ fit_locations = [
1828
+ os.path.join(self.strufile_dir, 'fit', f"{pdf_basename}_{best_strufile_name}.fit"),
1829
+ os.path.join(self.strufile_dir, f"{pdf_basename}_{best_strufile_name}.fit")
1830
+ ]
1831
+
1832
+ png_file = None
1833
+ for png_loc in png_locations:
1834
+ if os.path.exists(png_loc):
1835
+ png_file = png_loc
1836
+ break
1837
+
1838
+ fit_file = None
1839
+ for fit_loc in fit_locations:
1840
+ if os.path.exists(fit_loc):
1841
+ fit_file = fit_loc
1842
+ break
1843
+
1844
+ if png_file:
1845
+ img = plt.imread(png_file)
1846
+ ax_fit.imshow(img)
1847
+ ax_fit.axis('off')
1848
+ ax_fit.set_title(f'Best Fit: {best_strufile_name} (Rw={best_result["Rw"]:.4f})',
1849
+ fontsize=12, fontweight='bold')
1850
+ elif fit_file:
1851
+ data = np.loadtxt(fit_file, skiprows=0)
1852
+ r = data[:, 0]
1853
+ g_obs = data[:, 1]
1854
+ g_calc = data[:, 2]
1855
+ diff = g_obs - g_calc
1856
+
1857
+ ax_fit.plot(r, g_obs, 'bo', label='Observed', markersize=2, alpha=0.6)
1858
+ ax_fit.plot(r, g_calc, 'r-', label='Calculated', linewidth=1.5)
1859
+ ax_fit.set_ylabel('G(r) (Å⁻²)', fontsize=10)
1860
+ ax_fit.set_xlabel('r (Å)', fontsize=10)
1861
+ ax_fit.set_title(f'Best Fit: {best_strufile_name} (Rw={best_result["Rw"]:.4f})',
1862
+ fontsize=12, fontweight='bold')
1863
+ ax_fit.legend(fontsize=8)
1864
+ ax_fit.grid(alpha=0.3)
1865
+ else:
1866
+ ax_fit.text(0.5, 0.5, 'Fit curve not available',
1867
+ ha='center', va='center', fontsize=10)
1868
+ ax_fit.axis('off')
1869
+
1870
+ # Bottom left: Structure thumbnail
1871
+ ax_thumb = fig.add_subplot(gs[1, 0])
1872
+ if os.path.exists(best_thumb_path):
1873
+ thumb_img = plt.imread(best_thumb_path)
1874
+ ax_thumb.imshow(thumb_img)
1875
+ ax_thumb.set_title('3D Structure', fontsize=11, fontweight='bold')
1876
+ else:
1877
+ ax_thumb.text(0.5, 0.5, 'Thumbnail\nnot available',
1878
+ ha='center', va='center', fontsize=10)
1879
+ ax_thumb.axis('off')
1880
+
1881
+ # Bottom right: Detailed information
1882
+ ax_info = fig.add_subplot(gs[1, 1])
1883
+ ax_info.axis('off')
1884
+
1885
+ # Format values
1886
+ best_zoomscale = f"{best_result['zoomscale']:.6f}" if best_result['zoomscale'] else 'N/A'
1887
+ best_diameter = f"{best_result['diameter']:.2f}" if best_result['diameter'] else 'N/A'
1888
+ best_natoms = best_result['natoms'] if best_result['natoms'] else 'N/A'
1889
+ best_surface = best_result['surface_atoms'] if best_result['surface_atoms'] else 'N/A'
1890
+ best_surf_pct = f"({best_result['surface_fraction']*100:.1f}%)" if best_result['surface_fraction'] else ''
1891
+
1892
+ info_text = f"""
1893
+ PDF: {pdf_name}
1894
+
1895
+ ═══════════════════════
1896
+ REFINEMENT RESULTS:
1897
+ ═══════════════════════
1898
+ Rw: {best_result['Rw']:.4f}
1899
+ Zoomscale: {best_zoomscale}
1900
+
1901
+ ═══════════════════════
1902
+ STRUCTURE PROPERTIES:
1903
+ ═══════════════════════
1904
+ Name: {best_strufile_name}
1905
+ Diameter: {best_diameter} Å
1906
+ Total atoms: {best_natoms}
1907
+ Surface atoms: {best_surface} {best_surf_pct}
1908
+
1909
+ ═══════════════════════
1910
+ REPORT INFO:
1911
+ ═══════════════════════
1912
+ Generated: {np.datetime64('now')}
1913
+ Top {n_top} results included
1914
+ """
1915
+
1916
+ ax_info.text(0.05, 0.95, info_text, fontsize=9, family='monospace',
1917
+ verticalalignment='top', transform=ax_info.transAxes)
1918
+
1919
+ pdf.savefig(fig, bbox_inches='tight')
1920
+ plt.close()
1921
+
1922
+ # Page 2-3: Top N Results Table
1923
+ n_per_page = 15
1924
+ for page_num, i in enumerate(range(0, len(top_results), n_per_page)):
1925
+ fig, ax = plt.subplots(figsize=(8.5, 11))
1926
+ ax.axis('off')
1927
+
1928
+ page_results = top_results[i:i+n_per_page]
1929
+
1930
+ # Create table data
1931
+ table_data = [['Rank', 'Structure', 'Rw', 'Zoomscale', 'Diam.(Å)', 'Atoms', 'Surf.%']]
1932
+
1933
+ for idx, res in enumerate(page_results, start=i+1):
1934
+ row = [
1935
+ f'{idx}',
1936
+ res['structure_name'][:30],
1937
+ f"{res['Rw']:.4f}",
1938
+ f"{res['zoomscale']:.4f}" if res['zoomscale'] else 'N/A',
1939
+ f"{res['diameter']:.1f}" if res['diameter'] else 'N/A',
1940
+ f"{res['natoms']}" if res['natoms'] else 'N/A',
1941
+ f"{res['surface_fraction']*100:.1f}" if res['surface_fraction'] else 'N/A'
1942
+ ]
1943
+ table_data.append(row)
1944
+
1945
+ table = ax.table(cellText=table_data, loc='center', cellLoc='left')
1946
+ table.auto_set_font_size(False)
1947
+ table.set_fontsize(9)
1948
+ table.scale(1, 2)
1949
+
1950
+ # Style header row
1951
+ for i in range(len(table_data[0])):
1952
+ table[(0, i)].set_facecolor('#3498db')
1953
+ table[(0, i)].set_text_props(weight='bold', color='white')
1954
+
1955
+ # Alternate row colors
1956
+ for i in range(1, len(table_data)):
1957
+ for j in range(len(table_data[0])):
1958
+ if i % 2 == 0:
1959
+ table[(i, j)].set_facecolor('#f0f0f0')
1960
+
1961
+ ax.set_title(f'Top {n_top} Refinement Results (Page {page_num+1})',
1962
+ fontsize=14, fontweight='bold', pad=20)
1963
+
1964
+ pdf.savefig(fig, bbox_inches='tight')
1965
+ plt.close()
1966
+
1967
+ # Page: Structure Thumbnails (4 per page)
1968
+ for page_idx in range(0, min(n_top, 12), 4):
1969
+ fig, axes = plt.subplots(2, 2, figsize=(8.5, 11))
1970
+ fig.suptitle(f'Structure Visualizations (Rank {page_idx+1}-{page_idx+4})',
1971
+ fontsize=14, fontweight='bold')
1972
+
1973
+ axes = axes.flatten()
1974
+
1975
+ for idx in range(4):
1976
+ if page_idx + idx >= len(top_results):
1977
+ axes[idx].axis('off')
1978
+ continue
1979
+
1980
+ res = top_results[page_idx + idx]
1981
+
1982
+ # Use structure_path if available, otherwise construct it
1983
+ if 'structure_path' in res and os.path.exists(res['structure_path']):
1984
+ strufile_path = res['structure_path']
1985
+ else:
1986
+ strufile_path = os.path.join(self.strufile_dir, res['structure_name'] + '.xyz')
1987
+
1988
+ thumb_path = os.path.join(thumb_dir, f"thumb_{page_idx+idx}.png")
1989
+
1990
+ if os.path.exists(strufile_path):
1991
+ self.generate_structure_thumbnail(
1992
+ strufile_path,
1993
+ res['zoomscale'] if res['zoomscale'] else 1.0,
1994
+ thumb_path
1995
+ )
1996
+
1997
+ if os.path.exists(thumb_path):
1998
+ img = plt.imread(thumb_path)
1999
+ axes[idx].imshow(img)
2000
+
2001
+ axes[idx].axis('off')
2002
+ title_text = f"#{page_idx+idx+1}: Rw={res['Rw']:.4f}\n{res['natoms']} atoms" if res['natoms'] else f"#{page_idx+idx+1}: Rw={res['Rw']:.4f}"
2003
+ axes[idx].set_title(title_text, fontsize=10)
2004
+
2005
+ plt.tight_layout()
2006
+ pdf.savefig(fig, bbox_inches='tight')
2007
+ plt.close()
2008
+
2009
+ return output_full_path
2010
+
2011
+
2012
+
2013
+
2014
+ class PDFRefinement():
2015
+ def __init__(self,
2016
+ pdffile:str,
2017
+ strufile:str,
2018
+ qdamp:float=0.014,
2019
+ qbroad:float=0.04,
2020
+ refinement_tags:dict={'scale_factor': True, 'zoomscale': True, 'delta2': True, 'Uiso': True},
2021
+ save_tag:bool=False,
2022
+ RUN_PARALLEL:bool=True,
2023
+ rmin=0.01,
2024
+ rbins:int=1,
2025
+ screening_tag:bool=False):
2026
+
2027
+ """
2028
+ refinement_tags={'scale_factor': True, 'zoomscale': True, 'delta2': True, 'Uiso': True}
2029
+ pdffile: path to pdf file
2030
+ strufile path to structure file
2031
+ qdamp qdamp value (default=0.014)
2032
+ qbroad qbroad value (default==0.04)
2033
+ save_tag: save refinement data (default=False)
2034
+ RUN_PARALLEL=True
2035
+ rbins: int, can be adjusted to increase rstep (default=1)
2036
+ screening_tag=False
2037
+ """
2038
+ # Check file formats
2039
+ pdf_extension=os.path.basename(pdffile).split('.')[-1]
2040
+ if pdf_extension == 'gr':
2041
+ self.pdffile = pdffile
2042
+ else:
2043
+ print('PDF file should be a .gr file, extracted with pdfgtetx3')
2044
+ stru_extension=os.path.basename(strufile).split('.')[-1]
2045
+ if stru_extension == 'xyz':
2046
+ self.strufile = strufile
2047
+ else:
2048
+ print('Structure files must adopt the xyz standard format')
2049
+
2050
+ # Initialize attributes
2051
+ self.path=os.path.dirname(self.strufile)
2052
+ self.qdamp = qdamp
2053
+ self.qbroad = qbroad
2054
+ self.refinement_tags = refinement_tags
2055
+ self.save_tag = save_tag
2056
+ self.RUN_PARALLEL=RUN_PARALLEL
2057
+ self.rbins=rbins
2058
+ self.screening_tag=screening_tag
2059
+ # Read metadata from pdffile
2060
+ with open(self.pdffile, 'r') as f:
2061
+ for line in f:
2062
+ if "qmin" in line:
2063
+ self.qmin = float(line.split(' = ')[1].strip())
2064
+ if "qmax" in line:
2065
+ self.qmax = float(line.split(' = ')[1].strip())
2066
+ # Load data from the PDF file
2067
+ r = np.loadtxt(self.pdffile, usecols=(0), skiprows=29)
2068
+ self.rmin = rmin
2069
+ self.rmax = np.max(r)
2070
+ self.rstep = ((self.rmax-self.rmin) / (len(r) - 1))*self.rbins
2071
+
2072
+ # Create fit recipe
2073
+ self.recipe = self.make_recipe()
2074
+
2075
+ def file_extension(self, file):
2076
+ return os.path.basename(file).split('.')[-1]
2077
+
2078
+ def make_recipe(self):
2079
+ PDF_RMIN=self.rmin
2080
+ PDF_RMAX=self.rmax
2081
+ PDF_RSTEP=self.rstep
2082
+ QBROAD_I=self.qbroad
2083
+ QDAMP_I=self.qdamp
2084
+ QMIN=self.qmin
2085
+ QMAX=self.qmax
2086
+ ZOOMSCALE_I=1
2087
+ UISO_I=0.005
2088
+ stru1 = Structure(filename=self.strufile)
2089
+
2090
+ profile = Profile()
2091
+ parser = PDFParser()
2092
+ parser.parseFile(self.pdffile)
2093
+ profile.loadParsedData(parser)
2094
+ profile.setCalculationRange(xmin=PDF_RMIN, xmax=PDF_RMAX, dx=PDF_RSTEP)
2095
+
2096
+ # 10: Create a Debye PDF Generator object for the discrete structure model.
2097
+ generator_cluster1 = DebyePDFGenerator("G1")
2098
+ generator_cluster1.setStructure(stru1, periodic=False)
2099
+
2100
+ # 11: Create a Fit Contribution object.
2101
+ contribution = FitContribution("cluster")
2102
+ contribution.addProfileGenerator(generator_cluster1)
2103
+
2104
+ # If you have a multi-core computer (you probably do), run your refinement in parallel!
2105
+ if self.RUN_PARALLEL:
2106
+ try:
2107
+ import psutil
2108
+ import multiprocessing
2109
+ from multiprocessing import Pool
2110
+ except ImportError:
2111
+ print("\nYou don't appear to have the necessary packages for parallelization")
2112
+ syst_cores = multiprocessing.cpu_count()
2113
+ cpu_percent = psutil.cpu_percent()
2114
+ avail_cores = np.floor((100 - cpu_percent) / (100.0 / syst_cores))
2115
+ ncpu = int(np.max([1, avail_cores]))
2116
+ pool = Pool(processes=ncpu)
2117
+ generator_cluster1.parallel(ncpu=ncpu, mapfunc=pool.map)
2118
+
2119
+ contribution.setProfile(profile, xname="r")
2120
+
2121
+ # 13: Set an equation, based on your PDF generators.
2122
+ contribution.setEquation("s1*G1")
2123
+
2124
+ # 14: Create the Fit Recipe object that holds all the details of the fit.
2125
+ recipe = FitRecipe()
2126
+ recipe.addContribution(contribution)
2127
+
2128
+ # 15: Initialize the instrument parameters, Q_damp and Q_broad, and
2129
+ # assign Q_max and Q_min.
2130
+ generator_cluster1.qdamp.value = QDAMP_I
2131
+ generator_cluster1.qbroad.value = QBROAD_I
2132
+ generator_cluster1.setQmax(QMAX)
2133
+ generator_cluster1.setQmin(QMIN)
2134
+
2135
+ # 16: Add, initialize, and tag variables in the Fit Recipe object.
2136
+ # In this case we also add psize, which is the NP size.
2137
+ recipe.addVar(contribution.s1, float(1), tag="scale_factor")
2138
+
2139
+ # 17: Define a phase and lattice from the Debye PDF Generator
2140
+ # object and assign an isotropic lattice expansion factor tagged
2141
+ # "zoomscale" to the structure.
2142
+ phase_cluster1 = generator_cluster1.phase
2143
+ lattice1 = phase_cluster1.getLattice()
2144
+ recipe.newVar("zoomscale", ZOOMSCALE_I, tag="zoomscale")
2145
+ recipe.constrain(lattice1.a, 'zoomscale')
2146
+ recipe.constrain(lattice1.b, 'zoomscale')
2147
+ recipe.constrain(lattice1.c, 'zoomscale')
2148
+ # 18: Initialize an atoms object and constrain the isotropic
2149
+ # Atomic Displacement Paramaters (ADPs) per element.
2150
+ atoms1 = phase_cluster1.getScatterers()
2151
+ recipe.newVar("Uiso", UISO_I, tag="Uiso")
2152
+ for atom in atoms1:
2153
+ recipe.constrain(atom.Uiso, "Uiso")
2154
+ recipe.restrain("Uiso",lb=0,ub=1,scaled=True,sig=0.00001)
2155
+ recipe.addVar(generator_cluster1.delta2, name="delta2", value=float(4), tag="delta2")
2156
+ recipe.restrain("delta2",lb=0,ub=12,scaled=True,sig=0.00001)
2157
+ return recipe
2158
+
2159
+
2160
+ def get_filename(self,file):
2161
+ filename=os.path.basename(file).split('/')[-1]
2162
+ return filename.split('.')[0]
2163
+
2164
+ def refine(self):
2165
+ # Establish the location of the data and a name for our fit.
2166
+ gr_path = str(self.pdffile)
2167
+ FIT_ID=self.get_filename(self.pdffile)+'_'+self.get_filename(self.strufile)
2168
+ basename = FIT_ID
2169
+ # Establish the full path of the structure file
2170
+ stru_path = self.strufile
2171
+ recipe = self.recipe
2172
+ # Amount of information to write to the terminal during fitting.
2173
+ if not self.screening_tag:
2174
+ recipe.fithooks[0].verbose = 3
2175
+ else:
2176
+ recipe.fithooks[0].verbose = 0
2177
+
2178
+
2179
+ recipe.fix("all")
2180
+ # Define values to refin from self.refinement_tags
2181
+ tags=[]
2182
+ for key in self.refinement_tags:
2183
+ if self.refinement_tags[key]==True:
2184
+ tags.append(key)
2185
+
2186
+ tags.append("all")
2187
+ for tag in tags:
2188
+ recipe.free(tag)
2189
+
2190
+ least_squares(recipe.residual, recipe.values, x_scale="jac")
2191
+
2192
+ # Write the fitted data to a file.
2193
+ profile = recipe.cluster.profile
2194
+ #profile.savetxt(fitdir / f"{basename}.fit")
2195
+
2196
+ res = FitResults(recipe)
2197
+ if not self.screening_tag:
2198
+ res.printResults()
2199
+
2200
+ #res.saveResults(resdir / f"{basename}.res", header=header)
2201
+
2202
+ # Save refinement results
2203
+ if self.save_tag:
2204
+ self.save_fitresults(profile,res)
2205
+ else:
2206
+ pass
2207
+ return res.rw
2208
+
2209
+ def save_fitresults(self,profile,res):
2210
+ basename=self.get_filename(self.pdffile)+'_'+self.get_filename(self.strufile)
2211
+
2212
+ PWD=Path(self.path)
2213
+ # Make some folders to store our output files.
2214
+ resdir = PWD / "res"
2215
+ fitdir = PWD / "fit"
2216
+ figdir = PWD / "fig"
2217
+ folders = [resdir, fitdir, figdir]
2218
+ for folder in folders:
2219
+ if not folder.exists():
2220
+ folder.mkdir()
2221
+ # save exp and calc pdf
2222
+ profile.savetxt(fitdir / f"{basename}.fit")
2223
+ # Write the fit results to a file.
2224
+ header = "%s"%str(basename)+".\n"
2225
+ header+="data file:%s"%str(self.pdffile)+"\n"
2226
+ header+="structure file:%s"%str(self.strufile)+"\n"
2227
+ header+="Fitting parameters \n"
2228
+ header+="rmin=%f"%self.rmin+"\n"
2229
+ header+="rmax=%f"%self.rmax+"\n"
2230
+ header+="rstep=%f"%self.rstep+"\n"
2231
+ header+="QBROAD=%f"%self.qbroad+"\n"
2232
+ header+="QDAMP=%f"%self.qdamp+"\n"
2233
+ header+="QMIN=%f"%self.qmin+"\n"
2234
+ header+="QMAX=%f"%self.qmax+"\n"
2235
+ res.saveResults(resdir / f"{basename}.res", header=header)
2236
+
2237
+ #Make plot
2238
+ fig_name= figdir / basename
2239
+ if not isinstance(fig_name, Path):
2240
+ fig_name = Path(fig_name)
2241
+ plt.clf()
2242
+ plt.close('all')
2243
+ r = self.recipe.cluster.profile.x
2244
+ g = self.recipe.cluster.profile.y
2245
+ gcalc = self.recipe.cluster.profile.ycalc
2246
+ # Make an array of identical shape as g which is offset from g.
2247
+ diff = g - gcalc
2248
+ diffzero = (min(g)-np.abs(max(diff))) * \
2249
+ np.ones_like(g)
2250
+ # Calculate the residual (difference) array and offset it vertically.
2251
+ diff = g - gcalc + diffzero
2252
+ # Change some style details of the plot
2253
+ mpl.rcParams.update(mpl.rcParamsDefault)
2254
+ # Create a figure and an axis on which to plot
2255
+ fig, ax1 = plt.subplots(1, 1)
2256
+ # Plot the difference offset line
2257
+ ax1.plot(r, diffzero, lw=1.0, ls="--", c="black")
2258
+ # Plot the measured data
2259
+ ax1.plot(r,g,ls="None",marker="o",ms=5,mew=0.2,mfc="None",label="G(r) Data")
2260
+ ax1.plot(r, diff, lw=1.2, label="G(r) diff")
2261
+ ax1.plot(r,gcalc,'g',label='G(r) calc')
2262
+ ax1.set_xlabel(r"r ($\mathrm{\AA}$)")
2263
+ ax1.set_ylabel(r"G ($\mathrm{\AA}$$^{-2}$)")
2264
+ ax1.tick_params(axis="both",which="major",top=True,right=True)
2265
+ ax1.set_xlim(self.rmin, self.rmax)
2266
+ ax1.legend(ncol=2)
2267
+ fig.tight_layout()
2268
+ ax1.set_title(basename+'\n'+f'Rw={res.rw:.4f}')
2269
+ # Save plot
2270
+ fig.savefig(fig_name.parent / f"{fig_name.name}.png", format="png")
2271
+
2272
+
2273
+ class PDFRefinementFast:
2274
+ """
2275
+ Fast PDF refinement class for STRUCTURE SCREENING.
2276
+ Same interface as PDFRefinement, but MUCH faster.
2277
+ """
2278
+
2279
+ def __init__(self,
2280
+ pdffile: str,
2281
+ strufile: str,
2282
+ qdamp: float = 0.014,
2283
+ qbroad: float = 0.04,
2284
+ rbins: int = 4,
2285
+ rmin: float = 2.0,
2286
+ rmax_fast: float = 15.0,
2287
+ screening_tag: bool = True):
2288
+
2289
+ self.pdffile = pdffile
2290
+ self.strufile = strufile
2291
+ self.qdamp = qdamp
2292
+ self.qbroad = qbroad
2293
+ self.rbins = rbins
2294
+ self.rmin = rmin
2295
+ self.rmax_fast = rmax_fast
2296
+ self.screening_tag = screening_tag
2297
+
2298
+ self.recipe = self._make_fast_recipe()
2299
+
2300
+ # ------------------------------------------------------------
2301
+
2302
+ def _make_fast_recipe(self):
2303
+ import numpy as np
2304
+ from diffpy.srfit.fitbase import FitRecipe, FitContribution, Profile
2305
+ from diffpy.srfit.pdf import PDFParser, DebyePDFGenerator
2306
+ from diffpy.structure import Structure
2307
+
2308
+ # --- Structure
2309
+ stru = Structure(filename=self.strufile)
2310
+
2311
+ # --- PDF data
2312
+ profile = Profile()
2313
+ parser = PDFParser()
2314
+ parser.parseFile(self.pdffile)
2315
+ profile.loadParsedData(parser)
2316
+
2317
+ r = profile.x
2318
+ rmax_data = np.max(r)
2319
+ rmax = min(self.rmax_fast, rmax_data)
2320
+
2321
+ # Coarsen grid (rbins)
2322
+ rstep = (rmax - self.rmin) / (len(r) // self.rbins)
2323
+
2324
+ profile.setCalculationRange(
2325
+ xmin=self.rmin,
2326
+ xmax=rmax,
2327
+ dx=rstep
2328
+ )
2329
+
2330
+ # --- Debye generator
2331
+ gen = DebyePDFGenerator("G")
2332
+ gen.setStructure(stru, periodic=False)
2333
+ gen.qdamp.value = self.qdamp
2334
+ gen.qbroad.value = self.qbroad
2335
+
2336
+ # --- Contribution
2337
+ contrib = FitContribution("cluster")
2338
+ contrib.addProfileGenerator(gen)
2339
+ contrib.setProfile(profile, xname="r")
2340
+ contrib.setEquation("s*G")
2341
+
2342
+ # --- Recipe
2343
+ recipe = FitRecipe()
2344
+ recipe.addContribution(contrib)
2345
+
2346
+ # --- Minimal parameter set
2347
+ recipe.addVar(contrib.s, 1.0, tag="scale")
2348
+
2349
+ phase = gen.phase
2350
+ lattice = phase.getLattice()
2351
+
2352
+ recipe.newVar("zoomscale", 1.0, tag="zoomscale")
2353
+ recipe.constrain(lattice.a, "zoomscale")
2354
+ recipe.constrain(lattice.b, "zoomscale")
2355
+ recipe.constrain(lattice.c, "zoomscale")
2356
+
2357
+ # Fix everything except scale + zoomscale
2358
+ recipe.fix("all")
2359
+ recipe.free("scale")
2360
+ recipe.free("zoomscale")
2361
+
2362
+ # Silence output
2363
+ recipe.fithooks[0].verbose = 0
2364
+
2365
+ return recipe
2366
+
2367
+ # ------------------------------------------------------------
2368
+
2369
+ def refine(self):
2370
+ from scipy.optimize import least_squares
2371
+ from diffpy.srfit.fitbase import FitResults
2372
+
2373
+ least_squares(
2374
+ self.recipe.residual,
2375
+ self.recipe.values,
2376
+ x_scale="jac",
2377
+ max_nfev=12
2378
+ )
2379
+
2380
+ res = FitResults(self.recipe)
2381
+ return res.rw
2382
+
2383
+
2384
+
2385
+
2386
+ class StructureScreener():
2387
+
2388
+ def __init__(self,
2389
+ strufile_dir:str,
2390
+ pdffile_dir:str,
2391
+ qdamp:float =0.014,
2392
+ qbroad:float =0.04,
2393
+ refinement_tags: dict ={'scale_factor': True, 'zoomscale': True, 'delta2': True, 'Uiso': True},
2394
+ save_tag: bool=False,
2395
+ RUN_PARALLEL:bool =True,
2396
+ rbins : int =1,
2397
+ rmin=0.01,
2398
+ rmax_fast=15.0,
2399
+
2400
+ fast_screening: bool =False,
2401
+ candidate_list: dict =None,
2402
+ threshold_percent: float =5.0):
2403
+ """
2404
+ strufile_dir: path of directory containing structure files
2405
+ pdffile_dir: path of directory containing pdf files
2406
+ refinement_tags: dict ={'scale_factor': True, 'zoomscale': True, 'delta2': True, 'Uiso': True}
2407
+ qdamp:float =0.014
2408
+ qbroad:float =0.04
2409
+ save_tag: bool=False
2410
+ RUN_PARALLEL:bool =True
2411
+ rbins : int =1
2412
+ screening_tag: bool =True
2413
+ candidate_list: dict = None (pass short-list from first screening for refinement)
2414
+ threshold_percent: float = 5.0 (tolerance for candidate selection: min(Rw) ± threshold_percent%)
2415
+ """
2416
+ self.strufile_dir=strufile_dir
2417
+ self.pdffile_dir=pdffile_dir
2418
+ self.qdamp=qdamp
2419
+ self.qbroad=qbroad
2420
+ self.refinement_tags=refinement_tags
2421
+ self.save_tag=save_tag
2422
+ self.RUN_PARALLEL=RUN_PARALLEL
2423
+ self.rbins=rbins
2424
+ self.rmin = rmin
2425
+ self.screening_tag=True
2426
+ self.logfile=self.strufile_dir+'/structure_screening.log'
2427
+ self.fast_screening=fast_screening
2428
+ self.candidate_list=candidate_list
2429
+ self.threshold_percent=threshold_percent
2430
+ self.rmax_fast=rmax_fast
2431
+
2432
+ def get_filename(self,file):
2433
+ filename=os.path.basename(file).split('/')[-1]
2434
+ return filename.split('.')[0]
2435
+
2436
+ def extract_phi(self,filename):
2437
+ match = re.search(r'_phi=(\d+)', filename)
2438
+
2439
+ # Return the extracted number as an integer
2440
+ return int(match.group(1))
2441
+
2442
+
2443
+
2444
+ def run(self):
2445
+ """
2446
+ PDF refinement of each PDF file in pdffile_dir with each structure file in strufile_dir
2447
+ Returns:
2448
+ - If fast_screening=True: (best_results, candidate_list) tuple
2449
+ - If fast_screening=False: best_results dict only
2450
+ """
2451
+ from tqdm import tqdm
2452
+
2453
+ best_results={}
2454
+ candidate_list = {}
2455
+ pdffile_list=glob.glob(os.path.join(self.pdffile_dir,'*.gr'))
2456
+
2457
+ # Get structure list based on screening type
2458
+ if self.candidate_list is None: # First screening (fast or full)
2459
+ strufile_list=glob.glob(os.path.join(self.strufile_dir,'*.xyz'))
2460
+ strufile_list=sorted(strufile_list,key=self.extract_phi)
2461
+ else: # Second screening with candidate_list
2462
+ # Get all unique structures from candidate_list
2463
+ all_strufiles = set()
2464
+ for pdf_structures in self.candidate_list.values():
2465
+ all_strufiles.update(pdf_structures)
2466
+ strufile_list = sorted(list(all_strufiles), key=self.extract_phi)
2467
+
2468
+ # Check if generator has kept_structures attribute (from auto mode)
2469
+ # If yes, use only those structures
2470
+ kept_structures_file = os.path.join(self.strufile_dir, 'kept_structures.txt')
2471
+ if os.path.exists(kept_structures_file) and self.candidate_list is None:
2472
+ with open(kept_structures_file, 'r') as f:
2473
+ kept_list = [line.strip() for line in f if line.strip()]
2474
+ if kept_list:
2475
+ strufile_list = kept_list
2476
+ print(f"Using {len(strufile_list)} structures in target diameter range")
2477
+
2478
+ line2write= '*****************************************************\n\n'
2479
+ line2write+=' STRUCTURE SCREENING \n\n'
2480
+ line2write+='*****************************************************\n\n'
2481
+ line2write+=f'PDF file \tStructure file \tRw\n\n'
2482
+ j=0
2483
+ print(line2write)
2484
+ print(f"Number of PDF files to process: {len(pdffile_list)}")
2485
+
2486
+ # Calculate total number of refinements for progress bar AFTER determining structures to use
2487
+ total_refinements = 0
2488
+ for pdffile in pdffile_list:
2489
+ if self.candidate_list is not None:
2490
+ pdf_key = os.path.basename(pdffile)
2491
+ if pdf_key in self.candidate_list:
2492
+ total_refinements += len(self.candidate_list[pdf_key])
2493
+ else:
2494
+ # Count only structures that will actually be tested
2495
+ total_refinements += len(strufile_list)
2496
+
2497
+ # Single progress bar for all refinements
2498
+ pbar = tqdm(total=total_refinements, desc="Refining structures", ncols=80)
2499
+
2500
+ refinement_count = 0 # Track actual refinements
2501
+
2502
+ for pdffile in pdffile_list:
2503
+ pdfname=self.get_filename(pdffile)
2504
+
2505
+ # Determine which structures to test for this PDF
2506
+ if self.candidate_list is not None:
2507
+ # Use only candidates for this PDF
2508
+ pdf_key = os.path.basename(pdffile)
2509
+ if pdf_key not in self.candidate_list:
2510
+ j += 1
2511
+ continue
2512
+ strufile_list_to_use = self.candidate_list[pdf_key]
2513
+ else:
2514
+ # Use all structures (already filtered by kept_structures.txt if available)
2515
+ strufile_list_to_use = strufile_list
2516
+
2517
+ # Store refinement results (Rw and zoomscale) for this PDF
2518
+ refinement_results = {}
2519
+
2520
+ for strufile in strufile_list_to_use:
2521
+ struname=self.get_filename(strufile)
2522
+ if self.fast_screening:
2523
+ calc = PDFRefinementFast(
2524
+ pdffile,
2525
+ strufile,
2526
+ rbins=self.rbins,
2527
+ rmin=self.rmin,
2528
+ rmax_fast=self.rmax_fast
2529
+ )
2530
+ else:
2531
+ calc=PDFRefinement(pdffile,
2532
+ strufile,
2533
+ refinement_tags=self.refinement_tags,
2534
+ save_tag=self.save_tag,
2535
+ rbins=self.rbins,
2536
+ rmin = self.rmin,
2537
+ screening_tag=self.screening_tag)
2538
+ rw=calc.refine()
2539
+ # Extract zoomscale from recipe
2540
+ zoomscale = calc.recipe.zoomscale.value
2541
+ refinement_results[strufile] = {'Rw': rw, 'zoomscale': zoomscale}
2542
+ temp=f'{pdfname:15}\t{struname:50}\t{rw:.4f}\tzoomscale={zoomscale:.6f}'
2543
+ print(temp)
2544
+ line2write+=f'{pdfname:15}\t{struname:50}\t{rw:.4f}\tzoomscale={zoomscale:.6f}\n'
2545
+ refinement_count += 1
2546
+ pbar.update(1) # Update progress bar after each refinement
2547
+
2548
+ # Only compute candidate list if not already provided
2549
+ if self.candidate_list is None:
2550
+ # the following code is to extract structures with Min(Rwp) +- threshold%
2551
+ min_rw = min(result['Rw'] for result in refinement_results.values())
2552
+ threshold_low = min_rw * (1 - self.threshold_percent/100.0)
2553
+ threshold_high = min_rw * (1 + self.threshold_percent/100.0)
2554
+
2555
+ pdfname_full = os.path.basename(pdffile)
2556
+
2557
+ best_results_candidates = {}
2558
+ best_results_candidates[pdfname_full] = {}
2559
+
2560
+ for strufile, result in refinement_results.items():
2561
+ rw = result['Rw']
2562
+ if threshold_low <= rw <= threshold_high:
2563
+ best_results_candidates[pdfname_full][strufile] = result
2564
+
2565
+ # Affichage trié par Rw croissant
2566
+ print("****************************************************\nListe des meilleures structures candidates (min(R_w) ± "+str(self.threshold_percent)+"%) :\n")
2567
+ line2write += '*******************************************************\nListe des meilleures structures candidates (min(R_w) ± '+str(self.threshold_percent)+'%) :\n'
2568
+
2569
+ for key, struct_dict in best_results_candidates.items():
2570
+ # Trier par Rw croissant
2571
+ sorted_items = sorted(struct_dict.items(), key=lambda item: item[1]['Rw'])
2572
+ candidate_list[key] = [item[0] for item in sorted_items] # Store sorted structure paths
2573
+
2574
+ for file, result in sorted_items:
2575
+ print(f'Fichier PDF : {key}, Structure : {self.get_filename(file)}, Rw = {result["Rw"]:.4f}, zoomscale = {result["zoomscale"]:.6f}\n')
2576
+ line2write += f'Fichier PDF : {key}, Structure : {self.get_filename(file)}, Rw = {result["Rw"]:.4f}, zoomscale = {result["zoomscale"]:.6f}\n'
2577
+
2578
+ # Find best results (minimum Rw)
2579
+ best_strufile_item = min(refinement_results.items(), key=lambda x: x[1]['Rw'])
2580
+ best_strufile = best_strufile_item[0]
2581
+ best_result = best_strufile_item[1]
2582
+ best_rw = best_result['Rw']
2583
+ best_zoomscale = best_result['zoomscale']
2584
+ pdfname=os.path.basename(pdffile)
2585
+ beststru=os.path.basename(best_strufile)
2586
+ best_results[pdffile]={'strufile': best_strufile, 'Rw': best_rw, 'zoomscale': best_zoomscale}
2587
+ line2write+='*******************************************************\n'
2588
+ line2write+=f'{pdfname}\t best structure={beststru} \t Rw={best_rw:.4f}\t zoomscale={best_zoomscale:.6f}\n\n'
2589
+ print("****************************************************\n")
2590
+ print(f'{pdfname}\t best structure={beststru} \t Rw={best_rw:.4f}\t zoomscale={best_zoomscale:.6f}\n')
2591
+ j+=1
2592
+
2593
+ pbar.close() # Close progress bar
2594
+
2595
+ with open(self.logfile,'w') as f:
2596
+ f.write(line2write)
2597
+
2598
+ # Return candidate_list if fast_screening (for use in refinement pass)
2599
+ if self.fast_screening and self.candidate_list is None:
2600
+ return best_results, candidate_list
2601
+ else:
2602
+ return best_results
2603
+
2604
+
2605
+
2606
+
2607
+
2608
+
2609
+
2610
+
2611
+
2612
+
2613
+
2614
+
2615
+
2616
+