kim-tools 0.2.0b0__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.
- kim_tools/__init__.py +14 -0
- kim_tools/aflow_util/__init__.py +4 -0
- kim_tools/aflow_util/core.py +1782 -0
- kim_tools/aflow_util/data/README_PROTO.TXT +3241 -0
- kim_tools/ase/__init__.py +4 -0
- kim_tools/ase/core.py +749 -0
- kim_tools/kimunits.py +162 -0
- kim_tools/symmetry_util/__init__.py +4 -0
- kim_tools/symmetry_util/core.py +552 -0
- kim_tools/symmetry_util/data/possible_primitive_shifts.json +1 -0
- kim_tools/symmetry_util/data/primitive_GENPOS_ops.json +1 -0
- kim_tools/symmetry_util/data/space_groups_for_each_bravais_lattice.json +179 -0
- kim_tools/symmetry_util/data/wyck_pos_xform_under_normalizer.json +1344 -0
- kim_tools/symmetry_util/data/wyckoff_multiplicities.json +2193 -0
- kim_tools/symmetry_util/data/wyckoff_sets.json +232 -0
- kim_tools/test_driver/__init__.py +4 -0
- kim_tools/test_driver/core.py +1932 -0
- kim_tools/vc/__init__.py +4 -0
- kim_tools/vc/core.py +397 -0
- kim_tools-0.2.0b0.dist-info/METADATA +32 -0
- kim_tools-0.2.0b0.dist-info/RECORD +24 -0
- kim_tools-0.2.0b0.dist-info/WHEEL +5 -0
- kim_tools-0.2.0b0.dist-info/licenses/LICENSE.CDDL +380 -0
- kim_tools-0.2.0b0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1782 @@
|
|
1
|
+
"""Tools for working with crystal prototypes using the AFLOW command line tool"""
|
2
|
+
|
3
|
+
import json
|
4
|
+
import logging
|
5
|
+
import os
|
6
|
+
import re
|
7
|
+
import subprocess
|
8
|
+
import sys
|
9
|
+
from curses.ascii import isalpha, isdigit
|
10
|
+
from dataclasses import dataclass
|
11
|
+
from math import acos, cos, degrees, radians, sin, sqrt
|
12
|
+
from os import PathLike
|
13
|
+
from random import random
|
14
|
+
from tempfile import NamedTemporaryFile
|
15
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
16
|
+
|
17
|
+
import ase
|
18
|
+
import numpy as np
|
19
|
+
from ase import Atoms
|
20
|
+
from ase.cell import Cell
|
21
|
+
from numpy.typing import ArrayLike
|
22
|
+
from sympy import Symbol, linear_eq_to_matrix, matrix2numpy, parse_expr
|
23
|
+
|
24
|
+
from ..symmetry_util import (
|
25
|
+
A_CENTERED_ORTHORHOMBIC_GROUPS,
|
26
|
+
C_CENTERED_ORTHORHOMBIC_GROUPS,
|
27
|
+
CENTERING_DIVISORS,
|
28
|
+
IncorrectNumAtomsException,
|
29
|
+
are_in_same_wyckoff_set,
|
30
|
+
cartesian_rotation_is_in_point_group,
|
31
|
+
get_possible_primitive_shifts,
|
32
|
+
get_primitive_wyckoff_multiplicity,
|
33
|
+
get_wyck_pos_xform_under_normalizer,
|
34
|
+
space_group_numbers_are_enantiomorphic,
|
35
|
+
)
|
36
|
+
|
37
|
+
logger = logging.getLogger(__name__)
|
38
|
+
logging.basicConfig(filename="kim-tools.log", level=logging.INFO, force=True)
|
39
|
+
|
40
|
+
|
41
|
+
__author__ = ["ilia Nikiforov", "Ellad Tadmor"]
|
42
|
+
__all__ = [
|
43
|
+
"EquivalentEqnSet",
|
44
|
+
"EquivalentAtomSet",
|
45
|
+
"write_tmp_poscar_from_atoms_and_run_function",
|
46
|
+
"get_equivalent_atom_sets_from_prototype_and_atom_map",
|
47
|
+
"IncorrectSpaceGroupException",
|
48
|
+
"IncorrectSpeciesException",
|
49
|
+
"InconsistentWyckoffException",
|
50
|
+
"check_number_of_atoms",
|
51
|
+
"split_parameter_array",
|
52
|
+
"internal_parameter_sort_key",
|
53
|
+
"get_stoich_reduced_list_from_prototype",
|
54
|
+
"get_wyckoff_lists_from_prototype",
|
55
|
+
"prototype_labels_are_equivalent",
|
56
|
+
"get_space_group_number_from_prototype",
|
57
|
+
"get_pearson_symbol_from_prototype",
|
58
|
+
"get_bravais_lattice_from_prototype",
|
59
|
+
"read_shortnames",
|
60
|
+
"get_real_to_virtual_species_map",
|
61
|
+
"solve_for_aflow_cell_params_from_primitive_ase_cell_params",
|
62
|
+
"AFLOW",
|
63
|
+
]
|
64
|
+
|
65
|
+
|
66
|
+
class IncorrectSpaceGroupException(Exception):
|
67
|
+
"""
|
68
|
+
Raised when spglib or aflow --sgdata detects a different space group than the one
|
69
|
+
specified in the prototype label
|
70
|
+
"""
|
71
|
+
|
72
|
+
|
73
|
+
class IncorrectSpeciesException(Exception):
|
74
|
+
"""
|
75
|
+
Raised when number or identity of species is inconsistent
|
76
|
+
"""
|
77
|
+
|
78
|
+
|
79
|
+
class InconsistentWyckoffException(Exception):
|
80
|
+
"""
|
81
|
+
Raised when an insonsistency in Wyckoff positions is detected
|
82
|
+
"""
|
83
|
+
|
84
|
+
|
85
|
+
@dataclass
|
86
|
+
class EquivalentEqnSet:
|
87
|
+
"""
|
88
|
+
Set of equations representing the fractional positions of equivalent atoms
|
89
|
+
"""
|
90
|
+
|
91
|
+
species: str
|
92
|
+
wyckoff_letter: str
|
93
|
+
# The n free parameters associated with this Wyckoff postition, 0 <= n <= 3
|
94
|
+
param_names: List[str]
|
95
|
+
# m x 3 x n matrices of coefficients, where m is the multiplicity of the Wyckoff
|
96
|
+
# position
|
97
|
+
coeff_matrix_list: List[ArrayLike]
|
98
|
+
# m x 3 x 1 columns of constant terms in the coordinates. This gets subtracted from
|
99
|
+
# the RHS when solving
|
100
|
+
const_terms_list: List[ArrayLike]
|
101
|
+
|
102
|
+
|
103
|
+
@dataclass
|
104
|
+
class EquivalentAtomSet:
|
105
|
+
"""
|
106
|
+
Set of equivalent atoms
|
107
|
+
"""
|
108
|
+
|
109
|
+
species: str
|
110
|
+
wyckoff_letter: str
|
111
|
+
frac_position_list: List[ArrayLike] # m x 3 x 1 columns
|
112
|
+
|
113
|
+
|
114
|
+
def write_tmp_poscar_from_atoms_and_run_function(
|
115
|
+
atoms: Atoms, function: callable, *args, **kwargs
|
116
|
+
) -> Any:
|
117
|
+
"""
|
118
|
+
Write the Atoms file to a NamedTemporaryFile and run 'function' on it.
|
119
|
+
|
120
|
+
Args:
|
121
|
+
atoms:
|
122
|
+
The atoms object that will be written to a POSCAR file and fed as the
|
123
|
+
first argument to function
|
124
|
+
function: A function that takes a POSCAR file as the first argument
|
125
|
+
|
126
|
+
Returns:
|
127
|
+
Whatever `function` returns
|
128
|
+
"""
|
129
|
+
with NamedTemporaryFile("w+") as fp:
|
130
|
+
atoms.write(fp, sort=True, format="vasp")
|
131
|
+
fp.seek(0)
|
132
|
+
return function(fp.name, *args, **kwargs)
|
133
|
+
|
134
|
+
|
135
|
+
def check_number_of_atoms(
|
136
|
+
atoms: Atoms, prototype_label: str, primitive_cell: bool = True
|
137
|
+
) -> None:
|
138
|
+
"""
|
139
|
+
Check if the Atoms object (which must be a conventional or primitive unit cell)
|
140
|
+
has the correct number of atoms according to prototype_label
|
141
|
+
|
142
|
+
Raises:
|
143
|
+
IncorrectNumAtomsException
|
144
|
+
"""
|
145
|
+
prototype_label_list = prototype_label.split("_")
|
146
|
+
pearson = prototype_label_list[1]
|
147
|
+
|
148
|
+
# get the number of atoms in conventional cell from the Pearson symbol
|
149
|
+
num_conv_cell = 0
|
150
|
+
for character in pearson:
|
151
|
+
if character.isdigit():
|
152
|
+
num_conv_cell *= 10
|
153
|
+
num_conv_cell += int(character)
|
154
|
+
|
155
|
+
centering = pearson[1]
|
156
|
+
|
157
|
+
if centering == "R":
|
158
|
+
num_conv_cell *= 3
|
159
|
+
|
160
|
+
if not primitive_cell:
|
161
|
+
num_lattice = 1
|
162
|
+
else:
|
163
|
+
num_lattice = CENTERING_DIVISORS[centering]
|
164
|
+
|
165
|
+
# This check is probably really extraneous, but better safe than sorry
|
166
|
+
if num_conv_cell % num_lattice != 0:
|
167
|
+
raise IncorrectNumAtomsException(
|
168
|
+
f"WARNING: Number of atoms in conventional cell {num_conv_cell} derived "
|
169
|
+
f"from Pearson symbol of prototype {prototype_label} is not divisible by "
|
170
|
+
f"the number of lattice points {num_lattice}"
|
171
|
+
)
|
172
|
+
|
173
|
+
num_cell = num_conv_cell / num_lattice
|
174
|
+
|
175
|
+
if len(atoms) != num_cell:
|
176
|
+
raise IncorrectNumAtomsException(
|
177
|
+
f"WARNING: Number of ASE atoms {len(atoms)} does not match Pearson symbol "
|
178
|
+
f" of prototype {prototype_label}"
|
179
|
+
)
|
180
|
+
|
181
|
+
|
182
|
+
def split_parameter_array(
|
183
|
+
parameter_names: List[str], list_to_split: Optional[List] = None
|
184
|
+
) -> Tuple[List, List]:
|
185
|
+
"""
|
186
|
+
Split a list of parameters into cell and internal parameters.
|
187
|
+
|
188
|
+
Args:
|
189
|
+
parameter_names:
|
190
|
+
List of AFLOW parameter names, e.g.
|
191
|
+
`["a", "c/a", "x1", "x2", "y2", "z2"]`
|
192
|
+
Proper AFLOW order is assumed, i.e. cell parameters
|
193
|
+
first, then internal.
|
194
|
+
list_to_split:
|
195
|
+
List to split, must be same length as `parameter_names`
|
196
|
+
If omitted, `parameter_names` itself will be split
|
197
|
+
|
198
|
+
Returns:
|
199
|
+
`list_to_split` (or `parameter_names` if `list_to_split` is omitted),
|
200
|
+
split into lists corresponding to the split between cell and internal parameters
|
201
|
+
|
202
|
+
Raises:
|
203
|
+
AssertionError:
|
204
|
+
If lengths are incompatible or if `parameter_names` fails an (incomplete)
|
205
|
+
check that it is a sensible list of AFLOW parameters
|
206
|
+
"""
|
207
|
+
if list_to_split is None:
|
208
|
+
list_to_split = parameter_names
|
209
|
+
|
210
|
+
assert len(list_to_split) == len(
|
211
|
+
parameter_names
|
212
|
+
), "`list_to_split` must have the same length as `parameter_names`"
|
213
|
+
|
214
|
+
in_internal_part = False
|
215
|
+
|
216
|
+
cell_part = []
|
217
|
+
internal_part = []
|
218
|
+
|
219
|
+
CARTESIAN_AXES = ["x", "y", "z"]
|
220
|
+
|
221
|
+
for name, value in zip(parameter_names, list_to_split):
|
222
|
+
assert isinstance(
|
223
|
+
name, str
|
224
|
+
), "At least one element of `parameter_names` is not a string."
|
225
|
+
if not in_internal_part:
|
226
|
+
if name[0] in CARTESIAN_AXES:
|
227
|
+
in_internal_part = True
|
228
|
+
else:
|
229
|
+
# means we have already encountered
|
230
|
+
# an internal coordinate in a past iteration
|
231
|
+
assert name[0] in CARTESIAN_AXES, (
|
232
|
+
"`parameter_names` seems to have an internal parameter "
|
233
|
+
"followed by a non-internal one"
|
234
|
+
)
|
235
|
+
|
236
|
+
if in_internal_part:
|
237
|
+
internal_part.append(value)
|
238
|
+
else:
|
239
|
+
cell_part.append(value)
|
240
|
+
|
241
|
+
return cell_part, internal_part
|
242
|
+
|
243
|
+
|
244
|
+
def internal_parameter_sort_key(parameter_name: Union[Symbol, str]) -> int:
|
245
|
+
"""
|
246
|
+
Sorting key for internal free parameters. Sort by number first, then letter
|
247
|
+
"""
|
248
|
+
parameter_name_str = str(parameter_name)
|
249
|
+
axis = parameter_name_str[0]
|
250
|
+
assert (
|
251
|
+
axis == "x" or axis == "y" or axis == "z"
|
252
|
+
), "Parameter name must start with x, y, or z"
|
253
|
+
number = int(parameter_name_str[1:])
|
254
|
+
return 1000 * number + ord(axis)
|
255
|
+
|
256
|
+
|
257
|
+
def get_equivalent_atom_sets_from_prototype_and_atom_map(
|
258
|
+
atoms: Atoms, prototype_label: str, atom_map: List[int], sort_atoms: bool = False
|
259
|
+
) -> List[EquivalentAtomSet]:
|
260
|
+
"""
|
261
|
+
Get a list of objects representing sets of equivalent atoms from the atom_map of an
|
262
|
+
AFLOW comparison
|
263
|
+
|
264
|
+
The AFLOW comparison should be between `atoms` and `atoms_rebuilt`, in that order,
|
265
|
+
where `atoms_rebuilt` is regenerated from the prototype designation detected from
|
266
|
+
`atoms`, and `prototype_label` is the detected prototype label
|
267
|
+
|
268
|
+
Args:
|
269
|
+
sort_atoms: If `atom_map` was obtained by sorting `atoms` before writing it to
|
270
|
+
POSCAR, set this to True
|
271
|
+
"""
|
272
|
+
|
273
|
+
if sort_atoms:
|
274
|
+
with NamedTemporaryFile("w+") as f:
|
275
|
+
atoms.write(f.name, format="vasp", sort=True)
|
276
|
+
f.seek(0)
|
277
|
+
atoms_in_correct_order = ase.io.read(f.name, format="vasp")
|
278
|
+
else:
|
279
|
+
atoms_in_correct_order = atoms
|
280
|
+
|
281
|
+
# initialize return list
|
282
|
+
equivalent_atom_set_list = []
|
283
|
+
|
284
|
+
redetected_wyckoff_lists = get_wyckoff_lists_from_prototype(prototype_label)
|
285
|
+
sg_num = get_space_group_number_from_prototype(prototype_label)
|
286
|
+
|
287
|
+
index_in_atoms_rebuilt = 0
|
288
|
+
for i_species, species_wyckoff_list in enumerate(redetected_wyckoff_lists):
|
289
|
+
virtual_species_letter = chr(65 + i_species)
|
290
|
+
for wyckoff_letter in species_wyckoff_list:
|
291
|
+
equivalent_atom_set_list.append(
|
292
|
+
EquivalentAtomSet(virtual_species_letter, wyckoff_letter, [])
|
293
|
+
)
|
294
|
+
for _ in range(get_primitive_wyckoff_multiplicity(sg_num, wyckoff_letter)):
|
295
|
+
# atom_map[index_in_atoms] = index_in_atoms_rebuilt
|
296
|
+
index_in_atoms = atom_map.index(index_in_atoms_rebuilt)
|
297
|
+
equivalent_atom_set_list[-1].frac_position_list.append(
|
298
|
+
atoms_in_correct_order.get_scaled_positions()[
|
299
|
+
index_in_atoms
|
300
|
+
].reshape(3, 1)
|
301
|
+
)
|
302
|
+
index_in_atoms_rebuilt += 1
|
303
|
+
|
304
|
+
return equivalent_atom_set_list
|
305
|
+
|
306
|
+
|
307
|
+
def get_stoich_reduced_list_from_prototype(prototype_label: str) -> List[int]:
|
308
|
+
"""
|
309
|
+
Get numerical list of stoichiometry from prototype label, i.e. "AB3_hP8..." -> [1,3]
|
310
|
+
|
311
|
+
Args:
|
312
|
+
prototype_label:
|
313
|
+
AFLOW prototype label
|
314
|
+
|
315
|
+
Returns:
|
316
|
+
List of reduced stoichiometric numbers
|
317
|
+
"""
|
318
|
+
stoich_reduced_formula = prototype_label.split("_")[0]
|
319
|
+
stoich_reduced_list = []
|
320
|
+
stoich_reduced_curr = None
|
321
|
+
for char in stoich_reduced_formula:
|
322
|
+
if isalpha(char):
|
323
|
+
if stoich_reduced_curr is not None:
|
324
|
+
if stoich_reduced_curr == 0:
|
325
|
+
stoich_reduced_curr = 1
|
326
|
+
stoich_reduced_list.append(stoich_reduced_curr)
|
327
|
+
stoich_reduced_curr = 0
|
328
|
+
else:
|
329
|
+
assert isdigit(char)
|
330
|
+
# will throw an error if we haven't encountered an alphabetical letter, good
|
331
|
+
stoich_reduced_curr *= 10
|
332
|
+
stoich_reduced_curr += int(char)
|
333
|
+
# write final number
|
334
|
+
if stoich_reduced_curr == 0:
|
335
|
+
stoich_reduced_curr = 1
|
336
|
+
stoich_reduced_list.append(stoich_reduced_curr)
|
337
|
+
return stoich_reduced_list
|
338
|
+
|
339
|
+
|
340
|
+
def get_wyckoff_lists_from_prototype(prototype_label: str) -> List[str]:
|
341
|
+
"""
|
342
|
+
Expand the list of Wyckoff letters in the prototype to account for each individual
|
343
|
+
letter instead of using numerical multipliers for repeated letters.
|
344
|
+
e.g. A2B3C_mC48_15_aef_3f_2e -> ['aef','fff','ee']
|
345
|
+
"""
|
346
|
+
expanded_wyckoff_letters = []
|
347
|
+
prototype_label_split = prototype_label.split("_")
|
348
|
+
for species_wyckoff_string in prototype_label_split[3:]:
|
349
|
+
expanded_wyckoff_letters.append("")
|
350
|
+
curr_wyckoff_count = 0
|
351
|
+
for char in species_wyckoff_string:
|
352
|
+
if isalpha(char):
|
353
|
+
if curr_wyckoff_count == 0:
|
354
|
+
curr_wyckoff_count = 1
|
355
|
+
expanded_wyckoff_letters[-1] += char * curr_wyckoff_count
|
356
|
+
curr_wyckoff_count = 0
|
357
|
+
else:
|
358
|
+
assert isdigit(char)
|
359
|
+
curr_wyckoff_count *= 10 # if it's zero, we're all good
|
360
|
+
curr_wyckoff_count += int(char)
|
361
|
+
return expanded_wyckoff_letters
|
362
|
+
|
363
|
+
|
364
|
+
def prototype_labels_are_equivalent(
|
365
|
+
prototype_label_1: str,
|
366
|
+
prototype_label_2: str,
|
367
|
+
allow_enantiomorph: bool = False,
|
368
|
+
allow_species_permutation: bool = False,
|
369
|
+
) -> bool:
|
370
|
+
"""
|
371
|
+
Checks if two prototype labels are equivalent
|
372
|
+
"""
|
373
|
+
if allow_species_permutation:
|
374
|
+
# TODO: Possibly add this (for checking library prototype labels)
|
375
|
+
raise NotImplementedError("Species permutations not implemented")
|
376
|
+
|
377
|
+
if prototype_label_1 == prototype_label_2:
|
378
|
+
return True
|
379
|
+
|
380
|
+
# Check stoichiometry
|
381
|
+
stoich_reduced_list_1 = get_stoich_reduced_list_from_prototype(prototype_label_1)
|
382
|
+
|
383
|
+
if not stoich_reduced_list_1 == get_stoich_reduced_list_from_prototype(
|
384
|
+
prototype_label_2
|
385
|
+
):
|
386
|
+
logger.info(
|
387
|
+
"Found non-matching stoichiometry in labels "
|
388
|
+
f"{prototype_label_1} and {prototype_label_2}"
|
389
|
+
)
|
390
|
+
return False
|
391
|
+
|
392
|
+
# Check Pearson symbol
|
393
|
+
if not get_pearson_symbol_from_prototype(
|
394
|
+
prototype_label_1
|
395
|
+
) == get_pearson_symbol_from_prototype(prototype_label_2):
|
396
|
+
logger.info(
|
397
|
+
"Found non-matching Pearson symbol in labels "
|
398
|
+
f"{prototype_label_1} and {prototype_label_2}"
|
399
|
+
)
|
400
|
+
return False
|
401
|
+
|
402
|
+
# Check space group number
|
403
|
+
sg_num_1 = get_space_group_number_from_prototype(prototype_label_1)
|
404
|
+
sg_num_2 = get_space_group_number_from_prototype(prototype_label_2)
|
405
|
+
if allow_enantiomorph and not space_group_numbers_are_enantiomorphic(
|
406
|
+
sg_num_2, sg_num_1
|
407
|
+
):
|
408
|
+
logger.info(
|
409
|
+
"Found non-matching Space group in labels "
|
410
|
+
f"{prototype_label_1} and {prototype_label_2}"
|
411
|
+
)
|
412
|
+
return False
|
413
|
+
elif sg_num_2 != sg_num_1:
|
414
|
+
logger.info(
|
415
|
+
"Found non-matching Space group in labels "
|
416
|
+
f"{prototype_label_1} and {prototype_label_2}"
|
417
|
+
)
|
418
|
+
return False
|
419
|
+
|
420
|
+
# OK, so far everything matches, now check the Wyckoff letters
|
421
|
+
# Get lists of Wyckoff letters for each species,
|
422
|
+
# e.g. A2B3C_mC48_15_aef_3f_2e -> ['aef', 'fff', 'ee']
|
423
|
+
wyckoff_lists_1 = get_wyckoff_lists_from_prototype(prototype_label_1)
|
424
|
+
wyckoff_lists_2 = get_wyckoff_lists_from_prototype(prototype_label_2)
|
425
|
+
num_species = len(stoich_reduced_list_1)
|
426
|
+
assert len(wyckoff_lists_1) == len(wyckoff_lists_2) == num_species, (
|
427
|
+
"Somehow I got non-matching lists of Wyckoff letters, "
|
428
|
+
"the prototype labels are probably malformed"
|
429
|
+
)
|
430
|
+
|
431
|
+
if sg_num_1 >= 16:
|
432
|
+
# Theoretically, unless we are allowing species permutations, orthorhombic and
|
433
|
+
# higher SGs should always have identical prototype labels due to minimal
|
434
|
+
# Wyckoff enumeration. However, there are bugs in AFLOW making this untrue.
|
435
|
+
|
436
|
+
wyck_pos_xform_list = get_wyck_pos_xform_under_normalizer(sg_num_1)
|
437
|
+
for wyck_pos_xform in wyck_pos_xform_list:
|
438
|
+
wyckoff_lists_match_for_each_species = True
|
439
|
+
for i in range(num_species):
|
440
|
+
wyckoff_list_ref = wyckoff_lists_1[i]
|
441
|
+
wyckoff_list_test = ""
|
442
|
+
for test_letter in wyckoff_lists_2[i]:
|
443
|
+
if test_letter == "A":
|
444
|
+
# SG47 runs out of the alphabet and uses capital A for its
|
445
|
+
# general position (which is unchanged under any transformation
|
446
|
+
# in the normalizer). However, we need "A" to be last, so we
|
447
|
+
# make it "{" to make it sort after all lowercase letters and
|
448
|
+
# replace it after
|
449
|
+
wyckoff_list_test += "{"
|
450
|
+
else:
|
451
|
+
test_letter_index = ord(test_letter) - 97
|
452
|
+
wyckoff_list_test += wyck_pos_xform[test_letter_index]
|
453
|
+
wyckoff_list_test = "".join(sorted(wyckoff_list_test))
|
454
|
+
wyckoff_list_test = wyckoff_list_test.replace("{", "A")
|
455
|
+
if wyckoff_list_test != wyckoff_list_ref:
|
456
|
+
wyckoff_lists_match_for_each_species = False
|
457
|
+
break
|
458
|
+
if wyckoff_lists_match_for_each_species:
|
459
|
+
logger.warning(
|
460
|
+
f"Labels {prototype_label_1} and {prototype_label_2} were found to "
|
461
|
+
"be equivalent despite being non-identical. "
|
462
|
+
"This indicates a failure to find the lowest Wyckoff enumeration."
|
463
|
+
)
|
464
|
+
return True
|
465
|
+
logger.info(
|
466
|
+
f"Labels {prototype_label_1} and {prototype_label_2} were not found to be "
|
467
|
+
"equivalent under any permutations allowable by the normalizer."
|
468
|
+
)
|
469
|
+
return False
|
470
|
+
else:
|
471
|
+
for wyckoff_list_1, wyckoff_list_2 in zip(wyckoff_lists_1, wyckoff_lists_2):
|
472
|
+
for letter_1, letter_2 in zip(wyckoff_list_1, wyckoff_list_2):
|
473
|
+
# Wyckoff sets are alphabetically contiguous in SG1-15, there is no
|
474
|
+
# need to re-sort anything.
|
475
|
+
# This is NOT true for all SGs (e.g. #200, Wyckoff set eh )
|
476
|
+
if not are_in_same_wyckoff_set(letter_1, letter_2, sg_num_1):
|
477
|
+
logger.info(
|
478
|
+
f"Labels {prototype_label_1} and {prototype_label_2} have "
|
479
|
+
f"corresponding letters {letter_1} and {letter_2} that are not "
|
480
|
+
"in the same Wyckoff set"
|
481
|
+
)
|
482
|
+
return False
|
483
|
+
logger.info(
|
484
|
+
f"Labels {prototype_label_1} and {prototype_label_2} were found to be "
|
485
|
+
"equivalent despite being non-identical. This is a normal occurrence for "
|
486
|
+
"triclinic and monoclinic space groups such as this."
|
487
|
+
)
|
488
|
+
return True
|
489
|
+
|
490
|
+
|
491
|
+
def get_space_group_number_from_prototype(prototype_label: str) -> int:
|
492
|
+
return int(prototype_label.split("_")[2])
|
493
|
+
|
494
|
+
|
495
|
+
def get_pearson_symbol_from_prototype(prototype_label: str) -> str:
|
496
|
+
return prototype_label.split("_")[1]
|
497
|
+
|
498
|
+
|
499
|
+
def get_bravais_lattice_from_prototype(prototype_label: str) -> str:
|
500
|
+
return get_pearson_symbol_from_prototype(prototype_label)[:2]
|
501
|
+
|
502
|
+
|
503
|
+
def read_shortnames() -> Dict:
|
504
|
+
"""
|
505
|
+
This function parses "README_PROTO.TXT". It finds each line that (after stripping
|
506
|
+
whitespace) starts with "ANRL Label". These are headers of sections of prototype
|
507
|
+
listings. It finds the column of the word "notes". This will be the column that the
|
508
|
+
shortnames are in. Skipping various non-prototype lines, the first column in each
|
509
|
+
prototype line (before the ".") is the prototype, while the end of the line
|
510
|
+
starting from the "notes" column, cleaned up to remove whitespace and
|
511
|
+
end-of-shortname comments (i.e. "(part 3)"), is the shortname.
|
512
|
+
|
513
|
+
Returns:
|
514
|
+
A dictionary where the keys are the prototype strings, and the values are the
|
515
|
+
shortnames found in the corresponding lines.
|
516
|
+
"""
|
517
|
+
shortnames = {}
|
518
|
+
shortname_file = "data/README_PROTO.TXT"
|
519
|
+
notes_index = None
|
520
|
+
with open(
|
521
|
+
os.path.join(os.path.dirname(os.path.realpath(__file__)), shortname_file),
|
522
|
+
encoding="utf-8",
|
523
|
+
) as f:
|
524
|
+
for line in f:
|
525
|
+
line = line.strip()
|
526
|
+
if line.startswith("ANRL Label"):
|
527
|
+
try:
|
528
|
+
notes_index = line.index("notes")
|
529
|
+
continue
|
530
|
+
except Exception:
|
531
|
+
print("ERROR: ANRL Label line without notes header")
|
532
|
+
print(line)
|
533
|
+
sys.exit()
|
534
|
+
# Skip this line if it's before the first ANRL label
|
535
|
+
if notes_index is None:
|
536
|
+
continue
|
537
|
+
# Skip this line if it's empty, a comment, or a divider
|
538
|
+
if (
|
539
|
+
line == ""
|
540
|
+
or line == "\n"
|
541
|
+
or line.startswith("*")
|
542
|
+
or line.startswith("-")
|
543
|
+
or line.startswith("ANRL")
|
544
|
+
):
|
545
|
+
continue
|
546
|
+
# Skip this line if it only has content in the first column
|
547
|
+
# (prototype runover from previous line)
|
548
|
+
try:
|
549
|
+
_ = line.split(" ")[1]
|
550
|
+
except Exception:
|
551
|
+
continue
|
552
|
+
# Clean up prototype (remove decorations suffix)
|
553
|
+
prototype = line.split(" ")[0]
|
554
|
+
if "." in prototype:
|
555
|
+
idx = prototype.index(".")
|
556
|
+
prototype = prototype[:idx]
|
557
|
+
# Clean up short name
|
558
|
+
sname = line[notes_index:]
|
559
|
+
if "(part " in sname:
|
560
|
+
idx = sname.index("(part")
|
561
|
+
sname = sname[:idx]
|
562
|
+
sname = sname.replace(", part 3", "")
|
563
|
+
if "ICSD" in sname:
|
564
|
+
idx = sname.index("ICSD")
|
565
|
+
tmp = sname[idx:].split(" ")[1]
|
566
|
+
if tmp.endswith(","):
|
567
|
+
sname = sname[:idx] + "ICSD " + tmp[:-1]
|
568
|
+
if " similar to" in sname:
|
569
|
+
idx = sname.index(" similar to")
|
570
|
+
sname = sname[:idx]
|
571
|
+
if " equivalent to" in sname:
|
572
|
+
idx = sname.index(" equivalent to")
|
573
|
+
sname = sname[:idx]
|
574
|
+
if sname.endswith(","):
|
575
|
+
sname = sname[:-1]
|
576
|
+
# add prototype to shortnames dictionary
|
577
|
+
shortnames[prototype] = sname.rstrip()
|
578
|
+
return shortnames
|
579
|
+
|
580
|
+
|
581
|
+
def get_real_to_virtual_species_map(input: Union[List[str], Atoms]) -> Dict:
|
582
|
+
"""
|
583
|
+
Map real species to virtual species according to (alphabetized) AFLOW convention,
|
584
|
+
e.g. for SiC return {'C':'A', 'Si':'B'}
|
585
|
+
"""
|
586
|
+
if isinstance(input, Atoms):
|
587
|
+
species = sorted(list(set(input.get_chemical_symbols())))
|
588
|
+
else:
|
589
|
+
species = input
|
590
|
+
|
591
|
+
real_to_virtual_species_map = {}
|
592
|
+
for i, symbol in enumerate(species):
|
593
|
+
real_to_virtual_species_map[symbol] = chr(65 + i)
|
594
|
+
|
595
|
+
return real_to_virtual_species_map
|
596
|
+
|
597
|
+
|
598
|
+
def solve_for_aflow_cell_params_from_primitive_ase_cell_params(
|
599
|
+
cellpar_prim: ArrayLike, prototype_label: str
|
600
|
+
) -> List[float]:
|
601
|
+
"""
|
602
|
+
Get conventional cell parameters from primitive cell parameters. It is assumed that
|
603
|
+
the primitive cell is related to the conventional cell as specified in
|
604
|
+
10.1016/j.commatsci.2017.01.017. Equations obtained from Wolfram notebook in
|
605
|
+
``scripts/cell_param_solver.nb``
|
606
|
+
|
607
|
+
Args:
|
608
|
+
cellpar_prim:
|
609
|
+
The 6 cell parameters of the primitive unit cell:
|
610
|
+
[a, b, c, alpha, beta, gamma]
|
611
|
+
prototype_label:
|
612
|
+
The AFLOW prototype label of the crystal
|
613
|
+
|
614
|
+
Returns:
|
615
|
+
The cell parameters expected by AFLOW for the prototype label provided. The
|
616
|
+
first parameter is always "a" and is given in the same units as
|
617
|
+
``cellpar_prim``, the others are fractional parameters in terms of "a", or
|
618
|
+
angles in degrees. For example, if the ``prototype_label`` provided indicates a
|
619
|
+
monoclinic crystal, this function will return the values of [a, b/a, c/a, beta]
|
620
|
+
"""
|
621
|
+
bravais_lattice = get_bravais_lattice_from_prototype(prototype_label)
|
622
|
+
|
623
|
+
assert len(cellpar_prim) == 6, "Got a number of cell parameters that is not 6"
|
624
|
+
|
625
|
+
for length in cellpar_prim[0:3]:
|
626
|
+
assert length > 0, "Got a negative cell size"
|
627
|
+
for angle in cellpar_prim[3:]:
|
628
|
+
assert 0 < angle < 180, "Got a cell angle outside of (0,180)"
|
629
|
+
|
630
|
+
aprim = cellpar_prim[0]
|
631
|
+
bprim = cellpar_prim[1]
|
632
|
+
cprim = cellpar_prim[2]
|
633
|
+
alphaprim = cellpar_prim[3]
|
634
|
+
betaprim = cellpar_prim[4]
|
635
|
+
gammaprim = cellpar_prim[5]
|
636
|
+
|
637
|
+
if bravais_lattice == "aP":
|
638
|
+
return [aprim, bprim / aprim, cprim / aprim, alphaprim, betaprim, gammaprim]
|
639
|
+
elif bravais_lattice == "mP":
|
640
|
+
return [aprim, bprim / aprim, cprim / aprim, betaprim]
|
641
|
+
elif bravais_lattice == "oP":
|
642
|
+
return [aprim, bprim / aprim, cprim / aprim]
|
643
|
+
elif bravais_lattice == "tP" or bravais_lattice == "hP":
|
644
|
+
return [aprim, cprim / aprim]
|
645
|
+
elif bravais_lattice == "cP":
|
646
|
+
return [aprim]
|
647
|
+
elif bravais_lattice == "mC":
|
648
|
+
cos_alphaprim = cos(radians(alphaprim))
|
649
|
+
cos_gammaprim = cos(radians(gammaprim))
|
650
|
+
a = aprim * sqrt(2 + 2 * cos_gammaprim)
|
651
|
+
b = aprim * sqrt(2 - 2 * cos_gammaprim)
|
652
|
+
c = cprim
|
653
|
+
beta = degrees(acos(cos_alphaprim / sqrt((1 + cos_gammaprim) / 2)))
|
654
|
+
return [a, b / a, c / a, beta]
|
655
|
+
elif bravais_lattice == "oC":
|
656
|
+
# the 'C' is colloquial, and can refer to either C or A-centering
|
657
|
+
space_group_number = get_space_group_number_from_prototype(prototype_label)
|
658
|
+
if space_group_number in C_CENTERED_ORTHORHOMBIC_GROUPS:
|
659
|
+
cos_gammaprim = cos(radians(gammaprim))
|
660
|
+
a = bprim * sqrt(2 + 2 * cos_gammaprim)
|
661
|
+
b = bprim * sqrt(2 - 2 * cos_gammaprim)
|
662
|
+
c = cprim
|
663
|
+
elif space_group_number in A_CENTERED_ORTHORHOMBIC_GROUPS:
|
664
|
+
cos_alphaprim = cos(radians(alphaprim))
|
665
|
+
a = aprim
|
666
|
+
b = bprim * sqrt(2 + 2 * cos_alphaprim)
|
667
|
+
c = bprim * sqrt(2 - 2 * cos_alphaprim)
|
668
|
+
else:
|
669
|
+
raise IncorrectSpaceGroupException(
|
670
|
+
f"Space group in prototype label {prototype_label} not found in lists "
|
671
|
+
"of side-centered orthorhombic groups"
|
672
|
+
)
|
673
|
+
return [a, b / a, c / a]
|
674
|
+
elif bravais_lattice == "oI":
|
675
|
+
cos_alphaprim = cos(radians(alphaprim))
|
676
|
+
cos_betaprim = cos(radians(betaprim))
|
677
|
+
a = aprim * sqrt(2 + 2 * cos_alphaprim)
|
678
|
+
b = aprim * sqrt(2 + 2 * cos_betaprim)
|
679
|
+
# I guess the cosines must sum to a negative number!?
|
680
|
+
# Will raise a ValueError: math domain error if not
|
681
|
+
c = aprim * sqrt(-2 * (cos_alphaprim + cos_betaprim))
|
682
|
+
return [a, b / a, c / a]
|
683
|
+
elif bravais_lattice == "oF":
|
684
|
+
aprimsq = aprim * aprim
|
685
|
+
bprimsq = bprim * bprim
|
686
|
+
cprimsq = cprim * cprim
|
687
|
+
a = sqrt(2 * (-aprimsq + bprimsq + cprimsq))
|
688
|
+
b = sqrt(2 * (aprimsq - bprimsq + cprimsq))
|
689
|
+
c = sqrt(2 * (aprimsq + bprimsq - cprimsq))
|
690
|
+
return [a, b / a, c / a]
|
691
|
+
elif bravais_lattice == "tI":
|
692
|
+
cos_alphaprim = cos(radians(alphaprim))
|
693
|
+
a = aprim * sqrt(2 + 2 * cos_alphaprim)
|
694
|
+
# I guess primitive alpha is always obtuse!? Will raise a
|
695
|
+
# ValueError: math domain error if not
|
696
|
+
c = 2 * aprim * sqrt(-cos_alphaprim)
|
697
|
+
return [a, c / a]
|
698
|
+
elif bravais_lattice == "hR":
|
699
|
+
cos_alphaprim = cos(radians(alphaprim))
|
700
|
+
a = aprim * sqrt(2 - 2 * cos_alphaprim)
|
701
|
+
c = aprim * sqrt(3 + 6 * cos_alphaprim)
|
702
|
+
return [a, c / a]
|
703
|
+
elif bravais_lattice == "cF":
|
704
|
+
return [aprim * sqrt(2)]
|
705
|
+
elif bravais_lattice == "cI":
|
706
|
+
return [aprim * 2 / sqrt(3)]
|
707
|
+
|
708
|
+
|
709
|
+
class AFLOW:
|
710
|
+
"""
|
711
|
+
Class enabling access to the AFLOW executable
|
712
|
+
|
713
|
+
Attributes:
|
714
|
+
aflow_executable (str): Name of the AFLOW executable
|
715
|
+
aflow_work_dir (str): Path to the work directory
|
716
|
+
np (int):
|
717
|
+
Number of processors to use, passed to the AFLOW executable using the
|
718
|
+
``--np=...`` argument
|
719
|
+
|
720
|
+
"""
|
721
|
+
|
722
|
+
class ChangedSymmetryException(Exception):
|
723
|
+
"""
|
724
|
+
Raised when an unexpected symmetry change is detected
|
725
|
+
"""
|
726
|
+
|
727
|
+
class FailedToMatchException(Exception):
|
728
|
+
"""
|
729
|
+
Raised when ``aflow --compare...`` fails to match
|
730
|
+
"""
|
731
|
+
|
732
|
+
class FailedToSolveException(Exception):
|
733
|
+
"""
|
734
|
+
Raised when solution algorithm fails
|
735
|
+
"""
|
736
|
+
|
737
|
+
def __init__(
|
738
|
+
self, aflow_executable: str = "aflow", aflow_work_dir: str = "", np: int = 4
|
739
|
+
):
|
740
|
+
"""
|
741
|
+
Args:
|
742
|
+
aflow_executable: Sets :attr:`aflow_executable`
|
743
|
+
aflow_work_dir: Sets :attr:`aflow_work_dir`
|
744
|
+
np: Sets :attr:`np`
|
745
|
+
"""
|
746
|
+
self.aflow_executable = aflow_executable
|
747
|
+
self.np = np
|
748
|
+
if aflow_work_dir != "" and not aflow_work_dir.endswith("/"):
|
749
|
+
self.aflow_work_dir = aflow_work_dir + "/"
|
750
|
+
else:
|
751
|
+
self.aflow_work_dir = aflow_work_dir
|
752
|
+
|
753
|
+
def aflow_command(self, cmd: Union[str, List[str]], verbose=True) -> str:
|
754
|
+
"""
|
755
|
+
Run AFLOW executable with specified arguments and return the output, possibly
|
756
|
+
multiple times piping outputs to each other
|
757
|
+
|
758
|
+
Args:
|
759
|
+
cmd:
|
760
|
+
List of arguments to pass to each AFLOW executable.
|
761
|
+
If it's longer than 1, multiple commands will be piped to each other
|
762
|
+
verbose: Whether to echo command to log file
|
763
|
+
|
764
|
+
Raises:
|
765
|
+
AFLOW.ChangedSymmetryException:
|
766
|
+
if an ``aflow --proto=`` command complains that
|
767
|
+
"the structure has a higher symmetry than indicated by the label"
|
768
|
+
|
769
|
+
Returns:
|
770
|
+
Output of the AFLOW command
|
771
|
+
"""
|
772
|
+
if not isinstance(cmd, list):
|
773
|
+
cmd = [cmd]
|
774
|
+
|
775
|
+
cmd_list = [
|
776
|
+
self.aflow_executable + " --np=" + str(self.np) + " " + cmd_inst
|
777
|
+
for cmd_inst in cmd
|
778
|
+
]
|
779
|
+
cmd_str = " | ".join(cmd_list)
|
780
|
+
if verbose:
|
781
|
+
logger.info(cmd_str)
|
782
|
+
try:
|
783
|
+
return subprocess.check_output(
|
784
|
+
cmd_str, shell=True, stderr=subprocess.PIPE, encoding="utf-8"
|
785
|
+
)
|
786
|
+
except subprocess.CalledProcessError as exc:
|
787
|
+
if "--proto=" in cmd_str and (
|
788
|
+
"The structure has a higher symmetry than indicated by the "
|
789
|
+
"label. The correct label and parameters for this structure are:"
|
790
|
+
) in str(exc.stderr):
|
791
|
+
warn_str = (
|
792
|
+
"WARNING: the following command refused to write a POSCAR because "
|
793
|
+
f"it detected a higher symmetry: {cmd_str}. "
|
794
|
+
f"AFLOW error follows:\n{str(exc.stderr)}"
|
795
|
+
)
|
796
|
+
logger.warning(warn_str)
|
797
|
+
raise self.ChangedSymmetryException(warn_str)
|
798
|
+
else:
|
799
|
+
raise exc
|
800
|
+
|
801
|
+
def write_poscar_from_prototype(
|
802
|
+
self,
|
803
|
+
prototype_label: str,
|
804
|
+
species: Optional[List[str]] = None,
|
805
|
+
parameter_values: Optional[List[float]] = None,
|
806
|
+
output_file: Optional[str] = None,
|
807
|
+
verbose: bool = True,
|
808
|
+
addtl_args: str = "",
|
809
|
+
) -> Optional[str]:
|
810
|
+
"""
|
811
|
+
Run the ``aflow --proto`` command to write a POSCAR coordinate file
|
812
|
+
corresponding to the provided AFLOW prototype designation.
|
813
|
+
This file will have fractional coordinates.
|
814
|
+
|
815
|
+
Args:
|
816
|
+
prototype_label:
|
817
|
+
An AFLOW prototype label, with or without an enumeration suffix
|
818
|
+
species:
|
819
|
+
List of stoichiometric species of the crystal. If this is omitted,
|
820
|
+
the file will be written without species info
|
821
|
+
parameter_values:
|
822
|
+
The free parameters of the AFLOW prototype designation. If an
|
823
|
+
enumeration suffix is not included in `prototype_label`
|
824
|
+
and the prototype has free parameters besides `a`, this must be provided
|
825
|
+
output_file:
|
826
|
+
Name of the output file. If not provided,
|
827
|
+
the output is returned as a string
|
828
|
+
verbose: Whether to echo command to log file
|
829
|
+
addtl_args:
|
830
|
+
additional arguments to pass, e.g. ``--equations_only`` to get equations
|
831
|
+
|
832
|
+
Returns:
|
833
|
+
The output of the command or None if an `output_file` was given
|
834
|
+
|
835
|
+
Raises:
|
836
|
+
AFLOW.ChangedSymmetryException:
|
837
|
+
if an ``aflow --proto=`` command complains that
|
838
|
+
"the structure has a higher symmetry than indicated by the label"
|
839
|
+
"""
|
840
|
+
command = " --proto=" + prototype_label
|
841
|
+
if parameter_values:
|
842
|
+
command += " --params=" + ",".join(
|
843
|
+
[str(param) for param in parameter_values]
|
844
|
+
)
|
845
|
+
|
846
|
+
command += " " + addtl_args
|
847
|
+
|
848
|
+
try:
|
849
|
+
poscar_string_no_species = self.aflow_command(command, verbose=verbose)
|
850
|
+
except self.ChangedSymmetryException as e:
|
851
|
+
# re-raise, just indicating that this function knows about this exception
|
852
|
+
raise e
|
853
|
+
|
854
|
+
if species is None:
|
855
|
+
poscar_string = poscar_string_no_species
|
856
|
+
else:
|
857
|
+
poscar_string = ""
|
858
|
+
for i, line in enumerate(
|
859
|
+
poscar_string_no_species.splitlines(keepends=True)
|
860
|
+
):
|
861
|
+
poscar_string += line
|
862
|
+
if i == 4:
|
863
|
+
poscar_string += " ".join(species) + "\n"
|
864
|
+
if output_file is None:
|
865
|
+
return poscar_string
|
866
|
+
else:
|
867
|
+
with open(self.aflow_work_dir + output_file, "w") as f:
|
868
|
+
f.write(poscar_string)
|
869
|
+
|
870
|
+
def build_atoms_from_prototype(
|
871
|
+
self,
|
872
|
+
prototype_label: str,
|
873
|
+
species: List[str],
|
874
|
+
parameter_values: Optional[List[float]] = None,
|
875
|
+
proto_file: Optional[str] = None,
|
876
|
+
verbose: bool = True,
|
877
|
+
) -> Atoms:
|
878
|
+
"""
|
879
|
+
Build an atoms object from an AFLOW prototype designation
|
880
|
+
|
881
|
+
Args:
|
882
|
+
prototype_label:
|
883
|
+
An AFLOW prototype label, with or without an enumeration suffix
|
884
|
+
species:
|
885
|
+
Stoichiometric species, e.g. ["Mo", "S"] corresponding to A and B
|
886
|
+
respectively for prototype label AB2_hP6_194_c_f indicating molybdenite
|
887
|
+
parameter_values:
|
888
|
+
The free parameters of the AFLOW prototype designation. If an
|
889
|
+
enumeration suffix is not included in `prototype_label`
|
890
|
+
and the prototype has free parameters besides `a`, this must be provided
|
891
|
+
proto_file:
|
892
|
+
Write the POSCAR to this permanent file for debugging
|
893
|
+
instead of a temporary file
|
894
|
+
verbose:
|
895
|
+
Print details in the log file
|
896
|
+
|
897
|
+
Returns:
|
898
|
+
Object representing unit cell of the material
|
899
|
+
|
900
|
+
Raises:
|
901
|
+
AFLOW.ChangedSymmetryException:
|
902
|
+
if an ``aflow --proto=`` command complains that
|
903
|
+
"the structure has a higher symmetry than indicated by the label"
|
904
|
+
"""
|
905
|
+
try:
|
906
|
+
poscar_string = self.write_poscar_from_prototype(
|
907
|
+
prototype_label=prototype_label,
|
908
|
+
species=species,
|
909
|
+
parameter_values=parameter_values,
|
910
|
+
verbose=verbose,
|
911
|
+
)
|
912
|
+
except self.ChangedSymmetryException as e:
|
913
|
+
# re-raise, just indicating that this function knows about this exception
|
914
|
+
raise e
|
915
|
+
|
916
|
+
with (
|
917
|
+
NamedTemporaryFile(mode="w+")
|
918
|
+
if proto_file is None
|
919
|
+
else open(proto_file, mode="w+")
|
920
|
+
) as f:
|
921
|
+
f.write(poscar_string)
|
922
|
+
f.seek(0)
|
923
|
+
atoms = ase.io.read(f.name, format="vasp")
|
924
|
+
check_number_of_atoms(atoms, prototype_label)
|
925
|
+
atoms.wrap()
|
926
|
+
return atoms
|
927
|
+
|
928
|
+
def compare_materials_dir(
|
929
|
+
self, materials_subdir: str, no_scale_volume: bool = True
|
930
|
+
) -> List[Dict]:
|
931
|
+
"""
|
932
|
+
Compare a directory of materials using the aflow --compare_materials -D tool
|
933
|
+
|
934
|
+
Args:
|
935
|
+
materials_subdir:
|
936
|
+
Path to the directory to compare from self.aflow_work_dir
|
937
|
+
no_scale_volume:
|
938
|
+
If `True`, the default behavior of allowing arbitrary scaling of
|
939
|
+
structures before comparison is turned off
|
940
|
+
|
941
|
+
Returns:
|
942
|
+
Attributes of representative structures, their duplicates, and groups
|
943
|
+
as a whole
|
944
|
+
"""
|
945
|
+
# TODO: For efficiency, it is possible to --add_aflow_prototype_designation to
|
946
|
+
# the representative structures. This does not help if we need duplicate
|
947
|
+
# prototypes (for refdata), nor for library protos (as they are not ranked by
|
948
|
+
# match like we need)
|
949
|
+
command = " --compare_materials -D "
|
950
|
+
command += self.aflow_work_dir + materials_subdir
|
951
|
+
if no_scale_volume:
|
952
|
+
command += " --no_scale_volume"
|
953
|
+
output = self.aflow_command([command + " --screen_only --quiet --print=json"])
|
954
|
+
res_json = json.loads(output)
|
955
|
+
return res_json
|
956
|
+
|
957
|
+
def get_aflow_version(self) -> str:
|
958
|
+
"""
|
959
|
+
Run the ``aflow --version`` command to get the aflow version
|
960
|
+
|
961
|
+
Returns:
|
962
|
+
aflow++ executable version
|
963
|
+
"""
|
964
|
+
command = " --version"
|
965
|
+
output = self.aflow_command([command])
|
966
|
+
return output.strip().split()[2]
|
967
|
+
|
968
|
+
def compare_to_prototypes(self, input_file: str, prim: bool = True) -> List[Dict]:
|
969
|
+
"""
|
970
|
+
Run the ``aflow --compare2prototypes`` command to compare the input structure
|
971
|
+
to the AFLOW library of curated prototypes
|
972
|
+
|
973
|
+
Args:
|
974
|
+
input_file: path to the POSCAR file containing the structure to compare
|
975
|
+
prim: whether to primitivize the structure first
|
976
|
+
|
977
|
+
Returns:
|
978
|
+
JSON list of dictionaries containing information about matching prototypes.
|
979
|
+
In practice, this list should be of length zero or 1
|
980
|
+
"""
|
981
|
+
if prim:
|
982
|
+
command = [
|
983
|
+
" --prim < " + self.aflow_work_dir + input_file,
|
984
|
+
" --compare2prototypes --catalog=anrl --quiet --print=json",
|
985
|
+
]
|
986
|
+
else:
|
987
|
+
command = (
|
988
|
+
" --compare2prototypes --catalog=anrl --quiet --print=json < "
|
989
|
+
+ self.aflow_work_dir
|
990
|
+
+ input_file
|
991
|
+
)
|
992
|
+
|
993
|
+
output = self.aflow_command(command)
|
994
|
+
res_json = json.loads(output)
|
995
|
+
return res_json
|
996
|
+
|
997
|
+
def get_prototype_designation_from_file(
|
998
|
+
self,
|
999
|
+
input_file: str,
|
1000
|
+
prim: bool = True,
|
1001
|
+
force_wyckoff: bool = False,
|
1002
|
+
verbose: bool = False,
|
1003
|
+
) -> Dict:
|
1004
|
+
"""
|
1005
|
+
Run the ``aflow --prototype`` command to get the AFLOW prototype designation
|
1006
|
+
of the input structure
|
1007
|
+
|
1008
|
+
Args:
|
1009
|
+
input_file: path to the POSCAR file containing the structure to analyze
|
1010
|
+
prim: whether to primitivize the structure first. Faster
|
1011
|
+
force_wyckoff:
|
1012
|
+
If the input is cif, do this to avoid re-analysis and just take the
|
1013
|
+
parameters as-is
|
1014
|
+
verbose: Whether to echo command to log file
|
1015
|
+
|
1016
|
+
Returns:
|
1017
|
+
Dictionary describing the AFLOW prototype designation
|
1018
|
+
(label and parameters) of the input structure.
|
1019
|
+
"""
|
1020
|
+
if prim:
|
1021
|
+
command = [
|
1022
|
+
" --prim < " + self.aflow_work_dir + input_file,
|
1023
|
+
" --prototype --print=json",
|
1024
|
+
]
|
1025
|
+
assert not force_wyckoff, "Must specify prim=False with force_wyckoff"
|
1026
|
+
else:
|
1027
|
+
command = [
|
1028
|
+
" --prototype --print=json < " + self.aflow_work_dir + input_file
|
1029
|
+
]
|
1030
|
+
|
1031
|
+
if force_wyckoff:
|
1032
|
+
command[-1] += " --force_Wyckoff"
|
1033
|
+
|
1034
|
+
output = self.aflow_command(command, verbose=verbose)
|
1035
|
+
res_json = json.loads(output)
|
1036
|
+
return res_json
|
1037
|
+
|
1038
|
+
def get_prototype_designation_from_atoms(
|
1039
|
+
self, atoms: Atoms, prim: bool = True, verbose: bool = False
|
1040
|
+
) -> Dict:
|
1041
|
+
"""
|
1042
|
+
Run the ``aflow --prototype`` command to get the AFLOW prototype designation
|
1043
|
+
|
1044
|
+
Args:
|
1045
|
+
atoms: atoms object to analyze
|
1046
|
+
prim: whether to primitivize the structure first
|
1047
|
+
verbose: Whether to echo command to log file
|
1048
|
+
|
1049
|
+
Returns:
|
1050
|
+
Dictionary describing the AFLOW prototype designation
|
1051
|
+
(label and parameters) of the input structure.
|
1052
|
+
"""
|
1053
|
+
return write_tmp_poscar_from_atoms_and_run_function(
|
1054
|
+
atoms, self.get_prototype_designation_from_file, prim=prim, verbose=verbose
|
1055
|
+
)
|
1056
|
+
|
1057
|
+
def get_library_prototype_label_and_shortname_from_file(
|
1058
|
+
self, poscar_file: str, prim: bool = True, shortnames: Dict = read_shortnames()
|
1059
|
+
) -> Tuple[Union[str, None], Union[str, None]]:
|
1060
|
+
"""
|
1061
|
+
Use the aflow command line tool to determine the library prototype label for a
|
1062
|
+
structure and look up its human-readable shortname. In the case of multiple
|
1063
|
+
results, the enumeration with the smallest misfit that is in the prototypes
|
1064
|
+
list is returned. If none of the results are in the matching prototypes list,
|
1065
|
+
then the prototype with the smallest misfit is returned.
|
1066
|
+
|
1067
|
+
Args:
|
1068
|
+
poscar_file:
|
1069
|
+
Path to input coordinate file
|
1070
|
+
prim: whether to primitivize the structure first
|
1071
|
+
shortnames:
|
1072
|
+
Dictionary with library prototype labels as keys and human-readable
|
1073
|
+
shortnames as values.
|
1074
|
+
|
1075
|
+
Returns:
|
1076
|
+
* The library prototype label for the provided compound.
|
1077
|
+
* Shortname corresponding to this prototype
|
1078
|
+
"""
|
1079
|
+
|
1080
|
+
comparison_results = self.compare_to_prototypes(poscar_file, prim=prim)
|
1081
|
+
if len(comparison_results) > 1:
|
1082
|
+
# If zero results are returned it means the prototype is not in the
|
1083
|
+
# encyclopedia at all.
|
1084
|
+
# Not expecting a case where the number of results is greater than 1.
|
1085
|
+
raise RuntimeError(
|
1086
|
+
f"{comparison_results} results returned from comparison instead of "
|
1087
|
+
"zero or one as expected"
|
1088
|
+
)
|
1089
|
+
elif len(comparison_results) == 0:
|
1090
|
+
return None, None
|
1091
|
+
|
1092
|
+
# Try to find the result with the smallest misfit that is in the matching
|
1093
|
+
# prototype list, otherwise return result with smallest misfit
|
1094
|
+
misfit_min_overall = 1e60
|
1095
|
+
found_overall = False
|
1096
|
+
misfit_min_inlist = 1e60
|
1097
|
+
found_inlist = False
|
1098
|
+
|
1099
|
+
shortname = None
|
1100
|
+
for struct in comparison_results[0]["structures_duplicate"]:
|
1101
|
+
if struct["misfit"] < misfit_min_overall:
|
1102
|
+
misfit_min_overall = struct["misfit"]
|
1103
|
+
library_proto_overall = struct["name"]
|
1104
|
+
found_overall = True
|
1105
|
+
if struct["misfit"] < misfit_min_inlist and any(
|
1106
|
+
proto in struct["name"] for proto in shortnames
|
1107
|
+
):
|
1108
|
+
misfit_min_inlist = struct["misfit"]
|
1109
|
+
library_proto_inlist = struct["name"]
|
1110
|
+
found_inlist = True
|
1111
|
+
if found_inlist:
|
1112
|
+
matching_library_prototype_label = library_proto_inlist
|
1113
|
+
shortname = shortnames[matching_library_prototype_label]
|
1114
|
+
elif found_overall:
|
1115
|
+
matching_library_prototype_label = library_proto_overall
|
1116
|
+
else:
|
1117
|
+
matching_library_prototype_label = None
|
1118
|
+
|
1119
|
+
return matching_library_prototype_label, shortname
|
1120
|
+
|
1121
|
+
def get_library_prototype_label_and_shortname_from_atoms(
|
1122
|
+
self, atoms: Atoms, prim: bool = True, shortnames: Dict = read_shortnames()
|
1123
|
+
) -> Tuple[Union[str, None], Union[str, None]]:
|
1124
|
+
"""
|
1125
|
+
Use the aflow command line tool to determine the library prototype label for a
|
1126
|
+
structure and look up its human-readable shortname. In the case of multiple
|
1127
|
+
results, the enumeration with the smallest misfit that is in the prototypes
|
1128
|
+
list is returned. If none of the results are in the matching prototypes list,
|
1129
|
+
then the prototype with the smallest misfit is returned.
|
1130
|
+
|
1131
|
+
Args:
|
1132
|
+
atoms:
|
1133
|
+
Atoms object to compare
|
1134
|
+
prim: whether to primitivize the structure first
|
1135
|
+
shortnames:
|
1136
|
+
Dictionary with library prototype labels as keys and human-readable
|
1137
|
+
shortnames as values.
|
1138
|
+
|
1139
|
+
Returns:
|
1140
|
+
* The library prototype label for the provided compound.
|
1141
|
+
* Shortname corresponding to this prototype
|
1142
|
+
"""
|
1143
|
+
return write_tmp_poscar_from_atoms_and_run_function(
|
1144
|
+
atoms,
|
1145
|
+
self.get_library_prototype_label_and_shortname_from_file,
|
1146
|
+
prim=prim,
|
1147
|
+
shortnames=shortnames,
|
1148
|
+
)
|
1149
|
+
|
1150
|
+
def _compare_poscars(self, poscar1: PathLike, poscar2: PathLike) -> Dict:
|
1151
|
+
return json.loads(
|
1152
|
+
self.aflow_command(
|
1153
|
+
[
|
1154
|
+
f" --print=JSON --compare_materials={poscar1},{poscar2} "
|
1155
|
+
"--screen_only --no_scale_volume --optimize_match --quiet"
|
1156
|
+
],
|
1157
|
+
verbose=False,
|
1158
|
+
)
|
1159
|
+
)
|
1160
|
+
|
1161
|
+
def _compare_Atoms(
|
1162
|
+
self,
|
1163
|
+
atoms1: Atoms,
|
1164
|
+
atoms2: Atoms,
|
1165
|
+
sort_atoms1: bool = True,
|
1166
|
+
sort_atoms2: bool = True,
|
1167
|
+
) -> Dict:
|
1168
|
+
with NamedTemporaryFile() as f1, NamedTemporaryFile() as f2:
|
1169
|
+
atoms1.write(f1.name, "vasp", sort=sort_atoms1)
|
1170
|
+
atoms2.write(f2.name, "vasp", sort=sort_atoms2)
|
1171
|
+
f1.seek(0)
|
1172
|
+
f2.seek(0)
|
1173
|
+
compare = self._compare_poscars(f1.name, f2.name)
|
1174
|
+
return compare
|
1175
|
+
|
1176
|
+
def get_basistransformation_rotation_originshift_atom_map_from_atoms(
|
1177
|
+
self,
|
1178
|
+
atoms1: Atoms,
|
1179
|
+
atoms2: Atoms,
|
1180
|
+
sort_atoms1: bool = True,
|
1181
|
+
sort_atoms2: bool = True,
|
1182
|
+
) -> Tuple[
|
1183
|
+
Optional[ArrayLike],
|
1184
|
+
Optional[ArrayLike],
|
1185
|
+
Optional[ArrayLike],
|
1186
|
+
Optional[List[int]],
|
1187
|
+
]:
|
1188
|
+
"""
|
1189
|
+
Get operations to transform atoms2 to atoms1
|
1190
|
+
|
1191
|
+
Args:
|
1192
|
+
sort_atoms1:
|
1193
|
+
Whether to sort atoms1 before comparing. If species are not
|
1194
|
+
alphabetized, this is REQUIRED. However, the `atom_map` returned will
|
1195
|
+
be w.r.t the sorted order, use with care!
|
1196
|
+
sort_atoms2: Whether to sort atoms2 before comparing.
|
1197
|
+
|
1198
|
+
Returns:
|
1199
|
+
Tuple of arrays in the order:
|
1200
|
+
basis transformation, rotation, origin shift, atom_map
|
1201
|
+
atom_map[index_in_structure_1] = index_in_structure_2
|
1202
|
+
|
1203
|
+
Raises:
|
1204
|
+
AFLOW.FailedToMatchException: if AFLOW fails to match the crystals
|
1205
|
+
"""
|
1206
|
+
comparison_result = self._compare_Atoms(
|
1207
|
+
atoms1, atoms2, sort_atoms1, sort_atoms2
|
1208
|
+
)
|
1209
|
+
if (
|
1210
|
+
"structures_duplicate" in comparison_result[0]
|
1211
|
+
and comparison_result[0]["structures_duplicate"] != []
|
1212
|
+
):
|
1213
|
+
return (
|
1214
|
+
np.asarray(
|
1215
|
+
comparison_result[0]["structures_duplicate"][0][
|
1216
|
+
"basis_transformation"
|
1217
|
+
]
|
1218
|
+
),
|
1219
|
+
np.asarray(comparison_result[0]["structures_duplicate"][0]["rotation"]),
|
1220
|
+
np.asarray(
|
1221
|
+
comparison_result[0]["structures_duplicate"][0]["origin_shift"]
|
1222
|
+
),
|
1223
|
+
comparison_result[0]["structures_duplicate"][0]["atom_map"],
|
1224
|
+
)
|
1225
|
+
else:
|
1226
|
+
msg = "AFLOW was unable to match the provided crystals"
|
1227
|
+
logger.info(msg)
|
1228
|
+
raise self.FailedToMatchException(msg)
|
1229
|
+
|
1230
|
+
def get_param_names_from_prototype(self, prototype_label: str) -> List[str]:
|
1231
|
+
"""
|
1232
|
+
Get the parameter names by parsing the error message from AFLOW
|
1233
|
+
"""
|
1234
|
+
try:
|
1235
|
+
self.write_poscar_from_prototype(prototype_label, parameter_values=[1.0])
|
1236
|
+
# if no exception, it's a cubic crystal with no internal free params
|
1237
|
+
return ["a"]
|
1238
|
+
except subprocess.CalledProcessError as exc:
|
1239
|
+
re_match = re.search(r"parameter_list=(.*) - \[dir", str(exc.stderr))
|
1240
|
+
if re_match is not None:
|
1241
|
+
return re_match.groups()[0].split(",")
|
1242
|
+
# if we got here, the exception didn't have the format we expected, re-raise
|
1243
|
+
raise exc
|
1244
|
+
|
1245
|
+
def get_equation_sets_from_prototype(
|
1246
|
+
self, prototype_label: str
|
1247
|
+
) -> List[EquivalentEqnSet]:
|
1248
|
+
"""
|
1249
|
+
Get the symbolic equations for the fractional positions in the unit cell of an
|
1250
|
+
AFLOW prototype
|
1251
|
+
|
1252
|
+
Args:
|
1253
|
+
prototype_label:
|
1254
|
+
An AFLOW prototype label, without an enumeration suffix, without
|
1255
|
+
specified atomic species
|
1256
|
+
|
1257
|
+
Returns:
|
1258
|
+
List of EquivalentEqnSet objects
|
1259
|
+
|
1260
|
+
Each EquivalentEqnSet contains:
|
1261
|
+
- species: The species of the atoms in this set.
|
1262
|
+
- wyckoff_letter: The Wyckoff letter corresponding to this set.
|
1263
|
+
- param_names: The names of the free parameters associated with this
|
1264
|
+
Wyckoff position.
|
1265
|
+
- coeff_matrix_list: A list of 3 x n matrices of coefficients for the
|
1266
|
+
free parameters.
|
1267
|
+
- const_terms_list: A list of 3 x 1 columns of constant terms in the
|
1268
|
+
coordinates.
|
1269
|
+
"""
|
1270
|
+
param_names = self.get_param_names_from_prototype(prototype_label)
|
1271
|
+
MAX_ATTEMPTS = 20
|
1272
|
+
ANGLE_NAMES = ["alpha", "beta", "gamma"]
|
1273
|
+
for i in range(MAX_ATTEMPTS):
|
1274
|
+
param_values = []
|
1275
|
+
triclinic = False
|
1276
|
+
if all([angle_name in param_names for angle_name in ANGLE_NAMES]):
|
1277
|
+
triclinic = True
|
1278
|
+
beta = 5 + random() * 165
|
1279
|
+
gamma = 5 + random() * 165
|
1280
|
+
beta_rad = radians(beta)
|
1281
|
+
gamma_rad = radians(gamma)
|
1282
|
+
max_cosalpha = abs(sin(beta_rad) * sin(gamma_rad)) + cos(
|
1283
|
+
beta_rad
|
1284
|
+
) * cos(gamma_rad)
|
1285
|
+
min_cosalpha = -abs(sin(beta_rad) * sin(gamma_rad)) + cos(
|
1286
|
+
beta_rad
|
1287
|
+
) * cos(gamma_rad)
|
1288
|
+
cosalpha = min_cosalpha + random() * (max_cosalpha - min_cosalpha)
|
1289
|
+
alpha = degrees(acos(cosalpha))
|
1290
|
+
angles_dict = {"alpha": alpha, "beta": beta, "gamma": gamma}
|
1291
|
+
|
1292
|
+
for pname in param_names:
|
1293
|
+
if pname in ANGLE_NAMES:
|
1294
|
+
if triclinic:
|
1295
|
+
param_values.append(angles_dict[pname])
|
1296
|
+
else:
|
1297
|
+
param_values.append(180.0 * random())
|
1298
|
+
else:
|
1299
|
+
param_values.append(random())
|
1300
|
+
|
1301
|
+
try:
|
1302
|
+
equation_poscar = self.write_poscar_from_prototype(
|
1303
|
+
prototype_label,
|
1304
|
+
parameter_values=param_values,
|
1305
|
+
addtl_args="--equations_only",
|
1306
|
+
)
|
1307
|
+
break
|
1308
|
+
except subprocess.CalledProcessError:
|
1309
|
+
if i == MAX_ATTEMPTS - 1:
|
1310
|
+
raise RuntimeError(
|
1311
|
+
"Random parameters failed to pass "
|
1312
|
+
"AFLOW checks 20 times in a row"
|
1313
|
+
)
|
1314
|
+
else:
|
1315
|
+
pass
|
1316
|
+
|
1317
|
+
# get a string with one character per Wyckoff position
|
1318
|
+
# (with possible repeated letters for positions with free params)
|
1319
|
+
wyckoff_lists = get_wyckoff_lists_from_prototype(prototype_label)
|
1320
|
+
wyckoff_joined_list = "".join(wyckoff_lists)
|
1321
|
+
|
1322
|
+
coord_lines = equation_poscar.splitlines()[7:]
|
1323
|
+
coord_iter = iter(coord_lines)
|
1324
|
+
|
1325
|
+
space_group_number = get_space_group_number_from_prototype(prototype_label)
|
1326
|
+
|
1327
|
+
equation_sets = []
|
1328
|
+
|
1329
|
+
for wyckoff_letter in wyckoff_joined_list:
|
1330
|
+
species = None # have not seen a line yet, so don't know what species it is
|
1331
|
+
param_names = None # same as above.
|
1332
|
+
coeff_matrix_list = []
|
1333
|
+
const_terms_list = []
|
1334
|
+
# the next n positions should be equivalent
|
1335
|
+
# corresponding to this Wyckoff position
|
1336
|
+
for _ in range(
|
1337
|
+
get_primitive_wyckoff_multiplicity(space_group_number, wyckoff_letter)
|
1338
|
+
):
|
1339
|
+
line_split = next(coord_iter).split()
|
1340
|
+
if species is None:
|
1341
|
+
species = line_split[3]
|
1342
|
+
elif line_split[3] != species:
|
1343
|
+
raise InconsistentWyckoffException(
|
1344
|
+
"Encountered different species within what I thought should be "
|
1345
|
+
f"the lines corresponding to Wyckoff position {wyckoff_letter}"
|
1346
|
+
f"\nEquations obtained from prototype label {prototype_label}:"
|
1347
|
+
f"\n{equation_poscar}"
|
1348
|
+
)
|
1349
|
+
# first, get the free parameters of this line
|
1350
|
+
curr_line_free_params = set() # sympy.Symbol
|
1351
|
+
coordinate_expr_list = []
|
1352
|
+
for expression_string in line_split[:3]:
|
1353
|
+
coordinate_expr = parse_expr(expression_string)
|
1354
|
+
curr_line_free_params.update(coordinate_expr.free_symbols)
|
1355
|
+
coordinate_expr_list.append(coordinate_expr)
|
1356
|
+
|
1357
|
+
# They should all have the same number, i.e. x2, y2, z2 or x14, z14,
|
1358
|
+
# so we can just string sort them
|
1359
|
+
curr_line_free_params = list(curr_line_free_params)
|
1360
|
+
curr_line_free_params.sort(key=lambda param: str(param))
|
1361
|
+
|
1362
|
+
# Each line within a Wyckoff position should
|
1363
|
+
# have the same set of free parameters
|
1364
|
+
if param_names is None:
|
1365
|
+
param_names = curr_line_free_params
|
1366
|
+
elif param_names != curr_line_free_params:
|
1367
|
+
raise InconsistentWyckoffException(
|
1368
|
+
"Encountered different free params within what I thought "
|
1369
|
+
"should be the lines corresponding to Wyckoff position "
|
1370
|
+
f"{wyckoff_letter}\nEquations obtained from prototype label "
|
1371
|
+
f"{prototype_label}:\n{equation_poscar}"
|
1372
|
+
)
|
1373
|
+
|
1374
|
+
# Transform to matrices and vectors
|
1375
|
+
a, b = linear_eq_to_matrix(coordinate_expr_list, param_names)
|
1376
|
+
|
1377
|
+
assert a.shape == (3, len(param_names))
|
1378
|
+
assert b.shape == (3, 1)
|
1379
|
+
|
1380
|
+
coeff_matrix_list.append(matrix2numpy(a, dtype=np.float64))
|
1381
|
+
const_terms_list.append(matrix2numpy(-b, dtype=np.float64))
|
1382
|
+
|
1383
|
+
# Done looping over this set of equivalent positions
|
1384
|
+
equation_sets.append(
|
1385
|
+
EquivalentEqnSet(
|
1386
|
+
species=species,
|
1387
|
+
wyckoff_letter=wyckoff_letter,
|
1388
|
+
param_names=[str(param_name) for param_name in param_names],
|
1389
|
+
coeff_matrix_list=coeff_matrix_list,
|
1390
|
+
const_terms_list=const_terms_list,
|
1391
|
+
)
|
1392
|
+
)
|
1393
|
+
|
1394
|
+
# do some checks
|
1395
|
+
equation_sets_iter = iter(equation_sets)
|
1396
|
+
species = None
|
1397
|
+
for species_wyckoff_list in wyckoff_lists:
|
1398
|
+
species_must_change = True
|
1399
|
+
for _ in species_wyckoff_list:
|
1400
|
+
equation_set = next(equation_sets_iter)
|
1401
|
+
if species_must_change:
|
1402
|
+
if equation_set.species == species:
|
1403
|
+
raise InconsistentWyckoffException(
|
1404
|
+
"The species in the equations obtained below are "
|
1405
|
+
"inconsistent with the number and multiplicity of Wyckoff "
|
1406
|
+
f"positions in prototype label {prototype_label}\n"
|
1407
|
+
f"{equation_poscar}"
|
1408
|
+
)
|
1409
|
+
species = equation_set.species
|
1410
|
+
species_must_change = False
|
1411
|
+
|
1412
|
+
return equation_sets
|
1413
|
+
|
1414
|
+
def solve_for_params_of_known_prototype(
|
1415
|
+
self, atoms: Atoms, prototype_label: str, max_resid: float = 1e-5
|
1416
|
+
) -> List[float]:
|
1417
|
+
"""
|
1418
|
+
Given an Atoms object that is a primitive cell of its Bravais lattice as
|
1419
|
+
defined in doi.org/10.1016/j.commatsci.2017.01.017, and its presumed prototype
|
1420
|
+
label, solves for the free parameters of the prototype label. Raises an error
|
1421
|
+
if the solution fails (likely indicating that the Atoms object provided does
|
1422
|
+
not conform to the provided prototype label.) The Atoms object may be rotated,
|
1423
|
+
translated, and permuted, but the identity of the lattice vectors must be
|
1424
|
+
unchanged w.r.t. the crystallographic prototype. In other words, there must
|
1425
|
+
exist a permutation and translation of the fractional coordinates that enables
|
1426
|
+
them to match the equations defined by the prototype label.
|
1427
|
+
|
1428
|
+
Args:
|
1429
|
+
atoms: The Atoms object to analyze
|
1430
|
+
prototype_label:
|
1431
|
+
The assumed AFLOW prototype label. `atoms` must be a primitive cell
|
1432
|
+
(with lattice vectors defined in
|
1433
|
+
doi.org/10.1016/j.commatsci.2017.01.017) conforming to the symmetry
|
1434
|
+
defined by this prototype label. Rigid body rotations, translations,
|
1435
|
+
and permutations of `atoms` relative to the AFLOW setting are allowed,
|
1436
|
+
but the identity of the lattice vectors must be unchanged.
|
1437
|
+
max_resid:
|
1438
|
+
Maximum residual allowed when attempting to match the fractional
|
1439
|
+
positions of the atoms to the crystallographic equations
|
1440
|
+
|
1441
|
+
Returns:
|
1442
|
+
List of free parameters that will regenerate `atoms` (up to permutations,
|
1443
|
+
rotations, and translations) when paired with `prototype_label`
|
1444
|
+
|
1445
|
+
Raises:
|
1446
|
+
AFLOW.ChangedSymmetryException:
|
1447
|
+
if the symmetry of the atoms object is different from `prototype_label`
|
1448
|
+
AFLOW.FailedToMatchException:
|
1449
|
+
if AFLOW fails to match the re-generated crystal to the input crystal
|
1450
|
+
|
1451
|
+
"""
|
1452
|
+
# solve for cell parameters
|
1453
|
+
cell_params = solve_for_aflow_cell_params_from_primitive_ase_cell_params(
|
1454
|
+
atoms.cell.cellpar(), prototype_label
|
1455
|
+
)
|
1456
|
+
species = sorted(list(set(atoms.get_chemical_symbols())))
|
1457
|
+
|
1458
|
+
# First, redetect the prototype label. We can't use this as-is because it may
|
1459
|
+
# be rotated by an operation that's within the normalizer but not
|
1460
|
+
# in the space group itself
|
1461
|
+
detected_prototype_designation = self.get_prototype_designation_from_atoms(
|
1462
|
+
atoms
|
1463
|
+
)
|
1464
|
+
|
1465
|
+
prototype_label_detected = detected_prototype_designation[
|
1466
|
+
"aflow_prototype_label"
|
1467
|
+
]
|
1468
|
+
|
1469
|
+
if not prototype_labels_are_equivalent(
|
1470
|
+
prototype_label, prototype_label_detected
|
1471
|
+
):
|
1472
|
+
msg = (
|
1473
|
+
f"Redetected prototype label {prototype_label_detected} does not match "
|
1474
|
+
f"nominal {prototype_label}."
|
1475
|
+
)
|
1476
|
+
logger.info(msg)
|
1477
|
+
raise self.ChangedSymmetryException(msg)
|
1478
|
+
|
1479
|
+
# rebuild the atoms
|
1480
|
+
try:
|
1481
|
+
atoms_rebuilt = self.build_atoms_from_prototype(
|
1482
|
+
prototype_label=prototype_label_detected,
|
1483
|
+
species=species,
|
1484
|
+
parameter_values=detected_prototype_designation[
|
1485
|
+
"aflow_prototype_params_values"
|
1486
|
+
],
|
1487
|
+
)
|
1488
|
+
except self.ChangedSymmetryException as e:
|
1489
|
+
# re-raise, just indicating that this function knows about this exception
|
1490
|
+
raise e
|
1491
|
+
|
1492
|
+
# We want the negative of the origin shift from ``atoms_rebuilt`` to ``atoms``,
|
1493
|
+
# because the origin shift is the last operation to happen, so it will be in
|
1494
|
+
# the ``atoms`` frame.
|
1495
|
+
|
1496
|
+
# This function gets the transformation from its second argument to its first.
|
1497
|
+
# The origin shift is Cartesian if the POSCARs are Cartesian, which they are
|
1498
|
+
# when made from Atoms
|
1499
|
+
|
1500
|
+
# Sort atoms, but do not sort atoms_rebuilt
|
1501
|
+
try:
|
1502
|
+
_, _, neg_initial_origin_shift_cart, atom_map = (
|
1503
|
+
self.get_basistransformation_rotation_originshift_atom_map_from_atoms(
|
1504
|
+
atoms, atoms_rebuilt, sort_atoms1=True, sort_atoms2=False
|
1505
|
+
)
|
1506
|
+
)
|
1507
|
+
except self.FailedToMatchException:
|
1508
|
+
# Re-raise with a more informative error message
|
1509
|
+
msg = (
|
1510
|
+
"Execution cannot continue because AFLOW failed to match the crystal "
|
1511
|
+
"with its representation re-generated from the detected prototype "
|
1512
|
+
"designation.\nRarely, this can happen if the structure is on the edge "
|
1513
|
+
"of a symmetry increase (e.g. a BCT structure with c/a very close to 1)"
|
1514
|
+
)
|
1515
|
+
logger.error(msg)
|
1516
|
+
raise self.FailedToMatchException(msg)
|
1517
|
+
|
1518
|
+
# Transpose the change of basis equation for row vectors
|
1519
|
+
initial_origin_shift_frac = (-neg_initial_origin_shift_cart) @ np.linalg.inv(
|
1520
|
+
atoms.cell
|
1521
|
+
)
|
1522
|
+
|
1523
|
+
logger.info(
|
1524
|
+
"Initial shift (to SOME standard origin, not necessarily the desired one): "
|
1525
|
+
f"{-neg_initial_origin_shift_cart} (Cartesian), "
|
1526
|
+
f"{initial_origin_shift_frac} (fractional)"
|
1527
|
+
)
|
1528
|
+
|
1529
|
+
position_set_list = get_equivalent_atom_sets_from_prototype_and_atom_map(
|
1530
|
+
atoms, prototype_label, atom_map, sort_atoms=True
|
1531
|
+
)
|
1532
|
+
|
1533
|
+
# get equation sets
|
1534
|
+
equation_set_list = self.get_equation_sets_from_prototype(prototype_label)
|
1535
|
+
|
1536
|
+
if len(position_set_list) != len(equation_set_list):
|
1537
|
+
raise InconsistentWyckoffException(
|
1538
|
+
"Number of equivalent positions detected in Atoms object "
|
1539
|
+
"did not match the number of equivalent equations given"
|
1540
|
+
)
|
1541
|
+
|
1542
|
+
space_group_number = get_space_group_number_from_prototype(prototype_label)
|
1543
|
+
for prim_shift in get_possible_primitive_shifts(space_group_number):
|
1544
|
+
logger.info(
|
1545
|
+
"Additionally shifting atoms by "
|
1546
|
+
f"internal fractional translation {prim_shift}"
|
1547
|
+
)
|
1548
|
+
|
1549
|
+
free_params_dict = {}
|
1550
|
+
position_set_matched_list = [False] * len(position_set_list)
|
1551
|
+
|
1552
|
+
for equation_set in equation_set_list:
|
1553
|
+
# Because both equations and positions are sorted by species and
|
1554
|
+
# wyckoff letter, this should be pretty efficient
|
1555
|
+
matched_this_equation_set = False
|
1556
|
+
for i, position_set in enumerate(position_set_list):
|
1557
|
+
if position_set_matched_list[i]:
|
1558
|
+
continue
|
1559
|
+
if (
|
1560
|
+
position_set.species != equation_set.species
|
1561
|
+
): # These are virtual species
|
1562
|
+
continue
|
1563
|
+
if not are_in_same_wyckoff_set(
|
1564
|
+
equation_set.wyckoff_letter,
|
1565
|
+
position_set.wyckoff_letter,
|
1566
|
+
space_group_number,
|
1567
|
+
):
|
1568
|
+
continue
|
1569
|
+
for coeff_matrix, const_terms in zip(
|
1570
|
+
equation_set.coeff_matrix_list, equation_set.const_terms_list
|
1571
|
+
):
|
1572
|
+
for frac_position in position_set.frac_position_list:
|
1573
|
+
# Here we use column coordinate vectors
|
1574
|
+
frac_position_shifted = (
|
1575
|
+
frac_position
|
1576
|
+
+ np.asarray(prim_shift).reshape(3, 1)
|
1577
|
+
+ initial_origin_shift_frac.reshape(3, 1)
|
1578
|
+
) % 1
|
1579
|
+
possible_shifts = (-1, 0, 1)
|
1580
|
+
# explore all possible shifts around zero
|
1581
|
+
# to bring back in cell.
|
1582
|
+
# TODO: if this is too slow (27 possibilities), write an
|
1583
|
+
# algorithm to determine which shifts are possible
|
1584
|
+
for shift_list in [
|
1585
|
+
(x, y, z)
|
1586
|
+
for x in possible_shifts
|
1587
|
+
for y in possible_shifts
|
1588
|
+
for z in possible_shifts
|
1589
|
+
]:
|
1590
|
+
shift_array = np.asarray(shift_list).reshape(3, 1)
|
1591
|
+
candidate_internal_param_values, resid, _, _ = (
|
1592
|
+
np.linalg.lstsq(
|
1593
|
+
coeff_matrix,
|
1594
|
+
frac_position_shifted
|
1595
|
+
- const_terms
|
1596
|
+
- shift_array,
|
1597
|
+
)
|
1598
|
+
)
|
1599
|
+
if len(resid) == 0 or np.max(resid) < max_resid:
|
1600
|
+
assert len(candidate_internal_param_values) == len(
|
1601
|
+
equation_set.param_names
|
1602
|
+
)
|
1603
|
+
for param_name, param_value in zip(
|
1604
|
+
equation_set.param_names,
|
1605
|
+
candidate_internal_param_values,
|
1606
|
+
):
|
1607
|
+
assert param_name not in free_params_dict
|
1608
|
+
free_params_dict[param_name] = (
|
1609
|
+
param_value[0] % 1
|
1610
|
+
) # wrap to [0,1)
|
1611
|
+
# should only need one to match to check off this
|
1612
|
+
# Wyckoff position
|
1613
|
+
position_set_matched_list[i] = True
|
1614
|
+
matched_this_equation_set = True
|
1615
|
+
break
|
1616
|
+
# end loop over shifts
|
1617
|
+
if matched_this_equation_set:
|
1618
|
+
break
|
1619
|
+
# end loop over positions within a position set
|
1620
|
+
if matched_this_equation_set:
|
1621
|
+
break
|
1622
|
+
# end loop over equations within an equation set
|
1623
|
+
if matched_this_equation_set:
|
1624
|
+
break
|
1625
|
+
# end loop over position sets
|
1626
|
+
# end loop over equation sets
|
1627
|
+
|
1628
|
+
if all(position_set_matched_list):
|
1629
|
+
candidate_prototype_param_values = cell_params + [
|
1630
|
+
free_params_dict[key]
|
1631
|
+
for key in sorted(
|
1632
|
+
free_params_dict.keys(), key=internal_parameter_sort_key
|
1633
|
+
)
|
1634
|
+
]
|
1635
|
+
# The internal shift may have taken us to an internal parameter
|
1636
|
+
# solution that represents a rotation, so we need to check
|
1637
|
+
if self.confirm_unrotated_prototype_designation(
|
1638
|
+
atoms, species, prototype_label, candidate_prototype_param_values
|
1639
|
+
):
|
1640
|
+
logger.info(
|
1641
|
+
f"Found set of parameters for prototype {prototype_label} "
|
1642
|
+
"that is unrotated"
|
1643
|
+
)
|
1644
|
+
return candidate_prototype_param_values
|
1645
|
+
else:
|
1646
|
+
logger.info(
|
1647
|
+
f"Found set of parameters for prototype {prototype_label}, "
|
1648
|
+
"but it was rotated relative to the original cell"
|
1649
|
+
)
|
1650
|
+
else:
|
1651
|
+
logger.info(
|
1652
|
+
f"Failed to solve equations for prototype {prototype_label} "
|
1653
|
+
"on this shift attempt"
|
1654
|
+
)
|
1655
|
+
|
1656
|
+
msg = (
|
1657
|
+
f"Failed to solve equations for prototype {prototype_label} "
|
1658
|
+
"on any shift attempt"
|
1659
|
+
)
|
1660
|
+
logger.info(msg)
|
1661
|
+
raise self.FailedToSolveException(msg)
|
1662
|
+
|
1663
|
+
def confirm_atoms_unrotated_when_cells_aligned(
|
1664
|
+
self,
|
1665
|
+
test_atoms: Atoms,
|
1666
|
+
ref_atoms: Atoms,
|
1667
|
+
sgnum: Union[int, str],
|
1668
|
+
rtol: float = 1.0e-4,
|
1669
|
+
atol: float = 1.0e-8,
|
1670
|
+
) -> bool:
|
1671
|
+
"""
|
1672
|
+
Check whether `test_atoms` and `reference_atoms` are unrotated as follows:
|
1673
|
+
When the cells are in :meth:`ase.cell.Cell.standard_form`, the cells are
|
1674
|
+
identical. When both crystals are rotated to standard form (rotating the cell
|
1675
|
+
and keeping the fractional coordinates unchanged), the rotation part of the
|
1676
|
+
mapping the two crystals to each other found by AFLOW is in the point group of
|
1677
|
+
the reference crystal (using the generated crystal would give the same result).
|
1678
|
+
In other words, the crystals are identically oriented (but possibly translated)
|
1679
|
+
in reference to their lattice vectors, which in turn must be identical up to a
|
1680
|
+
rotation in reference to some Cartesian coordinate system.
|
1681
|
+
The crystals must be primitive cells as defined in
|
1682
|
+
https://doi.org/10.1016/j.commatsci.2017.01.017.
|
1683
|
+
|
1684
|
+
Args:
|
1685
|
+
test_atoms:
|
1686
|
+
Primitive cell of a crystal
|
1687
|
+
ref_atoms:
|
1688
|
+
Primitive cell of a crystal
|
1689
|
+
sgnum:
|
1690
|
+
Space group number
|
1691
|
+
rtol:
|
1692
|
+
Parameter to pass to :func:`numpy.allclose` for comparing cell params
|
1693
|
+
atol:
|
1694
|
+
Parameter to pass to :func:`numpy.allclose` for comparing cell params
|
1695
|
+
"""
|
1696
|
+
if not np.allclose(
|
1697
|
+
ref_atoms.cell.cellpar(), test_atoms.cell.cellpar(), atol=atol, rtol=rtol
|
1698
|
+
):
|
1699
|
+
logger.info(
|
1700
|
+
"Cell lengths and angles do not match.\n"
|
1701
|
+
f"Original: {ref_atoms.cell.cellpar()}\n"
|
1702
|
+
f"Regenerated: {test_atoms.cell.cellpar()}"
|
1703
|
+
)
|
1704
|
+
return False
|
1705
|
+
else:
|
1706
|
+
cell_lengths_and_angles = ref_atoms.cell.cellpar()
|
1707
|
+
|
1708
|
+
test_atoms_copy = test_atoms.copy()
|
1709
|
+
ref_atoms_copy = ref_atoms.copy()
|
1710
|
+
|
1711
|
+
test_atoms_copy.set_cell(
|
1712
|
+
Cell.fromcellpar(cell_lengths_and_angles), scale_atoms=True
|
1713
|
+
)
|
1714
|
+
ref_atoms_copy.set_cell(
|
1715
|
+
Cell.fromcellpar(cell_lengths_and_angles), scale_atoms=True
|
1716
|
+
)
|
1717
|
+
|
1718
|
+
# the rotation below is Cartesian.
|
1719
|
+
try:
|
1720
|
+
_, cart_rot, _, _ = (
|
1721
|
+
self.get_basistransformation_rotation_originshift_atom_map_from_atoms(
|
1722
|
+
test_atoms_copy, ref_atoms_copy
|
1723
|
+
)
|
1724
|
+
)
|
1725
|
+
except self.FailedToMatchException:
|
1726
|
+
logger.info("AFLOW failed to match the recreated crystal to reference")
|
1727
|
+
return False
|
1728
|
+
|
1729
|
+
return cartesian_rotation_is_in_point_group(
|
1730
|
+
cart_rot, sgnum, test_atoms_copy.cell
|
1731
|
+
)
|
1732
|
+
|
1733
|
+
def confirm_unrotated_prototype_designation(
|
1734
|
+
self,
|
1735
|
+
reference_atoms: Atoms,
|
1736
|
+
species: List[str],
|
1737
|
+
prototype_label: str,
|
1738
|
+
parameter_values: List[float],
|
1739
|
+
rtol: float = 1.0e-4,
|
1740
|
+
atol: float = 1.0e-8,
|
1741
|
+
) -> bool:
|
1742
|
+
"""
|
1743
|
+
Check whether the provided prototype designation recreates ``reference_atoms``
|
1744
|
+
as follows: When the cells are in :meth:`ase.cell.Cell.standard_form`, the
|
1745
|
+
cells are identical. When both crystals are rotated to standard form (rotating
|
1746
|
+
the cell and keeping the fractional coordinates unchanged), the rotation part
|
1747
|
+
of the mapping the two crystals to each other found by AFLOW is in the point
|
1748
|
+
group of the reference crystal (using the generated crystal would give the same
|
1749
|
+
result). In other words, the crystals are identically oriented (but possibly
|
1750
|
+
translated) in reference to their lattice vectors, which in turn must be
|
1751
|
+
identical up to a rotation in reference to some Cartesian coordinate system.
|
1752
|
+
|
1753
|
+
Args:
|
1754
|
+
species:
|
1755
|
+
Stoichiometric species, e.g. ["Mo", "S"] corresponding to A and B
|
1756
|
+
respectively for prototype label AB2_hP6_194_c_f indicating molybdenite
|
1757
|
+
prototype_label:
|
1758
|
+
An AFLOW prototype label, without an enumeration suffix,
|
1759
|
+
without specified atomic species
|
1760
|
+
parameter_values:
|
1761
|
+
The free parameters of the AFLOW prototype designation
|
1762
|
+
rtol:
|
1763
|
+
Parameter to pass to :func:`numpy.allclose` for comparing cell params
|
1764
|
+
atol:
|
1765
|
+
Parameter to pass to :func:`numpy.allclose` for comparing cell params
|
1766
|
+
|
1767
|
+
Returns:
|
1768
|
+
Whether or not the crystals match
|
1769
|
+
"""
|
1770
|
+
test_atoms = self.build_atoms_from_prototype(
|
1771
|
+
prototype_label=prototype_label,
|
1772
|
+
species=species,
|
1773
|
+
parameter_values=parameter_values,
|
1774
|
+
)
|
1775
|
+
|
1776
|
+
return self.confirm_atoms_unrotated_when_cells_aligned(
|
1777
|
+
test_atoms,
|
1778
|
+
reference_atoms,
|
1779
|
+
get_space_group_number_from_prototype(prototype_label),
|
1780
|
+
rtol,
|
1781
|
+
atol,
|
1782
|
+
)
|