Coverage for /builds/ase/ase/ase/geometry/analysis.py: 70.61%

262 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2025-08-02 00:12 +0000

1# fmt: off 

2 

3# flake8: noqa 

4"""Tools for analyzing instances of :class:`~ase.Atoms` 

5""" 

6 

7from typing import List, Optional 

8 

9import numpy as np 

10 

11from ase import Atoms 

12from ase.geometry.rdf import get_containing_cell_length, get_rdf 

13from ase.neighborlist import (build_neighbor_list, get_distance_indices, 

14 get_distance_matrix) 

15 

16__all__ = ['Analysis'] 

17 

18 

19def get_max_containing_cell_length(images: List[Atoms]): 

20 i2diff = np.zeros(3) 

21 for image in images: 

22 np.maximum(get_containing_cell_length(image), i2diff, out=i2diff) 

23 return i2diff 

24 

25 

26def get_max_volume_estimate(images: List[Atoms]) -> float: 

27 return np.prod(get_max_containing_cell_length(images)) 

28 

29 

30class Analysis: 

31 """Analysis class 

32 

33 Parameters for initialization: 

34 

35 images: :class:`~ase.Atoms` object or list of such 

36 Images to analyze. 

37 nl: None, :class:`~ase.neighborlist.NeighborList` object or list of such 

38 Neighborlist(s) for the given images. One or nImages, depending if bonding 

39 pattern changes or is constant. Using one Neigborlist greatly improves speed. 

40 kwargs: options, dict 

41 Arguments for constructing :class:`~ase.neighborlist.NeighborList` object if :data:`nl` is None. 

42 

43 The choice of ``bothways=True`` for the :class:`~ase.neighborlist.NeighborList` object 

44 will not influence the amount of bonds/angles/dihedrals you get, all are reported 

45 in both directions. Use the *unique*-labeled properties to get lists without 

46 duplicates. 

47 """ 

48 

49 def __init__(self, images, nl=None, **kwargs): 

50 self.images = images 

51 

52 if isinstance(nl, list): 

53 assert len(nl) == self.nImages 

54 self._nl = nl 

55 elif nl is not None: 

56 self._nl = [nl] 

57 else: 

58 self._nl = [build_neighbor_list(self.images[0], **kwargs)] 

59 

60 self._cache = {} 

61 

62 def _get_slice(self, imageIdx): 

63 """Return a slice from user input. 

64 Using *imageIdx* (can be integer or slice) the analyzed frames can be specified. 

65 If *imageIdx* is None, all frames will be analyzed. 

66 """ 

67 # get slice from imageIdx 

68 if isinstance(imageIdx, int): 

69 sl = slice(imageIdx, imageIdx + 1) 

70 elif isinstance(imageIdx, slice): 

71 sl = imageIdx 

72 elif imageIdx is None: 

73 sl = slice(0, None) 

74 else: 

75 raise ValueError( 

76 "Unsupported type for imageIdx in ase.geometry.analysis.Analysis._get_slice") 

77 return sl 

78 

79 @property 

80 def images(self): 

81 """Images. 

82 

83 Set during initialization but can also be set later. 

84 """ 

85 return self._images 

86 

87 @images.setter 

88 def images(self, images): 

89 """Set images""" 

90 if isinstance(images, list): 

91 self._images = images 

92 else: 

93 self._images = [images] 

94 

95 @images.deleter 

96 def images(self): 

97 """Delete images""" 

98 self._images = None 

99 

100 @property 

101 def nImages(self): 

102 """Number of Images in this instance. 

103 

104 Cannot be set, is determined automatically. 

105 """ 

106 return len(self.images) 

107 

108 @property 

109 def nl(self): 

110 """Neighbor Lists in this instance. 

111 

112 Set during initialization. 

113 

114 **No setter or deleter, only getter** 

115 """ 

116 return self._nl 

117 

118 def _get_all_x(self, distance): 

119 """Helper function to get bonds, angles, dihedrals""" 

120 maxIter = self.nImages 

121 if len(self.nl) == 1: 

122 maxIter = 1 

123 

124 xList = [] 

125 for i in range(maxIter): 

126 xList.append(get_distance_indices( 

127 self.distance_matrix[i], distance)) 

128 

129 return xList 

130 

131 @property 

132 def all_bonds(self): 

133 """All Bonds. 

134 

135 A list with indices of bonded atoms for each neighborlist in *self*. 

136 Atom i is connected to all atoms inside result[i]. Duplicates from PBCs are 

137 removed. See also :data:`unique_bonds`. 

138 

139 **No setter or deleter, only getter** 

140 """ 

141 if 'allBonds' not in self._cache: 

142 self._cache['allBonds'] = self._get_all_x(1) 

143 

144 return self._cache['allBonds'] 

145 

146 @property 

147 def all_angles(self): 

148 """All angles 

149 

150 A list with indices of atoms in angles for each neighborlist in *self*. 

151 Atom i forms an angle to the atoms inside the tuples in result[i]: 

152 i -- result[i][x][0] -- result[i][x][1] 

153 where x is in range(number of angles from i). See also :data:`unique_angles`. 

154 

155 **No setter or deleter, only getter** 

156 """ 

157 if 'allAngles' not in self._cache: 

158 self._cache['allAngles'] = [] 

159 distList = self._get_all_x(2) 

160 

161 for imI in range(len(distList)): 

162 self._cache['allAngles'].append([]) 

163 # iterate over second neighbors of all atoms 

164 for iAtom, secNeighs in enumerate(distList[imI]): 

165 self._cache['allAngles'][-1].append([]) 

166 if len(secNeighs) == 0: 

167 continue 

168 firstNeighs = self.all_bonds[imI][iAtom] 

169 # iterate over second neighbors of iAtom 

170 for kAtom in secNeighs: 

171 relevantFirstNeighs = [ 

172 idx for idx in firstNeighs if kAtom in self.all_bonds[imI][idx]] 

173 # iterate over all atoms that are connected to iAtom and kAtom 

174 for jAtom in relevantFirstNeighs: 

175 self._cache['allAngles'][-1][-1].append( 

176 (jAtom, kAtom)) 

177 

178 return self._cache['allAngles'] 

179 

180 @property 

181 def all_dihedrals(self): 

182 """All dihedrals 

183 

184 Returns a list with indices of atoms in dihedrals for each neighborlist in this instance. 

185 Atom i forms a dihedral to the atoms inside the tuples in result[i]: 

186 i -- result[i][x][0] -- result[i][x][1] -- result[i][x][2] 

187 where x is in range(number of dihedrals from i). See also :data:`unique_dihedrals`. 

188 

189 **No setter or deleter, only getter** 

190 """ 

191 if 'allDihedrals' not in self._cache: 

192 self._cache['allDihedrals'] = [] 

193 distList = self._get_all_x(3) 

194 

195 for imI in range(len(distList)): 

196 self._cache['allDihedrals'].append([]) 

197 for iAtom, thirdNeighs in enumerate(distList[imI]): 

198 self._cache['allDihedrals'][-1].append([]) 

199 if len(thirdNeighs) == 0: 

200 continue 

201 anglesI = self.all_angles[imI][iAtom] 

202 # iterate over third neighbors of iAtom 

203 for lAtom in thirdNeighs: 

204 secondNeighs = [angle[-1] for angle in anglesI] 

205 firstNeighs = [angle[0] for angle in anglesI] 

206 relevantSecondNeighs = [ 

207 idx for idx in secondNeighs if lAtom in self.all_bonds[imI][idx]] 

208 relevantFirstNeighs = [ 

209 firstNeighs[secondNeighs.index(idx)] for idx in relevantSecondNeighs] 

210 # iterate over all atoms that are connected to iAtom and lAtom 

211 for jAtom, kAtom in zip(relevantFirstNeighs, relevantSecondNeighs): 

212 # remove dihedrals in circles 

213 tupl = (jAtom, kAtom, lAtom) 

214 if len(set((iAtom, ) + tupl)) != 4: 

215 continue 

216 # avoid duplicates 

217 elif tupl in self._cache['allDihedrals'][-1][-1]: 

218 continue 

219 elif iAtom in tupl: 

220 raise RuntimeError( 

221 "Something is wrong in analysis.all_dihedrals!") 

222 self._cache['allDihedrals'][-1][-1].append( 

223 (jAtom, kAtom, lAtom)) 

224 

225 return self._cache['allDihedrals'] 

226 

227 @property 

228 def adjacency_matrix(self): 

229 """The adjacency/connectivity matrix. 

230 

231 If not already done, build a list of adjacency matrices for all :data:`nl`. 

232 

233 **No setter or deleter, only getter** 

234 """ 

235 

236 if 'adjacencyMatrix' not in self._cache: 

237 self._cache['adjacencyMatrix'] = [] 

238 for i in range(len(self.nl)): 

239 self._cache['adjacencyMatrix'].append( 

240 self.nl[i].get_connectivity_matrix()) 

241 

242 return self._cache['adjacencyMatrix'] 

243 

244 @property 

245 def distance_matrix(self): 

246 """The distance matrix. 

247 

248 If not already done, build a list of distance matrices for all :data:`nl`. See 

249 :meth:`ase.neighborlist.get_distance_matrix`. 

250 

251 **No setter or deleter, only getter** 

252 """ 

253 

254 if 'distanceMatrix' not in self._cache: 

255 self._cache['distanceMatrix'] = [] 

256 for i in range(len(self.nl)): 

257 self._cache['distanceMatrix'].append( 

258 get_distance_matrix(self.adjacency_matrix[i])) 

259 

260 return self._cache['distanceMatrix'] 

261 

262 @property 

263 def unique_bonds(self): 

264 """Get Unique Bonds. 

265 

266 :data:`all_bonds` i-j without j-i. This is the upper triangle of the 

267 connectivity matrix (i,j), `i < j` 

268 

269 """ 

270 bonds = [] 

271 for imI in range(len(self.all_bonds)): 

272 bonds.append([]) 

273 for iAtom, bonded in enumerate(self.all_bonds[imI]): 

274 bonds[-1].append([jAtom for jAtom in bonded if jAtom > iAtom]) 

275 

276 return bonds 

277 

278 def _filter_unique(self, l): 

279 """Helper function to filter for unique lists in a list 

280 that also contains the reversed items. 

281 """ 

282 r = [] 

283 # iterate over images 

284 for imI in range(len(l)): 

285 r.append([]) 

286 # iterate over atoms 

287 for i, tuples in enumerate(l[imI]): 

288 # add the ones where i is smaller than the last element 

289 r[-1].append([x for x in tuples if i < x[-1]]) 

290 return r 

291 

292 def clear_cache(self): 

293 """Delete all cached information.""" 

294 self._cache = {} 

295 

296 @property 

297 def unique_angles(self): 

298 """Get Unique Angles. 

299 

300 :data:`all_angles` i-j-k without k-j-i. 

301 

302 """ 

303 return self._filter_unique(self.all_angles) 

304 

305 @property 

306 def unique_dihedrals(self): 

307 """Get Unique Dihedrals. 

308 

309 :data:`all_dihedrals` i-j-k-l without l-k-j-i. 

310 

311 """ 

312 return self._filter_unique(self.all_dihedrals) 

313 

314 def _get_symbol_idxs(self, imI, sym): 

315 """Get list of indices of element *sym*""" 

316 if isinstance(imI, int): 

317 return [idx for idx in range(len(self.images[imI])) if self.images[imI][idx].symbol == sym] 

318 else: 

319 return [idx for idx in range(len(imI)) if imI[idx].symbol == sym] 

320 

321 def _idxTuple2SymbolTuple(self, imI, tup): 

322 """Converts a tuple of indices to their symbols""" 

323 return (self.images[imI][idx].symbol for idx in tup) 

324 

325 def get_bonds(self, A, B, unique=True): 

326 """Get bonds from element A to element B. 

327 

328 Parameters: 

329 

330 A, B: str 

331 Get Bonds between elements A and B 

332 unique: bool 

333 Return the bonds both ways or just one way (A-B and B-A or only A-B) 

334 

335 Returns: 

336 

337 return: list of lists of tuples 

338 return[imageIdx][atomIdx][bondI], each tuple starts with atomIdx. 

339 

340 Use :func:`get_values` to convert the returned list to values. 

341 """ 

342 r = [] 

343 for imI in range(len(self.all_bonds)): 

344 r.append([]) 

345 aIdxs = self._get_symbol_idxs(imI, A) 

346 if A != B: 

347 bIdxs = self._get_symbol_idxs(imI, B) 

348 for idx in aIdxs: 

349 bonded = self.all_bonds[imI][idx] 

350 if A == B: 

351 r[-1].extend([(idx, x) 

352 for x in bonded if (x in aIdxs) and (x > idx)]) 

353 else: 

354 r[-1].extend([(idx, x) for x in bonded if x in bIdxs]) 

355 

356 if not unique: 

357 r[-1] += [x[::-1] for x in r[-1]] 

358 

359 return r 

360 

361 def get_angles(self, A, B, C, unique=True): 

362 """Get angles from given elements A-B-C. 

363 

364 Parameters: 

365 

366 A, B, C: str 

367 Get Angles between elements A, B and C. **B will be the central atom**. 

368 unique: bool 

369 Return the angles both ways or just one way (A-B-C and C-B-A or only A-B-C) 

370 

371 Returns: 

372 

373 return: list of lists of tuples 

374 return[imageIdx][atomIdx][angleI], each tuple starts with atomIdx. 

375 

376 Use :func:`get_values` to convert the returned list to values. 

377 """ 

378 from itertools import combinations, product 

379 r = [] 

380 for imI in range(len(self.all_angles)): 

381 r.append([]) 

382 # Middle Atom is fixed 

383 bIdxs = self._get_symbol_idxs(imI, B) 

384 for bIdx in bIdxs: 

385 bondedA = [idx for idx in self.all_bonds[imI] 

386 [bIdx] if self.images[imI][idx].symbol == A] 

387 if len(bondedA) == 0: 

388 continue 

389 

390 if A != C: 

391 bondedC = [idx for idx in self.all_bonds[imI] 

392 [bIdx] if self.images[imI][idx].symbol == C] 

393 if len(bondedC) == 0: 

394 continue 

395 

396 if A == C: 

397 extend = [(x[0], bIdx, x[1]) 

398 for x in list(combinations(bondedA, 2))] 

399 else: 

400 extend = list(product(bondedA, [bIdx], bondedC)) 

401 

402 if not unique: 

403 extend += [x[::-1] for x in extend] 

404 

405 r[-1].extend(extend) 

406 return r 

407 

408 def get_dihedrals(self, A, B, C, D, unique=True): 

409 """Get dihedrals A-B-C-D. 

410 

411 Parameters: 

412 

413 A, B, C, D: str 

414 Get Dihedralss between elements A, B, C and D. **B-C will be the central axis**. 

415 unique: bool 

416 Return the dihedrals both ways or just one way (A-B-C-D and D-C-B-A or only A-B-C-D) 

417 

418 Returns: 

419 

420 return: list of lists of tuples 

421 return[imageIdx][atomIdx][dihedralI], each tuple starts with atomIdx. 

422 

423 Use :func:`get_values` to convert the returned list to values. 

424 """ 

425 r = [] 

426 for imI in range(len(self.all_dihedrals)): 

427 r.append([]) 

428 # get indices of elements 

429 aIdxs = self._get_symbol_idxs(imI, A) 

430 bIdxs = self._get_symbol_idxs(imI, B) 

431 cIdxs = self._get_symbol_idxs(imI, C) 

432 dIdxs = self._get_symbol_idxs(imI, D) 

433 for aIdx in aIdxs: 

434 dihedrals = [(aIdx, ) + d for d in self.all_dihedrals[imI][aIdx] 

435 if (d[0] in bIdxs) and (d[1] in cIdxs) and (d[2] in dIdxs)] 

436 if not unique: 

437 dihedrals += [d[::-1] for d in dihedrals] 

438 r[-1].extend(dihedrals) 

439 

440 return r 

441 

442 def get_bond_value(self, imIdx, idxs, mic=True, **kwargs): 

443 """Get bond length. 

444 

445 Parameters: 

446 

447 imIdx: int 

448 Index of Image to get value from. 

449 idxs: tuple or list of integers 

450 Get distance between atoms idxs[0]-idxs[1]. 

451 mic: bool 

452 Passed on to :func:`ase.Atoms.get_distance` for retrieving the value, defaults to True. 

453 If the cell of the image is correctly set, there should be no reason to change this. 

454 kwargs: options or dict 

455 Passed on to :func:`ase.Atoms.get_distance`. 

456 

457 Returns: 

458 

459 return: float 

460 Value returned by image.get_distance. 

461 """ 

462 return self.images[imIdx].get_distance(idxs[0], idxs[1], mic=mic, **kwargs) 

463 

464 def get_angle_value(self, imIdx, idxs, mic=True, **kwargs): 

465 """Get angle. 

466 

467 Parameters: 

468 

469 imIdx: int 

470 Index of Image to get value from. 

471 idxs: tuple or list of integers 

472 Get angle between atoms idxs[0]-idxs[1]-idxs[2]. 

473 mic: bool 

474 Passed on to :func:`ase.Atoms.get_angle` for retrieving the value, defaults to True. 

475 If the cell of the image is correctly set, there should be no reason to change this. 

476 kwargs: options or dict 

477 Passed on to :func:`ase.Atoms.get_angle`. 

478 

479 Returns: 

480 

481 return: float 

482 Value returned by image.get_angle. 

483 """ 

484 return self.images[imIdx].get_angle(idxs[0], idxs[1], idxs[2], mic=True, **kwargs) 

485 

486 def get_dihedral_value(self, imIdx, idxs, mic=True, **kwargs): 

487 """Get dihedral. 

488 

489 Parameters: 

490 

491 imIdx: int 

492 Index of Image to get value from. 

493 idxs: tuple or list of integers 

494 Get angle between atoms idxs[0]-idxs[1]-idxs[2]-idxs[3]. 

495 mic: bool 

496 Passed on to :func:`ase.Atoms.get_dihedral` for retrieving the value, defaults to True. 

497 If the cell of the image is correctly set, there should be no reason to change this. 

498 kwargs: options or dict 

499 Passed on to :func:`ase.Atoms.get_dihedral`. 

500 

501 Returns: 

502 

503 return: float 

504 Value returned by image.get_dihedral. 

505 """ 

506 return self.images[imIdx].get_dihedral(idxs[0], idxs[1], idxs[2], idxs[3], mic=mic, **kwargs) 

507 

508 def get_values(self, inputList, imageIdx=None, mic=True, **kwargs): 

509 """Get Bond/Angle/Dihedral values. 

510 

511 Parameters: 

512 

513 inputList: list of lists of tuples 

514 Can be any list provided by :meth:`~ase.geometry.analysis.Analysis.get_bonds`, 

515 :meth:`~ase.geometry.analysis.Analysis.get_angles` or 

516 :meth:`~ase.geometry.analysis.Analysis.get_dihedrals`. 

517 imageIdx: integer or slice 

518 The images from :data:`images` to be analyzed. If None, all frames will be analyzed. 

519 See :func:`~ase.geometry.analysis.Analysis._get_slice` for details. 

520 mic: bool 

521 Passed on to :class:`~ase.Atoms` for retrieving the values, defaults to True. 

522 If the cells of the images are correctly set, there should be no reason to change this. 

523 kwargs: options or dict 

524 Passed on to the :class:`~ase.Atoms` classes functions for retrieving the values. 

525 

526 Returns: 

527 

528 return: list of lists of floats 

529 return[imageIdx][valueIdx]. Has the same shape as the *inputList*, instead of each 

530 tuple there is a float with the value this tuple yields. 

531 

532 The type of value requested is determined from the length of the tuple inputList[0][0]. 

533 The methods from the :class:`~ase.Atoms` class are used. 

534 """ 

535 

536 sl = self._get_slice(imageIdx) 

537 

538 # get method to call from length of inputList 

539 if len(inputList[0][0]) == 2: 

540 get = self.get_bond_value 

541 elif len(inputList[0][0]) == 3: 

542 get = self.get_angle_value 

543 elif len(inputList[0][0]) == 4: 

544 get = self.get_dihedral_value 

545 else: 

546 raise ValueError( 

547 "inputList in ase.geometry.analysis.Analysis.get_values has a bad shape.") 

548 

549 # check if length of slice and inputList match 

550 singleNL = False 

551 if len(inputList) != len(self.images[sl]): 

552 # only one nl for all images 

553 if len(inputList) == 1 and len(self.nl) == 1: 

554 singleNL = True 

555 else: 

556 raise RuntimeError("Length of inputList does not match length of \ 

557 images requested, but it also is not one item long.") 

558 

559 r = [] 

560 for inputIdx, image in enumerate(self.images[sl]): 

561 imageIdx = self.images.index(image) 

562 r.append([]) 

563 # always use first list from input if only a single neighborlist was used 

564 if singleNL: 

565 inputIdx = 0 

566 for tupl in inputList[inputIdx]: 

567 r[-1].append(get(imageIdx, tupl, mic=mic, **kwargs)) 

568 

569 return r 

570 

571 def get_max_volume_estimate(self): 

572 return get_max_volume_estimate(self.images) 

573 

574 def get_rdf(self, rmax, nbins, imageIdx=None, elements=None, return_dists=False, 

575 volume: Optional[float] = None): 

576 """Get RDF. 

577 

578 Wrapper for :meth:`ase.ga.utilities.get_rdf` with more selection possibilities. 

579 

580 Parameters: 

581 

582 rmax: float 

583 Maximum distance of RDF. 

584 nbins: int 

585 Number of bins to divide RDF. 

586 imageIdx: int/slice/None 

587 Images to analyze, see :func:`_get_slice` for details. 

588 elements: str/int/list/tuple 

589 Make partial RDFs. 

590 

591 If elements is *None*, a full RDF is calculated. If elements is an *integer* or a *list/tuple 

592 of integers*, only those atoms will contribute to the RDF (like a mask). If elements 

593 is a *string* or a *list/tuple of strings*, only Atoms of those elements will contribute. 

594 

595 Returns: 

596 

597 return: list of lists / list of tuples of lists 

598 If return_dists is True, the returned tuples contain (rdf, distances). Otherwise 

599 only rdfs for each image are returned. 

600 """ 

601 

602 sl = self._get_slice(imageIdx) 

603 

604 ls_rdf = [] 

605 el = None 

606 

607 for image in self.images[sl]: 

608 if elements is None: 

609 tmp_image = image 

610 # integers 

611 elif isinstance(elements, int): 

612 tmp_image = Atoms(cell=image.get_cell(), pbc=image.get_pbc()) 

613 tmp_image.append(image[elements]) 

614 # strings 

615 elif isinstance(elements, str): 

616 tmp_image = Atoms(cell=image.get_cell(), pbc=image.get_pbc()) 

617 for idx in self._get_symbol_idxs(image, elements): 

618 tmp_image.append(image[idx]) 

619 # lists 

620 elif isinstance(elements, (list, tuple)): 

621 # list of ints 

622 if all(isinstance(x, int) for x in elements): 

623 if len(elements) == 2: 

624 # use builtin get_rdf mask 

625 el = elements 

626 tmp_image = image 

627 else: 

628 # create dummy image 

629 tmp_image = Atoms( 

630 cell=image.get_cell(), pbc=image.get_pbc()) 

631 for idx in elements: 

632 tmp_image.append(image[idx]) 

633 # list of strings 

634 elif all(isinstance(x, str) for x in elements): 

635 tmp_image = Atoms(cell=image.get_cell(), 

636 pbc=image.get_pbc()) 

637 for element in elements: 

638 for idx in self._get_symbol_idxs(image, element): 

639 tmp_image.append(image[idx]) 

640 else: 

641 raise ValueError( 

642 "Unsupported type of elements given in ase.geometry.analysis.Analysis.get_rdf!") 

643 else: 

644 raise ValueError( 

645 "Unsupported type of elements given in ase.geometry.analysis.Analysis.get_rdf!") 

646 

647 rdf = get_rdf(tmp_image, rmax, nbins, elements=el, no_dists=(not return_dists), 

648 volume=volume) 

649 ls_rdf.append(rdf) 

650 

651 return ls_rdf