Coverage for ase / geometry / analysis.py: 64.29%

266 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-30 08:22 +0000

1# fmt: off 

2 

3# flake8: noqa 

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

5""" 

6 

7from typing import List 

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) 

15from ase.utils import deprecated 

16 

17__all__ = ['Analysis'] 

18 

19 

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

21 i2diff = np.zeros(3) 

22 for image in images: 

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

24 return i2diff 

25 

26 

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

28 return np.prod(get_max_containing_cell_length(images)) 

29 

30 

31class Analysis: 

32 """Analysis class 

33 

34 Parameters for initialization: 

35 

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

37 Images to analyze. 

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

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

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

41 kwargs: options, dict 

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

43 

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

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

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

47 duplicates. 

48 """ 

49 

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

51 self.images = images 

52 

53 if isinstance(nl, list): 

54 assert len(nl) == self.nImages 

55 self._nl = nl 

56 elif nl is not None: 

57 self._nl = [nl] 

58 else: 

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

60 

61 self._cache = {} 

62 

63 def _get_slice(self, imageIdx): 

64 """Return a slice from user input. 

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

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

67 """ 

68 # get slice from imageIdx 

69 if isinstance(imageIdx, int): 

70 sl = slice(imageIdx, imageIdx + 1) 

71 elif isinstance(imageIdx, slice): 

72 sl = imageIdx 

73 elif imageIdx is None: 

74 sl = slice(0, None) 

75 else: 

76 raise ValueError( 

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

78 return sl 

79 

80 @property 

81 def images(self): 

82 """Images. 

83 

84 Set during initialization but can also be set later. 

85 """ 

86 return self._images 

87 

88 @images.setter 

89 def images(self, images): 

90 """Set images""" 

91 if isinstance(images, list): 

92 self._images = images 

93 else: 

94 self._images = [images] 

95 

96 @images.deleter 

97 def images(self): 

98 """Delete images""" 

99 self._images = None 

100 

101 @property 

102 def nImages(self): 

103 """Number of Images in this instance. 

104 

105 Cannot be set, is determined automatically. 

106 """ 

107 return len(self.images) 

108 

109 @property 

110 def nl(self): 

111 """Neighbor Lists in this instance. 

112 

113 Set during initialization. 

114 

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

116 """ 

117 return self._nl 

118 

119 def _get_all_x(self, distance): 

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

121 maxIter = self.nImages 

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

123 maxIter = 1 

124 

125 xList = [] 

126 for i in range(maxIter): 

127 xList.append(get_distance_indices( 

128 self.distance_matrix[i], distance)) 

129 

130 return xList 

131 

132 @property 

133 def all_bonds(self): 

134 """All Bonds. 

135 

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

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

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

139 

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

141 """ 

142 if 'allBonds' not in self._cache: 

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

144 

145 return self._cache['allBonds'] 

146 

147 @property 

148 def all_angles(self): 

149 """All angles 

150 

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

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

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

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

155 

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

157 """ 

158 if 'allAngles' not in self._cache: 

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

160 distList = self._get_all_x(2) 

161 

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

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

164 # iterate over second neighbors of all atoms 

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

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

167 if len(secNeighs) == 0: 

168 continue 

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

170 # iterate over second neighbors of iAtom 

171 for kAtom in secNeighs: 

172 relevantFirstNeighs = [ 

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

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

175 for jAtom in relevantFirstNeighs: 

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

177 (jAtom, kAtom)) 

178 

179 return self._cache['allAngles'] 

180 

181 @property 

182 def all_dihedrals(self): 

183 """All dihedrals 

184 

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

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

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

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

189 

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

191 """ 

192 if 'allDihedrals' not in self._cache: 

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

194 distList = self._get_all_x(3) 

195 

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

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

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

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

200 if len(thirdNeighs) == 0: 

201 continue 

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

203 # iterate over third neighbors of iAtom 

204 for lAtom in thirdNeighs: 

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

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

207 relevantSecondNeighs = [ 

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

209 relevantFirstNeighs = [ 

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

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

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

213 # remove dihedrals in circles 

214 tupl = (jAtom, kAtom, lAtom) 

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

216 continue 

217 # avoid duplicates 

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

219 continue 

220 elif iAtom in tupl: 

221 raise RuntimeError( 

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

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

224 (jAtom, kAtom, lAtom)) 

225 

226 return self._cache['allDihedrals'] 

227 

228 @property 

229 def adjacency_matrix(self): 

230 """The adjacency/connectivity matrix. 

231 

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

233 

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

235 """ 

236 

237 if 'adjacencyMatrix' not in self._cache: 

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

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

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

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

242 

243 return self._cache['adjacencyMatrix'] 

244 

245 @property 

246 def distance_matrix(self): 

247 """The distance matrix. 

248 

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

250 :meth:`ase.neighborlist.get_distance_matrix`. 

251 

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

253 """ 

254 

255 if 'distanceMatrix' not in self._cache: 

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

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

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

259 get_distance_matrix(self.adjacency_matrix[i])) 

260 

261 return self._cache['distanceMatrix'] 

262 

263 @property 

264 def unique_bonds(self): 

265 """Get Unique Bonds. 

266 

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

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

269 

270 """ 

271 bonds = [] 

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

273 bonds.append([]) 

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

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

276 

277 return bonds 

278 

279 def _filter_unique(self, l): 

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

281 that also contains the reversed items. 

282 """ 

283 r = [] 

284 # iterate over images 

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

286 r.append([]) 

287 # iterate over atoms 

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

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

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

291 return r 

292 

293 def clear_cache(self): 

294 """Delete all cached information.""" 

295 self._cache = {} 

296 

297 @property 

298 def unique_angles(self): 

299 """Get Unique Angles. 

300 

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

302 

303 """ 

304 return self._filter_unique(self.all_angles) 

305 

306 @property 

307 def unique_dihedrals(self): 

308 """Get Unique Dihedrals. 

309 

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

311 

312 """ 

313 return self._filter_unique(self.all_dihedrals) 

314 

315 def _get_symbol_idxs(self, imI, sym): 

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

317 if isinstance(imI, int): 

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

319 else: 

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

321 

322 def _idxTuple2SymbolTuple(self, imI, tup): 

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

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

325 

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

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

328 

329 Parameters: 

330 

331 A, B: str 

332 Get Bonds between elements A and B 

333 unique: bool 

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

335 

336 Returns: 

337 

338 return: list of lists of tuples 

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

340 

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

342 """ 

343 r = [] 

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

345 r.append([]) 

346 aIdxs = self._get_symbol_idxs(imI, A) 

347 if A != B: 

348 bIdxs = self._get_symbol_idxs(imI, B) 

349 for idx in aIdxs: 

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

351 if A == B: 

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

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

354 else: 

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

356 

357 if not unique: 

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

359 

360 return r 

361 

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

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

364 

365 Parameters: 

366 

367 A, B, C: str 

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

369 unique: bool 

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

371 

372 Returns: 

373 

374 return: list of lists of tuples 

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

376 

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

378 """ 

379 from itertools import combinations, product 

380 r = [] 

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

382 r.append([]) 

383 # Middle Atom is fixed 

384 bIdxs = self._get_symbol_idxs(imI, B) 

385 for bIdx in bIdxs: 

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

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

388 if len(bondedA) == 0: 

389 continue 

390 

391 if A != C: 

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

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

394 if len(bondedC) == 0: 

395 continue 

396 

397 if A == C: 

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

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

400 else: 

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

402 

403 if not unique: 

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

405 

406 r[-1].extend(extend) 

407 return r 

408 

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

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

411 

412 Parameters: 

413 

414 A, B, C, D: str 

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

416 unique: bool 

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

418 

419 Returns: 

420 

421 return: list of lists of tuples 

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

423 

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

425 """ 

426 r = [] 

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

428 r.append([]) 

429 # get indices of elements 

430 aIdxs = self._get_symbol_idxs(imI, A) 

431 bIdxs = self._get_symbol_idxs(imI, B) 

432 cIdxs = self._get_symbol_idxs(imI, C) 

433 dIdxs = self._get_symbol_idxs(imI, D) 

434 for aIdx in aIdxs: 

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

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

437 if not unique: 

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

439 r[-1].extend(dihedrals) 

440 

441 return r 

442 

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

444 """Get bond length. 

445 

446 Parameters: 

447 

448 imIdx: int 

449 Index of Image to get value from. 

450 idxs: tuple or list of integers 

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

452 mic: bool 

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

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

455 kwargs: options or dict 

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

457 

458 Returns: 

459 

460 return: float 

461 Value returned by image.get_distance. 

462 """ 

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

464 

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

466 """Get angle. 

467 

468 Parameters: 

469 

470 imIdx: int 

471 Index of Image to get value from. 

472 idxs: tuple or list of integers 

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

474 mic: bool 

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

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

477 kwargs: options or dict 

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

479 

480 Returns: 

481 

482 return: float 

483 Value returned by image.get_angle. 

484 """ 

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

486 

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

488 """Get dihedral. 

489 

490 Parameters: 

491 

492 imIdx: int 

493 Index of Image to get value from. 

494 idxs: tuple or list of integers 

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

496 mic: bool 

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

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

499 kwargs: options or dict 

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

501 

502 Returns: 

503 

504 return: float 

505 Value returned by image.get_dihedral. 

506 """ 

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

508 

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

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

511 

512 Parameters: 

513 

514 inputList: list of lists of tuples 

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

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

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

518 imageIdx: integer or slice 

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

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

521 mic: bool 

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

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

524 kwargs: options or dict 

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

526 

527 Returns: 

528 

529 return: list of lists of floats 

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

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

532 

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

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

535 """ 

536 

537 sl = self._get_slice(imageIdx) 

538 

539 # get method to call from length of inputList 

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

541 get = self.get_bond_value 

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

543 get = self.get_angle_value 

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

545 get = self.get_dihedral_value 

546 else: 

547 raise ValueError( 

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

549 

550 # check if length of slice and inputList match 

551 singleNL = False 

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

553 # only one nl for all images 

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

555 singleNL = True 

556 else: 

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

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

559 

560 r = [] 

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

562 imageIdx = self.images.index(image) 

563 r.append([]) 

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

565 if singleNL: 

566 inputIdx = 0 

567 for tupl in inputList[inputIdx]: 

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

569 

570 return r 

571 

572 def get_max_volume_estimate(self): 

573 return get_max_volume_estimate(self.images) 

574 

575 @deprecated("Use `ase.geometry.rdf.get_rdf` instead.") 

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

577 volume: float | None = None): 

578 """Get RDF. 

579 

580 Wrapper for :func:`ase.geometry.rdf.get_rdf` with more selection possibilities. 

581 

582 .. deprecated:: 3.28.0 

583 This method has bugs when ``elements`` is not ``None`` and will be 

584 removed soon. Use :func:`ase.geometry.rdf.get_rdf` instead. 

585 

586 Parameters: 

587 

588 rmax: float 

589 Maximum distance of RDF. 

590 nbins: int 

591 Number of bins to divide RDF. 

592 imageIdx: int/slice/None 

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

594 elements: str/int/list/tuple 

595 Make partial RDFs. 

596 

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

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

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

600 

601 Returns: 

602 

603 return: list of lists / list of tuples of lists 

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

605 only rdfs for each image are returned. 

606 """ 

607 

608 if elements is not None: 

609 raise RuntimeError( 

610 'There are bugs for `elements` is not `None`. ' 

611 'Use `ase.geometry.rdf.get_rdf` instead.' 

612 ) 

613 

614 sl = self._get_slice(imageIdx) 

615 

616 ls_rdf = [] 

617 el = None 

618 

619 for image in self.images[sl]: 

620 if elements is None: 

621 tmp_image = image 

622 # integers 

623 elif isinstance(elements, int): 

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

625 tmp_image.append(image[elements]) 

626 # strings 

627 elif isinstance(elements, str): 

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

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

630 tmp_image.append(image[idx]) 

631 # lists 

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

633 # list of ints 

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

635 if len(elements) == 2: 

636 # use builtin get_rdf mask 

637 el = elements 

638 tmp_image = image 

639 else: 

640 # create dummy image 

641 tmp_image = Atoms( 

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

643 for idx in elements: 

644 tmp_image.append(image[idx]) 

645 # list of strings 

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

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

648 pbc=image.get_pbc()) 

649 for element in elements: 

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

651 tmp_image.append(image[idx]) 

652 else: 

653 raise ValueError( 

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

655 else: 

656 raise ValueError( 

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

658 

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

660 volume=volume) 

661 ls_rdf.append(rdf) 

662 

663 return ls_rdf