Coverage for /builds/debichem-team/python-ase/ase/geometry/geometry.py: 97.56%

205 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2025-03-06 04:00 +0000

1# Copyright (C) 2010, Jesper Friis 

2# (see accompanying license files for details). 

3 

4"""Utility tools for atoms/geometry manipulations. 

5 - convenient creation of slabs and interfaces of 

6different orientations. 

7 - detection of duplicate atoms / atoms within cutoff radius 

8""" 

9 

10import itertools 

11 

12import numpy as np 

13 

14from ase.cell import Cell 

15from ase.geometry import complete_cell 

16from ase.geometry.minkowski_reduction import minkowski_reduce 

17from ase.utils import pbc2pbc 

18 

19 

20def translate_pretty(fractional, pbc): 

21 """Translates atoms such that fractional positions are minimized.""" 

22 

23 for i in range(3): 

24 if not pbc[i]: 

25 continue 

26 

27 indices = np.argsort(fractional[:, i]) 

28 sp = fractional[indices, i] 

29 

30 widths = (np.roll(sp, 1) - sp) % 1.0 

31 fractional[:, i] -= sp[np.argmin(widths)] 

32 fractional[:, i] %= 1.0 

33 return fractional 

34 

35 

36def wrap_positions(positions, cell, pbc=True, center=(0.5, 0.5, 0.5), 

37 pretty_translation=False, eps=1e-7): 

38 """Wrap positions to unit cell. 

39 

40 Returns positions changed by a multiple of the unit cell vectors to 

41 fit inside the space spanned by these vectors. See also the 

42 :meth:`ase.Atoms.wrap` method. 

43 

44 Parameters: 

45 

46 positions: float ndarray of shape (n, 3) 

47 Positions of the atoms 

48 cell: float ndarray of shape (3, 3) 

49 Unit cell vectors. 

50 pbc: one or 3 bool 

51 For each axis in the unit cell decides whether the positions 

52 will be moved along this axis. 

53 center: three float 

54 The positons in fractional coordinates that the new positions 

55 will be nearest possible to. 

56 pretty_translation: bool 

57 Translates atoms such that fractional coordinates are minimized. 

58 eps: float 

59 Small number to prevent slightly negative coordinates from being 

60 wrapped. 

61 

62 Example: 

63 

64 >>> from ase.geometry import wrap_positions 

65 >>> wrap_positions([[-0.1, 1.01, -0.5]], 

66 ... [[1, 0, 0], [0, 1, 0], [0, 0, 4]], 

67 ... pbc=[1, 1, 0]) 

68 array([[ 0.9 , 0.01, -0.5 ]]) 

69 """ 

70 

71 if not hasattr(center, '__len__'): 

72 center = (center,) * 3 

73 

74 pbc = pbc2pbc(pbc) 

75 shift = np.asarray(center) - 0.5 - eps 

76 

77 # Don't change coordinates when pbc is False 

78 shift[np.logical_not(pbc)] = 0.0 

79 

80 assert np.asarray(cell)[np.asarray(pbc)].any(axis=1).all(), (cell, pbc) 

81 

82 cell = complete_cell(cell) 

83 fractional = np.linalg.solve(cell.T, 

84 np.asarray(positions).T).T - shift 

85 

86 if pretty_translation: 

87 fractional = translate_pretty(fractional, pbc) 

88 shift = np.asarray(center) - 0.5 

89 shift[np.logical_not(pbc)] = 0.0 

90 fractional += shift 

91 else: 

92 for i, periodic in enumerate(pbc): 

93 if periodic: 

94 fractional[:, i] %= 1.0 

95 fractional[:, i] += shift[i] 

96 

97 return np.dot(fractional, cell) 

98 

99 

100def get_layers(atoms, miller, tolerance=0.001): 

101 """Returns two arrays describing which layer each atom belongs 

102 to and the distance between the layers and origo. 

103 

104 Parameters: 

105 

106 miller: 3 integers 

107 The Miller indices of the planes. Actually, any direction 

108 in reciprocal space works, so if a and b are two float 

109 vectors spanning an atomic plane, you can get all layers 

110 parallel to this with miller=np.cross(a,b). 

111 tolerance: float 

112 The maximum distance in Angstrom along the plane normal for 

113 counting two atoms as belonging to the same plane. 

114 

115 Returns: 

116 

117 tags: array of integres 

118 Array of layer indices for each atom. 

119 levels: array of floats 

120 Array of distances in Angstrom from each layer to origo. 

121 

122 Example: 

123 

124 >>> import numpy as np 

125 

126 >>> from ase.spacegroup import crystal 

127 >>> from ase.geometry.geometry import get_layers 

128 

129 >>> atoms = crystal('Al', [(0,0,0)], spacegroup=225, cellpar=4.05) 

130 >>> np.round(atoms.positions, decimals=5) # doctest: +NORMALIZE_WHITESPACE 

131 array([[ 0. , 0. , 0. ], 

132 [ 0. , 2.025, 2.025], 

133 [ 2.025, 0. , 2.025], 

134 [ 2.025, 2.025, 0. ]]) 

135 >>> get_layers(atoms, (0,0,1)) # doctest: +ELLIPSIS 

136 (array([0, 1, 1, 0]...), array([ 0. , 2.025])) 

137 """ 

138 miller = np.asarray(miller) 

139 

140 metric = np.dot(atoms.cell, atoms.cell.T) 

141 c = np.linalg.solve(metric.T, miller.T).T 

142 miller_norm = np.sqrt(np.dot(c, miller)) 

143 d = np.dot(atoms.get_scaled_positions(), miller) / miller_norm 

144 

145 keys = np.argsort(d) 

146 ikeys = np.argsort(keys) 

147 mask = np.concatenate(([True], np.diff(d[keys]) > tolerance)) 

148 tags = np.cumsum(mask)[ikeys] 

149 if tags.min() == 1: 

150 tags -= 1 

151 

152 levels = d[keys][mask] 

153 return tags, levels 

154 

155 

156def naive_find_mic(v, cell): 

157 """Finds the minimum-image representation of vector(s) v. 

158 Safe to use for (pbc.all() and (norm(v_mic) < 0.5 * min(cell.lengths()))). 

159 Can otherwise fail for non-orthorhombic cells. 

160 Described in: 

161 W. Smith, "The Minimum Image Convention in Non-Cubic MD Cells", 1989, 

162 http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.57.1696.""" 

163 f = Cell(cell).scaled_positions(v) 

164 f -= np.floor(f + 0.5) 

165 vmin = f @ cell 

166 vlen = np.linalg.norm(vmin, axis=1) 

167 return vmin, vlen 

168 

169 

170def general_find_mic(v, cell, pbc=True): 

171 """Finds the minimum-image representation of vector(s) v. Using the 

172 Minkowski reduction the algorithm is relatively slow but safe for any cell. 

173 """ 

174 

175 cell = complete_cell(cell) 

176 rcell, _ = minkowski_reduce(cell, pbc=pbc) 

177 positions = wrap_positions(v, rcell, pbc=pbc, eps=0) 

178 

179 # In a Minkowski-reduced cell we only need to test nearest neighbors, 

180 # or "Voronoi-relevant" vectors. These are a subset of combinations of 

181 # [-1, 0, 1] of the reduced cell vectors. 

182 

183 # Define ranges [-1, 0, 1] for periodic directions and [0] for aperiodic 

184 # directions. 

185 ranges = [np.arange(-1 * p, p + 1) for p in pbc] 

186 

187 # Get Voronoi-relevant vectors. 

188 # Pre-pend (0, 0, 0) to resolve issue #772 

189 hkls = np.array([(0, 0, 0)] + list(itertools.product(*ranges))) 

190 vrvecs = hkls @ rcell 

191 

192 # Map positions into neighbouring cells. 

193 x = positions + vrvecs[:, None] 

194 

195 # Find minimum images 

196 lengths = np.linalg.norm(x, axis=2) 

197 indices = np.argmin(lengths, axis=0) 

198 vmin = x[indices, np.arange(len(positions)), :] 

199 vlen = lengths[indices, np.arange(len(positions))] 

200 return vmin, vlen 

201 

202 

203def find_mic(v, cell, pbc=True): 

204 """Finds the minimum-image representation of vector(s) v using either one 

205 of two find mic algorithms depending on the given cell, v and pbc.""" 

206 

207 cell = Cell(cell) 

208 pbc = cell.any(1) & pbc2pbc(pbc) 

209 dim = np.sum(pbc) 

210 v = np.asarray(v) 

211 single = v.ndim == 1 

212 v = np.atleast_2d(v) 

213 

214 if dim > 0: 

215 naive_find_mic_is_safe = False 

216 if dim == 3: 

217 vmin, vlen = naive_find_mic(v, cell) 

218 # naive find mic is safe only for the following condition 

219 if (vlen < 0.5 * min(cell.lengths())).all(): 

220 naive_find_mic_is_safe = True # hence skip Minkowski reduction 

221 

222 if not naive_find_mic_is_safe: 

223 vmin, vlen = general_find_mic(v, cell, pbc=pbc) 

224 else: 

225 vmin = v.copy() 

226 vlen = np.linalg.norm(vmin, axis=1) 

227 

228 if single: 

229 return vmin[0], vlen[0] 

230 else: 

231 return vmin, vlen 

232 

233 

234def conditional_find_mic(vectors, cell, pbc): 

235 """Return vectors and their lengths considering cell and pbc. 

236 

237 The minimum image convention is applied if cell and pbc are set. 

238 This can be used like a simple version of get_distances. 

239 """ 

240 vectors = np.array(vectors) 

241 if (cell is None) != (pbc is None): 

242 raise ValueError("cell or pbc must be both set or both be None") 

243 if cell is not None: 

244 mics = [find_mic(v, cell, pbc) for v in vectors] 

245 vectors, vector_lengths = zip(*mics) 

246 else: 

247 vector_lengths = np.sqrt(np.add.reduce(vectors**2, axis=-1)) 

248 return vectors, vector_lengths 

249 

250 

251def get_angles(v0, v1, cell=None, pbc=None): 

252 """Get angles formed by two lists of vectors. 

253 

254 Calculate angle in degrees between vectors v0 and v1 

255 

256 Set a cell and pbc to enable minimum image 

257 convention, otherwise angles are taken as-is. 

258 """ 

259 (v0, v1), (nv0, nv1) = conditional_find_mic([v0, v1], cell, pbc) 

260 

261 if (nv0 <= 0).any() or (nv1 <= 0).any(): 

262 raise ZeroDivisionError('Undefined angle') 

263 v0n = v0 / nv0[:, np.newaxis] 

264 v1n = v1 / nv1[:, np.newaxis] 

265 # We just normalized the vectors, but in some cases we can get 

266 # bad things like 1+2e-16. These we clip away: 

267 angles = np.arccos(np.einsum('ij,ij->i', v0n, v1n).clip(-1.0, 1.0)) 

268 return np.degrees(angles) 

269 

270 

271def get_angles_derivatives(v0, v1, cell=None, pbc=None): 

272 """Get derivatives of angles formed by two lists of vectors (v0, v1) w.r.t. 

273 Cartesian coordinates in degrees. 

274 

275 Set a cell and pbc to enable minimum image 

276 convention, otherwise derivatives of angles are taken as-is. 

277 

278 There is a singularity in the derivatives for sin(angle) -> 0 for which 

279 a ZeroDivisionError is raised. 

280 

281 Derivative output format: [[dx_a0, dy_a0, dz_a0], [...], [..., dz_a2]. 

282 """ 

283 (v0, v1), (nv0, nv1) = conditional_find_mic([v0, v1], cell, pbc) 

284 

285 angles = np.radians(get_angles(v0, v1, cell=cell, pbc=pbc)) 

286 sin_angles = np.sin(angles) 

287 cos_angles = np.cos(angles) 

288 if (sin_angles == 0.).any(): # identify singularities 

289 raise ZeroDivisionError('Singularity for derivative of a planar angle') 

290 

291 product = nv0 * nv1 

292 deriv_d0 = (-(v1 / product[:, np.newaxis] # derivatives by atom 0 

293 - np.einsum('ij,i->ij', v0, cos_angles / nv0**2)) 

294 / sin_angles[:, np.newaxis]) 

295 deriv_d2 = (-(v0 / product[:, np.newaxis] # derivatives by atom 2 

296 - np.einsum('ij,i->ij', v1, cos_angles / nv1**2)) 

297 / sin_angles[:, np.newaxis]) 

298 deriv_d1 = -(deriv_d0 + deriv_d2) # derivatives by atom 1 

299 derivs = np.stack((deriv_d0, deriv_d1, deriv_d2), axis=1) 

300 return np.degrees(derivs) 

301 

302 

303def get_dihedrals(v0, v1, v2, cell=None, pbc=None): 

304 """Get dihedral angles formed by three lists of vectors. 

305 

306 Calculate dihedral angle (in degrees) between the vectors a0->a1, 

307 a1->a2 and a2->a3, written as v0, v1 and v2. 

308 

309 Set a cell and pbc to enable minimum image 

310 convention, otherwise angles are taken as-is. 

311 """ 

312 (v0, v1, v2), (_, nv1, _) = conditional_find_mic([v0, v1, v2], cell, pbc) 

313 

314 v1n = v1 / nv1[:, np.newaxis] 

315 # v, w: projection of v0, v2 onto plane perpendicular to v1 

316 v = -v0 - np.einsum('ij,ij,ik->ik', -v0, v1n, v1n) 

317 w = v2 - np.einsum('ij,ij,ik->ik', v2, v1n, v1n) 

318 

319 # formula returns 0 for undefined dihedrals; prefer ZeroDivisionError 

320 undefined_v = np.all(v == 0.0, axis=1) 

321 undefined_w = np.all(w == 0.0, axis=1) 

322 if np.any([undefined_v, undefined_w]): 

323 raise ZeroDivisionError('Undefined dihedral for planar inner angle') 

324 

325 x = np.einsum('ij,ij->i', v, w) 

326 y = np.einsum('ij,ij->i', np.cross(v1n, v, axis=1), w) 

327 dihedrals = np.arctan2(y, x) # dihedral angle in [-pi, pi] 

328 dihedrals[dihedrals < 0.] += 2 * np.pi # dihedral angle in [0, 2*pi] 

329 return np.degrees(dihedrals) 

330 

331 

332def get_dihedrals_derivatives(v0, v1, v2, cell=None, pbc=None): 

333 """Get derivatives of dihedrals formed by three lists of vectors 

334 (v0, v1, v2) w.r.t Cartesian coordinates in degrees. 

335 

336 Set a cell and pbc to enable minimum image 

337 convention, otherwise dihedrals are taken as-is. 

338 

339 Derivative output format: [[dx_a0, dy_a0, dz_a0], ..., [..., dz_a3]]. 

340 """ 

341 (v0, v1, v2), (nv0, nv1, nv2) = conditional_find_mic([v0, v1, v2], cell, 

342 pbc) 

343 

344 v0n = v0 / nv0[:, np.newaxis] 

345 v1n = v1 / nv1[:, np.newaxis] 

346 v2n = v2 / nv2[:, np.newaxis] 

347 normal_v01 = np.cross(v0n, v1n, axis=1) 

348 normal_v12 = np.cross(v1n, v2n, axis=1) 

349 cos_psi01 = np.einsum('ij,ij->i', v0n, v1n) # == np.sum(v0 * v1, axis=1) 

350 sin_psi01 = np.sin(np.arccos(cos_psi01)) 

351 cos_psi12 = np.einsum('ij,ij->i', v1n, v2n) 

352 sin_psi12 = np.sin(np.arccos(cos_psi12)) 

353 if (sin_psi01 == 0.).any() or (sin_psi12 == 0.).any(): 

354 msg = ('Undefined derivative for undefined dihedral with planar inner ' 

355 'angle') 

356 raise ZeroDivisionError(msg) 

357 

358 deriv_d0 = -normal_v01 / (nv0 * sin_psi01**2)[:, np.newaxis] # by atom 0 

359 deriv_d3 = normal_v12 / (nv2 * sin_psi12**2)[:, np.newaxis] # by atom 3 

360 deriv_d1 = (((nv1 + nv0 * cos_psi01) / nv1)[:, np.newaxis] * -deriv_d0 

361 + (cos_psi12 * nv2 / nv1)[:, np.newaxis] * deriv_d3) # by a1 

362 deriv_d2 = (-((nv1 + nv2 * cos_psi12) / nv1)[:, np.newaxis] * deriv_d3 

363 - (cos_psi01 * nv0 / nv1)[:, np.newaxis] * -deriv_d0) # by a2 

364 derivs = np.stack((deriv_d0, deriv_d1, deriv_d2, deriv_d3), axis=1) 

365 return np.degrees(derivs) 

366 

367 

368def get_distances(p1, p2=None, cell=None, pbc=None): 

369 """Return distance matrix of every position in p1 with every position in p2 

370 

371 If p2 is not set, it is assumed that distances between all positions in p1 

372 are desired. p2 will be set to p1 in this case. 

373 

374 Use set cell and pbc to use the minimum image convention. 

375 """ 

376 p1 = np.atleast_2d(p1) 

377 if p2 is None: 

378 np1 = len(p1) 

379 ind1, ind2 = np.triu_indices(np1, k=1) 

380 D = p1[ind2] - p1[ind1] 

381 else: 

382 p2 = np.atleast_2d(p2) 

383 D = (p2[np.newaxis, :, :] - p1[:, np.newaxis, :]).reshape((-1, 3)) 

384 

385 (D, ), (D_len, ) = conditional_find_mic([D], cell=cell, pbc=pbc) 

386 

387 if p2 is None: 

388 Dout = np.zeros((np1, np1, 3)) 

389 Dout[(ind1, ind2)] = D 

390 Dout -= np.transpose(Dout, axes=(1, 0, 2)) 

391 

392 Dout_len = np.zeros((np1, np1)) 

393 Dout_len[(ind1, ind2)] = D_len 

394 Dout_len += Dout_len.T 

395 return Dout, Dout_len 

396 

397 # Expand back to matrix indexing 

398 D.shape = (-1, len(p2), 3) 

399 D_len.shape = (-1, len(p2)) 

400 

401 return D, D_len 

402 

403 

404def get_distances_derivatives(v0, cell=None, pbc=None): 

405 """Get derivatives of distances for all vectors in v0 w.r.t. Cartesian 

406 coordinates in Angstrom. 

407 

408 Set cell and pbc to use the minimum image convention. 

409 

410 There is a singularity for distances -> 0 for which a ZeroDivisionError is 

411 raised. 

412 Derivative output format: [[dx_a0, dy_a0, dz_a0], [dx_a1, dy_a1, dz_a1]]. 

413 """ 

414 (v0, ), (dists, ) = conditional_find_mic([v0], cell, pbc) 

415 

416 if (dists <= 0.).any(): # identify singularities 

417 raise ZeroDivisionError('Singularity for derivative of a zero distance') 

418 

419 derivs_d0 = np.einsum('i,ij->ij', -1. / dists, v0) # derivatives by atom 0 

420 derivs_d1 = -derivs_d0 # derivatives by atom 1 

421 derivs = np.stack((derivs_d0, derivs_d1), axis=1) 

422 return derivs 

423 

424 

425def get_duplicate_atoms(atoms, cutoff=0.1, delete=False): 

426 """Get list of duplicate atoms and delete them if requested. 

427 

428 Identify all atoms which lie within the cutoff radius of each other. 

429 Delete one set of them if delete == True. 

430 """ 

431 from scipy.spatial.distance import pdist 

432 dists = pdist(atoms.get_positions(), 'sqeuclidean') 

433 dup = np.nonzero(dists < cutoff**2) 

434 rem = np.array(_row_col_from_pdist(len(atoms), dup[0])) 

435 if delete: 

436 if rem.size != 0: 

437 del atoms[rem[:, 0]] 

438 else: 

439 return rem 

440 

441 

442def _row_col_from_pdist(dim, i): 

443 """Calculate the i,j index in the square matrix for an index in a 

444 condensed (triangular) matrix. 

445 """ 

446 i = np.array(i) 

447 b = 1 - 2 * dim 

448 x = (np.floor((-b - np.sqrt(b**2 - 8 * i)) / 2)).astype(int) 

449 y = (i + x * (b + x + 2) / 2 + 1).astype(int) 

450 if i.shape: 

451 return list(zip(x, y)) 

452 else: 

453 return [(x, y)] 

454 

455 

456def permute_axes(atoms, permutation): 

457 """Permute axes of unit cell and atom positions. Considers only cell and 

458 atomic positions. Other vector quantities such as momenta are not 

459 modified.""" 

460 assert (np.sort(permutation) == np.arange(3)).all() 

461 

462 permuted = atoms.copy() 

463 scaled = permuted.get_scaled_positions() 

464 permuted.set_cell(permuted.cell.permute_axes(permutation), 

465 scale_atoms=False) 

466 permuted.set_scaled_positions(scaled[:, permutation]) 

467 permuted.set_pbc(permuted.pbc[permutation]) 

468 return permuted