Coverage for /builds/kinetik161/ase/ase/md/analysis.py: 63.48%

115 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-12-10 11:04 +0000

1# flake8: noqa 

2import numpy as np 

3 

4 

5class DiffusionCoefficient: 

6 

7 def __init__(self, traj, timestep, atom_indices=None, molecule=False): 

8 """ 

9 

10 This class calculates the Diffusion Coefficient for the given Trajectory using the Einstein Equation: 

11 

12 ..math:: \\left \\langle \\left | r(t) - r(0) \\right | ^{2} \\right \\rangle = 2nDt 

13 

14 where r(t) is the position of atom at time t, n is the degrees of freedom and D is the Diffusion Coefficient 

15 

16 Solved herein by fitting with :math:`y = mx + c`, i.e. :math:`\\frac{1}{2n} \\left \\langle \\left | r(t) - r(0) \\right | ^{2} \\right \\rangle = Dt`, with m = D and c = 0 

17 

18 wiki : https://en.wikibooks.org/wiki/Molecular_Simulation/Diffusion_Coefficients 

19 

20 Parameters: 

21 traj (Trajectory): 

22 Trajectory of atoms objects (images) 

23 timestep (Float): 

24 Timestep between *each image in the trajectory*, in ASE timestep units 

25 (For an MD simulation with timestep of N, and images written every M iterations, our timestep here is N * M) 

26 atom_indices (List of Int): 

27 The indices of atoms whose Diffusion Coefficient is to be calculated explicitly 

28 molecule (Boolean) 

29 Indicate if we are studying a molecule instead of atoms, therefore use centre of mass in calculations 

30 

31 """ 

32 

33 self.traj = traj 

34 self.timestep = timestep 

35 

36 # Condition used if user wants to calculate diffusion coefficients for 

37 # specific atoms or all atoms 

38 self.atom_indices = atom_indices 

39 if self.atom_indices is None: 

40 self.atom_indices = [i for i in range(len(traj[0]))] 

41 

42 # Condition if we are working with the mobility of a molecule, need to 

43 # manage arrays slightly differently 

44 self.is_molecule = molecule 

45 if self.is_molecule: 

46 self.types_of_atoms = ["molecule"] 

47 self.no_of_atoms = [1] 

48 else: 

49 self.types_of_atoms = sorted( 

50 set(traj[0].symbols[self.atom_indices])) 

51 self.no_of_atoms = [traj[0].get_chemical_symbols().count( 

52 symbol) for symbol in self.types_of_atoms] 

53 

54 # Dummy initialisation for important results data object 

55 self._slopes = [] 

56 

57 @property 

58 def no_of_types_of_atoms(self): 

59 """ 

60 

61 Dynamically returns the number of different atoms in the system 

62 

63 """ 

64 return len(self.types_of_atoms) 

65 

66 @property 

67 def slopes(self): 

68 """ 

69 

70 Method to return slopes fitted to datapoints. If undefined, calculate slopes 

71 

72 """ 

73 if len(self._slopes) == 0: 

74 self.calculate() 

75 return self._slopes 

76 

77 @slopes.setter 

78 def slopes(self, values): 

79 """ 

80 

81 Method to set slopes as fitted to datapoints 

82 

83 """ 

84 self._slopes = values 

85 

86 def _initialise_arrays(self, ignore_n_images, number_of_segments): 

87 """ 

88 

89 Private function to initialise data storage objects. This includes objects to store the total timesteps 

90 sampled, the average diffusivity for species in any given segment, and objects to store gradient and intercept from fitting. 

91 

92 Parameters: 

93 ignore_n_images (Int): 

94 Number of images you want to ignore from the start of the trajectory, e.g. during equilibration 

95 number_of_segments (Int): 

96 Divides the given trajectory in to segments to allow statistical analysis 

97 

98 """ 

99 

100 total_images = len(self.traj) - ignore_n_images 

101 self.no_of_segments = number_of_segments 

102 self.len_segments = total_images // self.no_of_segments 

103 

104 # These are the data objects we need when plotting information. First 

105 # the x-axis, timesteps 

106 self.timesteps = np.linspace( 

107 0, total_images * self.timestep, total_images + 1) 

108 # This holds all the data points for the diffusion coefficients, 

109 # averaged over atoms 

110 self.xyz_segment_ensemble_average = np.zeros( 

111 (self.no_of_segments, self.no_of_types_of_atoms, 3, self.len_segments)) 

112 # This holds all the information on linear fits, from which we get the 

113 # diffusion coefficients 

114 self.slopes = np.zeros( 

115 (self.no_of_types_of_atoms, self.no_of_segments, 3)) 

116 self.intercepts = np.zeros( 

117 (self.no_of_types_of_atoms, self.no_of_segments, 3)) 

118 

119 self.cont_xyz_segment_ensemble_average = 0 

120 

121 def calculate(self, ignore_n_images=0, number_of_segments=1): 

122 """ 

123 

124 Calculate the diffusion coefficients, using the previously supplied data. The user can break the data into segments and 

125 take the average over these trajectories, therefore allowing statistical analysis and derivation of standard deviations. 

126 Option is also provided to ignore initial images if data is perhaps unequilibrated initially. 

127 

128 Parameters: 

129 ignore_n_images (Int): 

130 Number of images you want to ignore from the start of the trajectory, e.g. during equilibration 

131 number_of_segments (Int): 

132 Divides the given trajectory in to segments to allow statistical analysis 

133 

134 """ 

135 

136 # Setup all the arrays we need to store information 

137 self._initialise_arrays(ignore_n_images, number_of_segments) 

138 

139 for segment_no in range(self.no_of_segments): 

140 start = segment_no * self.len_segments 

141 end = start + self.len_segments 

142 seg = self.traj[ignore_n_images + start:ignore_n_images + end] 

143 

144 # If we are considering a molecular system, work out the COM for the 

145 # starting structure 

146 if self.is_molecule: 

147 com_orig = np.zeros(3) 

148 for atom_no in self.atom_indices: 

149 com_orig += seg[0].positions[atom_no] / \ 

150 len(self.atom_indices) 

151 

152 # For each image, calculate displacement. 

153 # I spent some time deciding if this should run from 0 or 1, as the displacement will be zero for 

154 # t = 0, but this is a data point that needs fitting too and so 

155 # should be included 

156 for image_no in range(0, len(seg)): 

157 # This object collects the xyz displacements for all atom 

158 # species in the image 

159 xyz_disp = np.zeros((self.no_of_types_of_atoms, 3)) 

160 

161 # Calculating for each atom individually, grouping by species 

162 # type (e.g. solid state) 

163 if not self.is_molecule: 

164 # For each atom, work out displacement from start coordinate 

165 # and collect information with like atoms 

166 for atom_no in self.atom_indices: 

167 sym_index = self.types_of_atoms.index( 

168 seg[image_no].symbols[atom_no]) 

169 xyz_disp[sym_index] += np.square( 

170 seg[image_no].positions[atom_no] - seg[0].positions[atom_no]) 

171 

172 # Calculating for group of atoms (molecule) and work out squared 

173 # displacement 

174 else: 

175 com_disp = np.zeros(3) 

176 for atom_no in self.atom_indices: 

177 com_disp += seg[image_no].positions[atom_no] / \ 

178 len(self.atom_indices) 

179 xyz_disp[0] += np.square(com_disp - com_orig) 

180 

181 # For each atom species or molecule, use xyz_disp to calculate 

182 # the average data 

183 for sym_index in range(self.no_of_types_of_atoms): 

184 # Normalise by degrees of freedom and average overall atoms 

185 # for each axes over entire segment 

186 denominator = (2 * self.no_of_atoms[sym_index]) 

187 for xyz in range(3): 

188 self.xyz_segment_ensemble_average[segment_no][sym_index][xyz][image_no] = ( 

189 xyz_disp[sym_index][xyz] / denominator) 

190 

191 # We've collected all the data for this entire segment, so now to 

192 # fit the data. 

193 for sym_index in range(self.no_of_types_of_atoms): 

194 self.slopes[sym_index][segment_no], self.intercepts[sym_index][segment_no] = self._fit_data(self.timesteps[start:end], 

195 self.xyz_segment_ensemble_average[segment_no][sym_index]) 

196 

197 def _fit_data(self, x, y): 

198 """ 

199 Private function that returns slope and intercept for linear fit to mean square diffusion data 

200 

201 

202 Parameters: 

203 x (Array of floats): 

204 Linear list of timesteps in the calculation 

205 y (Array of floats): 

206 Mean square displacement as a function of time. 

207 

208 """ 

209 

210 # Simpler implementation but disabled as fails Conda tests. 

211 # from scipy.stats import linregress 

212 # slope, intercept, r_value, p_value, std_err = linregress(x,y) 

213 

214 # Initialise objects 

215 slopes = np.zeros(3) 

216 intercepts = np.zeros(3) 

217 

218 # Convert into suitable format for lstsq 

219 x_edited = np.vstack([np.array(x), np.ones(len(x))]).T 

220 # Calculate slopes for x, y and z-axes 

221 for xyz in range(3): 

222 slopes[xyz], intercepts[xyz] = np.linalg.lstsq( 

223 x_edited, y[xyz], rcond=-1)[0] 

224 

225 return slopes, intercepts 

226 

227 def get_diffusion_coefficients(self): 

228 """ 

229 

230 Returns diffusion coefficients for atoms (in alphabetical order) along with standard deviation. 

231 

232 All data is currently passed out in units of Å^2/<ASE time units> 

233 To convert into Å^2/fs => multiply by ase.units.fs 

234 To convert from Å^2/fs to cm^2/s => multiply by (10^-8)^2 / 10^-15 = 10^-1 

235 

236 """ 

237 

238 slopes = [np.mean(self.slopes[sym_index]) 

239 for sym_index in range(self.no_of_types_of_atoms)] 

240 std = [np.std(self.slopes[sym_index]) 

241 for sym_index in range(self.no_of_types_of_atoms)] 

242 

243 return slopes, std 

244 

245 def plot(self, ax=None, show=False): 

246 """ 

247 

248 Auto-plot of Diffusion Coefficient data. Provides basic framework for visualising analysis. 

249 

250 Parameters: 

251 ax (Matplotlib.axes.Axes) 

252 Axes object on to which plot can be created 

253 show (Boolean) 

254 Whether or not to show the created plot. Default: False 

255 

256 """ 

257 

258 # Necessary if user hasn't supplied an axis. 

259 import matplotlib.pyplot as plt 

260 

261 # Convert from ASE time units to fs (aesthetic) 

262 from ase.units import fs as fs_conversion 

263 

264 if ax is None: 

265 ax = plt.gca() 

266 

267 # Define some aesthetic variables 

268 color_list = plt.cm.Set3(np.linspace(0, 1, self.no_of_types_of_atoms)) 

269 xyz_labels = ['X', 'Y', 'Z'] 

270 xyz_markers = ['o', 's', '^'] 

271 

272 # Create an x-axis that is in a more intuitive format for the view 

273 graph_timesteps = self.timesteps / fs_conversion 

274 

275 for segment_no in range(self.no_of_segments): 

276 start = segment_no * self.len_segments 

277 end = start + self.len_segments 

278 label = None 

279 

280 for sym_index in range(self.no_of_types_of_atoms): 

281 for xyz in range(3): 

282 if segment_no == 0: 

283 label = 'Species: {} ({})'.format( 

284 self.types_of_atoms[sym_index], xyz_labels[xyz]) 

285 # Add scatter graph for the mean square displacement data 

286 # in this segment 

287 ax.scatter(graph_timesteps[start:end], self.xyz_segment_ensemble_average[segment_no][sym_index][xyz], 

288 color=color_list[sym_index], marker=xyz_markers[xyz], label=label, linewidth=1, edgecolor='grey') 

289 

290 # Print the line of best fit for segment 

291 line = np.mean(self.slopes[sym_index][segment_no]) * fs_conversion * \ 

292 graph_timesteps[start:end] + \ 

293 np.mean(self.intercepts[sym_index][segment_no]) 

294 if segment_no == 0: 

295 label = 'Segment Mean : %s' % ( 

296 self.types_of_atoms[sym_index]) 

297 ax.plot(graph_timesteps[start:end], line, color='C%d' % ( 

298 sym_index), label=label, linestyle='--') 

299 

300 # Plot separator at end of segment 

301 x_coord = graph_timesteps[end - 1] 

302 ax.plot([x_coord, 

303 x_coord], 

304 [-0.001, 

305 1.05 * np.amax(self.xyz_segment_ensemble_average)], 

306 color='grey', 

307 linestyle=":") 

308 

309 # Plot the overall mean (average of slopes) for each atom species 

310 # This only makes sense if the data is all plotted on the same x-axis timeframe, which currently we are not - everything is plotted sequentially 

311 # for sym_index in range(self.no_of_types_of_atoms): 

312 # line = np.mean(self.slopes[sym_index])*graph_timesteps+np.mean(self.intercepts[sym_index]) 

313 # label ='Mean, Total : %s'%(self.types_of_atoms[sym_index]) 

314 # ax.plot(graph_timesteps, line, color='C%d'%(sym_index), label=label, linestyle="-") 

315 

316 # Aesthetic parts of the plot 

317 ax.set_ylim(-0.001, 1.05 * np.amax(self.xyz_segment_ensemble_average)) 

318 ax.legend(loc='best') 

319 ax.set_xlabel('Time (fs)') 

320 ax.set_ylabel(r'Mean Square Displacement ($\AA^2$)') 

321 

322 if show: 

323 plt.show() 

324 

325 def print_data(self): 

326 """ 

327 

328 Output of statistical analysis for Diffusion Coefficient data. Provides basic framework for understanding calculation. 

329 

330 """ 

331 

332 from ase.units import fs as fs_conversion 

333 

334 # Collect statistical data for diffusion coefficient over all segments 

335 slopes, std = self.get_diffusion_coefficients() 

336 

337 # Useful notes for any consideration of conversion. 

338 # Converting gradient from Å^2/fs to more common units of cm^2/s => multiplying by (10^-8)^2 / 10^-15 = 10^-1 

339 # Converting intercept from Å^2 to more common units of cm^2 => multiply by (10^-8)^2 = 10^-16 

340 # 

341 # Note currently in ASE internal time units 

342 # Converting into fs => divide by 1/(fs_conversion) => multiply by 

343 # (fs_conversion) 

344 

345 # Print data for each atom, in each segment. 

346 for sym_index in range(self.no_of_types_of_atoms): 

347 print('---') 

348 print(r'Species: %4s' % self.types_of_atoms[sym_index]) 

349 print('---') 

350 for segment_no in range(self.no_of_segments): 

351 print(r'Segment %3d: Diffusion Coefficient = %.10f Å^2/fs; Intercept = %.10f Å^2;' % 

352 (segment_no, np.mean(self.slopes[sym_index][segment_no]) * fs_conversion, np.mean(self.intercepts[sym_index][segment_no]))) 

353 

354 # Print average overall data. 

355 print('---') 

356 for sym_index in range(self.no_of_types_of_atoms): 

357 print('Mean Diffusion Coefficient (X, Y and Z) : %s = %.10f Å^2/fs; Std. Dev. = %.10f Å^2/fs' % 

358 (self.types_of_atoms[sym_index], slopes[sym_index] * fs_conversion, std[sym_index] * fs_conversion)) 

359 print('---')