Coverage for ase / cell.py: 99.32%

147 statements  

« prev     ^ index     » next       coverage.py v7.13.3, created at 2026-02-04 10:20 +0000

1"""Cell.""" 

2 

3from typing import Mapping, Sequence, Union 

4 

5import numpy as np 

6 

7import ase 

8from ase.utils import pbc2pbc 

9from ase.utils.arraywrapper import arraylike 

10 

11__all__ = ['Cell'] 

12 

13 

14@arraylike 

15class Cell: 

16 """Parallel epipedal unit cell of up to three dimensions. 

17 

18 This object resembles a 3x3 array whose [i, j]-th element is the jth 

19 Cartesian coordinate of the ith unit vector. 

20 

21 Cells of less than three dimensions are represented by placeholder 

22 unit vectors that are zero.""" 

23 

24 ase_objtype = 'cell' # For JSON'ing 

25 

26 def __init__(self, array): 

27 """Create cell. 

28 

29 Parameters 

30 ---------- 

31 array: 3x3 arraylike object 

32 The three cell vectors: cell[0], cell[1], and cell[2]. 

33 """ 

34 array = np.asarray(array, dtype=float) 

35 assert array.shape == (3, 3) 

36 self.array = array 

37 

38 def cellpar(self, radians=False): 

39 """Get unit cell parameters. Sequence of 6 numbers. 

40 

41 First three are unit cell vector lengths and second three 

42 are angles between them:: 

43 

44 [len(a), len(b), len(c), angle(b,c), angle(a,c), angle(a,b)] 

45 

46 in degrees. 

47 

48 See also :func:`ase.geometry.cell.cell_to_cellpar`.""" 

49 from ase.geometry.cell import cell_to_cellpar 

50 

51 return cell_to_cellpar(self.array, radians) 

52 

53 def todict(self): 

54 return dict(array=self.array) 

55 

56 @classmethod 

57 def ascell(cls, cell): 

58 """Return argument as a Cell object. See :meth:`ase.cell.Cell.new`. 

59 

60 A new Cell object is created if necessary.""" 

61 if isinstance(cell, cls): 

62 return cell 

63 return cls.new(cell) 

64 

65 @classmethod 

66 def new(cls, cell=None): 

67 """Create new cell from any parameters. 

68 

69 If cell is three numbers, assume three lengths with right angles. 

70 

71 If cell is six numbers, assume three lengths, then three angles. 

72 

73 If cell is 3x3, assume three cell vectors.""" 

74 

75 if cell is None: 

76 cell = np.zeros((3, 3)) 

77 

78 cell = np.array(cell, float) 

79 

80 if cell.shape == (3,): 

81 cell = np.diag(cell) 

82 elif cell.shape == (6,): 

83 from ase.geometry.cell import cellpar_to_cell 

84 

85 cell = cellpar_to_cell(cell) 

86 elif cell.shape != (3, 3): 

87 raise ValueError( 

88 'Cell must be length 3 sequence, length 6 ' 

89 'sequence or 3x3 matrix!' 

90 ) 

91 

92 cellobj = cls(cell) 

93 return cellobj 

94 

95 @classmethod 

96 def fromcellpar(cls, cellpar, ab_normal=(0, 0, 1), a_direction=None): 

97 """Return new Cell from cell lengths and angles. 

98 

99 See also :func:`~ase.geometry.cell.cellpar_to_cell()`.""" 

100 from ase.geometry.cell import cellpar_to_cell 

101 

102 cell = cellpar_to_cell(cellpar, ab_normal, a_direction) 

103 return cls(cell) 

104 

105 def get_bravais_lattice(self, eps=2e-4, *, pbc=True): 

106 """Return :class:`~ase.lattice.BravaisLattice` for this cell: 

107 

108 >>> from ase.cell import Cell 

109 

110 >>> cell = Cell.fromcellpar([4, 4, 4, 60, 60, 60]) 

111 >>> print(cell.get_bravais_lattice()) 

112 FCC(a=5.65685) 

113 

114 .. note:: The Bravais lattice object follows the AFlow 

115 conventions. ``cell.get_bravais_lattice().tocell()`` may 

116 differ from the original cell by a permutation or other 

117 operation which maps it to the AFlow convention. For 

118 example, the orthorhombic lattice enforces a < b < c. 

119 

120 To build a bandpath for a particular cell, use 

121 :meth:`ase.cell.Cell.bandpath` instead of this method. 

122 This maps the kpoints back to the original input cell. 

123 

124 """ 

125 from ase.lattice import identify_lattice 

126 

127 pbc = self.mask() & pbc2pbc(pbc) 

128 lat, _op = identify_lattice(self, eps=eps, pbc=pbc) 

129 return lat 

130 

131 def bandpath( 

132 self, 

133 path: str = None, 

134 npoints: int = None, 

135 *, 

136 density: float = None, 

137 special_points: Mapping[str, Sequence[float]] = None, 

138 eps: float = 2e-4, 

139 pbc: Union[bool, Sequence[bool]] = True, 

140 ) -> 'ase.dft.kpoints.BandPath': 

141 """Build a :class:`~ase.dft.kpoints.BandPath` for this cell. 

142 

143 If special points are None, determine the Bravais lattice of 

144 this cell and return a suitable Brillouin zone path with 

145 standard special points. 

146 

147 If special special points are given, interpolate the path 

148 directly from the available data. 

149 

150 Parameters 

151 ---------- 

152 path: string 

153 String of special point names defining the path, e.g. 'GXL'. 

154 npoints: int 

155 Number of points in total. Note that at least one point 

156 is added for each special point in the path. 

157 density: float 

158 density of kpoints along the path in Å⁻¹. 

159 special_points: dict 

160 Dictionary mapping special points to scaled kpoint coordinates. 

161 For example ``{'G': [0, 0, 0], 'X': [1, 0, 0]}``. 

162 eps: float 

163 Tolerance for determining Bravais lattice. 

164 pbc: three bools 

165 Whether cell is periodic in each direction. Normally not 

166 necessary. If cell has three nonzero cell vectors, use 

167 e.g. pbc=[1, 1, 0] to request a 2D bandpath nevertheless. 

168 

169 Example 

170 ------- 

171 

172 >>> from ase.cell import Cell 

173 

174 >>> cell = Cell.fromcellpar([4, 4, 4, 60, 60, 60]) 

175 >>> cell.bandpath('GXW', npoints=20) 

176 BandPath(path='GXW', cell=[3x3], special_points={GKLUWX}, kpts=[20x3]) 

177 

178 """ 

179 # TODO: Combine with the rotation transformation from bandpath() 

180 

181 cell = self.uncomplete(pbc) 

182 

183 if special_points is None: 

184 from ase.lattice import identify_lattice 

185 

186 lat, op = identify_lattice(cell, eps=eps) 

187 bandpath = lat.bandpath(path, npoints=npoints, density=density) 

188 return bandpath.transform(op) 

189 else: 

190 from ase.dft.kpoints import BandPath, resolve_custom_points 

191 

192 path, special_points = resolve_custom_points( 

193 path, special_points, eps=eps 

194 ) 

195 bandpath = BandPath(cell, path=path, special_points=special_points) 

196 return bandpath.interpolate(npoints=npoints, density=density) 

197 

198 def uncomplete(self, pbc): 

199 """Return new cell, zeroing cell vectors where not periodic.""" 

200 _pbc = np.empty(3, bool) 

201 _pbc[:] = pbc 

202 cell = self.copy() 

203 cell[~_pbc] = 0 

204 return cell 

205 

206 def complete(self): 

207 """Convert missing cell vectors into orthogonal unit vectors.""" 

208 from ase.geometry.cell import complete_cell 

209 

210 return Cell(complete_cell(self.array)) 

211 

212 def copy(self): 

213 """Return a copy of this cell.""" 

214 return Cell(self.array.copy()) 

215 

216 def mask(self): 

217 """Boolean mask of which cell vectors are nonzero.""" 

218 return self.any(1) 

219 

220 @property 

221 def rank(self) -> int: 

222 """Return the dimension of the cell. 

223 

224 Equal to the number of nonzero lattice vectors.""" 

225 # The name ndim clashes with ndarray.ndim 

226 return sum(self.mask()) 

227 

228 @property 

229 def orthorhombic(self) -> bool: 

230 """Return whether this cell is represented by a diagonal matrix.""" 

231 from ase.geometry.cell import is_orthorhombic 

232 

233 return is_orthorhombic(self) 

234 

235 def lengths(self): 

236 """Return the length of each lattice vector as an array.""" 

237 return np.linalg.norm(self, axis=1) 

238 

239 def angles(self): 

240 """Return an array with the three angles alpha, beta, and gamma.""" 

241 return self.cellpar()[3:].copy() 

242 

243 def __array__(self, dtype=None, copy=False): 

244 return np.array(self.array, dtype=dtype, copy=copy) 

245 

246 def __bool__(self): 

247 return bool(self.any()) # need to convert from np.bool_ 

248 

249 @property 

250 def volume(self) -> float: 

251 """Get the volume of this cell. 

252 

253 If there are less than 3 lattice vectors, return 0.""" 

254 # Fail or 0 for <3D cells? 

255 # Definitely 0 since this is currently a property. 

256 # I think normally it is more convenient just to get zero 

257 return np.abs(np.linalg.det(self)) 

258 

259 @property 

260 def handedness(self) -> int: 

261 """Sign of the determinant of the matrix of cell vectors. 

262 

263 1 for right-handed cells, -1 for left, and 0 for cells that 

264 do not span three dimensions.""" 

265 return int(np.sign(np.linalg.det(self))) 

266 

267 def scaled_positions(self, positions) -> np.ndarray: 

268 """Calculate scaled positions from Cartesian positions. 

269 

270 The scaled positions are the positions given in the basis 

271 of the cell vectors. For the purpose of defining the basis, cell 

272 vectors that are zero will be replaced by unit vectors as per 

273 :meth:`~ase.cell.Cell.complete`.""" 

274 return np.linalg.solve(self.complete().T, np.transpose(positions)).T 

275 

276 def cartesian_positions(self, scaled_positions) -> np.ndarray: 

277 """Calculate Cartesian positions from scaled positions.""" 

278 return scaled_positions @ self.complete() 

279 

280 def reciprocal(self) -> 'Cell': 

281 """Get reciprocal lattice as a Cell object. 

282 

283 The reciprocal cell is defined such that 

284 

285 cell.reciprocal() @ cell.T == np.diag(cell.mask()) 

286 

287 within machine precision. 

288 

289 Does not include factor of 2 pi.""" 

290 icell = Cell(np.linalg.pinv(self).transpose()) 

291 icell[~self.mask()] = 0.0 # type: ignore[index] 

292 return icell 

293 

294 def normal(self, i): 

295 """Normal vector of the two vectors with index different from i. 

296 

297 This is the cross product of those vectors in cyclic order from i.""" 

298 return np.cross(self[i - 2], self[i - 1]) 

299 

300 def normals(self): 

301 """Normal vectors of each axis as a 3x3 matrix.""" 

302 return np.array([self.normal(i) for i in range(3)]) 

303 

304 def area(self, i): 

305 """Area spanned by the two vectors with index different from i.""" 

306 return np.linalg.norm(self.normal(i)) 

307 

308 def areas(self): 

309 """Areas spanned by cell vector pairs (1, 2), (2, 0), and (0, 1).""" 

310 return np.linalg.norm(self.normals(), axis=1) 

311 

312 def __repr__(self): 

313 if self.orthorhombic: 

314 numbers = self.lengths().tolist() 

315 else: 

316 numbers = self.tolist() 

317 

318 return f'Cell({numbers})' 

319 

320 def niggli_reduce(self, eps=1e-5): 

321 """Niggli reduce this cell, returning a new cell and mapping. 

322 

323 See also :func:`ase.build.tools.niggli_reduce_cell`.""" 

324 from ase.build.tools import niggli_reduce_cell 

325 

326 cell, op = niggli_reduce_cell(self, epsfactor=eps) 

327 result = Cell(cell) 

328 return result, op 

329 

330 def minkowski_reduce(self): 

331 """Minkowski-reduce this cell, returning new cell and mapping. 

332 

333 See also :func:`ase.geometry.minkowski_reduction.minkowski_reduce`.""" 

334 from ase.geometry.minkowski_reduction import minkowski_reduce 

335 

336 cell, op = minkowski_reduce(self, self.mask()) 

337 result = Cell(cell) 

338 return result, op 

339 

340 def permute_axes(self, permutation): 

341 """Permute axes of cell.""" 

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

343 permuted = Cell(self[permutation][:, permutation]) 

344 return permuted 

345 

346 def standard_form(self, form='lower'): 

347 """Rotate axes such that unit cell is lower/upper triangular. The cell 

348 handedness is preserved. 

349 

350 A lower-triangular cell with positive diagonal entries is a canonical 

351 (i.e. unique) description. For a left-handed cell the diagonal entries 

352 are negative. 

353 

354 Parameters 

355 ---------- 

356 form: str 

357 'lower' or 'upper' triangular form. The default is 'lower'. 

358 

359 Returns 

360 ------- 

361 rcell: the standardized cell object 

362 

363 Q: ndarray 

364 The orthogonal transformation. Here, rcell @ Q = cell, where cell 

365 is the input cell and rcell is the lower triangular (output) cell. 

366 """ 

367 

368 sign = self.handedness 

369 if sign == 0: 

370 sign = 1 

371 

372 # LQ decomposition provides an axis-aligned description of the cell. 

373 # Q is an orthogonal matrix and L is a lower triangular matrix. The 

374 # decomposition is a unique description if the diagonal elements are 

375 # all positive (negative for a left-handed cell). 

376 if form == 'lower': 

377 Q, L = np.linalg.qr(self.T) 

378 Q = Q.T 

379 L = L.T 

380 elif form == 'upper': 

381 Q, L = np.linalg.qr(self.T[::-1, ::-1]) 

382 Q = Q.T[::-1, ::-1] 

383 L = L.T[::-1, ::-1] 

384 else: 

385 raise ValueError('form must be either "lower" or "upper"') 

386 

387 # correct the signs of the diagonal elements 

388 signs = np.sign(np.diag(L)) 

389 indices = np.where(signs == 0)[0] 

390 signs[indices] = 1 

391 indices = np.where(signs != sign)[0] 

392 L[:, indices] *= -1 

393 Q[indices] *= -1 

394 return Cell(L), Q 

395 

396 # XXX We want a reduction function that brings the cell into 

397 # standard form as defined by Setyawan and Curtarolo.