Coverage for calorine/tools/structures.py: 100%

80 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-18 13:01 +0000

1from typing import List 

2import numpy as np 

3from ase import Atoms 

4from ase.filters import FrechetCellFilter 

5from ase.optimize import BFGS, LBFGS, FIRE, GPMin 

6from ase.optimize.sciopt import SciPyFminBFGS 

7from ase.units import GPa 

8 

9try: 

10 import spglib 

11 from spglib._spglib import SpglibCppError 

12 spglib_available = True 

13except ImportError: # pragma: no cover 

14 spglib_available = False 

15 

16 

17def relax_structure(structure: Atoms, 

18 fmax: float = 0.001, 

19 steps: int = 500, 

20 minimizer: str = 'bfgs', 

21 constant_cell: bool = False, 

22 constant_volume: bool = False, 

23 scalar_pressure: float = 0.0, 

24 **kwargs) -> None: 

25 """Relaxes the given structure. 

26 

27 Parameters 

28 ---------- 

29 structure 

30 Atomic configuration to relax. 

31 fmax 

32 Stop relaxation if the absolute force for all atoms falls below this value. 

33 steps 

34 Maximum number of relaxation steps the minimizer is allowed to take. 

35 minimizer 

36 Minimizer to use; possible values: 'bfgs', 'lbfgs', 'fire', 'gpmin', 'bfgs-scipy'. 

37 constant_cell 

38 If True do not relax the cell or the volume. 

39 constant_volume 

40 If True relax the cell shape but keep the volume constant. 

41 kwargs 

42 Keyword arguments to be handed over to the minimizer; possible arguments can be found 

43 in the `ASE documentation <https://wiki.fysik.dtu.dk/ase/ase/optimize.html>`_ 

44 scalar_pressure 

45 External pressure in GPa. 

46 """ 

47 if structure.calc is None: 

48 raise ValueError('Structure has no attached calculator object') 

49 if constant_cell: 

50 ucf = structure 

51 else: 

52 ucf = FrechetCellFilter( 

53 structure, constant_volume=constant_volume, scalar_pressure=scalar_pressure * GPa) 

54 kwargs['logfile'] = kwargs.get('logfile', None) 

55 if minimizer == 'bfgs': 

56 dyn = BFGS(ucf, **kwargs) 

57 dyn.run(fmax=fmax, steps=steps) 

58 elif minimizer == 'lbfgs': 

59 dyn = LBFGS(ucf, **kwargs) 

60 dyn.run(fmax=fmax, steps=steps) 

61 elif minimizer == 'bfgs-scipy': 

62 dyn = SciPyFminBFGS(ucf, **kwargs) 

63 dyn.run(fmax=fmax, steps=steps) 

64 elif minimizer == 'fire': 

65 dyn = FIRE(ucf, **kwargs) 

66 dyn.run(fmax=fmax, steps=steps) 

67 elif minimizer == 'gpmin': 

68 dyn = GPMin(ucf, **kwargs) 

69 dyn.run(fmax=fmax, steps=steps) 

70 else: 

71 raise ValueError(f'Unknown minimizer: {minimizer}') 

72 

73 

74def get_spacegroup( 

75 structure: Atoms, 

76 symprec: float = 1e-5, 

77 angle_tolerance: float = -1.0, 

78 style: str = 'international', 

79) -> str: 

80 """Returns the space group of a structure using spglib. 

81 This is a convenience interface to the :func:`get_spacegroup` 

82 function of spglib that works directly with ase Atoms objects. 

83 

84 Parameters 

85 ---------- 

86 structure 

87 Input atomic structure. 

88 symprec 

89 Tolerance imposed when analyzing the symmetry. 

90 angle_tolerance 

91 Tolerance imposed when analyzing angles. 

92 style 

93 Space group notation to be used. Can be ``'international'`` for the 

94 interational tables of crystallography (ITC) style (Hermann-Mauguin 

95 and ITC number) or ``'Schoenflies'`` for the Schoenflies notation. 

96 """ 

97 if not spglib_available: 

98 raise ImportError('\ 

99 spglib must be available in order for this function to work.') # pragma: no cover 

100 

101 if style == 'international': 

102 symbol_type = 0 

103 elif style == 'Schoenflies': 

104 symbol_type = 1 

105 else: 

106 raise ValueError(f'Unknown value for style: {style}') 

107 

108 structure_tuple = ( 

109 structure.get_cell(), 

110 structure.get_scaled_positions(), 

111 structure.numbers) 

112 spg = spglib.get_spacegroup( 

113 structure_tuple, symprec=symprec, 

114 angle_tolerance=angle_tolerance, symbol_type=symbol_type) 

115 

116 return spg 

117 

118 

119def get_primitive_structure( 

120 structure: Atoms, 

121 no_idealize: bool = True, 

122 to_primitive: bool = True, 

123 symprec: float = 1e-5, 

124) -> Atoms: 

125 """Returns the primitive structure using spglib. 

126 This is a convenience interface to the :func:`standardize_cell` 

127 function of spglib that works directly with ase Atoms objects. 

128 

129 Parameters 

130 ---------- 

131 structure 

132 Input atomic structure. 

133 no_idealize 

134 If ``True`` lengths and angles are not idealized. 

135 to_primitive 

136 If ``True`` convert to primitive structure. 

137 symprec 

138 Tolerance imposed when analyzing the symmetry. 

139 """ 

140 if not spglib_available: 

141 raise ImportError('\ 

142 spglib must be available in order for this function to work.') # pragma: no cover 

143 

144 structure_tuple = ( 

145 structure.get_cell(), 

146 structure.get_scaled_positions(), 

147 structure.numbers) 

148 try: 

149 result = spglib.standardize_cell( 

150 structure_tuple, to_primitive=to_primitive, 

151 no_idealize=no_idealize, symprec=symprec) 

152 except SpglibCppError: 

153 result = None 

154 if result is None: 

155 raise ValueError('spglib failed to find the primitive cell, maybe caused by large symprec.') 

156 lattice, scaled_positions, numbers = result 

157 scaled_positions = [np.round(pos, 12) for pos in scaled_positions] 

158 structure_prim = Atoms(scaled_positions=scaled_positions, 

159 numbers=numbers, cell=lattice, pbc=True) 

160 structure_prim.wrap() 

161 

162 return structure_prim 

163 

164 

165def get_wyckoff_sites( 

166 structure: Atoms, 

167 map_occupations: List[List[str]] = None, 

168 symprec: float = 1e-5, 

169 include_representative_atom_index: bool = False, 

170) -> List[str]: 

171 """Returns the Wyckoff symbols of the input structure. 

172 The Wyckoff labels can be conveniently attached as an array to the 

173 structure object as demonstrated in the examples section below. 

174 

175 By default the occupation of the sites is part of the symmetry 

176 analysis. If a chemically disordered structure is provided this 

177 will usually reduce the symmetry substantially. If one is 

178 interested in the symmetry of the underlying structure one can 

179 control how occupations are handled. To this end, one can provide 

180 the :attr:`map_occupations` keyword argument. The latter must be a 

181 list, each entry of which is a list of species that should be 

182 treated as indistinguishable. As a shortcut, if *all* species 

183 should be treated as indistinguishable one can provide an empty 

184 list. Examples that illustrate the usage of the keyword are given 

185 below. 

186 

187 Parameters 

188 ---------- 

189 structure 

190 Input structure. Note that the occupation of the sites is 

191 included in the symmetry analysis. 

192 map_occupations 

193 Each sublist in this list specifies a group of chemical 

194 species that shall be treated as indistinguishable for the 

195 purpose of the symmetry analysis. 

196 symprec 

197 Tolerance imposed when analyzing the symmetry using spglib. 

198 include_representative_atom_index 

199 If True the index of the first atom in the structure that is 

200 representative of the Wyckoff site is included in the symbol. 

201 This is in particular useful in cases when there are multiple 

202 Wyckoff sites sites with the same Wyckoff letter. 

203 

204 Examples 

205 -------- 

206 Wyckoff sites of a hexagonal-close packed structure:: 

207 

208 >>> from ase.build import bulk 

209 >>> structure = bulk('Ti') 

210 >>> wyckoff_sites = get_wyckoff_sites(structure) 

211 >>> print(wyckoff_sites) 

212 ['2d', '2d'] 

213 

214 

215 The Wyckoff labels can also be attached as an array to the 

216 structure, in which case the information is also included when 

217 storing the Atoms object:: 

218 

219 >>> from ase.io import write 

220 >>> structure.new_array('wyckoff_sites', wyckoff_sites, str) 

221 >>> write('structure.xyz', structure) 

222 

223 The function can also be applied to supercells:: 

224 

225 >>> structure = bulk('GaAs', crystalstructure='zincblende', a=3.0).repeat(2) 

226 >>> wyckoff_sites = get_wyckoff_sites(structure) 

227 >>> print(wyckoff_sites) 

228 ['4a', '4c', '4a', '4c', '4a', '4c', '4a', '4c', 

229 '4a', '4c', '4a', '4c', '4a', '4c', '4a', '4c'] 

230 

231 Now assume that one is given a supercell of a (Ga,Al)As 

232 alloy. Applying the function directly yields much lower symmetry 

233 since the symmetry of the original structure is broken:: 

234 

235 >>> structure.set_chemical_symbols( 

236 ... ['Ga', 'As', 'Al', 'As', 'Ga', 'As', 'Al', 'As', 

237 ... 'Ga', 'As', 'Ga', 'As', 'Al', 'As', 'Ga', 'As']) 

238 >>> print(get_wyckoff_sites(structure)) 

239 ['8g', '8i', '4e', '8i', '8g', '8i', '2c', '8i', 

240 '2d', '8i', '8g', '8i', '4e', '8i', '8g', '8i'] 

241 

242 Since Ga and Al occupy the same sublattice, they should, however, 

243 be treated as indistinguishable for the purpose of the symmetry 

244 analysis, which can be achieved via the :attr:`map_occupations` 

245 keyword:: 

246 

247 >>> print(get_wyckoff_sites(structure, map_occupations=[['Ga', 'Al'], ['As']])) 

248 ['4a', '4c', '4a', '4c', '4a', '4c', '4a', '4c', 

249 '4a', '4c', '4a', '4c', '4a', '4c', '4a', '4c'] 

250 

251 If occupations are to ignored entirely, one can simply provide an 

252 empty list. In the present case, this turns the zincblende lattice 

253 into a diamond lattice, on which case there is only one Wyckoff 

254 site:: 

255 

256 >>> print(get_wyckoff_sites(structure, map_occupations=[])) 

257 ['8a', '8a', '8a', '8a', '8a', '8a', '8a', '8a', 

258 '8a', '8a', '8a', '8a', '8a', '8a', '8a', '8a'] 

259 """ 

260 if not spglib_available: 

261 raise ImportError('\ 

262 spglib must be available in order for this function to work.') # pragma: no cover 

263 

264 structure_copy = structure.copy() 

265 if map_occupations is not None: 

266 if len(map_occupations) > 0: 

267 new_symbols = [] 

268 for symb in structure_copy.get_chemical_symbols(): 

269 for group in map_occupations: # pragma: no cover - because of the break 

270 if symb in group: 

271 new_symbols.append(group[0]) 

272 break 

273 else: 

274 new_symbols = len(structure) * ['H'] 

275 structure_copy.set_chemical_symbols(new_symbols) 

276 structure_tuple = ( 

277 structure_copy.get_cell(), 

278 structure_copy.get_scaled_positions(), 

279 structure_copy.numbers) 

280 dataset = spglib.get_symmetry_dataset(structure_tuple, symprec=symprec) 

281 n_unitcells = np.linalg.det(dataset.transformation_matrix) 

282 

283 equivalent_atoms = list(dataset.equivalent_atoms) 

284 wyckoffs = {} 

285 for index in set(equivalent_atoms): 

286 multiplicity = list(dataset.equivalent_atoms).count(index) / n_unitcells 

287 multiplicity = int(round(multiplicity)) 

288 wyckoff = '{}{}'.format(multiplicity, dataset.wyckoffs[index]) 

289 if include_representative_atom_index: 

290 wyckoff += f'-{index}' 

291 wyckoffs[index] = wyckoff 

292 

293 return [wyckoffs[equivalent_atoms[a.index]] for a in structure_copy]