Coverage for dynasor/sample.py: 100%

173 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-03 19:46 +0000

1import getpass 

2import socket 

3from copy import deepcopy 

4from datetime import datetime 

5from typing import Any, Optional 

6import numpy as np 

7from dynasor.logging_tools import logger 

8from numpy.typing import NDArray 

9from pandas import DataFrame 

10 

11 

12class Sample: 

13 """ 

14 Class for holding correlation functions and additional metadata. 

15 Objects of this class are most commonly generated by calling functions such 

16 as :func:`compute_static_structure_factors 

17 <dynasor.compute_static_structure_factors>` or 

18 :func:`compute_dynamic_structure_factors 

19 <dynasor.compute_dynamic_structure_factors>`. 

20 They can then be written to and subsequently read from file. 

21 

22 You can see which correlation functions are available via the 

23 :attr:`available_correlation_functions` property. 

24 You can then access the correlation functions either by key or as property. 

25 For example, you could access the static structure factor in the following ways:: 

26 

27 sample.Sq # as property 

28 sample['Sq'] # via key 

29 

30 The correlation functions are provided as numpy arrays. 

31 

32 There are several additional fields, the availability of which depends on the 

33 type of correlation function that was sampled. Static samples, for example, do 

34 not contain the `time` and `omega` fields. `q_norms` is only available if spherical 

35 averaging was carried out, typically via :func:`get_spherically_averaged_sample_smearing 

36 <dynasor.post_processing.get_spherically_averaged_sample_smearing>`. 

37 

38 * `q_points`: list of q-point coordinates 

39 * `q_norms`: norms of the momentum vector 

40 * `time`: time 

41 * `omega`: frequency 

42 

43 You can also see which fields are available by "printing" the :class:`Sample` object. 

44 

45 Parameters 

46 ---------- 

47 data_dict 

48 Dictionary with correlation functions. 

49 simulation_data 

50 Dictionary with simulation data. The following fields are strongly encouraged 

51 (but not enforced): `atom_types`, `cell`, `particle_counts`. 

52 history 

53 Previous history of operations on :class:`Sample` object. 

54 """ 

55 

56 def __init__( 

57 self, 

58 data_dict: dict[str, Any], 

59 simulation_data: dict[str, Any], 

60 history: Optional[list[dict[str, Any]]] = None, 

61 ): 

62 reserved_keys = set(dir(type(self))) | {'_data_keys', '_metadata'} 

63 colliding_keys = sorted(reserved_keys.intersection(data_dict)) 

64 if colliding_keys: 

65 raise ValueError(f'data_dict contains reserved Sample field names: {colliding_keys}') 

66 

67 # set data dict as attributes 

68 self._data_keys = list(data_dict) 

69 for key in data_dict: 

70 setattr(self, key, data_dict[key]) 

71 

72 # set metadata 

73 # (using deepcopy here to avoid accidental transfer by reference) 

74 self._metadata = dict() 

75 self._metadata['simulation_data'] = deepcopy(simulation_data) 

76 if history is not None: 

77 logger.debug('Copying history') 

78 self._metadata['history'] = deepcopy(history) 

79 else: 

80 self._metadata['history'] = [] 

81 

82 def _append_history( 

83 self, 

84 calling_function: str, 

85 caller_metadata: Optional[dict[str, Any]] = None, 

86 ): 

87 """Add record to history. 

88 

89 Parameters 

90 ---------- 

91 calling_function 

92 Name of calling function. 

93 caller_metadata 

94 Metadata associated with the calling function. 

95 """ 

96 from dynasor import __version__ as dynasor_version 

97 

98 new_record = dict(func=calling_function) 

99 if caller_metadata is not None: 

100 new_record.update(caller_metadata.copy()) 

101 new_record.update(dict( 

102 date_time=datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), 

103 username=getpass.getuser(), 

104 hostname=socket.gethostname(), 

105 dynasor_version=dynasor_version, 

106 )) 

107 self._metadata['history'].append(new_record) 

108 

109 def __getitem__(self, key): 

110 """ Makes it possible to get the attributes using Sample['key'] """ 

111 try: 

112 return getattr(self, key) 

113 except AttributeError: 

114 raise KeyError(key) 

115 

116 def write_to_npz(self, fname: str): 

117 """ Write object to file in numpy npz format. 

118 

119 Parameters 

120 ---------- 

121 fname 

122 Name of the file in which to store the Sample object. 

123 """ 

124 data_to_save = dict(name=self.__class__.__name__) 

125 data_to_save['metadata'] = self._metadata 

126 data_dict = dict() 

127 for key in self._data_keys: 

128 data_dict[key] = getattr(self, key) 

129 data_to_save['data_dict'] = data_dict 

130 np.savez_compressed(fname, **data_to_save) 

131 

132 @property 

133 def available_correlation_functions(self) -> list[str]: 

134 """ All the available correlation functions in sample. """ 

135 keys_to_skip = set(['q_points', 'q_norms', 'time', 'omega']) 

136 return sorted(list(set(self._data_keys) - keys_to_skip)) 

137 

138 @property 

139 def dimensions(self) -> list[str]: 

140 r"""The dimensions for the samples, e.g., for :math:`S(q, \omega)` 

141 the dimensions would be the :math:`q` and :math:`\omega` axes. 

142 """ 

143 keys_to_skip = set(self.available_correlation_functions) 

144 return sorted(list(set(self._data_keys) - keys_to_skip)) 

145 

146 @property 

147 def metadata(self) -> dict[str, Any]: 

148 """ Metadata. """ 

149 return deepcopy(self._metadata) 

150 

151 @property 

152 def simulation_data(self) -> dict[str, Any]: 

153 """ Simulation data. """ 

154 return deepcopy(self._metadata['simulation_data']) 

155 

156 @property 

157 def history(self) -> list[dict[str, Any]]: 

158 """ List of operations applied to this :class:`Sample` object. """ 

159 return deepcopy(self._metadata['history']) 

160 

161 @property 

162 def atom_types(self) -> list[str]: 

163 """ Atom types of the simulated system. """ 

164 return self.simulation_data['atom_types'].copy() \ 

165 if 'atom_types' in self.simulation_data else None 

166 

167 @property 

168 def particle_counts(self) -> dict[str, int]: 

169 """ Number of particles per atom type in the simulated system. """ 

170 return self.simulation_data['particle_counts'].copy() \ 

171 if 'particle_counts' in self.simulation_data else None 

172 

173 @property 

174 def pairs(self) -> list[tuple[str, str]]: 

175 """ Pairs of types for which correlation functions are available. """ 

176 return self.simulation_data['pairs'].copy() \ 

177 if 'pairs' in self.simulation_data else None 

178 

179 @property 

180 def cell(self) -> NDArray[float]: 

181 """ Cell metric of the simulated system. """ 

182 return self.simulation_data['cell'].copy() \ 

183 if 'cell' in self.simulation_data else None 

184 

185 @property 

186 def has_incoherent(self) -> bool: 

187 """ Whether this sample contains the incoherent correlation functions or not. """ 

188 return False 

189 

190 @property 

191 def has_currents(self) -> bool: 

192 """ Whether this sample contains the current correlation functions or not. """ 

193 return False 

194 

195 def __repr__(self) -> str: 

196 return str(self) 

197 

198 _special_fields = dict( 

199 atom_types='Atom types', 

200 cell='Cell', 

201 number_of_frames='Number of frames', 

202 particle_counts='Particle counts', 

203 maximum_time_lag='Maximum time lag', 

204 angular_frequency_resolution='Angular frequency resolution', 

205 time_between_frames='Time between frames', 

206 ) 

207 

208 def __str__(self) -> str: 

209 s_contents = [self.__class__.__name__] 

210 for key, value in sorted(self.simulation_data.items()): 

211 s_contents.append(f'{self._special_fields.get(key, key)}: {value}') 

212 for key in self.dimensions: 

213 s_i = f'{key:15} with shape: {np.shape(getattr(self, key))}' 

214 s_contents.append(s_i) 

215 for key in self.available_correlation_functions: 

216 s_i = f'{key:15} with shape: {np.shape(getattr(self, key))}' 

217 s_contents.append(s_i) 

218 s = '\n'.join(s_contents) 

219 return s 

220 

221 def _repr_html_(self) -> str: 

222 s = [f'<h3>{self.__class__.__name__}</h3>'] 

223 

224 s += ['<h4>Simulation</h4>'] 

225 s += ['<table border="1" class="dataframe">'] 

226 s += ['<thead><tr>' 

227 '<th style="text-align: left">Name</th>' 

228 '<th>Content</th>' 

229 '</tr></thead>'] 

230 s += ['<tbody>'] 

231 for key, value in sorted(self.simulation_data.items()): 

232 if key not in self._special_fields: 

233 continue 

234 s += [f'<tr><td style="text-align: left;">{self._special_fields[key]}</td>' 

235 f'<td>{value}</td></tr>'] 

236 s += ['</tbody>'] 

237 s += ['</table>'] 

238 

239 s += ['<h4>Dimensions</h4>'] 

240 s += ['<table border="1" class="dataframe">'] 

241 s += ['<thead><tr>' 

242 '<th style="text-align: left">Field</th>' 

243 '<th>Size</th>' 

244 '</tr></thead>'] 

245 s += ['<tbody>'] 

246 for key in self.dimensions: 

247 s += [f'<tr><td style="text-align: left">{key}</td>' 

248 f'<td>{np.shape(getattr(self, key))}</td></tr>'] 

249 s += ['</tbody>'] 

250 s += ['</table>'] 

251 

252 s += ['<h4>History</h4>'] 

253 s += ['<table border="1" class="dataframe">'] 

254 s += ['<thead><tr>' 

255 '<th style="text-align: left">Function</th>' 

256 '<th style="text-align: left">Field</th>' 

257 '<th style="text-align: left">Content</th>' 

258 '</tr></thead>'] 

259 s += ['<tbody>'] 

260 for entry in self.history: 

261 title = entry.get('func', '') 

262 for key, value in entry.items(): 

263 if key == 'func': 

264 continue 

265 s += [f'<tr><td style="text-align: left">{title}</td>' 

266 f'<td style="text-align: left">{key}</td>' 

267 f'<td>{value}</td></tr>'] 

268 title = '' 

269 s += ['</tbody>'] 

270 s += ['</table>'] 

271 return '\n'.join(s) 

272 

273 

274class StaticSample(Sample): 

275 """ 

276 Class for holding static correlation functions and additional metadata. 

277 Objects of this class are most commonly generated by calling 

278 :func:`compute_static_structure_factors <dynasor.compute_static_structure_factors>`. 

279 They can then be written to and subsequently read from file. 

280 

281 You can see which correlation functions are available via the 

282 :attr:`available_correlation_functions` property. 

283 You can then access the correlation functions either by key or as property. 

284 For example, you could access the static structure factor :math:`S(q)` 

285 in the following ways:: 

286 

287 sample.Sq # as property 

288 sample['Sq'] # via key 

289 

290 The correlation functions are provided as numpy arrays. 

291 

292 There are several additional fields: 

293 

294 * `q_points`: list of q-point coordinates 

295 * `q_norms`: norms of the momentum vector (available when the :class:`StaticSample` 

296 object was generated via :func:`get_spherically_averaged_sample_smearing 

297 <dynasor.post_processing.get_spherically_averaged_sample_smearing>` or similar 

298 functions) 

299 

300 You can also see which fields are available by "printing" the :class:`StaticSample` object. 

301 

302 Parameters 

303 ---------- 

304 data_dict 

305 Dictionary with correlation functions. 

306 simulation_data 

307 Dictionary with simulation data. The following fields are strongly encouraged 

308 (but not enforced): `atom_types`, `cell`, `particle_counts`. 

309 history 

310 Previous history of operations on :class:`Sample` object. 

311 """ 

312 

313 def to_dataframe(self) -> DataFrame: 

314 """ Returns correlation functions as pandas dataframe """ 

315 df = DataFrame() 

316 for dim in self.dimensions: 

317 df[dim] = self[dim].tolist() # to list to make q-points (N, 3) work in dataframe 

318 for key in self.available_correlation_functions: 

319 df[key] = self[key].reshape(-1, ) 

320 return df 

321 

322 

323class DynamicSample(Sample): 

324 r""" 

325 Class for holding dynamic correlation functions and additional metadata. 

326 Objects of this class are most commonly generated by calling 

327 :func:`compute_dynamic_structure_factors <dynasor.compute_dynamic_structure_factors>`. 

328 They can then be written to and subsequently read from file. 

329 

330 You can see which correlation functions are available via the 

331 :attr:`available_correlation_functions` property. 

332 You can then access the correlation functions either by key or as property. 

333 For example, you could access the dynamic structure factor :math:`S(q,\omega)` 

334 in the following ways:: 

335 

336 sample.Sqw # as property 

337 sample['Sqw'] # via key 

338 

339 The correlation functions are provided as numpy arrays. 

340 

341 There are several additional fields, the availability of which depends on the 

342 type of correlation function that was sampled. 

343 

344 * `q_points`: list of q-point coordinates 

345 * `q_norms`: norms of the momentum vector (available when the :class:`DynamicSample` 

346 object was generated, e.g., via :func:`get_spherically_averaged_sample_smearing 

347 <dynasor.post_processing.get_spherically_averaged_sample_smearing>` or similar 

348 functions) 

349 * `time`: time 

350 * `omega`: frequency 

351 

352 You can also see which fields are available by "printing" the :class:`DynamicSample` object. 

353 

354 Parameters 

355 ---------- 

356 data_dict 

357 Dictionary with correlation functions. 

358 simulation_data 

359 Dictionary with simulation data. The following fields are strongly encouraged 

360 (but not enforced): `atom_types`, `cell`, `particle_counts`. 

361 history 

362 Previous history of operations on :class:`Sample` object. 

363 """ 

364 

365 @property 

366 def has_incoherent(self) -> bool: 

367 return 'Fqt_incoh' in self.available_correlation_functions 

368 

369 @property 

370 def has_currents(self) -> bool: 

371 pair_string = '_'.join(self.pairs[0]) 

372 return f'Clqt_{pair_string}' in self.available_correlation_functions 

373 

374 def to_dataframe(self, q_index: int) -> DataFrame: 

375 """ Returns correlation functions as pandas dataframe for the given q-index. 

376 

377 Parameters 

378 ---------- 

379 q_index 

380 Index of q-point to return. 

381 """ 

382 df = DataFrame() 

383 for dim in self.dimensions: 

384 if dim in ['q_points', 'q_norms']: 

385 continue 

386 df[dim] = self[dim] 

387 for key in self.available_correlation_functions: 

388 df[key] = self[key][q_index] 

389 return df 

390 

391 

392def read_sample_from_npz(fname: str) -> Sample: 

393 """ Read :class:`Sample <dynasor.sample.Sample>` from file. 

394 

395 Parameters 

396 ---------- 

397 fname 

398 Path to the file (numpy npz format) from which to read 

399 the :class:`Sample <dynasor.sample.Sample>` object. 

400 """ 

401 with np.load(fname, allow_pickle=True) as data_read: 

402 try: 

403 metadata = data_read['metadata'].item() 

404 except KeyError: 

405 # fallback for versions<=2.2 

406 metadata = data_read['meta_data'].item() 

407 data_dict = data_read['data_dict'].item() 

408 sample_name = data_read['name'].item() 

409 

410 if 'simulation_data' in metadata: 

411 logger.debug(f'Reading Sample object from {fname} assuming version >=2.3') 

412 simulation_data = metadata['simulation_data'] 

413 else: 

414 logger.debug(f'Reading Sample object from {fname} assuming version <=2.2') 

415 simulation_data = {} 

416 for key in [ 

417 'atom_types', 'pairs', 'particle_counts', 'cell', 

418 'time_between_frames', 'maximum_time_lag', 'angular_frequency_resolution', 

419 'maximum_angular_frequency', 'number_of_frames', 

420 ]: 

421 if key in metadata: 

422 simulation_data[key] = metadata[key] 

423 

424 history = metadata['history'] if 'history' in metadata else None 

425 

426 if sample_name == 'StaticSample': 

427 return StaticSample(data_dict, simulation_data, history=history) 

428 elif sample_name == 'DynamicSample': 

429 return DynamicSample(data_dict, simulation_data, history=history) 

430 else: 

431 return Sample(data_dict, simulation_data, history=history)