r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1"""Bravais.py - class for generating Bravais lattices etc.

3This is a base class for numerous classes setting up pieces of crystal.

4"""

6import math

7from typing import Optional, Sequence

9import numpy as np

11from ase.atoms import Atoms

12import ase.data

15class Bravais:

16 """Bravais lattice factory.

18 This is a base class for the objects producing various lattices

19 (SC, FCC, ...).

20 """

22 # The following methods are NOT defined here, but must be defined

23 # in classes inhering from Bravais:

24 # get_lattice_constant

25 # make_crystal_basis

26 # The following class attributes are NOT defined here, but must be defined

27 # in classes inhering from Bravais:

28 # int_basis

29 # inverse_basis

31 other = {0: (1, 2), 1: (2, 0), 2: (0, 1)}

33 # For Bravais lattices with a basis, set the basis here. Leave as

34 # None if no basis is present.

35 bravais_basis: Optional[Sequence[Sequence[float]]] = None

37 # If more than one type of element appear in the crystal, give the

38 # order here. For example, if two elements appear in a 3:1 ratio,

39 # bravais_basis could contain four vectors, and element_basis

40 # could be (0,0,1,0) - the third atom in the basis is different

41 # from the other three. Leave as None if all atoms are of the

42 # same type.

43 element_basis: Optional[Sequence[int]] = None

45 # How small numbers should be considered zero in the unit cell?

46 chop_tolerance = 1e-10

48 def __call__(self, symbol,

49 directions=(None, None, None), miller=(None, None, None),

50 size=(1, 1, 1), latticeconstant=None,

51 pbc=True, align=True, debug=0):

52 "Create a lattice."

53 self.size = size

54 self.pbc = pbc

55 self.debug = debug

56 self.process_element(symbol)

57 self.find_directions(directions, miller)

58 if self.debug:

59 self.print_directions_and_miller()

60 self.convert_to_natural_basis()

61 if self.debug >= 2:

62 self.print_directions_and_miller(" (natural basis)")

63 if latticeconstant is None:

64 if self.element_basis is None:

65 self.latticeconstant = self.get_lattice_constant()

66 else:

67 raise ValueError(

68 "A lattice constant must be specified for a compound")

69 else:

70 self.latticeconstant = latticeconstant

71 if self.debug:

72 print(

73 "Expected number of atoms in unit cell:",

74 self.calc_num_atoms())

75 if self.debug >= 2:

76 print("Bravais lattice basis:", self.bravais_basis)

77 if self.bravais_basis is not None:

78 print(" ... in natural basis:", self.natural_bravais_basis)

79 self.make_crystal_basis()

80 self.make_unit_cell()

81 if align:

82 self.align()

83 return self.make_list_of_atoms()

85 def align(self):

86 "Align the first axis along x-axis and the second in the x-y plane."

87 degree = 180 / np.pi

88 if self.debug >= 2:

89 print("Basis before alignment:")

90 print(self.basis)

91 if self.basis[0][0]**2 + \

92 self.basis[0][2]**2 < 0.01 * self.basis[0][1]**2:

93 # First basis vector along y axis - rotate 90 deg along z

94 t = np.array([[0, -1, 0],

95 [1, 0, 0],

96 [0, 0, 1]], float)

97 self.basis = np.dot(self.basis, t)

98 transf = t

99 if self.debug >= 2:

100 print("Rotating -90 degrees around z axis for numerical "

101 "stability.")

102 print(self.basis)

103 else:

104 transf = np.identity(3, float)

105 assert abs(np.linalg.det(transf) - 1) < 1e-6

106 # Rotate first basis vector into xy plane

107 theta = math.atan2(self.basis[0, 2], self.basis[0, 0])

108 t = np.array([[np.cos(theta), 0, -np.sin(theta)],

109 [0, 1, 0],

110 [np.sin(theta), 0, np.cos(theta)]])

111 self.basis = np.dot(self.basis, t)

112 transf = np.dot(transf, t)

113 if self.debug >= 2:

114 print("Rotating %f degrees around y axis." % (-theta * degree,))

115 print(self.basis)

116 assert abs(np.linalg.det(transf) - 1) < 1e-6

117 # Rotate first basis vector to point along x axis

118 theta = math.atan2(self.basis[0, 1], self.basis[0, 0])

119 t = np.array([[np.cos(theta), -np.sin(theta), 0],

120 [np.sin(theta), np.cos(theta), 0],

121 [0, 0, 1]])

122 self.basis = np.dot(self.basis, t)

123 transf = np.dot(transf, t)

124 if self.debug >= 2:

125 print("Rotating %f degrees around z axis." % (-theta * degree,))

126 print(self.basis)

127 assert abs(np.linalg.det(transf) - 1) < 1e-6

128 # Rotate second basis vector into xy plane

129 theta = math.atan2(self.basis[1, 2], self.basis[1, 1])

130 t = np.array([[1, 0, 0],

131 [0, np.cos(theta), -np.sin(theta)],

132 [0, np.sin(theta), np.cos(theta)]])

133 self.basis = np.dot(self.basis, t)

134 transf = np.dot(transf, t)

135 if self.debug >= 2:

136 print("Rotating %f degrees around x axis." % (-theta * degree,))

137 print(self.basis)

138 assert abs(np.linalg.det(transf) - 1) < 1e-6

139 # Now we better rotate the atoms as well

140 self.atoms = np.dot(self.atoms, transf)

141 # ... and rotate miller_basis

142 self.miller_basis = np.dot(self.miller_basis, transf)

144 def make_list_of_atoms(self):

145 "Repeat the unit cell."

146 nrep = self.size[0] * self.size[1] * self.size[2]

147 if nrep <= 0:

148 raise ValueError(

149 "Cannot create a non-positive number of unit cells")

150 # Now the unit cells must be merged.

151 a2 = []

152 e2 = []

153 for i in range(self.size[0]):

154 offset = self.basis[0] * i

155 a2.append(self.atoms + offset[np.newaxis, :])

156 e2.append(self.elements)

157 atoms = np.concatenate(a2)

158 elements = np.concatenate(e2)

159 a2 = []

160 e2 = []

161 for j in range(self.size[1]):

162 offset = self.basis[1] * j

163 a2.append(atoms + offset[np.newaxis, :])

164 e2.append(elements)

165 atoms = np.concatenate(a2)

166 elements = np.concatenate(e2)

167 a2 = []

168 e2 = []

169 for k in range(self.size[2]):

170 offset = self.basis[2] * k

171 a2.append(atoms + offset[np.newaxis, :])

172 e2.append(elements)

173 atoms = np.concatenate(a2)

174 elements = np.concatenate(e2)

175 del a2, e2

176 assert len(atoms) == nrep * len(self.atoms)

177 basis = np.array([[self.size[0], 0, 0],

178 [0, self.size[1], 0],

179 [0, 0, self.size[2]]])

180 basis = np.dot(basis, self.basis)

182 # Tiny elements should be replaced by zero. The cutoff is

183 # determined by chop_tolerance which is a class attribute.

184 basis = np.where(np.abs(basis) < self.chop_tolerance,

185 0.0, basis)

187 # None should be replaced, and memory should be freed.

188 lattice = Lattice(positions=atoms, cell=basis, numbers=elements,

189 pbc=self.pbc)

190 lattice.millerbasis = self.miller_basis

193 return lattice

195 def process_element(self, element):

196 "Extract atomic number from element"

197 # The types that can be elements: integers and strings

198 if self.element_basis is None:

199 if isinstance(element, str):

200 self.atomicnumber = ase.data.atomic_numbers[element]

201 elif isinstance(element, int):

202 self.atomicnumber = element

203 else:

204 raise TypeError(

205 "The symbol argument must be a string or an atomic number.")

206 else:

207 atomicnumber = []

208 try:

209 if len(element) != max(self.element_basis) + 1:

210 oops = True

211 else:

212 oops = False

213 except TypeError:

214 oops = True

215 if oops:

216 raise TypeError(

217 ("The symbol argument must be a sequence of length %d"

218 + " (one for each kind of lattice position")

219 % (max(self.element_basis) + 1,))

220 for e in element:

221 if isinstance(e, str):

222 atomicnumber.append(ase.data.atomic_numbers[e])

223 elif isinstance(e, int):

224 atomicnumber.append(e)

225 else:

226 raise TypeError(

227 "The symbols argument must be a sequence of strings "

228 "or atomic numbers.")

229 self.atomicnumber = [atomicnumber[i] for i in self.element_basis]

230 assert len(self.atomicnumber) == len(self.bravais_basis)

232 def convert_to_natural_basis(self):

233 "Convert directions and miller indices to the natural basis."

234 self.directions = np.dot(self.directions, self.inverse_basis)

235 if self.bravais_basis is not None:

236 self.natural_bravais_basis = np.dot(self.bravais_basis,

237 self.inverse_basis)

238 for i in (0, 1, 2):

239 self.directions[i] = reduceindex(self.directions[i])

240 for i in (0, 1, 2):

241 (j, k) = self.other[i]

242 self.miller[i] = reduceindex(self.handedness *

243 cross(self.directions[j],

244 self.directions[k]))

246 def calc_num_atoms(self):

247 v = int(round(abs(np.linalg.det(self.directions))))

248 if self.bravais_basis is None:

249 return v

250 else:

251 return v * len(self.bravais_basis)

253 def make_unit_cell(self):

254 "Make the unit cell."

255 # Make three loops, and find the positions in the integral

256 # lattice. Each time a position is found, the atom is placed

257 # in the real unit cell by put_atom().

258 self.natoms = self.calc_num_atoms()

259 self.nput = 0

260 self.atoms = np.zeros((self.natoms, 3), float)

261 self.elements = np.zeros(self.natoms, int)

262 self.farpoint = sum(self.directions)

263 # printprogress = self.debug and (len(self.atoms) > 250)

264 # Find the radius of the sphere containing the whole system

266 for i in (0, 1):

267 for j in (0, 1):

268 for k in (0, 1):

269 vect = (i * self.directions[0] +

270 j * self.directions[1] +

271 k * self.directions[2])

272 if np.dot(vect, vect) > sqrad:

274 del i, j, k

275 # Loop along first crystal axis (i)

276 for (istart, istep) in ((0, 1), (-1, -1)):

277 i = istart

278 icont = True

279 while icont:

280 nj = 0

281 for (jstart, jstep) in ((0, 1), (-1, -1)):

282 j = jstart

283 jcont = True

284 while jcont:

285 nk = 0

286 for (kstart, kstep) in ((0, 1), (-1, -1)):

287 k = kstart

288 kcont = True

289 while kcont:

290 # Now (i,j,k) loops over Z^3, except that

291 # the loops can be cut off when we get outside

292 # the unit cell.

293 point = np.array((i, j, k))

294 if self.inside(point):

295 self.put_atom(point)

296 nk += 1

297 nj += 1

298 # Is k too high?

299 if np.dot(point, point) > sqrad:

300 assert not self.inside(point)

301 kcont = False

302 k += kstep

303 # Is j too high?

304 if i * i + j * j > sqrad:

305 jcont = False

306 j += jstep

307 # Is i too high?

308 if i * i > sqrad:

309 icont = False

310 i += istep

311 # if printprogress:

312 # perce = int(100*self.nput / len(self.atoms))

313 # if perce > percent + 10:

314 # print ("%d%%" % perce),

315 # percent = perce

316 assert self.nput == self.natoms

318 def inside(self, point):

319 "Is a point inside the unit cell?"

320 return (np.dot(self.miller[0], point) >= 0 and

321 np.dot(self.miller[0], point - self.farpoint) < 0 and

322 np.dot(self.miller[1], point) >= 0 and

323 np.dot(self.miller[1], point - self.farpoint) < 0 and

324 np.dot(self.miller[2], point) >= 0 and

325 np.dot(self.miller[2], point - self.farpoint) < 0)

327 def put_atom(self, point):

328 "Place an atom given its integer coordinates."

329 if self.bravais_basis is None:

330 # No basis - just place a single atom

331 pos = np.dot(point, self.crystal_basis)

332 if self.debug >= 2:

333 print('Placing an atom at (%d,%d,%d) ~ (%.3f, %.3f, %.3f).' %

334 (tuple(point) + tuple(pos)))

335 self.atoms[self.nput] = pos

336 self.elements[self.nput] = self.atomicnumber

337 self.nput += 1

338 else:

339 for i, offset in enumerate(self.natural_bravais_basis):

340 pos = np.dot(point + offset, self.crystal_basis)

341 if self.debug >= 2:

342 print('Placing an atom at (%d+%f, %d+%f, %d+%f) ~ '

343 '(%.3f, %.3f, %.3f).' %

344 (point[0], offset[0], point[1], offset[1],

345 point[2], offset[2], pos[0], pos[1], pos[2]))

346 self.atoms[self.nput] = pos

347 if self.element_basis is None:

348 self.elements[self.nput] = self.atomicnumber

349 else:

350 self.elements[self.nput] = self.atomicnumber[i]

351 self.nput += 1

353 def find_directions(self, directions, miller):

354 """

355 Find missing directions and miller indices from the specified ones.

356 """

357 directions = np.asarray(directions).tolist()

358 miller = list(miller) # np.asarray(miller).tolist()

359 # If no directions etc are specified, use a sensible default.

360 if directions == [None, None, None] and miller == [None, None, None]:

361 directions = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]

362 # Now fill in missing directions and miller indices. This is an

363 # iterative process.

364 change = 1

365 while change:

366 change = False

367 missing = 0

368 for i in (0, 1, 2):

369 j, k = self.other[i]

370 if directions[i] is None:

371 missing += 1

372 if miller[j] is not None and miller[k] is not None:

373 directions[i] = reduceindex(cross(miller[j],

374 miller[k]))

375 change = True

376 if self.debug >= 2:

377 print(

378 "Calculating directions[%d] from miller "

379 "indices" % i)

380 if miller[i] is None:

381 missing += 1

382 if directions[j] is not None and directions[k] is not None:

383 miller[i] = reduceindex(cross(directions[j],

384 directions[k]))

385 change = True

386 if self.debug >= 2:

387 print("Calculating miller[%d] from directions" % i)

388 if missing:

389 raise ValueError(

390 "Specification of directions and miller indices is incomplete.")

391 # Make sure that everything is Numeric arrays

392 self.directions = np.array(directions)

393 self.miller = np.array(miller)

394 # Check for zero-volume unit cell

395 if abs(np.linalg.det(self.directions)) < 1e-10:

396 raise ValueError(

397 "The direction vectors are linearly dependent "

398 "(unit cell volume would be zero)")

399 # Check for left-handed coordinate system

400 if np.linalg.det(self.directions) < 0:

401 print("WARNING: Creating a left-handed coordinate system!")

402 self.miller = -self.miller

403 self.handedness = -1

404 else:

405 self.handedness = 1

406 # Now check for consistency

407 for i in (0, 1, 2):

408 (j, k) = self.other[i]

409 m = reduceindex(self.handedness *

410 cross(self.directions[j], self.directions[k]))

411 if sum(np.not_equal(m, self.miller[i])):

412 print(

413 "ERROR: Miller index %s is inconsisten with "

414 "directions %d and %d" % (i, j, k))

415 print("Miller indices:")

416 print(str(self.miller))

417 print("Directions:")

418 print(str(self.directions))

419 raise ValueError(

420 "Inconsistent specification of miller indices and "

421 "directions.")

423 def print_directions_and_miller(self, txt=""):

424 "Print direction vectors and Miller indices."

425 print("Direction vectors of unit cell%s:" % (txt,))

426 for i in (0, 1, 2):

427 print(" ", self.directions[i])

428 print("Miller indices of surfaces%s:" % (txt,))

429 for i in (0, 1, 2):

430 print(" ", self.miller[i])

433class MillerInfo:

434 """Mixin class to provide information about Miller indices."""

436 def miller_to_direction(self, miller):

437 """Returns the direction corresponding to a given Miller index."""

438 return np.dot(miller, self.millerbasis)

441class Lattice(Atoms, MillerInfo):

442 """List of atoms initially containing a regular lattice of atoms.

444 A part from the usual list of atoms methods this list of atoms type

445 also has a method, `miller_to_direction`, used to convert from Miller

446 indices to directions in the coordinate system of the lattice.

447 """

448 pass

451# Helper functions

452def cross(a, b):

453 """The cross product of two vectors."""

454 return np.array((a[1] * b[2] - b[1] * a[2],

455 a[2] * b[0] - b[2] * a[0],

456 a[0] * b[1] - b[0] * a[1]))

459def reduceindex(M):

460 """Reduce Miller index to the lowest equivalent integers."""

461 oldM = M

462 g = math.gcd(M[0], M[1])

463 h = math.gcd(g, M[2])

464 while h != 1:

465 if h == 0:

466 raise ValueError(

467 "Division by zero: Are the miller indices linearly dependent?")

468 M = M // h

469 g = math.gcd(M[0], M[1])

470 h = math.gcd(g, M[2])

471 if np.dot(oldM, M) > 0:

472 return M

473 else:

474 return -M