Coverage for icet/tools/constituent_strain.py: 99%

147 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-12-26 04:12 +0000

1import numpy as np 

2import itertools 

3from ase import Atoms 

4from typing import List, Tuple, Callable 

5from ase.symbols import chemical_symbols as ase_chemical_symbols 

6from functools import partial 

7from .constituent_strain_helper_functions import (_get_structure_factor, 

8 _get_partial_structure_factor) 

9 

10 

11class KPoint: 

12 """ 

13 Class for handling each k point in a supercell separately. 

14 

15 Parameters 

16 ---------- 

17 kpt 

18 k-point coordinates. 

19 multiplicity 

20 Multiplicity of this k-point. 

21 structure_factor 

22 Current structure associated with this k-point. 

23 strain_energy_function 

24 Function that takes a concentration and a list of parameters 

25 and returns strain energy. 

26 damping 

27 Damping at this k-point in units of Ångstrom. 

28 """ 

29 def __init__(self, kpt: np.ndarray, multiplicity: float, structure_factor: float, 

30 strain_energy_function: Callable[[float, List[float]], float], 

31 damping: float): 

32 

33 self.kpt = kpt 

34 self.multiplicity = multiplicity 

35 self.structure_factor = structure_factor 

36 self.structure_factor_after = self.structure_factor 

37 self.strain_energy_function = strain_energy_function 

38 self.damping_factor = np.exp(-(damping * np.linalg.norm(self.kpt)) ** 2) 

39 

40 

41class ConstituentStrain: 

42 r""" 

43 Class for handling constituent strain in cluster expansions 

44 (see Laks et al., Phys. Rev. B **46**, 12587 (1992) [LakFerFro92]_). 

45 This makes it possible to use cluster expansions to describe systems 

46 with strain due to, for example, coherent phase separation. 

47 For an extensive example on how to use this module, 

48 please see :ref:`this example <constituent_strain_example>`. 

49 

50 Parameters 

51 ---------- 

52 supercell 

53 Defines supercell that will be used when 

54 calculating constituent strain. 

55 primitive_structure 

56 Primitive structure the supercell is based on. 

57 chemical_symbols 

58 List with chemical symbols involved, such as ``['Ag', 'Cu']``. 

59 concentration_symbol 

60 Chemical symbol used to define concentration, 

61 such as ``'Ag'``. 

62 strain_energy_function 

63 A function that takes two arguments, a list of parameters and 

64 concentration (e.g., ``[0.5, 0.5, 0.5]`` and ``0.3``), 

65 and returns the corresponding strain energy. 

66 The parameters are in turn determined by ``k_to_parameter_function`` 

67 (see below). If ``k_to_parameter_function`` is None, 

68 the parameters list will be the k-point. For more information, see 

69 :ref:`this example <constituent_strain_example>`. 

70 k_to_parameter_function 

71 A function that takes a k-point as a list of three floats 

72 and returns a parameter vector that will be fed into 

73 :attr:`strain_energy_function` (see above). If ``None``, the k-point 

74 itself will be the parameter vector to :attr:`strain_energy_function`. 

75 The purpose of this function is to be able to precompute 

76 any factor in the strain energy that depends on the k-point 

77 but not the concentration. For more information, see 

78 :ref:`this example <constituent_strain_example>`. 

79 damping 

80 Damping factor :math:`\eta` used to suppress impact of 

81 large-magnitude k-points by multiplying strain with 

82 :math:`\exp(-(\eta \mathbf{k})^2)` (unit Ångstrom). 

83 tol 

84 Numerical tolerance when comparing k-points (units of 

85 inverse Ångstrom). 

86 """ 

87 

88 def __init__(self, 

89 supercell: Atoms, 

90 primitive_structure: Atoms, 

91 chemical_symbols: List[str], 

92 concentration_symbol: str, 

93 strain_energy_function: Callable[[float, List[float]], float], 

94 k_to_parameter_function: Callable[[List[float]], List[float]] = None, 

95 damping: float = 1.0, 

96 tol: float = 1e-6): 

97 self.natoms = len(supercell) 

98 

99 if len(chemical_symbols) < 2: 

100 raise ValueError('Please specify two chemical symbols.') 

101 elif len(chemical_symbols) > 2: 

102 raise NotImplementedError('The constituent strain module currently' 

103 ' only works for binary systems.') 

104 spins = [1, -1] 

105 self.spin_variables = {ase_chemical_symbols.index(sym): spin 

106 for sym, spin in zip(sorted(chemical_symbols), spins)} 

107 for key, value in self.spin_variables.items(): 107 ↛ 112line 107 didn't jump to line 112, because the loop on line 107 didn't complete

108 if value == 1: 108 ↛ 107line 108 didn't jump to line 107, because the condition on line 108 was never false

109 self.spin_up = key 

110 break 

111 

112 self.supercell = supercell 

113 self.concentration_number = ase_chemical_symbols.index(concentration_symbol) 

114 

115 # Initialize k-points for this supercell 

116 self.kpoints = [] 

117 initial_occupations = self.supercell.get_atomic_numbers() 

118 for kpt, multiplicity in _generate_k_points(primitive_structure, supercell, tol=tol): 

119 if np.allclose(kpt, [0, 0, 0], atol=tol): 

120 continue 

121 

122 S = _get_structure_factor(occupations=initial_occupations, 

123 positions=self.supercell.get_positions(), 

124 kpt=kpt, 

125 spin_up=self.spin_up) 

126 

127 if k_to_parameter_function is None: 

128 parameters = kpt 

129 else: 

130 parameters = k_to_parameter_function(kpt) 

131 self.kpoints.append(KPoint(kpt=kpt, 

132 multiplicity=multiplicity, 

133 structure_factor=S, 

134 damping=damping, 

135 strain_energy_function=partial(strain_energy_function, 

136 parameters))) 

137 

138 def get_concentration(self, occupations: np.ndarray) -> float: 

139 """ 

140 Calculate current concentration. 

141 

142 Parameters 

143 ---------- 

144 occupations 

145 Current occupations. 

146 """ 

147 return sum(occupations == self.concentration_number) / len(occupations) 

148 

149 def _get_constituent_strain_term(self, kpoint: KPoint, 

150 occupations: List[int], 

151 concentration: float) -> float: 

152 """Calculate constituent strain corresponding to a specific k-point. 

153 That value is returned and also stored in the corresponding 

154 :class:`KPoint <icet.tools.constituent_strain.KPoint>`. 

155 

156 Parameters 

157 ---------- 

158 kpoint 

159 The k-point to be calculated. 

160 occupations 

161 Current occupations of the structure. 

162 concentration 

163 Concentration in the structure. 

164 

165 """ 

166 if abs(concentration) < 1e-9 or abs(1 - concentration) < 1e-9: 

167 kpoint.structure_factor = 0.0 

168 return 0.0 

169 else: 

170 # Calculate structure factor 

171 S = _get_structure_factor( 

172 occupations, self.supercell.get_positions(), kpoint.kpt, self.spin_up) 

173 

174 # Save it for faster calculation later on 

175 kpoint.structure_factor = S 

176 

177 # Constituent strain excluding structure factor 

178 DE_CS = kpoint.strain_energy_function(concentration) 

179 return DE_CS * kpoint.multiplicity * abs(S)**2 * kpoint.damping_factor / \ 

180 (4 * concentration * (1 - concentration)) 

181 

182 def get_constituent_strain(self, occupations: List[int]) -> float: 

183 """ 

184 Calculate total constituent strain. 

185 

186 Parameters 

187 ---------- 

188 occupations 

189 Current occupations. 

190 """ 

191 c = self.get_concentration(occupations) 

192 E_CS = 0.0 

193 for kpoint in self.kpoints: 

194 E_CS += self._get_constituent_strain_term(kpoint=kpoint, 

195 occupations=occupations, 

196 concentration=c) 

197 return E_CS 

198 

199 def get_constituent_strain_change(self, occupations: np.ndarray, atom_index: int) -> float: 

200 """ 

201 Calculate change in constituent strain upon change of the 

202 occupation of one site. 

203 

204 .. warning :: 

205 This function is dependent on the internal state of the 

206 :class:`ConstituentStrain` object and **should typically only 

207 be used internally by mchammer**. Specifically, the structure 

208 factor is saved internally to speed up computation. 

209 The first time this function is called, :attr:`occupations` 

210 must be the same array as was used to initialize the 

211 :class:`ConstituentStrain` object, or the same as was last used 

212 when :func:`get_constituent_strain` was called. After the 

213 present function has been called, the same occupations vector 

214 need to be used the next time as well, unless :func:`accept_change` 

215 has been called, in which case :attr:`occupations` should incorporate 

216 the changes implied by the previous call to the function. 

217 

218 Parameters 

219 ---------- 

220 occupations 

221 Occupations before change. 

222 atom_index 

223 Index of site the occupation of which is to be changed. 

224 """ 

225 # Determine concentration before and after 

226 n = sum(occupations == self.concentration_number) 

227 c_before = n / self.natoms 

228 if occupations[atom_index] == self.concentration_number: 

229 c_after = (n - 1) / self.natoms 

230 else: 

231 c_after = (n + 1) / self.natoms 

232 

233 E_CS_change = 0.0 

234 position = self.supercell[atom_index].position 

235 spin = self.spin_variables[occupations[atom_index]] 

236 for kpoint in self.kpoints: 

237 # Change in structure factor 

238 S_before = kpoint.structure_factor 

239 dS = _get_partial_structure_factor(kpoint.kpt, position, self.natoms) 

240 S_after = S_before - 2 * spin * dS 

241 

242 # Save the new value in a variable such that it can be 

243 # accessed later if the change is accepted 

244 kpoint.structure_factor_after = S_after 

245 

246 # Energy before 

247 if abs(c_before) < 1e-9 or abs(c_before - 1) < 1e-9: 

248 E_before = 0.0 

249 else: 

250 E_before = kpoint.strain_energy_function(c_before) 

251 E_before *= abs(S_before)**2 / (4 * c_before * (1 - c_before)) 

252 

253 # Energy after 

254 if abs(c_after) < 1e-9 or abs(c_after - 1) < 1e-9: 

255 E_after = 0.0 

256 else: 

257 E_after = kpoint.strain_energy_function(c_after) 

258 E_after *= abs(S_after)**2 / (4 * c_after * (1 - c_after)) 

259 

260 # Difference 

261 E_CS_change += kpoint.multiplicity * kpoint.damping_factor * (E_after - E_before) 

262 return E_CS_change 

263 

264 def accept_change(self) -> None: 

265 """ 

266 Update structure factor for each kpoint to the value 

267 in :attr:`structure_factor_after`. This makes it possible to 

268 efficiently calculate changes in constituent strain with 

269 the :func:`get_constituent_strain_change` function; this function 

270 should be called if the last occupations used to call 

271 :func:`get_constituent_strain_change` should be the starting point 

272 for the next call of :func:`get_constituent_strain_change`. 

273 This is taken care of automatically by the Monte Carlo 

274 simulations in :program:`mchammer`. 

275 """ 

276 for kpoint in self.kpoints: 

277 kpoint.structure_factor = kpoint.structure_factor_after 

278 

279 

280def _generate_k_points(primitive_structure: Atoms, 

281 supercell: Atoms, 

282 tol: float) -> Tuple[np.ndarray, float]: 

283 """ 

284 Generate all k-points in the 1BZ of the primitive cell 

285 that potentially correspond to a nonzero structure factor. 

286 These are all the k-points that are integer multiples of 

287 the reciprocal supercell. 

288 

289 Parameters 

290 ---------- 

291 primitive_structure 

292 Primitive structure that the supercell is based on. 

293 supercell 

294 Supercell of primitive structure. 

295 """ 

296 reciprocal_primitive = np.linalg.inv(primitive_structure.cell) # column vectors 

297 reciprocal_supercell = np.linalg.inv(supercell.cell) # column vectors 

298 

299 # How many k-points are there? 

300 # This information is needed to know when to stop looking for more, 

301 # i.e., it implicitly determines the iteration limits. 

302 nkpoints = int(round(np.linalg.det(reciprocal_primitive) 

303 / np.linalg.det(reciprocal_supercell))) 

304 

305 # The gamma point is always present 

306 yield np.array([0., 0., 0.]), 1.0 

307 found_kpoints = [np.array([0., 0., 0.])] 

308 covered_nkpoints = 1 

309 

310 # Now loop until we have found all k-points. 

311 # We loop by successively increasing the sum of the 

312 # absolute values of the components of the reciprocal 

313 # supercell vectors, and we stop when we have 

314 # found the correct number of k-points. 

315 component_sum = 0 

316 while covered_nkpoints < nkpoints: 316 ↛ exitline 316 didn't return from function '_generate_k_points', because the condition on line 316 was never false

317 component_sum += 1 

318 for ijk_p in _ordered_combinations(component_sum, 3): 

319 for signs in itertools.product([-1, 1], repeat=3): 

320 if np.any([ijk_p[i] == 0 and signs[i] < 0 for i in range(3)]): 

321 continue 

322 q_sc = np.multiply(ijk_p, signs) 

323 k = np.dot(reciprocal_supercell, q_sc) 

324 

325 # Now check whether we can come closer to the gamma point 

326 # by translation with primitive reciprocal lattice vectors. 

327 # In other words, we translate the point to the 1BZ. 

328 k = _translate_to_1BZ(k, reciprocal_primitive, tol=tol) 

329 equivalent_kpoints = _find_equivalent_kpoints(k, reciprocal_primitive, tol=tol) 

330 

331 # Have we already found this k-point? 

332 found = False 

333 for k in equivalent_kpoints: 

334 for k_comp in found_kpoints: 

335 if np.linalg.norm(k - k_comp) < tol: 

336 # Then we had already found it 

337 found = True 

338 break 

339 if found: 

340 break 

341 else: 

342 # Then this is a new k-point 

343 # Yield it and its equivalent friends 

344 covered_nkpoints += 1 

345 for k in equivalent_kpoints: 

346 found_kpoints.append(k) 

347 yield k, 1 / len(equivalent_kpoints) 

348 

349 # Break if we have found all k-points 

350 assert covered_nkpoints <= nkpoints 

351 if covered_nkpoints == nkpoints: 

352 break 

353 

354 

355def _ordered_combinations(s: int, n: int) -> List[int]: 

356 """ 

357 Recursively generate all combinations of n integers 

358 that sum to s. 

359 

360 Parameters 

361 ---------- 

362 s 

363 Required sum of the combination 

364 n 

365 Number of intergers in the list 

366 """ 

367 if n == 1: 

368 yield [s] 

369 else: 

370 for i in range(s + 1): 

371 for rest in _ordered_combinations(s - i, n - 1): 

372 rest.append(i) 

373 yield rest 

374 

375 

376def _translate_to_1BZ(kpt: np.ndarray, primitive: np.ndarray, tol: float) -> np.ndarray: 

377 """ 

378 Translate k-point into 1BZ by translating it by 

379 primitive lattice vectors and testing if that takes 

380 us closer to gamma. The algorithm works by recursion 

381 such that we keep translating until we no longer get 

382 closer (will this always work?). 

383 

384 Parameters 

385 ---------- 

386 kpt 

387 coordinates of k-point that we translate 

388 primitive 

389 primitive reciprocal cell with lattice vectors as 

390 column vectors 

391 tol 

392 numerical tolerance when comparing k-points (units of 

393 inverse Ångstrom) 

394 

395 Returns 

396 ------- 

397 the k-point translated into 1BZ 

398 """ 

399 original_distance_from_gamma = np.linalg.norm(kpt) 

400 min_distance_from_gamma = original_distance_from_gamma 

401 best_kpt = kpt 

402 # Loop through translations and see if any takes us closer to gamma 

403 for translation in _generate_primitive_translations(primitive): 

404 new_kpt = kpt + translation 

405 new_distance_from_gamma = np.linalg.norm(new_kpt) 

406 if new_distance_from_gamma < min_distance_from_gamma: 

407 min_distance_from_gamma = new_distance_from_gamma 

408 best_kpt = new_kpt 

409 if abs(original_distance_from_gamma - min_distance_from_gamma) < tol: 

410 # Then we did not come closer to gamma, so we are done 

411 return best_kpt 

412 else: 

413 # We came closer to gamma. Maybe we can come even closer? 

414 return _translate_to_1BZ(best_kpt, primitive, tol=tol) 

415 

416 

417def _find_equivalent_kpoints(kpt: np.ndarray, 

418 primitive: np.ndarray, 

419 tol: float) -> List[np.ndarray]: 

420 """ 

421 Find k-points in 1BZ equivalent to this k-point, i.e., 

422 points in the 1BZ related by a primitive translation 

423 (for example, if a k-point lies at the edge of the 1BZ, we 

424 will find it on the opposite side of the 1BZ too). 

425 

426 Parameters 

427 ---------- 

428 kpt 

429 k-point coordinates 

430 primitive 

431 primitive reciprocal lattice vectors as columns 

432 tol 

433 numerical tolerance when comparing k-points (units of 

434 inverse Ångstrom) 

435 

436 Returns 

437 ------- 

438 list of equivalent k-points 

439 """ 

440 original_norm = np.linalg.norm(kpt) 

441 equivalent_kpoints = [kpt] 

442 for translation in _generate_primitive_translations(primitive): 

443 if abs(original_norm - np.linalg.norm(kpt + translation)) < tol: 

444 equivalent_kpoints.append(kpt + translation) 

445 return equivalent_kpoints 

446 

447 

448def _generate_primitive_translations(primitive: np.ndarray) -> np.ndarray: 

449 """ 

450 Generate the 26 (3x3x3 - 1) primitive translation vectors 

451 defined by (0/+1/-1, 0/+1/-1, 0/+1/-1) (exlcluding (0, 0, 0)) 

452 

453 Parameters 

454 ---------- 

455 primitive 

456 Lattice vectors as columns 

457 

458 Yields 

459 ------ 

460 traslation vector, a multiple of primitive lattice vectors 

461 """ 

462 for ijk in itertools.product((0, 1, -1), repeat=3): 

463 if ijk == (0, 0, 0): 

464 continue 

465 yield np.dot(primitive, ijk)