Coverage for mchammer/observers/structure_factor_observer.py: 100%

101 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2025-09-14 04:08 +0000

1from collections.abc import Sequence 

2import numpy as np 

3from ase import Atoms 

4from ase.data import chemical_symbols 

5from mchammer.observers.base_observer import BaseObserver 

6from icet.input_output.logging_tools import logger 

7logger = logger.getChild('structure_factor_observer') 

8 

9 

10class StructureFactorObserver(BaseObserver): 

11 r"""This class represents a structure factor observer. 

12 

13 This observer allows one to compute structure factors along the 

14 trajectory sampled by a Monte Carlo (MC) simulation. Structure 

15 factors are convenient for monitoring long-range order. The 

16 `structure factor <https://en.wikipedia.org/wiki/Structure_factor>`_ 

17 is defined as: 

18 

19 .. math:: 

20 

21 S(\vec{q}) = \frac{1}{\sum_{j=1}^N f_j^2} 

22 \sum_{j,k}^N e^{-i \vec{q} \cdot (\vec{R}_k - \vec{R}_j)} 

23 

24 In addition to this "total" structure factor, this observer 

25 calculates pair-specific structure factors, which correspond to parts 

26 of the summation defined above, with the summation restricted to pairs 

27 of specific types, e.g., Au-Au, Au-Cu and Cu-Cu in the example below. 

28 

29 Parameters 

30 ---------- 

31 structure 

32 Prototype for the structures for which the structure factor will 

33 be computed later. The supercell size (but not its decoration) 

34 must be identical. This structure is also used to determine the 

35 the possible pairs if :attr:`symbol_pairs=None`. 

36 q_points 

37 Array of q-points at which to evaluate the structure factor. 

38 The q-points need to be compatible with the supercell in order 

39 for the structure factor to be real. 

40 symbol_pairs 

41 List of symbol pairs for which structure factors will be computed, 

42 e.g., ``[('Al', 'Cu'), ('Al', 'Al')]``. If ``None`` (default) use all 

43 pairs possible based on the input structure. 

44 form_factors 

45 Form factors for each atom type. This can be used to 

46 (coarsely) simulate X-ray or neutron spectra. Note that in 

47 general the form factors are q-dependent, see, e.g., `here 

48 <https://wiki.fysik.dtu.dk/ase/ase/xrdebye.html>`__ and `here 

49 <https://wiki.fysik.dtu.dk/ase/ase/ 

50 xrdebye.html#ase.utils.xrdebye.XrDebye.get_waasmaier>`__. By 

51 default (``None``) all form factors are set to 1. 

52 interval 

53 Observation interval. Defaults to ``None`` meaning that if the 

54 observer is used in a Monte Carlo simulation, the :class:`Ensemble` object 

55 will determine the interval. 

56 

57 Raises 

58 ------ 

59 ValueError 

60 If q-point is not consistent with metric of input structure. 

61 

62 Example 

63 ------- 

64 The following snippet illustrates how to use the structure factor 

65 observer in a simulated annealing run of dummy Cu-Au model to 

66 observe the emergence of a long-range ordered :math:`L1_2` structure:: 

67 

68 >>> import numpy as np 

69 >>> from ase.build import bulk 

70 >>> from icet import ClusterSpace, ClusterExpansion 

71 >>> from mchammer.calculators import ClusterExpansionCalculator 

72 >>> from mchammer.ensembles import CanonicalAnnealing 

73 >>> from mchammer.observers import StructureFactorObserver 

74 

75 >>> # parameters 

76 >>> size = 3 

77 >>> alat = 4.0 

78 >>> symbols = ['Cu', 'Au'] 

79 

80 >>> # setup 

81 >>> prim = bulk('Cu', a=alat, cubic=True) 

82 >>> cs = ClusterSpace(prim, [0.9*alat], symbols) 

83 >>> ce = ClusterExpansion(cs, [0, 0, 0.2]) 

84 

85 >>> # make supercell 

86 >>> supercell = prim.repeat(size) 

87 >>> ns = int(0.25 * len(supercell)) 

88 >>> supercell.symbols[0:ns] = 'Au' 

89 >>> np.random.shuffle(supercell.symbols) 

90 

91 >>> # define q-points to sample 

92 >>> q_points = [] 

93 >>> q_points.append(2 * np.pi / alat * np.array([1, 0, 0])) 

94 >>> q_points.append(2 * np.pi / alat * np.array([0, 1, 0])) 

95 >>> q_points.append(2 * np.pi / alat * np.array([0, 0, 1])) 

96 

97 >>> # set up structure factor observer 

98 >>> sfo = StructureFactorObserver(supercell, q_points) 

99 

100 >>> # run simulation 

101 >>> calc = ClusterExpansionCalculator(supercell, ce) 

102 >>> mc = CanonicalAnnealing(supercell, calc, 

103 ... T_start=900, T_stop=500, cooling_function='linear', 

104 ... n_steps=400*len(supercell)) 

105 >>> mc.attach_observer(sfo) 

106 >>> mc.run() 

107 

108 After having run this snippet, one can access the structure factors via the data 

109 container:: 

110 

111 >>> dc = mc.data_container 

112 >>> print(dc.data) 

113 

114 The emergence of the ordered low-temperature structure can be monitored by 

115 following the temperature dependence of any of the pair-specific structure 

116 factors. 

117 

118 """ 

119 

120 def __init__(self, 

121 structure: Atoms, 

122 q_points: list[Sequence], 

123 symbol_pairs: list[tuple[str, str]] = None, 

124 form_factors: dict[str, float] = None, 

125 interval: int = None) -> None: 

126 super().__init__(interval=interval, return_type=dict, tag='StructureFactorObserver') 

127 

128 self._original_structure = structure.copy() 

129 self._q_points = q_points 

130 self._Sq_lookup = self._get_Sq_lookup(structure, q_points) 

131 

132 if symbol_pairs is not None: 

133 self._pairs = symbol_pairs 

134 else: 

135 self._pairs = [(e1, e2) 

136 for e1 in set(structure.symbols) 

137 for e2 in set(structure.symbols)] 

138 self._pairs = sorted(set([tuple(sorted(p)) for p in self._pairs])) 

139 if len(self._pairs) == 0: 

140 raise ValueError('The StructureFactorObserver requires ' 

141 'at least one pair to be defined') 

142 if len(self._pairs) == 1 and self._pairs[0][0] == self._pairs[0][1]: 

143 logger.warning(f'Only one pair requested {self._pairs[0]}; ' 

144 'use either a structure occupied by more than ' 

145 'one species or use the symbol_pairs argument ' 

146 'to specify more pairs.') 

147 

148 self._unique_symbols = set(sorted([s for p in self._pairs for s in p])) 

149 if form_factors is not None: 

150 self._form_factors = form_factors 

151 else: 

152 self._form_factors = {s: 1 for s in self._unique_symbols} 

153 self._form_factors = dict(sorted(self._form_factors.items())) 

154 

155 for symbol in self._unique_symbols: 

156 if symbol not in chemical_symbols: 

157 raise ValueError(f'Unknown species {symbol}') 

158 if symbol not in self._form_factors: 

159 raise ValueError(f'Form factor missing for {symbol}') 

160 

161 for symbol, ff in self._form_factors.items(): 

162 if ff == 0: 

163 raise ValueError(f'Form factor for {symbol} is zero') 

164 

165 def _get_Sq_lookup(self, 

166 structure: Atoms, 

167 q_points: list[Sequence]) -> np.ndarray: 

168 """ Get S(q) lookup data for a given supercell and q-points. """ 

169 n_atoms = len(structure) 

170 Sq_lookup = np.zeros((n_atoms, n_atoms, len(self._q_points)), dtype=np.complex128) 

171 for i in range(n_atoms): 

172 inds = [f for f in range(i, n_atoms)] 

173 dist_vectors = structure.get_distances(i, inds, mic=True, vector=True) 

174 for j in range(i, n_atoms): 

175 Rij = dist_vectors[j - i] 

176 Sq_lookup[i, j, :] = np.exp(-1j * np.dot(q_points, Rij)) 

177 Sq_lookup[j, i, :] = np.exp(-1j * np.dot(q_points, -Rij)) 

178 return Sq_lookup 

179 

180 def _get_indices(self, 

181 structure: Atoms) -> dict[str, list[int]]: 

182 """Returns the indices of all atoms by type considering all unique 

183 atom types in the structure used to instantiate the observer object. 

184 """ 

185 indices = dict() 

186 symbols = structure.get_chemical_symbols() 

187 for symbol in self._unique_symbols: 

188 indices[symbol] = np.where(np.array(symbols) == symbol)[0] 

189 return indices 

190 

191 def _compute_structure_factor(self, 

192 structure: Atoms) -> dict[tuple[str, str], float]: 

193 indices = self._get_indices(structure) 

194 norm = np.sum([self._form_factors[sym] ** 2 * len(lst) for sym, lst in indices.items()]) 

195 if norm <= 0: 

196 prefactor = 0 # occurs if none of the specified atom types are present in the structure 

197 else: 

198 prefactor = 1 / norm 

199 

200 Sq_dict = dict() 

201 for sym1, sym2 in self._pairs: 

202 inds1 = indices[sym1] 

203 inds2 = indices[sym2] 

204 norm = prefactor * self._form_factors[sym1] * self._form_factors[sym2] 

205 Sq = np.zeros(len(self._q_points), dtype=np.complex128) 

206 if len(inds1) != 0 and len(inds2) != 0: 

207 for i in inds1: 

208 Sq += self._Sq_lookup[i, inds2].sum(axis=0) * norm 

209 if sym1 == sym2: 

210 # the imaginary part must be zero because both ij and ji appear in the sum 

211 Sq_dict[(sym1, sym2)] = Sq.real 

212 else: 

213 # since we only consider A-B (not B-A; see __init__) we explicitly add ij and ji, 

214 # which comes down to adding the complex conjugate 

215 Sq_dict[(sym1, sym2)] = np.real(Sq + Sq.conj()) 

216 return Sq_dict 

217 

218 def get_observable(self, 

219 structure: Atoms) -> dict[str, float]: 

220 """Returns the structure factors for a given atomic configuration. 

221 

222 Parameters 

223 ---------- 

224 structure 

225 Input atomic structure. 

226 

227 Raises 

228 ------ 

229 ValueError 

230 If the input structure is incompatible with structure used 

231 for initialization of the :class:`StructureFactorObserver`. 

232 """ 

233 if len(structure) != len(self._original_structure): 

234 raise ValueError('Input structure incompatible with structure used for initialization\n' 

235 f' n_input: {len(structure)}\n' 

236 f' n_original: {len(self._original_structure)}') 

237 Sq_dict = self._compute_structure_factor(structure) 

238 return_dict = dict() 

239 for pair, Sq in Sq_dict.items(): 

240 for i in range(len(Sq)): 

241 tag = 'sfo_{}_{}_q{}'.format(*pair, i) 

242 return_dict[tag] = Sq[i] 

243 return_dict[f'total_q{i}'] = return_dict.get(f'total_q{i}', 0) + Sq[i] 

244 return_dict = dict(sorted(return_dict.items())) 

245 return return_dict 

246 

247 @property 

248 def form_factors(self) -> dict[str, float]: 

249 """ Form factors used in structure factor calculation. """ 

250 return self._form_factors.copy() 

251 

252 @property 

253 def q_points(self) -> list[np.ndarray]: 

254 """ q-points for which structure factor is calculated. """ 

255 return self._q_points.copy() 

256 

257 def __str__(self) -> str: 

258 """ String representation of observer object. """ 

259 width = 60 

260 name = self.__class__.__name__ 

261 s = [' {} '.format(name).center(width, '=')] 

262 

263 fmt = '{:15} : {}' 

264 s += [fmt.format('tag', self.tag)] 

265 s += [fmt.format('return_type', self.return_type)] 

266 s += [fmt.format('interval', self.interval)] 

267 s += [fmt.format('pairs', self._pairs)] 

268 s += [fmt.format('form_factors', self.form_factors)] 

269 for k, qpt in enumerate(self.q_points): 

270 s += [fmt.format(f'q-point{k}', qpt)] 

271 return '\n'.join(s)