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.
- epdfsuite/PDF_BatchAnalysis.py +2616 -0
- epdfsuite/__init__.py +23 -0
- epdfsuite/_launcher.py +9 -0
- epdfsuite/app_epdfsuite.py +813 -0
- epdfsuite/calibration.py +180 -0
- epdfsuite/camera_library.py +153 -0
- epdfsuite/ePDFsuite.py +1078 -0
- epdfsuite/filereader.py +444 -0
- epdfsuite/lobato_scattering.py +184 -0
- epdfsuite/pdf_extraction.py +510 -0
- epdfsuite/recalibration.py +352 -0
- epdfsuite/snr.py +52 -0
- epdfsuite/utilities.py +1415 -0
- epdfsuite-0.1.4.dist-info/METADATA +166 -0
- epdfsuite-0.1.4.dist-info/RECORD +18 -0
- epdfsuite-0.1.4.dist-info/WHEEL +5 -0
- epdfsuite-0.1.4.dist-info/entry_points.txt +2 -0
- epdfsuite-0.1.4.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
|