ncrystal-python 3.9.81__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. NCrystal/__init__.py +85 -0
  2. NCrystal/__main__.py +98 -0
  3. NCrystal/_chooks.py +854 -0
  4. NCrystal/_cli_cif2ncmat.py +269 -0
  5. NCrystal/_cli_endf2ncmat.py +503 -0
  6. NCrystal/_cli_hfg2ncmat.py +144 -0
  7. NCrystal/_cli_mcstasunion.py +74 -0
  8. NCrystal/_cli_ncmat2cpp.py +31 -0
  9. NCrystal/_cli_ncmat2hkl.py +180 -0
  10. NCrystal/_cli_nctool.py +1018 -0
  11. NCrystal/_cli_vdos2ncmat.py +463 -0
  12. NCrystal/_cli_verifyatompos.py +257 -0
  13. NCrystal/_cliimpl.py +307 -0
  14. NCrystal/_cliwrap_config.py +36 -0
  15. NCrystal/_common.py +499 -0
  16. NCrystal/_coreimpl.py +114 -0
  17. NCrystal/_hfgdata.py +546 -0
  18. NCrystal/_hklobjects.py +136 -0
  19. NCrystal/_is_std.py +0 -0
  20. NCrystal/_locatelib.py +210 -0
  21. NCrystal/_miscimpl.py +354 -0
  22. NCrystal/_mmc.py +757 -0
  23. NCrystal/_msg.py +60 -0
  24. NCrystal/_ncmat2cpp_impl.py +445 -0
  25. NCrystal/_ncmatimpl.py +2131 -0
  26. NCrystal/_numpy.py +76 -0
  27. NCrystal/_testimpl.py +579 -0
  28. NCrystal/api.py +56 -0
  29. NCrystal/atomdata.py +177 -0
  30. NCrystal/cfgstr.py +77 -0
  31. NCrystal/cifutils.py +1795 -0
  32. NCrystal/cli.py +96 -0
  33. NCrystal/constants.py +134 -0
  34. NCrystal/core.py +1910 -0
  35. NCrystal/datasrc.py +226 -0
  36. NCrystal/exceptions.py +66 -0
  37. NCrystal/hfg2ncmat.py +270 -0
  38. NCrystal/mcstasutils.py +438 -0
  39. NCrystal/misc.py +317 -0
  40. NCrystal/mmc.py +35 -0
  41. NCrystal/ncmat.py +778 -0
  42. NCrystal/ncmat2cpp.py +80 -0
  43. NCrystal/obsolete.py +67 -0
  44. NCrystal/plot.py +484 -0
  45. NCrystal/plugins.py +49 -0
  46. NCrystal/test.py +76 -0
  47. NCrystal/vdos.py +1034 -0
  48. ncrystal_python-3.9.81.dist-info/LICENSE +206 -0
  49. ncrystal_python-3.9.81.dist-info/METADATA +515 -0
  50. ncrystal_python-3.9.81.dist-info/RECORD +53 -0
  51. ncrystal_python-3.9.81.dist-info/WHEEL +5 -0
  52. ncrystal_python-3.9.81.dist-info/entry_points.txt +10 -0
  53. ncrystal_python-3.9.81.dist-info/top_level.txt +1 -0
NCrystal/_ncmatimpl.py ADDED
@@ -0,0 +1,2131 @@
1
+
2
+ ################################################################################
3
+ ## ##
4
+ ## This file is part of NCrystal (see https://mctools.github.io/ncrystal/) ##
5
+ ## ##
6
+ ## Copyright 2015-2024 NCrystal developers ##
7
+ ## ##
8
+ ## Licensed under the Apache License, Version 2.0 (the "License"); ##
9
+ ## you may not use this file except in compliance with the License. ##
10
+ ## You may obtain a copy of the License at ##
11
+ ## ##
12
+ ## http://www.apache.org/licenses/LICENSE-2.0 ##
13
+ ## ##
14
+ ## Unless required by applicable law or agreed to in writing, software ##
15
+ ## distributed under the License is distributed on an "AS IS" BASIS, ##
16
+ ## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ##
17
+ ## See the License for the specific language governing permissions and ##
18
+ ## limitations under the License. ##
19
+ ## ##
20
+ ################################################################################
21
+
22
+ """
23
+
24
+ Internal implementation details for NCMAT utilities in ncmat.py
25
+
26
+ """
27
+
28
+ __all__ = []
29
+ from . import core as _nc_core
30
+ from . import _common as _nc_common
31
+ import math
32
+ import copy
33
+
34
+ _magic_two_space=b'\xc3\x98@'.decode('utf8')
35
+
36
+ class NCMATComposerImpl:
37
+
38
+ def set_plotlabel( self, lbl ):
39
+ if lbl != self.__params.get('plotlabel'):
40
+ self.__dirty()
41
+ self.__params['plotlabel'] = lbl
42
+
43
+ @property
44
+ def plotlabel( self ):
45
+ return self.__params.get('plotlabel')
46
+
47
+ def add_secondary_phase(self, fraction, cfgstr, normalise ):
48
+ if normalise:
49
+ from .cfgstr import normaliseCfg
50
+ c = normaliseCfg( cfgstr )
51
+ else:
52
+ c = ' '.join(str(cfgstr).split())
53
+ if not c:
54
+ raise _nc_core.NCBadInput('empty cfg-string used to define secondary phase')
55
+ if '#' in c:
56
+ raise _nc_core.NCBadInput('cfg-strings used to define secondary phases in NCMAT data can not contain "#" characters')
57
+ if len(c.splitlines())>1 or '\n' in c or '\r' in c:
58
+ raise _nc_core.NCBadInput('cfg-strings used to define secondary phases in NCMAT data can not contain newline characters')
59
+ if not c.isascii():
60
+ raise _nc_core.NCBadInput('cfg-strings used to define secondary phases in NCMAT data must be pure ASCII.')
61
+ p = self.__params.get('secondary_phases',[])[:]
62
+ p.append( ( float(fraction), c) )
63
+ ftot = math.fsum([_f for _f,_ in p])
64
+ if ftot >= 1.0-1e-14:
65
+ raise _nc_core.NCBadInput('Secondary phases have too high a fraction (a total of {ftot*100.0}%%)')
66
+ self.__dirty()
67
+ self.__params['secondary_phases'] = p
68
+
69
+ def add_hard_sphere_sans_model( self, sphere_radius ):
70
+ assert sphere_radius > 0.0
71
+ self.__dirty()
72
+ self.__params['custom_hardspheresans'] = float( sphere_radius )
73
+
74
+ def add_raw_content( self, content ):
75
+ if not isinstance(content,str):
76
+ raise _nc_core.NCBadInput('Invalid raw content (must be a string)')
77
+ if not content:
78
+ return
79
+ self.__dirty()
80
+ if 'raw_content' in self.__params:
81
+ self.__params['raw_content'] += str(content)
82
+ else:
83
+ self.__params['raw_content'] = str(content)
84
+
85
+ def get_raw_content( self ):
86
+ return self.__params.get('raw_content','')
87
+
88
+ def clear_raw_content( self ):
89
+ if 'raw_content' in self.__params:
90
+ self.__dirty()
91
+ del self.__params['raw_content']
92
+
93
+ def get_custom_section_data( self, section_name ):
94
+ if section_name is None:
95
+ dd = self.__params.get('custom_sections')
96
+ return copy.deepcopy(dd) if dd else {}
97
+ _check_valid_custom_section_name(section_name)
98
+ if 'custom_sections' in self.__params:
99
+ return self.__params['custom_sections'].get(section_name)
100
+
101
+ def set_custom_section_data( self, section_name, content ):
102
+ if section_name == 'HARDSPHERESANS':
103
+ raise _nc_core.NCBadInput('For HARDSPHERESANS use the'
104
+ ' .add_hard_sphere_sans_model() method'
105
+ ' instead of set_custom_section_data.')
106
+ if section_name == 'UNOFFICIALHACKS':
107
+ raise _nc_core.NCBadInput('Do not set the @CUSTOM_UNOFFICIALHACKS'
108
+ ' content directly with '
109
+ 'set_custom_section_data.')
110
+ _check_valid_custom_section_name(section_name)
111
+ if not isinstance(content,str):
112
+ raise _nc_core.NCBadInput('Invalid custom section data'
113
+ ' content (must be a string)')
114
+ if self.__params.get('custom_sections',{}).get(section_name) == content:
115
+ return
116
+ self.__dirty()
117
+ if 'custom_sections' not in self.__params:
118
+ self.__params['custom_sections'] = {}
119
+ self.__params['custom_sections'][section_name] = content
120
+
121
+ def clear_custom_section_data( self, section_name ):
122
+ if section_name is not None:
123
+ _check_valid_custom_section_name(section_name)
124
+ if 'custom_sections' not in self.__params:
125
+ return
126
+ if section_name is None:
127
+ self.__dirty()
128
+ del self.__params['custom_sections']
129
+ else:
130
+ if section_name not in self.__params['custom_sections']:
131
+ return
132
+ self.__dirty()
133
+ del self.__params['custom_sections'][section_name]
134
+
135
+ def __init__(self, *, data, fmt, quiet ):
136
+ self._ichange = 0#increment on all changes, for downstream caching
137
+ self.__loadcache = None
138
+ self.__params = {}
139
+ assert not (fmt is not None and data is None)
140
+ if data is None:
141
+ return
142
+ if isinstance( data, NCMATComposerImpl ):
143
+ self.__params = data.clone().__params
144
+ return
145
+ if fmt == 'via_ase':
146
+ data= NCMATComposerImpl._ase_io_read( data, quiet = quiet )
147
+ fmt = 'ase'
148
+ if fmt is None and isinstance( data, _nc_core.TextData ) and data.dataType == 'ncmat':
149
+ fmt = 'ncmat'
150
+ if fmt is None:
151
+ if isinstance(data,dict) or ( hasattr(data,'keys') and hasattr(data,'get') ):
152
+ pass#Might be a dictionary from to_dict() but to be safe we do not autodetect this!
153
+ elif isinstance(data,_nc_core.Info):
154
+ fmt = 'info'
155
+ elif hasattr(data,'__fspath__'):
156
+ import pathlib
157
+ _ = pathlib.Path(data).name.lower()
158
+ if _.endswith('.ncmat'):
159
+ fmt='ncmat'
160
+ if _.endswith('.cif'):
161
+ fmt='cif'
162
+ elif hasattr(data,'startswith') and hasattr(data,'__contains__'):
163
+ if '\n' in data:
164
+ if data.startswith('NCMAT'):
165
+ fmt='ncmat'
166
+ elif 'loop_' in data and '_atom_site' in data:
167
+ fmt='cif'
168
+ elif data.startswith('codid::') or data.startswith('mpid::') or data.lower().endswith('.cif'):
169
+ fmt='cif'
170
+ elif data.lower().endswith('.ncmat'):
171
+ fmt='cfgstr'
172
+ else:
173
+ fmt='cfgstr'
174
+ elif NCMATComposerImpl._is_ase_like_object( data ):
175
+ fmt = 'ase'
176
+
177
+ if fmt=='__internal_state__':
178
+ self.__params = dict( copy.deepcopy( (k,v) ) for k,v in data.items() )
179
+ elif fmt=='cif':
180
+ o = NCMATComposerImpl.from_cif(data,quiet=quiet)
181
+ self.__params = o.__params
182
+ elif fmt=='ncmat':
183
+ o = NCMATComposerImpl.from_ncmat(data)
184
+ self.__params = o.__params
185
+ elif fmt=='cfgstr':
186
+ o = NCMATComposerImpl.from_cfgstr(data)
187
+ self.__params = o.__params
188
+ elif fmt=='info':
189
+ o = NCMATComposerImpl.from_info(data)
190
+ self.__params = o.__params
191
+ elif fmt=='ase':
192
+ o = NCMATComposerImpl.from_ase(data,quiet=quiet)
193
+ self.__params = o.__params
194
+ else:
195
+ raise _nc_core.NCBadInput('Unknown data type (specify with the fmt keyword if this is just an auto-detection issue))')
196
+
197
+ @staticmethod
198
+ def from_cif( cifsrc, quiet, mp_apikey = None, uiso_temperature = None, override_spacegroup = None, **kwargs ):
199
+ from . import cifutils as nc_cifutils
200
+ lc = nc_cifutils.CIFLoader( cifsrc, quiet = quiet, mp_apikey = mp_apikey,
201
+ override_spacegroup = override_spacegroup )
202
+ return lc.create_ncmat_composer( quiet = quiet,
203
+ uiso_temperature = uiso_temperature,
204
+ **kwargs )._get_impl_obj()
205
+
206
+ @staticmethod
207
+ def from_cfgstr( cfgstr ):
208
+ info = _nc_core.createInfo( cfgstr )
209
+ return NCMATComposerImpl.from_info( info )
210
+
211
+ @staticmethod
212
+ def _is_ase_like_object( o ):
213
+ return all( hasattr( o, e ) for e in ( 'cell', 'get_scaled_positions', 'get_atomic_numbers' ) )
214
+
215
+ @staticmethod
216
+ def _ase_io_read( obj, quiet, fmt = None ):
217
+ if not quiet:
218
+ _nc_common.print('Trying to load object via ase.io.read')
219
+ ase, ase_io = _import_ase()
220
+ return ase_io.read( obj, format = fmt )
221
+
222
+ @staticmethod
223
+ def from_ase( ase_obj, quiet, ase_format = None, **kwargs ):
224
+ _nc_common.warn('Trying to load ASE object. Note that such objects might lack'
225
+ ' information including space group and atomic displacements.')
226
+ if not NCMATComposerImpl._is_ase_like_object( ase_obj ):
227
+ arg = _nc_common.extract_path( ase_obj )
228
+ if arg is None:
229
+ if ase_format is None:
230
+ _nc_common.warn('Anonymous text data might not be recognised by ASE.'
231
+ ' Consider specifying the format explicitly in this case (e.g. supply ase_format="cif").')
232
+ from .misc import AnyTextData
233
+ td = AnyTextData(ase_obj)
234
+ import io
235
+ with io.BytesIO(td.content.encode()) as memfile:
236
+ ase_obj = NCMATComposerImpl._ase_io_read( memfile, quiet = quiet, fmt = ase_format )
237
+ else:
238
+ ase_obj = NCMATComposerImpl._ase_io_read( arg, quiet = quiet, fmt = ase_format )
239
+
240
+ assert NCMATComposerImpl._is_ase_like_object( ase_obj )
241
+
242
+ # Note1: We support this through CIF, since site occupancies are
243
+ # currently not available in a standard way through the OO interface
244
+ # (cf. https://gitlab.com/ase/ase/-/issues/263).
245
+ #
246
+ # Note2: Atomic displacement info seems to be ignored.
247
+ #
248
+ # Note3: D is detected as H (cf. https://matsci.org/t/how-to-load-structures-including-deuterium-with-ase/41950/2).
249
+ cifdata = _cifdata_via_ase( ase_obj, ase_format = 'ase', quiet = quiet )
250
+ if 'no_formula_check' not in kwargs:
251
+ #ASE cif writer puts formula which does not take site occupancies into account:
252
+ kwargs['no_formula_check'] = True
253
+ o = NCMATComposerImpl.from_cif( cifsrc = cifdata, quiet=quiet, **kwargs )
254
+ o.add_comments('NOTICE: Data here is based on an ASE (Atoms)'
255
+ ' structure through a conversion to CIF format.',
256
+ add_empty_line_divider=True)
257
+ return o
258
+
259
+ @staticmethod
260
+ def from_info( info_obj ):
261
+ return _composerimpl_from_info( info_obj )
262
+
263
+ @staticmethod
264
+ def from_ncmat( data, keep_header = False ):
265
+ from .misc import AnyTextData
266
+ td = AnyTextData(data)
267
+ if '\n' not in td.content and not td.content.startswith('NCMAT'):
268
+ return NCMATComposerImpl.from_cfgstr( td.content )
269
+ o = _nc_core.directLoad( td, doScatter = False, doAbsorption = False )
270
+ c = NCMATComposerImpl.from_info( o.info )
271
+ comments = None
272
+ if keep_header:
273
+ comments = _extractInitialHeaderCommentsFromNCMATData(td)
274
+ if comments:
275
+ _hr='---------------------------------------------------------'
276
+ comments = [
277
+ '>'*70,
278
+ 'Comments found in NCMAT data from which the NCMATComposer',
279
+ 'was initialised. Subsequent updates to the NCMATComposer',
280
+ 'object might have invalidated some or all of these:'
281
+ ]+ list('>>> '+e for e in comments) + ['<'*70]
282
+ if comments:
283
+ c.add_comments( comments, add_empty_line_divider = True )
284
+ return c
285
+
286
+ def update_atomdb( self, element_or_isotope, data, *, mass=None,
287
+ coh_scat_len=None, incoh_xs=None, abs_xs=None ):
288
+ label,res=_decode_update_atomdb( element_or_isotope,
289
+ data=data,mass=mass,
290
+ coh_scat_len=coh_scat_len,
291
+ incoh_xs=incoh_xs,
292
+ abs_xs=abs_xs )
293
+ self.__dirty()
294
+ if 'atomdb' not in self.__params:
295
+ self.__params['atomdb'] = {}
296
+ self.__params['atomdb'][label] = res
297
+
298
+ def find_label( self, element, allow_multi ):
299
+ import numbers
300
+ from .atomdata import elementNameToZValue
301
+ def _name2z( name ):
302
+ return elementNameToZValue( name, allow_isotopes = True ) or None
303
+ if isinstance(element,numbers.Integral):
304
+ search_Z = int( element )
305
+ else:
306
+ search_Z = _name2z( element ) or 0
307
+ if not 1<=search_Z<=150:
308
+ return [] if allow_multi else None
309
+
310
+ compos = self.__params.get('compositions',{})
311
+ def lbl_zval_iter( lbl ):
312
+ c = compos.get(lbl)
313
+ for _,name in ( c or [(None,lbl)] ):
314
+ z = _name2z(name)
315
+ if z:
316
+ yield z
317
+ ll = []
318
+ for lbl in self.get_labels():
319
+ if any( search_Z == z for z in lbl_zval_iter(lbl) ):
320
+ ll.append( lbl )
321
+
322
+ return ( list(sorted(ll))
323
+ if allow_multi
324
+ else ( ll[0] if len(ll)==1 else None ) )
325
+
326
+ def get_state_of_matter( self ):
327
+ return self.__params.get('state_of_matter')
328
+
329
+ def set_state_of_matter( self, state_of_matter ):
330
+ if not state_of_matter:
331
+ state_of_matter = None
332
+ allowed = ['solid','liquid','gas']
333
+ if state_of_matter not in allowed and state_of_matter is not None:
334
+ s='", "'.join(allowed)
335
+ raise _nc_core.NCBadInput(f'Invalid state of matter value "{state_of_matter}" (allowed: "{s}")')
336
+ if state_of_matter is None:
337
+ if 'state_of_matter' not in self.__params:
338
+ return
339
+ self.__dirty()
340
+ del self.__params['state_of_matter']
341
+ return
342
+
343
+ if self.__params.get('state_of_matter') == state_of_matter:
344
+ return
345
+ self.__dirty()
346
+ self.__params['state_of_matter'] = state_of_matter
347
+
348
+ def lock_temperature( self, value ):
349
+ if value is None:
350
+ if 'temperature' not in self.__params:
351
+ return
352
+ self.__params.pop('temperature',None)
353
+ self.__dirty()
354
+ return
355
+ v=float(value)
356
+ assert v>0.0
357
+ self.__dirty()
358
+ self.__params['temperature'] = { 'value' : v, 'lock' : True }
359
+
360
+ def set_default_temperature( self, value ):
361
+ if value is None:
362
+ self.__params.pop('temperature',None)
363
+ return
364
+ v=float(value)
365
+ assert v>0.0
366
+ self.__dirty()
367
+ self.__params['temperature'] = { 'value' : v, 'lock' : False }
368
+
369
+ def get_temperature_setting( self ):
370
+ _ = self.__params.get('temperature')
371
+ return (_['value'],_['lock']) if _ else (None,None)
372
+
373
+ def set_composition( self, label, *composition ):
374
+ label, composition = _decode_composition(label,*composition)
375
+ self.__dirty()
376
+ if 'compositions' not in self.__params:
377
+ self.__params['compositions'] = {}
378
+ self.__params['compositions'][ label ] = composition
379
+
380
+ def remap_atom( self, element_or_isotope, *composition ):
381
+ elem = _nc_common.check_elem_or_isotope_marker( element_or_isotope )
382
+ if not elem:
383
+ raise _nc_core.NCBadInput('Invalid element/isotope marker: "%s"'%element_or_isotope)
384
+
385
+ elem, composition = _decode_composition(elem,*composition)
386
+ compos = self.__params.get('compositions',{})
387
+ set_compos_args = []
388
+ for lbl in self.get_labels():
389
+ lblcompos = compos.get(lbl,None)
390
+ if lblcompos:
391
+ frac_elem = sum( fr for fr,en in lblcompos if elem==en )
392
+ if frac_elem > 0.0:
393
+ #update existing composition:
394
+ _ = [ (fr,en) for fr,en in lblcompos if elem!=en ]
395
+ _ += [ (frac_elem*fr,en) for fr,en in composition ]
396
+ set_compos_args.append( (lbl,_) )
397
+ else:
398
+ if lbl == elem:
399
+ set_compos_args.append( (lbl,composition) )
400
+ for lbl, _compos in set_compos_args:
401
+ self.set_composition( lbl, _compos )
402
+
403
+ def clear_comments( self ):
404
+ self.__dirty()#not strictly needed but to be safe
405
+ self.__params['top_comments'] = []
406
+
407
+ def add_comments( self, comments, add_empty_line_divider ):
408
+ is_str = isinstance(comments,str) or hasattr(comments,'split')
409
+ ll = comments if not is_str else list(comments.splitlines())
410
+
411
+ self.__dirty()#not strictly needed as comments do not affect loading
412
+
413
+ if 'top_comments' not in self.__params:
414
+ self.__params['top_comments'] = []
415
+ elif ( add_empty_line_divider
416
+ and self.__params['top_comments']
417
+ and self.__params['top_comments'][-1] ):
418
+ self.__params['top_comments'].append('')
419
+
420
+ for e in ll:
421
+ self.__params['top_comments'].append( self.__prunecomment(e) )
422
+
423
+ def __prunecomment(self,c):
424
+ return str(c).rstrip().replace('\t',' ')
425
+
426
+ def clone(self):
427
+ o = NCMATComposerImpl( data = self.__params, fmt = '__internal_state__', quiet = True )
428
+ o._ichange = self._ichange
429
+ o.__loadcache = self.__loadcache
430
+ return o
431
+
432
+ def __dirty(self):
433
+ self._ichange += 1
434
+ self.__loadcache = None
435
+
436
+ def get_cache_key( self ):
437
+ return ( int(id(self), self._ichange) )
438
+
439
+ def to_dict(self):
440
+ return copy.deepcopy(self.__params)
441
+
442
+ def set_cellsg( self, *, a,b,c, alpha,beta,gamma, spacegroup ):
443
+ #TODO: input validation! Also ensure high precision in numbers below
444
+ self.__dirty()
445
+ self.__params['cellsg'] = dict(a=a,b=b,c=c,alpha=alpha,beta=beta,gamma=gamma,spacegroup=spacegroup)
446
+
447
+ def set_cellsg_cubic( self, a, *, spacegroup ):
448
+ if not (spacegroup is None or 195<=int(spacegroup)<=230):
449
+ raise _nc_core.NCBadInput(f'Spacegroup {spacegroup} is not a cubic spacegroup (must be integer in range 195..230)')
450
+ self.set_cellsg( a=a,b=a,c=a,alpha=90.,beta=90.,gamma=90.,
451
+ spacegroup = ( int(spacegroup) if spacegroup else None ) )
452
+
453
+ def set_density( self, value, unit ):
454
+ allowed = ['g/cm3','kg/m3','atoms/Aa3']
455
+ if unit not in allowed:
456
+ s='", "'.join(allowed)
457
+ raise _nc_core.NCBadInput(f'Invalid density unit "{unit}" (allowed: "{s}")')
458
+ if not ( value>0.0 ):
459
+ raise _nc_core.NCBadInput(f'Invalid density value (must be >0): {value} {unit}')
460
+ self.__dirty()
461
+ self.__params['density'] = ( value, unit )
462
+
463
+ def set_fraction( self, label, value ):
464
+ _checklabel(label)
465
+ if not 0 < value <= 1.0:
466
+ raise _nc_core.NCBadInput(f'Invalid component fraction is not in (0,1]: {value}')
467
+ self.__dirty()
468
+ if 'fractions' not in self.__params:
469
+ self.__params['fractions'] = {}
470
+ self.__params['fractions'][label] = value
471
+
472
+ def __lines_cellsg(self,cellsg):
473
+ if not cellsg:
474
+ return ''
475
+ c = cellsg
476
+ spacegroup = c.get('spacegroup',None)
477
+ _sa = f"{c['a']:g}"
478
+ _sb = f"{c['b']:g}"
479
+ _sc = f"{c['c']:g}"
480
+ if spacegroup and 195<=spacegroup<=230:
481
+ if not ( _sa == _sb and _sb == _sc ):
482
+ raise _nc_core.NCBadInput(f'Invalid lattice parameters for cubic spacegroup ({spacegroup}): a={_sa}, b={_sb}, c={_sc}')
483
+ if not ( c['alpha']==90 and c['beta']==90 and c['gamma']==90 ):
484
+ raise _nc_core.NCBadInput(f"Invalid lattice angles for cubic spacegroup ({spacegroup}): alpha={c['alpha']}, beta={c['beta']}, gamma={c['gamma']}")
485
+ ll = f"""
486
+ @CELL
487
+ cubic {_sa}
488
+ """
489
+ else:
490
+ if _sa == _sb:
491
+ _sb = '!!'
492
+ if _sc == _sa:
493
+ _sc = '!!'
494
+ elif _sb == _sc:
495
+ _sc = '!!'
496
+
497
+ ll = f"""
498
+ @CELL
499
+ lengths {_sa} {_sb} {_sc}
500
+ angles {c['alpha']:g} {c['beta']:g} {c['gamma']:g}
501
+ """
502
+ if spacegroup:
503
+ ll+=f"""
504
+ @SPACEGROUP
505
+ {spacegroup}
506
+ """
507
+ return ll
508
+
509
+ def __add_dyninfo( self, label, fraction, dyninfo ):
510
+ _checklabel(label)
511
+ self.__dirty()
512
+ if 'dyninfos' not in self.__params:
513
+ self.__params['dyninfos'] = { label : dyninfo }
514
+ else:
515
+ self.__params['dyninfos'][label] = dyninfo
516
+ if fraction is not None:
517
+ self.set_fraction( label, fraction )
518
+
519
+ def allow_fallback_dyninfo( self, debye_temp ):
520
+ dt = _nc_common._decodeflt( debye_temp )
521
+ if not ( dt and dt>0.0 ):
522
+ raise _nc_core.NCBadInput(f'Invalid Debye temperature value: {debye_temp}')
523
+ self.__dirty()
524
+ self.__params['fallback_debye_temp'] = dt
525
+
526
+ def set_dyninfo_vdosdebye( self, label, debye_temp, *, comment, fraction ):
527
+ self.__add_dyninfo( label, fraction, dict( ditype='vdosdebye',
528
+ debye_temp = float(debye_temp),
529
+ comment = None if not comment else self.__prunecomment(comment) ) )
530
+
531
+ def set_dyninfo_freegas( self, label, *, comment, fraction ):
532
+ self.__add_dyninfo( label, fraction, dict( ditype='freegas',
533
+ comment = None if not comment else self.__prunecomment(comment) ) )
534
+
535
+ def set_dyninfo_sterile( self, label, *, comment, fraction ):
536
+ self.__add_dyninfo( label, fraction, dict( ditype='sterile',
537
+ comment = None if not comment else self.__prunecomment(comment) ) )
538
+
539
+ def set_dyninfo_vdos( self, label, vdos_egrid, vdos, *, comment, fraction ):
540
+ self.__add_dyninfo( label, fraction, dict( ditype='vdos',
541
+ vdos_egrid = _copyarray_or_None(vdos_egrid),
542
+ vdos = _copyarray_or_None(vdos),
543
+ comment = None if not comment else self.__prunecomment(comment) ) )
544
+
545
+ def set_dyninfo_scatknl( self, label, *, alphagrid, betagrid, temperature,
546
+ sab = None, sab_scaled = None, egrid = None,
547
+ comment = None, fraction = None ):
548
+ def present( x ):
549
+ return x is not None and _is_nonempty_array(x)
550
+ if not present(sab) and not present(sab_scaled):
551
+ raise _nc_core.NCBadInput('Missing either sab or sab_scaled arguments')
552
+ if present(sab) and present(sab_scaled):
553
+ raise _nc_core.NCBadInput('Do not specify both sab and sab_scaled')
554
+ self.__add_dyninfo( label, fraction, dict( ditype='scatknl',
555
+ temperature = float(temperature),
556
+ egrid = _copyarray_or_None(egrid),
557
+ sab = _copyarray_or_None(sab),
558
+ sab_scaled = _copyarray_or_None(sab_scaled),
559
+ alphagrid = _copyarray_or_None(alphagrid),
560
+ betagrid = _copyarray_or_None(betagrid),
561
+ comment = None if not comment else self.__prunecomment(comment) ) )
562
+
563
+ def set_dyninfo_msd( self, label, *, msd, temperature, comment, fraction ):
564
+ self.__add_dyninfo( label, fraction, dict( ditype='msd',
565
+ msd_value = float(msd),
566
+ msd_temperature = float(temperature),
567
+ comment = None if not comment else self.__prunecomment(comment) ) )
568
+
569
+ def set_dyninfo_from_object( self, label, source_dyninfo, comment = None, fraction = None ):
570
+ di = source_dyninfo
571
+ if not isinstance(di,_nc_core.Info.DynamicInfo):
572
+ raise _nc_core.NCBadInput('source_dyninfo object must be an object derived from NCrystal.Info.DynamicInfo')
573
+ _infoobj = di._info_wr() if di._info_wr else None
574
+ if not _infoobj:
575
+ raise _nc_core.NCBadInput('source_dyninfo object seems to be associated with expired Info object (or it was improperly initialised!)')
576
+
577
+ if comment is None:
578
+ comment = f'Transferred from "{di.atomData.displayLabel()}" in existing NCrystal.DynamicInfo object'
579
+
580
+
581
+ if isinstance(di,_nc_core.Info.DI_VDOSDebye):
582
+ self.set_dyninfo_vdosdebye( label,
583
+ debye_temp=di.debyeTemperature(),
584
+ comment=comment,
585
+ fraction=fraction )
586
+ return
587
+
588
+ if isinstance(di,_nc_core.Info.DI_VDOS):
589
+ self.set_dyninfo_vdos( label,
590
+ vdos_egrid = di.vdosOrigEgrid(),
591
+ vdos = di.vdosOrigDensity(),
592
+ comment = comment,
593
+ fraction = fraction)
594
+ return
595
+
596
+ if isinstance(di,_nc_core.Info.DI_Sterile):
597
+ self.set_dyninfo_sterile( label,
598
+ comment = comment,
599
+ fraction = fraction )
600
+ return
601
+
602
+ if isinstance(di,_nc_core.Info.DI_FreeGas):
603
+ self.set_dyninfo_freegas( label,
604
+ comment = comment,
605
+ fraction = fraction )
606
+ return
607
+
608
+ if isinstance(di,_nc_core.Info.DI_ScatKnlDirect):
609
+ knl=di.loadKernel()
610
+ #NB: Ignores knl['suggestedEmax'].
611
+ #NB: From DI object we can only extract the sab, even if the input
612
+ #file originally had e.g. sab_scaled.
613
+ self.set_dyninfo_scatknl( label,
614
+ temperature = di.temperature,
615
+ alphagrid = knl['alpha'],
616
+ betagrid = knl['beta'],
617
+ sab = knl['sab'],
618
+ egrid = knl['egrid'],
619
+ comment = comment,
620
+ fraction = fraction )
621
+ return
622
+
623
+ from .exceptions import nc_assert
624
+ nc_assert(False,'set_dyninfo_from_object not implemented yet'
625
+ ' for %s type DynamicInfo'%di.__class__.__name__ )
626
+
627
+ def transfer_dyninfo_objects( self, source, mapping, allow_none ):
628
+ lbls = set(mapping.keys()) if mapping else self.get_labels()
629
+ from ._ncmatimpl import extract_dyninfo_objects
630
+ _keepalive, lbl2dyninfos = extract_dyninfo_objects( lbls,
631
+ self.__params.get('compositions',{}),
632
+ source,
633
+ mapping )
634
+ if not lbl2dyninfos and not allow_none:
635
+ raise _nc_core.NCBadInput('No dynamic info was transferred from source')
636
+ for lbl, di in sorted( lbl2dyninfos.items() ):
637
+ self.set_dyninfo_from_object( lbl, di['obj'], comment = di['comment'] )
638
+
639
+ def as_spglib_cell( self ):
640
+ cellsg = self.__params.get('cellsg',None)
641
+ atomposlist = self.__params.get('atompos',{}).get('pos',[])
642
+ if not cellsg or not atomposlist:
643
+ return None, None
644
+ lattice = _cellparams_to_spglib_lattice(cellsg)
645
+ lbl2atomidx = {}
646
+ atomic_points, atomic_types = [], []
647
+ for lbl,x,y,z in sorted(atomposlist):
648
+ if lbl not in lbl2atomidx:
649
+ lbl2atomidx[lbl] = len(lbl2atomidx)
650
+ #idx = lbl2atomidx[lbl]
651
+ atomic_points.append( (x,y,z) )
652
+ atomic_types.append( lbl2atomidx[lbl] )
653
+ return ( lattice, atomic_points, atomic_types ), dict( (v,k) for k,v in lbl2atomidx.items())
654
+
655
+ def refine_crystal_structure( self, symprec, quiet ):
656
+ self._impl_refine( mode_refine = True, symprec = symprec, quiet=quiet )
657
+
658
+ def verify_crystal_structure( self, symprec, quiet ):
659
+ self._impl_refine( mode_refine = False, symprec = symprec, quiet=quiet )
660
+
661
+ def _impl_refine( self, *, mode_refine, symprec, quiet ):
662
+ cellsg = self.__params.get('cellsg',None)
663
+ atompos = self.__params.get('atompos',None)
664
+ #atomposlist = atompos.get('pos',None) if atompos else None
665
+ if not cellsg and not atompos:
666
+ return#do nothing if not crystal
667
+ if int(bool(cellsg))+int(bool(atompos)) == 1:
668
+ raise _nc_core.NCBadInput('Must set both unit cell parameters and atom'
669
+ ' positions before trying to refine or verify a crystal structure')
670
+ assert cellsg and atompos
671
+ sgnumber = cellsg.get('spacegroup',None)
672
+ if sgnumber is None and not mode_refine:
673
+ raise _nc_core.NCBadInput('Must provide a space group number (or invoke'
674
+ ' .refine_crystal_structure()) before it is'
675
+ ' possible to verify a crystal structure')
676
+ spglib_cell, spglib_idx2lbl = self.as_spglib_cell()
677
+ d = _spglib_refine_cell( spglib_cell, symprec = symprec ) if spglib_cell else None
678
+ if not d:
679
+ raise _nc_core.NCBadInput(f'Failed to {"refine" if mode_refine else "verify"} crystal structure with spglib.')
680
+ assert len(d)==7
681
+ if mode_refine:
682
+ if not quiet:
683
+ for m in d['msgs']:
684
+ _nc_common.print(m)
685
+ for w in d['warnings']:
686
+ _nc_common.warn(w)
687
+ if mode_refine:
688
+ #NB: We do not (yet) have anisotropic atomic properties (like
689
+ #anisotropic displacements), so we can simply update just the
690
+ #positions. This is different from the case in cifutils.py where
691
+ #d['can_keep_anisotropic_properties'] is important!
692
+ new_cellsg = dict(d['cellparams_snapped'].items())
693
+ new_cellsg['spacegroup'] = d['sgno']#NB: ignoring sgsymb_hm here!
694
+ #Could we have spacegroup_hm as
695
+ #optional field in the cellsg
696
+ #array?
697
+ ll = []
698
+ for pos, atomidx in zip(d['refined_cell'][1],d['refined_cell'][2]):
699
+ lbl = spglib_idx2lbl[atomidx]
700
+ ll.append( (lbl,
701
+ _remap_fract_pos(pos[0]),
702
+ _remap_fract_pos(pos[1]),
703
+ _remap_fract_pos(pos[2]) ) )
704
+ ll.sort()
705
+ new_atompos = dict( (k,copy.deepcopy(v))
706
+ for k,v in atompos.items()
707
+ if k!='pos' )
708
+ new_atompos['pos'] = ll
709
+ self.__dirty()
710
+ self.__params['cellsg'] = new_cellsg
711
+ self.__params['atompos'] = new_atompos
712
+ else:
713
+ if sgnumber != d['sgno']:
714
+ raise _nc_core.NCBadInput(f'Failed to verify crystal structure with spglib. Expected SG-{sgnumber}, got SG-{d["sgno"]} ({d["sgsymb_hm"]}).')
715
+ rdl = _reldiff_cellparams( cellsg, d['cellparams_snapped'] )
716
+ rda = _reldiff_atompos( spglib_cell, d['refined_cell'] )
717
+ #rd = None if (rdl is None or rda is None) else max(rdl,rda)
718
+ if rdl is None or rda is None or max(rdl,rda) > 0.01:
719
+ raise _nc_core.NCBadInput('Failed to verify crystal structure with spglib.')
720
+
721
+ def set_atompos( self, atompos ):
722
+ pos,occumap = [],{}
723
+ for e in atompos:
724
+ assert len(e) in (4,5)
725
+ lbl,x,y,z = str(e[0]),_remap_fract_pos(e[1]),_remap_fract_pos(e[2]),_remap_fract_pos(e[3])
726
+ _checklabel(lbl)
727
+ pos.append( (lbl,x,y,z) )
728
+ occu = float(e[4]) if len(e)==5 else 1.0
729
+ if not ( 0.0 < occu <= 1.0 ):
730
+ raise _nc_core.NCBadInput('site_occupancy values must be in (0.0,1.0]')
731
+ if lbl not in occumap:
732
+ occumap[lbl]=occu
733
+ else:
734
+ if not occumap[lbl] == occu:
735
+ raise _nc_core.NCBadInput('All site occupancies for label "%s" are not identical'%lbl)
736
+
737
+ #remove occu==1.0 entries from map (and sort):
738
+ occumap = dict( sorted( (k,v) for k,v in occumap.items() if v!=1.0 ) )
739
+ if occumap:
740
+ _nc_common.warn('Support for site_occupancy is highly experimental. Do *not* attempt to directly override'
741
+ +' value with the "density" cfg-parameter for this material (unless it is to simply scale it)')
742
+ self.__dirty()
743
+ self.__params['atompos'] = dict( pos=pos, occumap=occumap )
744
+ #
745
+ def get_labels( self ):
746
+ #from positions:
747
+ atompos = self.__params.get('atompos',None)
748
+ s = set( lbl for lbl,x,y,z in atompos['pos'] ) if atompos else set()
749
+ #from dyninfo:
750
+ for lbl in self.__params.get('dyninfos',{}).keys():
751
+ s.add(lbl)
752
+ #from composition:
753
+ for lbl in self.__params.get('compositions',{}).keys():
754
+ s.add(lbl)
755
+ #from fractions:
756
+ for lbl in self.__params.get('fractions',{}).keys():
757
+ s.add(lbl)
758
+ return s
759
+
760
+ def __lines_atompos(self, lbl_map, atompos ):
761
+ if not atompos:
762
+ return ''
763
+ ll=[]
764
+ fmt = _nc_common.prettyFmtValue
765
+ #NB: We sort on the val->string->val values, not the original
766
+ #values. Because otherwise we get irreproducibilities when in
767
+ #principle two x values are almost identical and their order might
768
+ #vary slightly.
769
+ def extractval(s):
770
+ if '/' not in s:
771
+ return float(s)
772
+ p=s.split('/')
773
+ assert len(p)==2
774
+ return float(p[0]) / float(p[1])
775
+ for lbl,x,y,z in atompos['pos']:
776
+ fx,fy,fz = fmt(x), fmt(y), fmt(z)
777
+ ll.append( ( lbl_map.get(lbl,lbl),
778
+ extractval(fx), extractval(fy), extractval(fz),
779
+ fx, fy, fz ) )
780
+ ll.sort()
781
+ out="@ATOMPOSITIONS\n"
782
+ for lbl,_,_,_,fx,fy,fz in ll:
783
+ out += f'{lbl} {fx} {fy} {fz}\n'
784
+ return out
785
+
786
+ def __lines_dyninfo(self,lbl_map,fractions):
787
+ dyninfos = self.__params.get('dyninfos',{})
788
+
789
+ lines=''
790
+ natoms_with_fallback_dyninfo = 0
791
+
792
+ def formatVector(name,values):
793
+ return formatVectorForNCMAT(name,values,_magic_two_space)
794
+
795
+ def transform_msd_to_vdosdebye( lbl, dyninfo ):
796
+ if not dyninfo or not dyninfo.get('ditype','') == 'msd':
797
+ return dyninfo
798
+ d = {}
799
+ d['ditype'] = 'vdosdebye'
800
+ mass = calc_mass(lbl,self.__params.get('compositions',{}).get(lbl,None))
801
+ if not mass:
802
+ raise _nc_core.NCBadInput( f'Failed to calculate the atomic mass associated with the label {lbl}'
803
+ ' (needed for dyninfo of type "msd"). Please ensure that the label has a'
804
+ ' proper composition set (with .set_composition(..)) and that all component'
805
+ ' atoms have known masses (otherwise use .update_atomdb(..) to provide them)')
806
+ assert mass>0.0
807
+ from .vdos import debyeTempFromIsotropicMSD
808
+ msd = dyninfo['msd_value']
809
+ msd_temp = dyninfo['msd_temperature']
810
+ dt = debyeTempFromIsotropicMSD( msd = msd, mass = mass,
811
+ temperature = msd_temp )
812
+ assert dt > 0.0
813
+ d['debye_temp'] = dt
814
+ c = f'Debye temperature value derived from msd={msd:g}Aa^2 @ T={msd_temp:g}K (mass={mass:g}u)'
815
+ oldcomment = dyninfo.get('comment',None)
816
+ d['comment'] = c if not oldcomment else f'{oldcomment.strip()}\n{c}'
817
+ return d
818
+
819
+ for lbl,fracinfo in sorted(fractions.items()):
820
+ if not isinstance(fracinfo,float) and len(fracinfo)==3:
821
+ _fracval_flt,frac_prettyprint,_ = fracinfo
822
+ else:
823
+ _fracval_flt,frac_prettyprint = fracinfo, _nc_common.prettyFmtValue( fracinfo )
824
+ lines += '@DYNINFO\n'
825
+ dyninfo = dyninfos.get(lbl,None)
826
+ dyninfo = transform_msd_to_vdosdebye( lbl, dyninfo )
827
+
828
+ if not dyninfo:
829
+ fbdt = self.__params.get('fallback_debye_temp',None)
830
+ if fbdt is not None:
831
+ natoms_with_fallback_dyninfo += 1
832
+ dyninfo = dict( ditype='vdosdebye',
833
+ debye_temp = fbdt,
834
+ comment = 'WARNING: Using fallback Debye temperature value!' )
835
+ if not dyninfo:
836
+ raise _nc_core.NCMissingInfo(f'Missing dyninfo for atom label "{lbl}" (add via call to set_dyninfo_...)')
837
+ comment = dyninfo['comment']
838
+ if comment:
839
+ for e in comment.splitlines():
840
+ lines += f' # {e.strip()}\n'
841
+
842
+ lines += f'element {lbl_map.get(lbl,lbl)}\n'
843
+ lines += f'fraction {frac_prettyprint}\n'
844
+ ditype = dyninfo['ditype']
845
+ lines += f'type {ditype}\n'
846
+ if ditype == 'vdosdebye':
847
+ dt=dyninfo['debye_temp']
848
+ lines += f'debye_temp {dt:g}\n'
849
+ elif ditype == 'vdos':
850
+ x = dyninfo['vdos_egrid']
851
+ from ._common import _grid_is_linspace
852
+ if len(x)>2 and _grid_is_linspace(x):
853
+ lines += f'vdos_egrid {_fmtprecisenum(x[0])} {_fmtprecisenum(x[-1])}\n'
854
+ else:
855
+ lines += formatVector('vdos_egrid',x)
856
+ lines += formatVector('vdos_density',dyninfo['vdos'])
857
+ elif ditype in ('sterile','freegas'):
858
+ pass
859
+ elif ditype == 'scatknl':
860
+ lines += f'temperature {_fmtprecisenum(dyninfo["temperature"])}\n'
861
+ if dyninfo.get('egrid') is not None:
862
+ from ._common import _grid_is_linspace
863
+ x = dyninfo['egrid']
864
+ if ( len(x) > 3 and _grid_is_linspace(x) ):
865
+ lines += f'egrid {_fmtprecisenum(x[0])} {_fmtprecisenum(x[-1])} {len(x)}\n'
866
+ elif len(x) <= 3:
867
+ if len(x)==3 and x[0]==0.0 and x[2]==0.0:
868
+ x = [ x[1] ]#single Emax value
869
+ _=' '.join(_fmtprecisenum(e) for e in x)
870
+ lines += f'egrid {_}\n'
871
+ else:
872
+ lines += formatVector('egrid',x)
873
+ lines += formatVector('alphagrid',dyninfo['alphagrid'])
874
+ lines += formatVector('betagrid',dyninfo['betagrid'])
875
+ if dyninfo['sab'] is not None:
876
+ assert dyninfo['sab_scaled'] is None
877
+ lines += formatVector('sab',dyninfo['sab'])
878
+ else:
879
+ assert dyninfo['sab_scaled'] is not None
880
+ lines += formatVector('sab_scaled',dyninfo['sab_scaled'])
881
+ else:
882
+ assert False,f'Unexpected dyninfo type : "{ditype}"'
883
+ return lines, natoms_with_fallback_dyninfo
884
+
885
+ def register_as( self, virtual_filename, cfg_params ):
886
+ from .datasrc import registerInMemoryFileData
887
+ registerInMemoryFileData( virtual_filename, self.create_ncmat( cfg_params = cfg_params ) )
888
+ return virtual_filename
889
+
890
+ def write( self, path, cfg_params ):
891
+ import pathlib
892
+ p = pathlib.Path( path )
893
+ assert p.name.endswith('.ncmat') and len(p.name) > 6
894
+ _nc_common.write_text( p,
895
+ self.create_ncmat( cfg_params = cfg_params ) )
896
+ return p
897
+
898
+ def load(self,cfg_params,force ):
899
+ if force or self.__loadcache is None or self.__loadcache[0] != cfg_params:
900
+ self.__loadcache = ( cfg_params, _nc_core.directLoad( self.create_ncmat(), cfg_params ) )
901
+ return self.__loadcache[1]
902
+
903
+ def plot_xsect( self, composer, cfg_params, kwargs_plot_xsect ):
904
+ from .misc import MaterialSource
905
+ from .plot import plot_xsect
906
+ ms = MaterialSource( composer, cfg_params = cfg_params )
907
+ return plot_xsect( ms, **kwargs_plot_xsect )
908
+
909
+ def inspect( self, composer, cfg_params, kwargs_plot_xsect ):
910
+ from .misc import MaterialSource
911
+ from .plot import plot_xsect
912
+ ms = MaterialSource( composer, cfg_params = cfg_params )
913
+ loadedmat = ms.load()
914
+ import sys
915
+ def flush():
916
+ ( sys.stdout.flush(),sys.stderr.flush() )
917
+ flush()
918
+ loadedmat.info.dump()
919
+ flush()
920
+ _nc_common.print("Absorption process (objects):")
921
+ flush()
922
+ loadedmat.absorption.dump(prefix=' ')
923
+ flush()
924
+ _nc_common.print("Scattering process (objects):")
925
+ flush()
926
+ loadedmat.scatter.dump(prefix=' ')
927
+ flush()
928
+ return plot_xsect( ms, **kwargs_plot_xsect )
929
+
930
+ def __determine_atompos_fractions( self, atompos ):
931
+ natoms = len(atompos['pos']) if atompos else 0
932
+ if not natoms:
933
+ return
934
+ out = {}
935
+ from collections import Counter as _collections_Counter
936
+ counts = _collections_Counter(list(e[0] for e in atompos['pos']))
937
+ for lbl,count in counts.items():
938
+ frac = float(count) / natoms
939
+ gcd = math.gcd(count,natoms)
940
+ out[ lbl ] = ( frac, ( '1' if count==natoms else '%i/%i'%( count // gcd, natoms // gcd ) ), count )
941
+ return out
942
+
943
+ def get_chemical_composition( self, as_str ):
944
+ atompos = self.__params.get('atompos')
945
+ _lbl_counts = None
946
+ if atompos:
947
+ _afr = self.__determine_atompos_fractions( atompos )
948
+ _lbl_counts = list( (lbl,count) for lbl,(_,_,count) in _afr.items() )
949
+ elif self.__params.get('cellsg'):
950
+ #We could in principle proceed below, but for crystals, we promise that the
951
+ #return value is always numbers per unit cell!
952
+ raise _nc_core.NCMissingInfo("Can not provide chemical composition of incomplete crystalline materials (missing atomic positions).")
953
+ if _lbl_counts is None:
954
+ fractions = self.__params.get('fractions')
955
+ dyninfos = self.__params.get('dyninfos')
956
+ if not fractions and not atompos and len(dyninfos)==1:
957
+ fractions = {list(dyninfos.keys())[0] : 1.0 }
958
+ if fractions:
959
+ _lbl_counts = list( sorted( fractions.items()) )
960
+ if not _lbl_counts:
961
+ return None
962
+
963
+ compos = self.__params.get('compositions')
964
+ occumap = atompos['occumap'] if atompos else None
965
+ ll = []
966
+ for lbl, weight in _lbl_counts:
967
+ c = compos.get(lbl) if compos else None
968
+ _occufactor = occumap.get(lbl,1.0) if occumap else 1.0
969
+ if not c:
970
+ assert _nc_common.check_elem_or_isotope_marker( lbl ) is not None
971
+ ll.append( (lbl, weight * _occufactor ) )
972
+ else:
973
+ for frac,elemisomarker in c:
974
+ ll.append( (elemisomarker, weight * frac * _occufactor ) )
975
+ d = {}
976
+ for k,v in ll:
977
+ if k in d:
978
+ d[k] += v
979
+ else:
980
+ d[k] = v
981
+ res = list( sorted( (k,v) for k,v in d.items() ) )
982
+ if as_str:
983
+ return _nc_common.format_chemform( res )
984
+ else:
985
+ return res
986
+
987
+ def _unofficial_vdos2sab_ignore( self, *, order_low, order_high = None, mode = None ):
988
+ assert mode is None or mode in ('coherent','incoherent')
989
+ import numbers
990
+ assert isinstance(order_low,numbers.Integral)
991
+ assert order_high is None or isinstance(order_low,numbers.Integral)
992
+ order_high = int(order_high) if order_high is not None else None
993
+ order_low = int(order_low)
994
+ assert order_high is None or order_high >= order_low
995
+ line = ['vdos2sab_ignorecontrib',str(order_low)]
996
+ if order_high is not None:
997
+ line.append( str(order_high) )
998
+ if mode is not None:
999
+ line.append(mode)
1000
+ old = [e for e in self.__params.get('unofficial_hacks',[]) if ( e and not e[0]=='vdos2sab_ignorecontrib' ) ]
1001
+ self.__dirty()
1002
+ self.__params['unofficial_hacks'] = old + [ line ]
1003
+
1004
+ def create_ncmat( self, *, cfg_params='', meta_data = False,
1005
+ verify_crystal_structure = True ):
1006
+ #docstr: set meta_data to also return a dictionary with a bit of high
1007
+ #level metadata like chemical composition, spacegroup, etc.
1008
+ md = {} if meta_data else None
1009
+
1010
+ cellsg = self.__params.get('cellsg')
1011
+ atompos = self.__params.get('atompos')
1012
+
1013
+ is_crystal = bool(cellsg or atompos)
1014
+ did_verify_xtal_struct = False
1015
+ if verify_crystal_structure and cellsg and atompos:
1016
+ self.verify_crystal_structure(symprec = 0.01, quiet = False)
1017
+ did_verify_xtal_struct = True
1018
+
1019
+ if verify_crystal_structure and cellsg and cellsg.get('spacegroup',None):
1020
+ _check_cell_sg_consistency( cellsg['spacegroup'],
1021
+ cellsg['a'], cellsg['b'], cellsg['c'],
1022
+ cellsg['alpha'], cellsg['beta'], cellsg['gamma'] )
1023
+
1024
+ dyninfos = self.__params.get('dyninfos',None)
1025
+
1026
+ if md is not None:
1027
+ md['cellsg'] = dict( cellsg.items() ) if cellsg else None
1028
+
1029
+ if atompos and not cellsg:
1030
+ raise _nc_core.NCMissingInfo("Atom positions in unit cell added but unit cell info missing (add via call to set_cellsg)")
1031
+ if cellsg and not atompos:
1032
+ raise _nc_core.NCMissingInfo("Cell/spacegroup information added but atom positions in unit cell are missing (add via call to set_atompos)")
1033
+ is_crystal = bool(cellsg or atompos)
1034
+ if md is not None:
1035
+ md['is_crystal'] = is_crystal
1036
+
1037
+ density = self.__params.get('density',None)
1038
+ fractions = self.__params.get('fractions',None)
1039
+ if is_crystal and density:
1040
+ raise _nc_core.NCBadInput('Density should not be added explicitly for crystalline materials (if'
1041
+ +' needed, it can be modified via the "density" cfg-string parameter)')
1042
+
1043
+ if is_crystal and dyninfos:
1044
+ for lbl,di in sorted(dyninfos.items()):
1045
+ _ditype=di.get('ditype',None)
1046
+ if _ditype not in ('vdos','vdosdebye','msd'):
1047
+ raise _nc_core.NCBadInput('Crystalline material are currently only supported with dynamic info type of'
1048
+ +f' either "vdos", "vdosdebye", or "msd" (offending type "{_ditype}" for "{lbl}")')
1049
+
1050
+ if not is_crystal and len(dyninfos)==1 and not fractions:
1051
+ #in case of a single dyninfo we can assume the fraction is 1.0
1052
+ fractions = {list(dyninfos.keys())[0] : 1.0 }
1053
+
1054
+
1055
+ atompos_fractions = None if not is_crystal else self.__determine_atompos_fractions( atompos )
1056
+ if atompos_fractions and fractions:
1057
+ for lbl,frac in sorted(fractions.items()):
1058
+ afrac,_,_ = atompos_fractions.get(lbl,None)
1059
+ if afrac is None:
1060
+ raise _nc_core.NCBadInput(f'Label "{lbl}" has fraction explicitly set, but the material'
1061
+ +' is crystalline and the label has no atomic positions sets')
1062
+ if abs(afrac-frac)>1e-5:
1063
+ raise _nc_core.NCBadInput(f'Label "{lbl}" has both explicit and implicit (via atomic'
1064
+ +f' position count) fractions provided, and they do NOT match up ({afrac} vs. {frac})')
1065
+ if not is_crystal:
1066
+ if not density:
1067
+ raise _nc_core.NCBadInput('Density must be set explicitly for non-crystalline materials (add via call to set_density)')
1068
+ if not dyninfos:
1069
+ raise _nc_core.NCBadInput('Material incompletely specified.')
1070
+ if set(dyninfos.keys()) != set(lbl for lbl,fv in (fractions or {}).items() if fv is not None):
1071
+ raise _nc_core.NCBadInput('For non-crystalline materials with more than one component, all components must have fractions specified.')
1072
+ _fracsum = math.fsum(val for lbl,val in sorted((fractions or {}).items()))
1073
+ if abs( 1.0 - _fracsum ) > 1e-10:
1074
+ raise _nc_core.NCBadInput(f'Invalid material - sum of dyninfo fractions is not 1 (it is {_fracsum})')
1075
+
1076
+ lbl_map,atomdb_lines = determine_labels_and_atomdb( self.__params, fractions = fractions )
1077
+
1078
+ ll = self.__lines_cellsg( cellsg )
1079
+ ll += self.__lines_atompos( lbl_map, atompos )
1080
+ if density:
1081
+ _dval,_dunit = density
1082
+ _ncmat_dunit = {'g/cm3': 'g_per_cm3',
1083
+ 'kg/m3': 'kg_per_m3',
1084
+ 'atoms/Aa3': 'atoms_per_aa3' }[_dunit]
1085
+ ll+=f'@DENSITY\n{_dval} {_ncmat_dunit}\n'
1086
+
1087
+ som = self.get_state_of_matter()
1088
+ if som is not None:
1089
+ assert som in ('solid','liquid','gas')
1090
+ ll += f'@STATEOFMATTER\n{som}\n'
1091
+
1092
+ _t = self.__params.get('temperature',None)
1093
+ _v = '%.14g'%_t['value'] if _t else None
1094
+ if _t and ( _t['lock'] or _v != '293.15' ):
1095
+ ll += '@TEMPERATURE\n'
1096
+ if not _t['lock']:
1097
+ ll += 'default '
1098
+ ll += f'{_v}\n'
1099
+
1100
+
1101
+ if atomdb_lines:
1102
+ ll += '@ATOMDB\n'
1103
+ ll += '\n'.join(atomdb_lines)
1104
+ ll += '\n'
1105
+
1106
+ secondary_phases = self.__params.get('secondary_phases')
1107
+ if secondary_phases:
1108
+ ll += '@OTHERPHASES\n'
1109
+ for frac,cfgstr in secondary_phases:
1110
+ ll += f'{frac} {cfgstr}\n'
1111
+
1112
+ custom_hardspheresans = self.__params.get('custom_hardspheresans')
1113
+ if custom_hardspheresans:
1114
+ if not secondary_phases:
1115
+ raise _nc_core.NCBadInput('Material with hard-sphere SANS'
1116
+ ' enabled must have at least one'
1117
+ ' secondary phase added.')
1118
+ ll += '@CUSTOM_HARDSPHERESANS\n'
1119
+ ll += f'{custom_hardspheresans} #sphere radius in angstrom.\n'
1120
+
1121
+ unofficial_hacks = self.__params.get('unofficial_hacks')
1122
+ if unofficial_hacks:
1123
+ ll += '@CUSTOM_UNOFFICIALHACKS\n'
1124
+ for e in unofficial_hacks:
1125
+ ll += ' '.join(e)+'\n'
1126
+
1127
+ for sn,cnt in sorted(self.__params.get('custom_sections',{}).items()):
1128
+ ll += f'@CUSTOM_{sn}\n'
1129
+ ll += cnt
1130
+ if not cnt.endswith('\n'):
1131
+ ll += '\n'
1132
+
1133
+ #Dyninfo lines last, since they might contain huge arrays of data, and
1134
+ #people might only look at the top of the file:
1135
+ ld, natoms_with_fallback_dyninfo = (
1136
+ self.__lines_dyninfo( lbl_map, atompos_fractions or fractions )
1137
+ )
1138
+ ll += ld
1139
+
1140
+ out=["NCMAT v7"]
1141
+ comments = copy.deepcopy(self.__params.get('top_comments',[]))
1142
+
1143
+ if natoms_with_fallback_dyninfo:
1144
+ _ = 'values were' if natoms_with_fallback_dyninfo>1 else 'value was'
1145
+ if comments and comments[-1]:
1146
+ comments.append('')
1147
+ comments += ['WARNING: Fallback (dummy) Debye temperature %s used for '%_
1148
+ +'%i atom%s!'%(
1149
+ natoms_with_fallback_dyninfo,
1150
+ 's' if natoms_with_fallback_dyninfo>1 else ''),'']
1151
+
1152
+ if did_verify_xtal_struct:
1153
+ comments += ['NOTICE: crystal structure was verified with spglib to be self-consistent.']
1154
+ elif is_crystal:
1155
+ comments += ['WARNING: crystal structure was not verified automatically with spglib.']
1156
+
1157
+
1158
+ #determine chemical formula
1159
+ if atompos_fractions is not None:
1160
+ _lbl_counts = list( (lbl,count) for lbl,(_,_,count) in atompos_fractions.items() )
1161
+ else:
1162
+ assert fractions
1163
+ _lbl_counts = list( sorted( fractions.items()) )
1164
+
1165
+ _chem_compos = self.get_chemical_composition( as_str = False)
1166
+ _chemform = _nc_common.format_chemform( _chem_compos )
1167
+
1168
+ _title = _chemform
1169
+ if cellsg:
1170
+ _sgno = cellsg['spacegroup']
1171
+ if _sgno:
1172
+ _title += f' ({_nc_common._classifySG(_sgno)}, SG-{_sgno})'
1173
+
1174
+ if md is not None:
1175
+ md['chemform'] = _chemform
1176
+ #md['chemform_per_unit_cell'] = _chemform_per_cell
1177
+ md['title'] = _title
1178
+
1179
+ disable_autotitle = False
1180
+ if comments and comments[0]=='<<disableautotitle>>':
1181
+ #hidden feature (might not actually be used yet)
1182
+ comments = comments[1:]
1183
+ disable_autotitle = True
1184
+
1185
+ if comments and comments[0]=='<<disableautogennotice>>':
1186
+ #hidden feature for our command-line scripts.
1187
+ comments = comments[1:]
1188
+ else:
1189
+ _ = self.__class__.__name__
1190
+ if _.endswith('Impl') and len(_)>4:
1191
+ _ = _[:-4]
1192
+ out.append('# Autogenerated by %s'%_)
1193
+
1194
+ if not disable_autotitle:
1195
+ out += [ '#',f'# {_title}','#' ]
1196
+ if is_crystal:
1197
+ _ = '+'.join(f'{count:g}x{lbl}' for lbl,count in _chem_compos)
1198
+ out += [ f'# Atoms per unit cell: {_}','#' ]
1199
+
1200
+ for tc in comments:
1201
+ out.append(('# %s'%tc).rstrip())
1202
+
1203
+ if cfg_params:
1204
+ if out and out[-1] != '#':
1205
+ out += [ '#' ]
1206
+ out += [ f'# NCRYSTALMATCFG[{str(cfg_params).strip()}]', '#']
1207
+
1208
+ if out and out[-1] != '#':
1209
+ out += [ '#' ]
1210
+
1211
+ for e in ll.splitlines():
1212
+ e=e.strip()
1213
+ if e.startswith('@'):
1214
+ out.append(e)
1215
+ elif e:
1216
+ out.append(' '+e.replace(_magic_two_space,' '))
1217
+
1218
+ out = '\n'.join(e.rstrip() for e in out)+'\n'
1219
+
1220
+ raw_cnt = self.__params.get('raw_content')
1221
+ if raw_cnt:
1222
+ out += raw_cnt
1223
+ return out if md is None else ( out, md )
1224
+
1225
+ def _determine_dyninfo_mapping( labels, composition, dilist ):
1226
+ #mapping is composer label -> srclbl
1227
+
1228
+ def extract_z2lbl( datalist, extractfct, allow_multi = False ):
1229
+ z2lbl = {}
1230
+ for d in datalist:
1231
+ atomdata_or_z,lbl = extractfct(d)
1232
+ if atomdata_or_z is None:
1233
+ z = None
1234
+ elif hasattr(atomdata_or_z,'isElement'):
1235
+ z = atomdata_or_z.Z() if atomdata_or_z.isElement() else None
1236
+ else:
1237
+ z = int(atomdata_or_z) if atomdata_or_z else None
1238
+ if z is not None:
1239
+ if z in z2lbl:
1240
+ z2lbl[z].append(lbl)
1241
+ else:
1242
+ z2lbl[z] = [ lbl ]
1243
+ if allow_multi:
1244
+ return z2lbl
1245
+ return dict( (k,v[0]) for k,v in z2lbl.items() if len(v)==1 )
1246
+
1247
+ def lookup_zval_for_label(lbl):
1248
+ c = composition.get(lbl,None)
1249
+ if not c:
1250
+ return _atomdb(lbl)
1251
+ zvals = set()
1252
+ for frac,s in c:
1253
+ ad = _atomdb(s)
1254
+ zvals.add(ad.Z() if (ad and ad.isElement()) else None)
1255
+ if len(zvals)==1:
1256
+ return zvals.pop()
1257
+
1258
+ z_2_dilbl = extract_z2lbl( dilist, lambda di : (di.atomData, di.atomData.displayLabel()) )
1259
+ z_2_atomlbls = extract_z2lbl( labels, lambda lbl : ( lookup_zval_for_label(lbl), lbl ), allow_multi = True )
1260
+ d = {}
1261
+ for z,lbls in z_2_atomlbls.items():
1262
+ dilbl = z_2_dilbl.get(z,None)
1263
+ if dilbl:
1264
+ for lbl in lbls:
1265
+ d[lbl] = dilbl
1266
+ return d
1267
+
1268
+ def extract_dyninfo_objects( labels, compositions, source, mapping ):
1269
+
1270
+ keepalive = None
1271
+ if isinstance( source, _nc_core.Info.DynamicInfo ):
1272
+ description, dilist = 'DynamicInfo object', [source]
1273
+ elif ( hasattr(source,'__len__')
1274
+ and not hasattr(source,'keys') #NB: '__len__' but not 'keys': list-like but not dict-like.
1275
+ and ( len(source)==0 or all(isinstance(e, _nc_core.Info.DynamicInfo) for e in source ) ) ):
1276
+ #sequence of dyninfo objects
1277
+ description, dilist = 'list of DynamicInfo objects', list( e for e in source )
1278
+ else:
1279
+ from .misc import MaterialSource
1280
+ ms = MaterialSource(source)
1281
+ description = ms.description
1282
+ _loadedmat = ms.load( doScatter = False, doAbsorption = False )
1283
+ keepalive = _loadedmat
1284
+ if not _loadedmat.info:
1285
+ raise _nc_core.NCBadInput('Material source has no Info object'
1286
+ ' (and therefore no DynamicInfo objects)')
1287
+ dilist = _loadedmat.info.dyninfos
1288
+
1289
+ if not mapping:
1290
+ #mapping is srclbl -> label
1291
+ mapping = _determine_dyninfo_mapping( labels, compositions, dilist )
1292
+
1293
+ out = {}
1294
+ dilbl_2_di = dict( (di.atomData.displayLabel(),di) for di in dilist )
1295
+ for lbl in labels:
1296
+ target_dilbl = mapping.get(lbl,None)
1297
+ if not target_dilbl:
1298
+ continue
1299
+ di = dilbl_2_di.get(target_dilbl,None)
1300
+ if not di:
1301
+ _='", "'.join(sorted(dilbl_2_di.keys()))
1302
+ raise _nc_core.NCBadInput(f'No display label "{target_dilbl}" found in source (source has display labels "{_}").')
1303
+ comment = f'Transferred from "{target_dilbl}" in "{description}"'
1304
+ out[lbl] = dict( obj=di, comment=comment )
1305
+
1306
+ return keepalive, out
1307
+
1308
+ def _atomdb(lbl):
1309
+ from .atomdata import atomDB
1310
+ return atomDB( lbl, throwOnErrors=False )
1311
+
1312
+ def calc_mass( label, composition ):
1313
+ if not composition:
1314
+ _ = _atomdb( label )
1315
+ return _.averageMassAMU() if _ else None
1316
+ elif len( composition )==1:
1317
+ from .atomdata import atomDB
1318
+ return atomDB( composition[0][1] ).averageMassAMU()
1319
+ else:
1320
+ ll=[]
1321
+ fracs = []
1322
+ for frac, _lbl in composition:
1323
+ _ = _atomdb( _lbl )
1324
+ if not _:
1325
+ return None
1326
+ fracs.append( frac )
1327
+ ll.append( frac * _.averageMassAMU() )
1328
+ return math.fsum( ll ) / math.fsum( fracs) #NB: sum(fracs) is likely
1329
+ #already guaranteed 1 here,
1330
+ #playing it safe
1331
+
1332
+ def _composerimpl_from_info( infoobj ):
1333
+ if isinstance(infoobj,_nc_core.Info):
1334
+ i = infoobj
1335
+ elif hasattr(infoobj,'info') and isinstance(infoobj.info,_nc_core.Info):
1336
+ i = infoobj.info
1337
+ else:
1338
+ raise _nc_core.NCBadInput('NCMATComposer.from_info got unexpected argument of wrong type')
1339
+
1340
+ if not i.isSinglePhase():
1341
+ raise _nc_core.NCBadInput('NCMATComposer can only be initialsed from single-phase materials')
1342
+ o = NCMATComposerImpl(data=None,fmt=None,quiet=True)
1343
+ top_atomdata = []
1344
+
1345
+ #Transfer temperature. In case of gasses we also lock the value, since
1346
+ #density depends very strongly on temperature for such materials (it is of
1347
+ #course true for any state of matter, but for gasses it is extreme):
1348
+ if i.stateOfMatter() == _nc_core.StateOfMatter.Gas:
1349
+ o.lock_temperature( i.getTemperature() )
1350
+ else:
1351
+ o.set_default_temperature( i.getTemperature() )
1352
+
1353
+ if i.isCrystalline():
1354
+ si = i.structure_info
1355
+ si_natoms = si['n_atoms']
1356
+ o.set_cellsg( a = si['a'], b = si['b'], c = si['c'],
1357
+ alpha = si['alpha'], beta = si['beta'], gamma = si['gamma'],
1358
+ spacegroup = ( si['spacegroup'] or None ) )
1359
+ all_atompos = []
1360
+ for ai in i.atominfos:
1361
+ ad = ai.atomData
1362
+ top_atomdata.append(ad)
1363
+ lbl = ad.displayLabel()
1364
+ di = ai.dyninfo
1365
+ o.set_dyninfo_from_object( lbl, di )
1366
+ for x,y,z in ai.positions:
1367
+ all_atompos.append( (lbl,x,y,z) )#NB: site_occupancy=1 here!
1368
+ from .exceptions import nc_assert
1369
+ nc_assert(len(all_atompos)==si_natoms)
1370
+ o.set_atompos( all_atompos )
1371
+ else:
1372
+ o.set_density( i.density, unit='g/cm3' )
1373
+ if i.stateOfMatter() == _nc_core.StateOfMatter.Solid:
1374
+ o.set_state_of_matter( 'solid' )
1375
+ elif i.stateOfMatter() == _nc_core.StateOfMatter.Liquid:
1376
+ o.set_state_of_matter( 'liquid' )
1377
+ elif i.stateOfMatter() == _nc_core.StateOfMatter.Gas:
1378
+ o.set_state_of_matter( 'gas' )
1379
+ else:
1380
+ assert i.stateOfMatter() == _nc_core.StateOfMatter.Unknown
1381
+ for di in i.dyninfos:
1382
+ ad = di.atomData
1383
+ top_atomdata.append(ad)
1384
+ lbl = ad.displayLabel()
1385
+ o.set_dyninfo_from_object( lbl, di )
1386
+ o.set_fraction( lbl, di.fraction )
1387
+
1388
+ #Handle ATOMDB:
1389
+ def _extractad( ad ):
1390
+ if not ad.isComposite():
1391
+ return [ ( 1.0, ad )], [ ad ]
1392
+ basicads = []
1393
+ compos = []
1394
+ for comp in ad.components:
1395
+ _frac, _ad = comp.fraction, comp.data
1396
+ if not _ad.isComposite():
1397
+ compos.append( ( _frac, _ad ) )
1398
+ basicads.append( _ad )
1399
+ else:
1400
+ _subcompos, _subbasicads = _extractad( _ad )
1401
+ basicads += _subbasicads
1402
+ for _subfrac, _subad in _subcompos:
1403
+ compos.append( (_frac*_subfrac, _subad) )
1404
+ return compos, basicads
1405
+
1406
+ basic_atomdata = []#all non-composite atomdatas
1407
+ for ad in top_atomdata:
1408
+ _compos, _bads = _extractad( ad )
1409
+ basic_atomdata += _bads
1410
+ o.set_composition( ad.displayLabel(), [(frac,ad.description(False)) for frac,ad in _compos] )
1411
+
1412
+ #any non-builtin atom data:
1413
+ _seen = {}
1414
+ for ad in basic_atomdata:
1415
+ assert ad.isElement() or ad.isIsotope()
1416
+ key = (ad.Z(),ad.A())
1417
+ _adstr = ad.to_atomdb_str()
1418
+ _seenstr = _seen.get(key,None)
1419
+ if _seenstr is not None:
1420
+ if _adstr != _seenstr:
1421
+ raise _nc_core.NCBadInput('Atom with (Z,A)=(%i,%i) appears in multiple roles with different data values in material. Such materials are not supported by the NCMATComposer.'%key)
1422
+ continue
1423
+ _seen[ key ] = _adstr
1424
+ from .atomdata import atomDB
1425
+ _builtin = atomDB(Z=key[0],A=key[1],throwOnErrors=False)
1426
+ if not _builtin or _adstr != _builtin.to_atomdb_str():
1427
+ o.update_atomdb( ad.description(False), _adstr )
1428
+
1429
+ #Custom sections:
1430
+ for nm,cnt_lists in (i.customsections or []):
1431
+ if nm == 'HARDSPHERESANS':
1432
+ _nc_common.warn('Ignoring @CUSTOM_HARDSPHERESANS sections in input.')
1433
+ continue
1434
+ if nm == 'UNOFFICIALHACKS':
1435
+ _nc_common.warn('Ignoring @CUSTOM_UNOFFICIALHACKS sections in input.')
1436
+ continue
1437
+ if o.get_custom_section_data(nm) is not None:
1438
+ raise _nc_core.NCBadInput(f'Multiple @CUSTOM_{nm} sections in input'
1439
+ 'is not supported by NCMATComposer.')
1440
+ cnt=''
1441
+ for linedata in cnt_lists:
1442
+ cnt += ' '.join(linedata)
1443
+ cnt += '\n'
1444
+ o.set_custom_section_data(nm,cnt)
1445
+
1446
+ return o
1447
+
1448
+ def _checklabel(lbl):
1449
+ if not lbl:
1450
+ raise ValueError(f'invalid label "{lbl}"')
1451
+
1452
+ def _decode_composition(label,*composition):
1453
+ _checklabel(label)
1454
+
1455
+ label = ' '.join(str(label).split())
1456
+ errmsg='Invalid composition syntax'
1457
+
1458
+ if ' is ' in label:
1459
+ _=label.split()
1460
+ if len(_)>2 and _[1]=='is':
1461
+ if composition:
1462
+ raise _nc_core.NCBadInput(errmsg)
1463
+ return _decode_composition( _[0], _[2:] )
1464
+
1465
+ if not composition:
1466
+ raise _nc_core.NCBadInput(errmsg)
1467
+
1468
+ _decodeflt = _nc_common._decodeflt
1469
+ #We want composition to end up in the final form: [(f0,name0),(f1,name1),...].
1470
+ def is_final_form(c):
1471
+ return ( all(hasattr(e,'__len__') for e in c)
1472
+ and all(len(e)==2 for e in c)
1473
+ and all(_decodeflt(e[0]) for e in c)
1474
+ and all(hasattr(e[1],'split') for e in c) )
1475
+
1476
+ single_arg = composition[0] if len(composition)==1 else None
1477
+ if single_arg and is_final_form(single_arg):
1478
+ single_arg,composition = None,single_arg
1479
+
1480
+ if single_arg:
1481
+ if hasattr(single_arg,'split'):
1482
+ #assume string form like: 'Al' or '0.2 Al 0.8 Cr'
1483
+ p = single_arg.split()
1484
+ if len(p)!=1:
1485
+ return _decode_composition( label, *p )
1486
+ #single element form like: 'Al'
1487
+ norm_ident = _nc_common.check_elem_or_isotope_marker( p[0] )
1488
+ if not norm_ident:
1489
+ raise _nc_core.NCBadInput(errmsg+': invalid element/isotope marker "%s"'%p[0])
1490
+ return label, [(1.0,norm_ident)]
1491
+ elif ( hasattr(single_arg,'__len__')
1492
+ and len(single_arg)>=2 and len(single_arg)%2==0
1493
+ and all(_decodeflt(e) for e in single_arg[::2])
1494
+ and all(hasattr(e,'split') for e in single_arg[1::2]) ):
1495
+ #assume single_arg was a a list form like: [0.2,'Al',0.8,Cr]
1496
+ return _decode_composition( label, list(zip(single_arg[::2],single_arg[1::2])) )
1497
+ #unknown single arg form
1498
+ raise _nc_core.NCBadInput(errmsg)
1499
+
1500
+
1501
+ assert len(composition)>1 or (len(composition)==1 and is_final_form(composition))
1502
+
1503
+ if ( not is_final_form(composition)
1504
+ and len(composition)%2 == 0
1505
+ and all(hasattr(e,'split') for e in composition[1::2])
1506
+ and all(_decodeflt(e) for e in composition[::2]) ):
1507
+ #assume form [f0,name0,f1,name1,...]
1508
+ composition = list(zip(composition[::2],composition[1::2]))
1509
+
1510
+ if not is_final_form(composition):
1511
+ raise _nc_core.NCBadInput(errmsg)
1512
+
1513
+ #composition is now in the form: [(f0,name0),(f1,name1),...].
1514
+
1515
+ ll=[]
1516
+
1517
+ for frac_orig, ident in composition:
1518
+ frac_val = _decodeflt( frac_orig )
1519
+ if frac_val is None:
1520
+ raise _nc_core.NCBadInput(errmsg+': invalid fraction specification "%s"'%frac_orig)
1521
+ if not (0<frac_val<=1.0):
1522
+ raise _nc_core.NCBadInput(errmsg+': fraction specification "%s" is not in (0,1]'%frac_orig)
1523
+ norm_ident = _nc_common.check_elem_or_isotope_marker( ident )
1524
+ if not norm_ident:
1525
+ raise _nc_core.NCBadInput(errmsg+': invalid element or isotope identifier "%s"'%ident)
1526
+ ll.append( ( frac_val, norm_ident ) )
1527
+ fractot = math.fsum(f for f,lbl in ll)
1528
+ if abs(fractot-1.0)>1e-5:
1529
+ raise _nc_core.NCBadInput(errmsg+': fractions do not sum to 1')
1530
+
1531
+ #Now merge identical entries and snap fraction sum to 1:
1532
+ d = {}
1533
+ for fr,nme in ll:
1534
+ if nme not in d:
1535
+ d[nme] = [fr]
1536
+ else:
1537
+ d[nme] += [fr]
1538
+ ll = []
1539
+ for mfr, nme in sorted( (-math.fsum(frs)/fractot,nme) for nme,frs in d.items()):
1540
+ if mfr:
1541
+ ll.append( ( -mfr, nme ) )
1542
+ return label, ll
1543
+
1544
+ def determine_labels_and_atomdb( _self_params, *, fractions, allow_siteoccu_ncmatv5_hack = True ):
1545
+ compositions = _self_params.get('compositions',{})
1546
+ #compositions = copy.deepcopy(compositions)#we will modify a bit below and need to retain immutability
1547
+ atomdb = _self_params.get('atomdb',{})
1548
+ atompos = _self_params.get('atompos',None)
1549
+ occumap = atompos['occumap'] if atompos else None
1550
+ dyninfos = _self_params.get('dyninfos',{})
1551
+ param_fallback_debye_temp = _self_params.get('fallback_debye_temp',None)
1552
+
1553
+ #labels:
1554
+ dyninfo_lbls = set( dyninfos.keys() )
1555
+ atompos_lbls = set( lbl for lbl,x,y,z in atompos['pos'] ) if atompos else set()
1556
+ fractions_lbls = set( (fractions or {}).keys() )
1557
+ direct_lbls = dyninfo_lbls.union( atompos_lbls ).union( fractions_lbls )
1558
+
1559
+ #fill expand implicit compositions to be included explicitly:
1560
+ def _expand_implicit_compositions( direct_lbls, compositions ):
1561
+ for lbl,_ in compositions.items():
1562
+ if lbl not in direct_lbls:
1563
+ raise _nc_core.NCBadInput('Label "%s" was given a composition but is not actually used in the material'%lbl)
1564
+
1565
+ compositions = copy.deepcopy(compositions)#retain immutability
1566
+ for lbl in direct_lbls:
1567
+ if lbl not in compositions:
1568
+ _ = _nc_common.check_elem_or_isotope_marker( lbl )
1569
+ if not _:
1570
+ raise _nc_core.NCBadInput('Not able to determine composition associated with label '
1571
+ f'"{lbl}" since it was not an element or isotope. Please add a definition with .set_composition(..).')
1572
+ compositions[lbl] = [(1.0,_)]
1573
+ return compositions
1574
+ compositions = _expand_implicit_compositions( direct_lbls, compositions )
1575
+
1576
+ #check completeness of labels in other sections:
1577
+ if atompos:
1578
+ if (dyninfo_lbls - atompos_lbls):
1579
+ raise _nc_core.NCBadInput('Some atoms with dynamic information are missing atomic positions: "%s"'%('", "'.join(dyninfo_lbls - atompos_lbls)))
1580
+ if not param_fallback_debye_temp and (atompos_lbls-dyninfo_lbls):
1581
+ raise _nc_core.NCBadInput('Missing dynamic information for some atoms: "%s"'%('", "'.join(atompos_lbls-dyninfo_lbls)))
1582
+ else:
1583
+ if (dyninfo_lbls-fractions_lbls):
1584
+ raise _nc_core.NCBadInput('Some atoms with dynamic information are missing fractions: "%s"'%('", "'.join(dyninfo_lbls - fractions_lbls)))
1585
+
1586
+ lbls_with_nonunit_occu = set( lbl for lbl,occu in occumap.items() if occu != 1.0 ) if occumap else set()
1587
+
1588
+ #SPECIAL HACK BEGIN
1589
+ if lbls_with_nonunit_occu and not allow_siteoccu_ncmatv5_hack:
1590
+ raise _nc_core.NCBadInput('Can not support site occupancies != 1.0 without the special hack for NCMAT v5 (and allow_siteoccu_ncmatv5_hack was set to False)')
1591
+
1592
+ if lbls_with_nonunit_occu:
1593
+ #Handle via (non-persistent) modifications of compositions + atomdb:
1594
+ atomdb = copy.deepcopy(atomdb)
1595
+ atomdb_hack_comments = []
1596
+ for i,lbl in enumerate(sorted(lbls_with_nonunit_occu)):
1597
+ origelem_mass = calc_mass(lbl,compositions[lbl])
1598
+ compos = compositions[lbl][:]
1599
+ origelem_lbl = lbl if compos is None else _nc_common.format_chemform(list((v,k) for k,v in compos))
1600
+ assert 0.0 < origelem_mass < 2000.0
1601
+ #In principle I should check this is not already used, but if anyone
1602
+ #actually needs Og999 or thereabouts, they are obviously just trying
1603
+ #to break our hack by being smartasses :-)
1604
+ hijackedIsotope = 'Og%i'%(299-i)
1605
+ assert hijackedIsotope not in atomdb
1606
+ atomdb[hijackedIsotope] = _nc_core.AtomData.fmt_atomdb_str( origelem_mass, 0.0, 0.0, 0.0 )
1607
+ occu = occumap[lbl]
1608
+ compositions[ lbl ] = [ (occu*frac,elem) for frac,elem in compos ] + [ ( 1.0 - occu, hijackedIsotope ) ]
1609
+ atomdb_hack_comments.append ( f'#Note: Emulating site_occupancy={occu:g} for {origelem_lbl} by hijacking {hijackedIsotope} to play the role as "sterile {origelem_lbl}".' )
1610
+ #SPECIAL HACK END
1611
+
1612
+ atomdb_lines = []
1613
+ atomdb_lines += atomdb_hack_comments
1614
+ for lbl,paramstr in sorted( atomdb.items() ):
1615
+ atomdb_lines.append('%s %s'%(lbl,paramstr))
1616
+
1617
+ #We must remap exactly those labels which are not a single element or
1618
+ #isotopes, or those single/element isotopes that have more than one
1619
+ #role. So first we fill the composition for ALL:
1620
+ lblmap = {}
1621
+ Xidx_next = 1
1622
+ for lbl in sorted(direct_lbls):
1623
+ compos = compositions[lbl]
1624
+ if len(compos) == 1 and sum(1 for lbl,e in compositions.items() if (len(e)==1 and e[0][1]==compos[0][1]) ) == 1:
1625
+ lblmap[ lbl ] = compos[0][1]
1626
+ continue
1627
+ #must use special label X1..X99/X:
1628
+ if Xidx_next>100:
1629
+ raise _nc_core.NCBadInput('Material requires more than 100 special labels which is'
1630
+ +' not supported by NCMAT (allowed are only X1,X2,...,X99 and X')
1631
+ speciallbl = 'X' if Xidx_next==100 else 'X%i'%Xidx_next
1632
+ Xidx_next += 1
1633
+ lblmap[lbl] = speciallbl
1634
+ if len(compos)==1:
1635
+ assert compos[0][0]==1.0
1636
+ atomdb_lines.append( '%s is %s'%(speciallbl,compos[0][1]))
1637
+ else:
1638
+ s=''
1639
+ for frac,name in compos:
1640
+ s += f' {frac:.12g} {name}'
1641
+ atomdb_lines.append( '%s is%s'%(speciallbl,s))
1642
+ return lblmap,atomdb_lines
1643
+
1644
+ def _is_nonempty_array( x ):
1645
+ return hasattr(x,'__len__') and len(x)>0
1646
+
1647
+ def _fmtprecisenum( v, strip_leading_zero = True ):
1648
+ _ = f'{v:.14g}' if v else '0'
1649
+ return _[1:] if (strip_leading_zero and _.startswith('0.')) else _
1650
+
1651
+ def _copyarray_or_None( x ):
1652
+ if x is None or (hasattr(x,'__len__') and len(x)==0):
1653
+ return None
1654
+ from ._numpy import _np
1655
+ return copy.deepcopy(_np.asarray(x,dtype=float) if _np else x)
1656
+
1657
+ def _decode_update_atomdb(element_or_isotope, data, *, mass=None, coh_scat_len=None, incoh_xs=None, abs_xs=None ):
1658
+ e = _nc_common.check_elem_or_isotope_marker( element_or_isotope )
1659
+ if not e:
1660
+ raise _nc_core.NCBadInput(f'Invalid element or isotope label (must be of form "Al", "O16", "D", ...): {e}')
1661
+ label = e
1662
+
1663
+ nphys = int(bool(mass))+int(bool(coh_scat_len))+int(bool(incoh_xs))+int(bool(abs_xs))
1664
+ hasdata = data is not None
1665
+ if not hasdata and not nphys:
1666
+ raise _nc_core.NCBadInput('Missing data parameter(s).')
1667
+ if (hasdata and nphys) or nphys not in (0,4):
1668
+ raise _nc_core.NCBadInput('Inconsistent set of parameters provided.')
1669
+ if hasdata:
1670
+ if hasattr(data,'to_atomdb_str'):
1671
+ #Handle AtomData objects (and others with to_atomdb_str method)
1672
+ res = data.to_atomdb_str()
1673
+ else:
1674
+ res = ' '.join(str(data).replace(':',' ').strip().split())
1675
+ else:
1676
+ assert nphys==4
1677
+ res = _nc_core.AtomData.fmt_atomdb_str( mass = mass,
1678
+ coh_scat_len = coh_scat_len,
1679
+ incoh_xs = incoh_xs,
1680
+ abs_xs = abs_xs )
1681
+ assert res and 'fm ' in res and 'u ' in res and 'b ' in res
1682
+ return label,res
1683
+
1684
+ def _lattice_params_to_vectors( a, b, c, alpha, beta, gamma ):
1685
+ if max(max(alpha,beta),gamma)<=math.pi:
1686
+ raise _nc_core.NCBadInput("angles are expected in degrees not radians")
1687
+ def _sincos( x ):
1688
+ if x == 0:
1689
+ return 0.0, 1.0
1690
+ if x == 90.0:
1691
+ return 1.0, 0.0
1692
+ if x == 120.0:
1693
+ return 0.86602540378443864676372317075293618347140262690519, -0.5
1694
+ return math.sin( x*(math.pi/180) ), math.cos( x*(math.pi/180) )
1695
+ sa,ca = _sincos( alpha )
1696
+ sb,cb = _sincos( beta )
1697
+ sg,cg = _sincos( gamma )
1698
+ assert sg > 0.0
1699
+ vect_a = tuple( a*e for e in ( 1, 0.0, 0.0 ) )
1700
+ vect_b = tuple( b*e for e in ( cg, sg, 0.0 ) )
1701
+ ux = cb
1702
+ assert sg > 0.0
1703
+ assert sg**2 > 0.0
1704
+ uy = (ca-cb*cg)/sg
1705
+ uzsq = sb**2 - uy**2
1706
+ if uzsq < -1e-99:
1707
+ raise _nc_core.NCBadInput('Inconsistent cell parameters detected')
1708
+ vect_c = tuple( c*e for e in ( ux, uy, math.sqrt(max(0.0,uzsq)) ) )#sign of third component positive since we want the three vectors to form a right handed basis.
1709
+ return ( vect_a, vect_b, vect_c )
1710
+
1711
+ def _lattice_vectors_to_params( vect_a, vect_b, vect_c, as_dict = True ):
1712
+ def mag( v ):
1713
+ return math.sqrt( math.fsum(e**2 for e in v) )
1714
+ def dot( v1,v2 ):
1715
+ return math.fsum(e1*e2 for e1,e2 in zip(v1,v2))
1716
+ def ang( v1,v2 ):
1717
+ return math.acos( dot(v1,v2)/(mag(v1)*mag(v2)) )*180/math.pi
1718
+ a,b,c = mag(vect_a), mag(vect_b), mag(vect_c)
1719
+ alpha,beta,gamma = ang(vect_b,vect_c), ang(vect_a,vect_c), ang(vect_a,vect_b)
1720
+ if as_dict:
1721
+ return dict( a=a, b=b, c=c, alpha=alpha, beta=beta, gamma=gamma )
1722
+ else:
1723
+ return a, b, c, alpha, beta, gamma
1724
+
1725
+ def _snap_lattice_params( params_dict, relative_tolerance = 1e-6 ):
1726
+ log = []
1727
+ p = copy.deepcopy(params_dict)
1728
+ def rd( x,y ):
1729
+ return abs(x-y)/(max(1e-300,abs(x)+abs(y)))
1730
+ super_tolerance = 1e-14#almost 64 bit FP prec
1731
+ assert relative_tolerance > super_tolerance
1732
+ def is_near( x,y ):
1733
+ return rd(x,y) < relative_tolerance
1734
+ def is_super_near( x,y ):
1735
+ return rd(x,y) < super_tolerance
1736
+
1737
+ #"snap" b,c to a,b and angles to 60/90/120:
1738
+ for l1,l2 in (('a','b'),('a','c'),('b','c')):
1739
+ if is_near(p[l1],p[l2]):
1740
+ if not is_super_near(p[l1],p[l2]):
1741
+ log.append(f'Snapping lattice parameter {l2} to {l1} since it was very near.')
1742
+ p[l2] = p[l1]
1743
+ for commonangle in (45,60,90,120):
1744
+ for angname in ['alpha','beta','gamma']:
1745
+ if is_near(p[angname],commonangle):
1746
+ if not is_super_near(p[angname],commonangle):
1747
+ log.append(f'Snapping lattice angle {angname} to {commonangle} since it was very near.')
1748
+ p[angname] = commonangle
1749
+ return log, p
1750
+
1751
+ def _cellparams_to_spglib_lattice( cellsg ):
1752
+ return _lattice_params_to_vectors( cellsg['a'], cellsg['b'], cellsg['c'],
1753
+ cellsg['alpha'], cellsg['beta'], cellsg['gamma'] )
1754
+
1755
+ def _reldiff_cellparams( c1, c2 ):
1756
+ #compares a,b,c,alpha,beta,gamma in the two cellsg arrays.
1757
+ def rd( x,y ):
1758
+ return abs(x-y)/(max(1e-300,abs(x)+abs(y)))
1759
+ def rdf( s ):
1760
+ return rd(c1[s],c2[s])
1761
+ return max( rdf(s) for s in ('a','b','c','alpha','beta','gamma') )
1762
+
1763
+ def _reldiff_atompos( spglib_cell1, spglib_cell2 ):
1764
+ #input in spglib cell format, returns largest dist between positions, or
1765
+ #None if not the same type+number of atoms.
1766
+
1767
+ for c in ( spglib_cell1, spglib_cell2 ):
1768
+ assert c is not None
1769
+ assert len(c) >= 3
1770
+ assert c[0] is not None
1771
+ _,allpos1,idxlist1 = spglib_cell1
1772
+ _,allpos2,idxlist2 = spglib_cell2
1773
+ assert len(idxlist1) == len(allpos1)
1774
+ assert len(idxlist2) == len(allpos2)
1775
+ if list(sorted(idxlist1)) != list(sorted(idxlist2)) or len(idxlist1)!=len(idxlist2):
1776
+ return None
1777
+
1778
+ def calc_maxdistsq( l1, l2 ):
1779
+ n = len(l1)
1780
+ assert n == len(l2)
1781
+ distsqmax = 0.0
1782
+ for e1 in l1:
1783
+ dsqmin_e1 = None
1784
+ for e2 in l2:
1785
+ _ = _unit_cell_point_distsq(e1,e2)
1786
+ if dsqmin_e1 is None or _ < dsqmin_e1:
1787
+ dsqmin_e1 = _
1788
+ if _ == 0.0:
1789
+ break
1790
+ assert dsqmin_e1 is not None
1791
+ distsqmax = max( dsqmin_e1, distsqmax )
1792
+ return distsqmax
1793
+
1794
+ max_distsq_seen = None
1795
+ for idx in sorted(set(idxlist1)):
1796
+ def fixp( p ):
1797
+ return (_remap_fract_pos(p[0]),
1798
+ _remap_fract_pos(p[1]),
1799
+ _remap_fract_pos(p[2]))
1800
+ l1 = list( sorted( fixp(p) for i,p in zip(idxlist1,allpos1) if i == idx ) )#nb: fragile sort order!
1801
+ l2 = list( sorted( fixp(p) for i,p in zip(idxlist2,allpos2) if i == idx ) )#nb: fragile sort order!
1802
+ d = calc_maxdistsq( l1, l2 )
1803
+ if d is None:
1804
+ return None
1805
+ if max_distsq_seen is None or d > max_distsq_seen:
1806
+ max_distsq_seen = d
1807
+ return math.sqrt(max_distsq_seen) if max_distsq_seen is not None else None
1808
+
1809
+ def _import_spglib( *, sysexit = False ):
1810
+ try:
1811
+ import spglib#both available on pypi and conda-forge
1812
+ except ImportError:
1813
+ m = ( 'Could not import spglib module needed to standardise and verify crystal structures.'
1814
+ +' The spglib package is available on both PyPI ("python3 -mpip install'
1815
+ +' spglib") and conda ("conda install -c conda-forge spglib")' )
1816
+ if sysexit:
1817
+ raise SystemExit(m)
1818
+ else:
1819
+ raise ImportError(m)
1820
+ return spglib.spglib
1821
+
1822
+ def _import_ase( *, sysexit = False ):
1823
+ try:
1824
+ import ase#both available on pypi and conda-forge
1825
+ import ase.io
1826
+ except ImportError:
1827
+ m = ( 'Could not import ase module.'
1828
+ +' The ase package is available on both PyPI ("python3 -mpip install'
1829
+ +' ase") and conda ("conda install -c conda-forge ase")' )
1830
+ if sysexit:
1831
+ raise SystemExit(m)
1832
+ else:
1833
+ raise ImportError(m)
1834
+ return ase, ase.io
1835
+
1836
+ def _spglib_extractsg( spglib_symdata ):
1837
+ sd = spglib_symdata
1838
+ sgno = ( getattr(sd,'number')
1839
+ if hasattr(sd,'number')
1840
+ else sd['number'] )
1841
+ sgsymb_hermann_mauguin = ( getattr(sd,'international')
1842
+ if hasattr(sd,'international')
1843
+ else sd['international'] )
1844
+ c = ( getattr(sd,'choice')
1845
+ if hasattr(sd,'choice')
1846
+ else sd.get('choice',None))
1847
+ if c:
1848
+ sgsymb_hermann_mauguin += f':{c}'
1849
+ return sgno, sgsymb_hermann_mauguin
1850
+
1851
+ def _check_cell_sg_consistency( sgnumber, a, b, c, alpha, beta, gamma ):
1852
+ sgclass = _nc_common._classifySG(int(sgnumber))
1853
+ if sgclass in ( 'orthorhombic', 'tetragonal', 'cubic'):
1854
+ if sgclass=='cubic':
1855
+ if a!=b or a!=c:
1856
+ raise _nc_core.NCBadInput(f'Cubic space group {sgnumber} requires cell parameters a==b==c (found a={a:g}, b={b:g}, c={c:g})')
1857
+ if not all( (_ == 90) for _ in (alpha, beta, gamma) ):
1858
+ raise _nc_core.NCBadInput(f'Space group {sgnumber} requires cell parameters alpha=beta=gamma=90 '
1859
+ +f'(found alpha={alpha}, beta={beta}, gamma={gamma})')
1860
+ elif sgclass in ('trigonal','hexagonal'):
1861
+ if not ( alpha == 90 and beta == 90 and gamma == 120 ):
1862
+ raise _nc_core.NCBadInput(f'Space group {sgnumber} requires cell parameters alpha=beta=90 and gamma=120 '
1863
+ +f'(found alpha={alpha}, beta={beta}, gamma={gamma})')
1864
+ else:
1865
+ if not all( (0<a<180) for a in ( alpha, beta, gamma ) ):
1866
+ raise _nc_core.NCBadInput('Unit cell must have all angles between 0 and 180'
1867
+ +f' (found alpha={alpha}, beta={beta}, gamma={gamma})')
1868
+ if int(sgnumber) >= 75:
1869
+ if a!=b:
1870
+ raise _nc_core.NCBadInput(f'Space group {sgnumber} requires cell parameters a==b (found a={a:g}, b={b:g}, c={c:g})')
1871
+
1872
+ def _spglib_refine_cell( spglib_cell, symprec = 0.01 ):
1873
+ assert spglib_cell
1874
+ p_orig = _lattice_vectors_to_params( *spglib_cell[0], as_dict = True )
1875
+
1876
+ warnings, msgs = [], []
1877
+ refined_cell, symdata = _impl_spglib_refine_cell( spglib_cell, symprec=symprec, warnings=warnings, msgs=msgs )
1878
+
1879
+ #check difference from first to final, to warn/msg about corrections:
1880
+ p_final = _lattice_vectors_to_params( *refined_cell[0], as_dict = True )
1881
+ _,p_final = _snap_lattice_params( p_final )
1882
+ sgno, sgsymb_hm = _spglib_extractsg( symdata )
1883
+ rdl = _reldiff_cellparams( p_orig, p_final )
1884
+ rda = _reldiff_atompos( spglib_cell, refined_cell )
1885
+ rd = None if (rdl is None or rda is None) else max(rdl,rda)
1886
+ discard_aniso = False
1887
+ if rd is None or rd > 1e-2:
1888
+ discard_aniso = True
1889
+ _ = 'possibly due to conversion from primitive cell' if rd is None else f'at the {100.0*rd:g}% level'
1890
+ warnings.append(f'Structure received major corrections by spglib ({_})')
1891
+ elif rd > 1e-6:
1892
+ warnings.append(f'Structure received minor corrections by spglib (at the {100.0*rd:g}% level)')
1893
+ else:
1894
+ msgs.append('Self-consistency of structure was verified by spglib')
1895
+
1896
+ #remove all but the last of any kind of error message (otherwise we might get many repetitions of 'snapping b=a' kind of messages, due to the recursion.
1897
+
1898
+ warnrev = []
1899
+ for w in warnings[::-1]:
1900
+ if w not in warnrev:
1901
+ warnrev.append(w)
1902
+ warnings = warnrev[::-1]
1903
+
1904
+ return dict( refined_cell = refined_cell,
1905
+ cellparams_snapped = p_final,
1906
+ sgno = sgno,
1907
+ sgsymb_hm = sgsymb_hm,
1908
+ warnings = warnings,
1909
+ msgs = msgs,
1910
+ can_keep_anisotropic_properties = not discard_aniso )
1911
+
1912
+ def _impl_spglib_refine_cell( spglib_cell, *, symprec, warnings, msgs, nrepeat = 0 ):
1913
+ spglib = _import_spglib()
1914
+
1915
+ orig_cell = spglib_cell
1916
+
1917
+ orig_cell_copy = copy.deepcopy( orig_cell )#copy is just a safeguard
1918
+ refined_cell = spglib.standardize_cell( orig_cell_copy, symprec = symprec )
1919
+ symdata = spglib.get_symmetry_dataset( orig_cell_copy )
1920
+ if not refined_cell or not symdata or refined_cell[0] is None:
1921
+ raise _nc_core.NCBadInput('Could not standardize provided crystal structure with spglib')
1922
+
1923
+ p = _lattice_vectors_to_params( *refined_cell[0], as_dict = True )
1924
+
1925
+ snaplog, p = _snap_lattice_params( p )
1926
+ if snaplog or nrepeat == 0:
1927
+ #This is either the first try, of we snapped some parameter
1928
+ #(e.g. alpha=89.99999 -> alpha=90.0). After this a second refinement
1929
+ #might find a different (higher) symmetry:
1930
+ warnings += snaplog[:]
1931
+ refined_cell = tuple( [ _cellparams_to_spglib_lattice( p ) ] + list( e for e in refined_cell[1:] ) )
1932
+ if nrepeat <= 3:
1933
+ return _impl_spglib_refine_cell( refined_cell, symprec = symprec, warnings=warnings, msgs=msgs, nrepeat = nrepeat + 1 )
1934
+
1935
+ return refined_cell, symdata
1936
+
1937
+
1938
+ def _remap_fract_pos_pt(xyz):
1939
+ return ( _remap_fract_pos(xyz[0]),
1940
+ _remap_fract_pos(xyz[1]),
1941
+ _remap_fract_pos(xyz[2]) )
1942
+
1943
+ def _remap_fract_pos(x):
1944
+ x = float(x)
1945
+ if -1.0<=x<0.0:
1946
+ x += 1.0
1947
+ if 1.0<=x<2.0:
1948
+ x -= 1.0
1949
+ return x
1950
+
1951
+ def _unit_cell_point_dist( p1, p2 ):
1952
+ return math.sqrt( _unit_cell_point_distsq( p1, p2 ) )
1953
+
1954
+ def _unit_cell_point_distsq( p1, p2 ):
1955
+ #take into account unit cell periodicity, so e.g. (a,b,epsilon) and
1956
+ #(a,b,1.0-epsilon) are actually only 2*epsilon from each other, not
1957
+ #1.0-epsilon.
1958
+ def remap( x ):
1959
+ return x-math.floor(x)
1960
+ d = [ abs(remap(e1)-remap(e2)) for e1,e2 in zip(p1,p2)]
1961
+ return math.fsum( min(e,1.0-e)**2 for e in d )
1962
+
1963
+ def _cifdata_via_ase( data_or_file, ase_format = None, quiet = False ):
1964
+ import io
1965
+ ase,ase_io = _import_ase()
1966
+ ase_obj = data_or_file if (ase_format=='ase' or NCMATComposerImpl._is_ase_like_object( data_or_file )) else None
1967
+ prfct = ( lambda *a, **kwa : None ) if quiet else _nc_common.print
1968
+
1969
+ if not ase_obj and hasattr( data_or_file, 'load_data' ) and hasattr( data_or_file, 'codid' ) and hasattr( data_or_file, 'mpid' ):
1970
+ #assume object is CIFSource object:
1971
+ prfct('Attempting to load data via ASE')
1972
+ ase_obj = ase_io.read( io.StringIO( data_or_file.load_data( quiet = quiet) ),
1973
+ format = ase_format or 'cif' )
1974
+
1975
+ isbytes = hasattr( data_or_file, 'decode' )
1976
+ str_to_sb = ( lambda s : s ) if not isbytes else ( lambda s : s.encode() )
1977
+ if not ase_obj and ( hasattr(data_or_file,'__fspath__')
1978
+ or str_to_sb('\n') not in data_or_file ):
1979
+ #on-disk data:
1980
+ _ = 'data'
1981
+ if ( hasattr(data_or_file,'__fspath__') or isinstance(data_or_file,str) ):
1982
+ import pathlib
1983
+ _ = pathlib.Path(data_or_file).name
1984
+ prfct(f'Attempting to load {_} via ASE')
1985
+ ase_obj = ase_io.read( data_or_file, format = ase_format )
1986
+ if not ase_obj:
1987
+ #fall back to in-mem data
1988
+ if isbytes:
1989
+ dof_bytes = data_or_file
1990
+ try:
1991
+ dof_str = data_or_file.decode('utf8')
1992
+ except UnicodeDecodeError:
1993
+ dof_str = None
1994
+ else:
1995
+ dof_str = data_or_file
1996
+ try:
1997
+ dof_bytes = data_or_file.encode('utf8')
1998
+ except UnicodeEncodeError:
1999
+ dof_bytes = None
2000
+
2001
+ if not ase_format:
2002
+ import ase.io.formats
2003
+ if dof_bytes is not None:
2004
+ try:
2005
+ ase_format = ase.io.formats.match_magic( dof_bytes ).name
2006
+ except ase.io.formats.UnknownFileTypeError:
2007
+ ase_format = None
2008
+ if ase_format is None and dof_str is not None and '\n' not in dof_str:
2009
+ try:
2010
+ ase_format = ase.io.formats.filetype( dof_str ).name
2011
+ except ase.io.formats.UnknownFileTypeError:
2012
+ ase_format = None
2013
+ if not ase_format:
2014
+ _nc_common.warn('Could not determine ASE format of'
2015
+ ' input data. Assuming CIF.')
2016
+ ase_format = 'cif'
2017
+ assert isinstance(ase_format,str)
2018
+ prfct('Attempting to load data via ASE')
2019
+ if dof_str:
2020
+ assert isinstance(dof_str,str)
2021
+ with io.StringIO(dof_str) as memfile:
2022
+ ase_obj = ase_io.read( memfile, format = ase_format )
2023
+ else:
2024
+ assert isinstance(dof_bytes,bytes)
2025
+ with io.BytesIO(dof_bytes) as memfile:
2026
+ ase_obj = ase_io.read( memfile, format = ase_format )
2027
+
2028
+ assert ase_obj is not None
2029
+
2030
+ with io.BytesIO() as s:
2031
+ ase_io.write(s, ase_obj, format='cif')
2032
+ return s.getvalue().decode()
2033
+
2034
+ def formatVectorForNCMAT(name,values,indent):
2035
+ def provideFormattedEntries():
2036
+ def _fmtnum(num):
2037
+ _ = '%g'%num if num else '0'#avoid 0.0, -0, etc.
2038
+ if _.startswith('0.'):
2039
+ _=_[1:]
2040
+ return _
2041
+ from ._numpy import _np
2042
+ v = _np.asarray(values,dtype=float).flatten() if _np else values
2043
+ i, nv = 0, len(v)
2044
+ while i < nv:
2045
+ fmt_vi=_fmtnum(v[i])
2046
+ #check if is repeated:
2047
+ irepeat=i
2048
+ while irepeat+1<nv:
2049
+ if _fmtnum(v[irepeat+1])==fmt_vi:
2050
+ irepeat+=1
2051
+ else:
2052
+ break
2053
+ yield '%sr%i'%(fmt_vi,1+irepeat-i) if irepeat>i else '%s'%fmt_vi
2054
+ i=irepeat+1#advance
2055
+ out=''
2056
+ line=' %s'%name
2057
+ collim=80
2058
+ for e in provideFormattedEntries():
2059
+ snext=' %s'%e
2060
+ line_next=line+snext
2061
+ if len(line_next)>collim:
2062
+ out += line
2063
+ out += '\n'
2064
+ line = indent+snext
2065
+ else:
2066
+ line = line_next
2067
+ if line:
2068
+ out += line
2069
+ out += '\n'
2070
+ return out
2071
+
2072
+ def _stripCommentsAndPackNCMATData( ncmat_data ):
2073
+ if hasattr( ncmat_data, 'rawData' ):
2074
+ return _stripCommentsAndPackNCMATData( ncmat_data.rawData )
2075
+ if not ncmat_data.startswith('NCMAT'):
2076
+ return ncmat_data#pass through broken data unchanged!
2077
+ out = ['']
2078
+ def addline( s ):
2079
+ if s:
2080
+ out[0] += ( s + '\n' )
2081
+ for e in ncmat_data.splitlines():
2082
+ p = e.split('#',1)
2083
+ if not p:
2084
+ continue
2085
+ p0 = ' '.join(p[0].split())
2086
+ if len(p)==2 and 'NCRYSTALMATCFG[' in p[1]:
2087
+ c = p[1].split('NCRYSTALMATCFG[',1)
2088
+ if len(c)==2:
2089
+ i = c[1].rfind(']')
2090
+ if i == -1:
2091
+ #seems to be a syntax error, just keep everything:
2092
+ addline(p0 + '#' + p[1])
2093
+ else:
2094
+ addline(p0 + '#NCRYSTALMATCFG[' + c[1][0:i].strip() + ']')
2095
+ else:
2096
+ addline(p0)
2097
+ return out[0]
2098
+
2099
+ def _extractInitialHeaderCommentsFromNCMATData( ncmat_data, dedent = True ):
2100
+ #Extracts initial comments, dedented appropriately. Returns None if data
2101
+ #does not look like NCMAT data (an empty list returned is not a failure, it
2102
+ #just means no comments).
2103
+ from .misc import AnyTextData
2104
+ td = AnyTextData(ncmat_data)
2105
+ if '\n' not in td.content and not td.content.startswith('NCMAT'):
2106
+ return None
2107
+ comments = []
2108
+ first = True
2109
+ for line in td.content.splitlines():
2110
+ if first:
2111
+ first = False
2112
+ continue
2113
+ if line.strip().startswith('@'):
2114
+ break
2115
+ line=line.lstrip()
2116
+ if line.startswith('#'):
2117
+ comments.append(line[1:].rstrip())
2118
+ #dedent and return:
2119
+ if dedent:
2120
+ while all( ( e.startswith(' ') or not e) for e in comments ):
2121
+ comments = [ e[1:] for e in comments ]
2122
+ return comments
2123
+
2124
+ def _check_valid_custom_section_name( name ):
2125
+ if ( not name
2126
+ or not isinstance(name,str)
2127
+ or not name.isalpha()
2128
+ or not name.isupper() ):
2129
+ raise _nc_core.NCBadInput(f'Invalid custom section name: "{name}" '
2130
+ '(must be non-empty and contain only'
2131
+ ' capitalised letters A-Z)')