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.
@@ -0,0 +1,1932 @@
1
+ ################################################################################
2
+ #
3
+ # CDDL HEADER START
4
+ #
5
+ # The contents of this file are subject to the terms of the Common Development
6
+ # and Distribution License Version 1.0 (the "License").
7
+ #
8
+ # You can obtain a copy of the license at
9
+ # http:# www.opensource.org/licenses/CDDL-1.0. See the License for the
10
+ # specific language governing permissions and limitations under the License.
11
+ #
12
+ # When distributing Covered Code, include this CDDL HEADER in each file and
13
+ # include the License file in a prominent location with the name LICENSE.CDDL.
14
+ # If applicable, add the following below this CDDL HEADER, with the fields
15
+ # enclosed by brackets "[]" replaced with your own identifying information:
16
+ #
17
+ # Portions Copyright (c) [yyyy] [name of copyright owner]. All rights reserved.
18
+ #
19
+ # CDDL HEADER END
20
+ #
21
+ # Copyright (c) 2017-2019, Regents of the University of Minnesota.
22
+ # All rights reserved.
23
+ #
24
+ # Contributor(s):
25
+ # Ilia Nikiforov
26
+ # Eric Fuemmler
27
+ #
28
+ ################################################################################
29
+ """
30
+ Helper classes for KIM Test Drivers
31
+
32
+ """
33
+ import logging
34
+ import os
35
+ import shutil
36
+ from abc import ABC, abstractmethod
37
+ from copy import deepcopy
38
+ from pathlib import Path
39
+ from tempfile import NamedTemporaryFile, TemporaryDirectory
40
+ from typing import IO, Any, Dict, List, Optional, Union
41
+
42
+ import ase
43
+ import kim_edn
44
+ import numpy as np
45
+ from ase import Atoms
46
+ from ase.calculators.calculator import Calculator
47
+ from ase.constraints import FixSymmetry
48
+ from ase.data import atomic_masses
49
+ from ase.filters import FrechetCellFilter, UnitCellFilter
50
+ from ase.optimize import LBFGSLineSearch
51
+ from ase.optimize.optimize import Optimizer
52
+ from kim_property import (
53
+ get_properties,
54
+ get_property_id_path,
55
+ kim_property_create,
56
+ kim_property_dump,
57
+ kim_property_modify,
58
+ )
59
+ from kim_property.modify import STANDARD_KEYS_SCLAR_OR_WITH_EXTENT
60
+ from kim_query import raw_query
61
+ from numpy.typing import ArrayLike
62
+
63
+ from ..aflow_util import (
64
+ AFLOW,
65
+ get_space_group_number_from_prototype,
66
+ prototype_labels_are_equivalent,
67
+ )
68
+ from ..kimunits import convert_list, convert_units
69
+ from ..symmetry_util import (
70
+ cartesian_rotation_is_in_point_group,
71
+ change_of_basis_atoms,
72
+ get_cell_from_poscar,
73
+ get_change_of_basis_matrix_to_conventional_cell_from_formal_bravais_lattice,
74
+ get_formal_bravais_lattice_from_space_group,
75
+ )
76
+
77
+ logger = logging.getLogger(__name__)
78
+ logging.basicConfig(filename="kim-tools.log", level=logging.INFO, force=True)
79
+
80
+ __author__ = ["ilia Nikiforov", "Eric Fuemmeler"]
81
+ __all__ = [
82
+ "KIMTestDriverError",
83
+ "KIMTestDriver",
84
+ "get_crystal_structure_from_atoms",
85
+ "get_poscar_from_crystal_structure",
86
+ "get_atoms_from_crystal_structure",
87
+ "SingleCrystalTestDriver",
88
+ "query_crystal_structures",
89
+ "detect_unique_crystal_structures",
90
+ "get_deduplicated_property_instances",
91
+ "minimize_wrapper",
92
+ ]
93
+
94
+ # Force tolerance for the optional initial relaxation of the provided cell
95
+ FMAX_INITIAL = 1e-5
96
+ # Maximum steps for the optional initial relaxation of the provided cell
97
+ MAXSTEPS_INITIAL = 200
98
+ PROP_SEARCH_PATHS_INFO = (
99
+ "- $KIM_PROPERTY_PATH (expanding globs including recursive **)\n"
100
+ "- $PWD/local-props/**/\n"
101
+ "- $PWD/local_props/**/"
102
+ )
103
+
104
+
105
+ def _get_optional_source_value(property_instance: Dict, key: str) -> Any:
106
+ """
107
+ Function for getting an optional property key's source-value, or None if the key
108
+ doesn't exist
109
+ """
110
+ if key in property_instance:
111
+ return property_instance[key]["source-value"]
112
+ else:
113
+ return None
114
+
115
+
116
+ def minimize_wrapper(
117
+ atoms: Atoms,
118
+ fmax: float = FMAX_INITIAL,
119
+ steps: int = MAXSTEPS_INITIAL,
120
+ variable_cell: bool = True,
121
+ logfile: Optional[Union[str, IO]] = "kim-tools.log",
122
+ algorithm: Optimizer = LBFGSLineSearch,
123
+ cell_filter: UnitCellFilter = FrechetCellFilter,
124
+ fix_symmetry: bool = False,
125
+ opt_kwargs: Dict = {},
126
+ flt_kwargs: Dict = {},
127
+ ) -> bool:
128
+ """
129
+ Use LBFGSLineSearch (default) to Minimize cell energy with respect to cell shape and
130
+ internal atom positions.
131
+
132
+ LBFGSLineSearch convergence behavior is as follows:
133
+
134
+ - The solver returns True if it is able to converge within the optimizer
135
+ iteration limits (which can be changed by the ``steps`` argument passed
136
+ to ``run``), otherwise it returns False.
137
+ - The solver raises an exception in situations where the line search cannot
138
+ improve the solution, typically due to an incompatibility between the
139
+ potential's values for energy, forces, and/or stress.
140
+
141
+ This routine attempts to minimizes the energy until the force and stress
142
+ reduce below specified tolerances given a provided limit on the number of
143
+ allowed steps. The code returns when convergence is achieved or no
144
+ further progress can be made, either due to reaching the iteration step
145
+ limit, or a stalled minimization due to line search failures.
146
+
147
+ Args:
148
+ atoms:
149
+ Atomic configuration to be minimized.
150
+ fmax:
151
+ Force convergence tolerance (the magnitude of the force on each
152
+ atom must be less than this for convergence)
153
+ steps:
154
+ Maximum number of iterations for the minimization
155
+ variable_cell:
156
+ True to allow relaxation with respect to cell shape
157
+ logfile:
158
+ Log file. ``'-'`` means STDOUT
159
+ algorithm:
160
+ ASE optimizer algorithm
161
+ CellFilter:
162
+ Filter to use if variable_cell is requested
163
+ fix_symmetry:
164
+ Whether to fix the crystallographic symmetry
165
+ opt_kwargs:
166
+ Dictionary of kwargs to pass to optimizer
167
+ flt_kwargs:
168
+ Dictionary of kwargs to pass to filter (e.g. "scalar_pressure")
169
+
170
+ Returns:
171
+ Whether the minimization succeeded
172
+ """
173
+ if fix_symmetry:
174
+ symmetry = FixSymmetry(atoms)
175
+ atoms.set_constraint(symmetry)
176
+ if variable_cell:
177
+ supercell_wrapped = cell_filter(atoms, **flt_kwargs)
178
+ opt = algorithm(supercell_wrapped, logfile=logfile, **opt_kwargs)
179
+ else:
180
+ opt = algorithm(atoms, logfile=logfile, **opt_kwargs)
181
+ try:
182
+ converged = opt.run(fmax=fmax, steps=steps)
183
+ iteration_limits_reached = not converged
184
+ minimization_stalled = False
185
+ except Exception as e:
186
+ minimization_stalled = True
187
+ iteration_limits_reached = False
188
+ logger.info("The following exception was caught during minimization:")
189
+ logger.info(repr(e))
190
+
191
+ logger.info(
192
+ "Minimization "
193
+ + (
194
+ "stalled"
195
+ if minimization_stalled
196
+ else "stopped" if iteration_limits_reached else "converged"
197
+ )
198
+ + " after "
199
+ + (
200
+ ("hitting the maximum of " + str(steps))
201
+ if iteration_limits_reached
202
+ else str(opt.nsteps)
203
+ )
204
+ + " steps."
205
+ )
206
+
207
+ del atoms.constraints
208
+
209
+ if minimization_stalled or iteration_limits_reached:
210
+ logger.info("Final forces:")
211
+ logger.info(atoms.get_forces())
212
+ logger.info("Final stress:")
213
+ logger.info(atoms.get_stress())
214
+ return False
215
+ else:
216
+ return True
217
+
218
+
219
+ def _add_property_instance(
220
+ property_name: str,
221
+ disclaimer: Optional[str] = None,
222
+ property_instances: Optional[str] = None,
223
+ ) -> str:
224
+ """
225
+ Initialize a new property instance in the serialized ``property_instances``. This
226
+ wraps the ``kim_property.kim_property_create()`` function, with the simplification
227
+ of setting "instance-id" automatically, as well as automatically searching for a
228
+ definition file if ``property_name`` is not found.
229
+
230
+ NOTE: Is there any need to allow Test Driver authors to specify an instance-id?
231
+
232
+ Args:
233
+ property_name:
234
+ The property name, e.g.
235
+ "tag:staff@noreply.openkim.org,2023-02-21:property/binding-energy-crystal"
236
+ or "binding-energy-crystal"
237
+ disclaimer:
238
+ An optional disclaimer commenting on the applicability of this result, e.g.
239
+ "This relaxation did not reach the desired tolerance."
240
+ property_instances:
241
+ A pre-existing EDN-serialized list of KIM Property instances to add to
242
+
243
+ Returns:
244
+ Updated EDN-serialized list of property instances
245
+ """
246
+ if property_instances is None:
247
+ property_instances = "[]"
248
+ # Get and check the instance-id to use.
249
+ property_instances_deserialized = kim_edn.loads(property_instances)
250
+ new_instance_index = len(property_instances_deserialized) + 1
251
+ for property_instance in property_instances_deserialized:
252
+ assert (
253
+ property_instance["instance-id"] != new_instance_index
254
+ ), "instance-id conflict"
255
+
256
+ existing_properties = get_properties()
257
+ property_in_existing_properties = False
258
+ for existing_property in existing_properties:
259
+ if (
260
+ existing_property == property_name
261
+ or get_property_id_path(existing_property)[3] == property_name
262
+ ):
263
+ property_in_existing_properties = True
264
+
265
+ if not property_in_existing_properties:
266
+ print(
267
+ f"\nThe property name or id\n{property_name}\nwas not found in "
268
+ "kim-properties.\n"
269
+ )
270
+ print(
271
+ "I will now look for an .edn file containing its definition in the "
272
+ f"following locations:\n{PROP_SEARCH_PATHS_INFO}\n"
273
+ )
274
+
275
+ property_search_paths = []
276
+
277
+ # environment varible
278
+ if "KIM_PROPERTY_PATH" in os.environ:
279
+ property_search_paths += os.environ["KIM_PROPERTY_PATH"].split(":")
280
+
281
+ # CWD
282
+ property_search_paths.append(os.path.join(Path.cwd(), "local_props", "**"))
283
+ property_search_paths.append(os.path.join(Path.cwd(), "local-props", "**"))
284
+
285
+ # recursively search for .edn files in the paths, check if they are a property
286
+ # definition with the correct name
287
+
288
+ found_custom_property = False
289
+
290
+ for search_path in property_search_paths:
291
+ if found_custom_property:
292
+ break
293
+ else:
294
+ # hack to expand globs in both absolute and relative paths
295
+ if search_path[0] == "/":
296
+ base_path = Path("/")
297
+ search_glob = os.path.join(search_path[1:], "*.edn")
298
+ else:
299
+ base_path = Path()
300
+ search_glob = os.path.join(search_path, "*.edn")
301
+
302
+ for path in base_path.glob(search_glob):
303
+ if not os.path.isfile(
304
+ path
305
+ ): # in case there's a directory named *.edn
306
+ continue
307
+ try:
308
+ path_str = str(path)
309
+ dict_from_edn = kim_edn.load(path_str)
310
+ if ("property-id") in dict_from_edn:
311
+ property_id = dict_from_edn["property-id"]
312
+ if (
313
+ property_id == property_name
314
+ or get_property_id_path(property_id)[3] == property_name
315
+ ):
316
+ property_name = path_str
317
+ found_custom_property = True
318
+ break
319
+ except Exception:
320
+ pass
321
+
322
+ if not found_custom_property:
323
+ raise KIMTestDriverError(
324
+ f"\nThe property name or id\n{property_name}\nwas not found in "
325
+ "kim-properties.\nI failed to find an .edn file containing a matching "
326
+ '"property-id" key in the following locations:\n'
327
+ + PROP_SEARCH_PATHS_INFO
328
+ )
329
+
330
+ return kim_property_create(
331
+ new_instance_index, property_name, property_instances, disclaimer
332
+ )
333
+
334
+
335
+ def _add_key_to_current_property_instance(
336
+ property_instances: str,
337
+ name: str,
338
+ value: ArrayLike,
339
+ unit: Optional[str] = None,
340
+ uncertainty_info: Optional[dict] = None,
341
+ ) -> str:
342
+ """
343
+ Write a key to the last element of property_instances. This wraps
344
+ ``kim_property.kim_property_modify()`` with a simplified (and more restricted)
345
+ interface.
346
+
347
+ Note: if the value is an array, this function will assume you want to write to the
348
+ beginning of the array in every dimension.
349
+
350
+ This function is intended to write entire keys in one go, and should not be used for
351
+ modifying existing keys.
352
+
353
+ WARNING! It is the developer's responsibility to make sure the array shape matches
354
+ the extent specified in the property definition. This method uses fills the values
355
+ of array keys as slices through the last dimension. If those slices are incomplete,
356
+ kim_property automatically initializes the other elements in that slice to zero. For
357
+ example, consider writing coordinates to a key with extent [":", 3]. The correct way
358
+ to write a single atom would be to provide [[x, y, z]]. If you accidentally provide
359
+ [[x], [y], [z]], it will fill the field with the coordinates
360
+ [[x, 0, 0], [y, 0, 0], [z, 0, 0]]. This will not raise an error, only exceeding
361
+ the allowed dimesnsions of the key will do so.
362
+
363
+ Args:
364
+ property_instances:
365
+ An EDN-serialized list of dictionaries representing KIM Property Instances.
366
+ The key will be added to the last dictionary in the list
367
+ name:
368
+ Name of the key, e.g. "cell-cauchy-stress"
369
+ value:
370
+ The value of the key. The function will attempt to convert it to a NumPy
371
+ array, then use the dimensions of the resulting array. Scalars, lists,
372
+ tuples, and arrays should work.
373
+ Data type of the elements should be str, float, or int
374
+ unit:
375
+ The units
376
+ uncertainty_info:
377
+ dictionary containing any uncertainty keys you wish to include. See
378
+ https://openkim.org/doc/schema/properties-framework/ for the possible
379
+ uncertainty key names. These must be the same dimension as ``value``, or
380
+ they may be scalars regardless of the shape of ``value``.
381
+
382
+ Returns:
383
+ Updated EDN-serialized list of property instances
384
+ """
385
+
386
+ def recur_dimensions(
387
+ prev_indices: List[int],
388
+ sub_value: np.ndarray,
389
+ modify_args: list,
390
+ key_name: str = "source-value",
391
+ ):
392
+ sub_shape = sub_value.shape
393
+ assert (
394
+ len(sub_shape) != 0
395
+ ), "Should not have gotten to zero dimensions in the recursive function"
396
+ if len(sub_shape) == 1:
397
+ # only if we have gotten to a 1-dimensional sub-array do we write stuff
398
+ modify_args += [key_name, *prev_indices, "1:%d" % sub_shape[0], *sub_value]
399
+ else:
400
+ for i in range(sub_shape[0]):
401
+ prev_indices.append(i + 1) # convert to 1-based indices
402
+ recur_dimensions(prev_indices, sub_value[i], modify_args, key_name)
403
+ prev_indices.pop()
404
+
405
+ value_arr = np.array(value)
406
+ value_shape = value_arr.shape
407
+
408
+ current_instance_index = len(kim_edn.loads(property_instances))
409
+ modify_args = ["key", name]
410
+ if len(value_shape) == 0:
411
+ modify_args += ["source-value", value]
412
+ else:
413
+ prev_indices = []
414
+ recur_dimensions(prev_indices, value_arr, modify_args)
415
+
416
+ if unit is not None:
417
+ modify_args += ["source-unit", unit]
418
+
419
+ if uncertainty_info is not None:
420
+ for uncertainty_key in uncertainty_info:
421
+ if uncertainty_key not in STANDARD_KEYS_SCLAR_OR_WITH_EXTENT:
422
+ raise KIMTestDriverError(
423
+ "Uncertainty key %s is not one of the allowed options %s."
424
+ % (uncertainty_key, str(STANDARD_KEYS_SCLAR_OR_WITH_EXTENT))
425
+ )
426
+ uncertainty_value = uncertainty_info[uncertainty_key]
427
+ uncertainty_value_arr = np.array(uncertainty_value)
428
+ uncertainty_value_shape = uncertainty_value_arr.shape
429
+
430
+ if not (
431
+ len(uncertainty_value_shape) == 0
432
+ or uncertainty_value_shape == value_shape
433
+ ):
434
+ raise KIMTestDriverError(
435
+ f"The value {uncertainty_value_arr} provided for uncertainty key "
436
+ f"{uncertainty_key} has shape {uncertainty_value_shape}.\n"
437
+ f"It must either be a scalar or match the shape {value_shape} of "
438
+ "the source value you provided."
439
+ )
440
+ if len(uncertainty_value_shape) == 0:
441
+ modify_args += [uncertainty_key, uncertainty_value]
442
+ else:
443
+ prev_indices = []
444
+ recur_dimensions(
445
+ prev_indices, uncertainty_value_arr, modify_args, uncertainty_key
446
+ )
447
+
448
+ return kim_property_modify(property_instances, current_instance_index, *modify_args)
449
+
450
+
451
+ class KIMTestDriverError(Exception):
452
+ def __init__(self, msg) -> None:
453
+ # Call the base class constructor with the parameters it needs
454
+ super(KIMTestDriverError, self).__init__(msg)
455
+ self.msg = msg
456
+
457
+ def __str__(self) -> str:
458
+ return self.msg
459
+
460
+
461
+ class KIMTestDriver(ABC):
462
+ """
463
+ A base class for creating KIM Test Drivers. It has attributes that are likely
464
+ to be useful to for most KIM tests
465
+
466
+ Attributes:
467
+ __kim_model_name (str, optional):
468
+ KIM model name, absent if a non-KIM ASE calculator was provided
469
+ __calc (:obj:`~ase.calculators.calculator.Calculator`):
470
+ ASE calculator
471
+ __output_property_instances (str):
472
+ Property instances, possibly accumulated over multiple invocations of
473
+ the Test Driver
474
+ """
475
+
476
+ def __init__(self, model: Union[str, Calculator]) -> None:
477
+ """
478
+ Args:
479
+ model:
480
+ ASE calculator or KIM model name to use
481
+ """
482
+ if isinstance(model, Calculator):
483
+ self.__calc = model
484
+ self.__kim_model_name = None
485
+ else:
486
+ from ase.calculators.kim.kim import KIM
487
+
488
+ self.__kim_model_name = model
489
+ self.__calc = KIM(self.__kim_model_name)
490
+
491
+ self.__output_property_instances = "[]"
492
+
493
+ def _setup(self, material, **kwargs) -> None:
494
+ """
495
+ Empty method, for optional overrides
496
+ """
497
+ pass
498
+
499
+ @abstractmethod
500
+ def _calculate(self, **kwargs) -> None:
501
+ """
502
+ Abstract calculate method
503
+ """
504
+ raise NotImplementedError("Subclasses must implement the _calculate method.")
505
+
506
+ def __call__(self, material: Any = None, **kwargs) -> List[Dict]:
507
+ """
508
+
509
+ Main operation of a Test Driver:
510
+
511
+ * Run :func:`~KIMTestDriver._setup` (the base class provides a barebones
512
+ version, derived classes may override)
513
+ * Call :func:`~KIMTestDriver._calculate` (implemented by each individual
514
+ Test Driver)
515
+
516
+ Args:
517
+ material:
518
+ Placeholder object for arguments describing the material to run
519
+ the Test Driver on
520
+
521
+ Returns:
522
+ The property instances calculated during the current run
523
+ """
524
+ # count how many instances we had before we started
525
+ previous_properties_end = len(kim_edn.loads(self.__output_property_instances))
526
+
527
+ # _setup is likely overridden by an derived class
528
+ self._setup(material, **kwargs)
529
+
530
+ # implemented by each individual Test Driver
531
+ self._calculate(**kwargs)
532
+
533
+ # The current invocation returns a Python list of dictionaries containing all
534
+ # properties computed during this run
535
+ return kim_edn.loads(self.__output_property_instances)[previous_properties_end:]
536
+
537
+ def _add_property_instance(
538
+ self, property_name: str, disclaimer: Optional[str] = None
539
+ ) -> None:
540
+ """
541
+ Initialize a new property instance.
542
+ NOTE: Is there any need to allow Test Driver authors to specify an instance-id?
543
+
544
+ Args:
545
+ property_name:
546
+ The property name, e.g.
547
+ "tag:staff@noreply.openkim.org,2023-02-21:property/binding-energy-crystal"
548
+ or "binding-energy-crystal"
549
+ disclaimer:
550
+ An optional disclaimer commenting on the applicability of this result,
551
+ e.g. "This relaxation did not reach the desired tolerance."
552
+ """
553
+ self.__output_property_instances = _add_property_instance(
554
+ property_name, disclaimer, self.__output_property_instances
555
+ )
556
+
557
+ def _add_key_to_current_property_instance(
558
+ self,
559
+ name: str,
560
+ value: ArrayLike,
561
+ unit: Optional[str] = None,
562
+ uncertainty_info: Optional[dict] = None,
563
+ ) -> None:
564
+ """
565
+ Add a key to the most recent property instance added with
566
+ :func:`~kim_tools.KIMTestDriver._add_property_instance`. If the value is an
567
+ array, this function will assume you want to write to the beginning of the array
568
+ in every dimension. This function is intended to write entire keys in one go,
569
+ and should not be used for modifying existing keys.
570
+
571
+ WARNING! It is the developer's responsibility to make sure the array shape
572
+ matches the extent specified in the property definition. This method uses
573
+ ``kim_property.kim_property_modify``, and fills the values of array keys as
574
+ slices through the last dimension. If those slices are incomplete, kim_property
575
+ automatically initializes the other elements in that slice to zero. For example,
576
+ consider writing coordinates to a key with extent [":", 3]. The correct way to
577
+ write a single atom would be to provide [[x, y, z]]. If you accidentally provide
578
+ [[x], [y], [z]], it will fill the field with the coordinates
579
+ [[x, 0, 0], [y, 0, 0], [z, 0, 0]]. This will not raise an error, only exceeding
580
+ the allowed dimesnsions of the key will do so.
581
+
582
+ Args:
583
+ name:
584
+ Name of the key, e.g. "cell-cauchy-stress"
585
+ value:
586
+ The value of the key. The function will attempt to convert it to a NumPy
587
+ array, then use the dimensions of the resulting array. Scalars, lists,
588
+ tuples, and arrays should work. Data type of the elements should be str,
589
+ float, int, or bool
590
+ unit:
591
+ The units
592
+ uncertainty_info:
593
+ dictionary containing any uncertainty keys you wish to include. See
594
+ https://openkim.org/doc/schema/properties-framework/ for the possible
595
+ uncertainty key names. These must be the same dimension as ``value``, or
596
+ they may be scalars regardless of the shape of ``value``.
597
+ """
598
+ self.__output_property_instances = _add_key_to_current_property_instance(
599
+ self.__output_property_instances, name, value, unit, uncertainty_info
600
+ )
601
+
602
+ def _add_file_to_current_property_instance(
603
+ self, name: str, filename: os.PathLike, add_instance_id: bool = True
604
+ ) -> None:
605
+ """
606
+ add a "file" type key-value pair to the current property instance.
607
+
608
+ Args:
609
+ name:
610
+ Name of the key, e.g. "restart-file"
611
+ filename:
612
+ The path to the filename. If it is not in "$CWD/output/",
613
+ the file will be moved there
614
+ add_instance_id:
615
+ By default, a numerical index will be added before the file extension or
616
+ at the end of a file with no extension. This is to ensure files do not
617
+ get overwritten when the _calculate method is called repeatedly.
618
+
619
+ Raises:
620
+ KIMTestDriverError:
621
+ If the provided filename does not exist
622
+ """
623
+ if not os.path.isfile(filename):
624
+ raise KIMTestDriverError("Provided file {filename} does not exist.")
625
+
626
+ # all paths here should be absolute
627
+ cwd_path = Path(os.getcwd())
628
+ output_path = cwd_path / "output"
629
+ filename_path = Path(filename).resolve()
630
+
631
+ if output_path not in filename_path.parents:
632
+ # Need to move file to output
633
+ if cwd_path in filename_path.parents:
634
+ # The file is somewhere under CWD,
635
+ # so move it under CWD/output with
636
+ # its whole directory structure
637
+ final_dir = os.path.join(
638
+ output_path, os.path.relpath(filename_path.parent)
639
+ )
640
+ os.makedirs(final_dir, exist_ok=True)
641
+ else:
642
+ # We got a file that isn't even under CWD.
643
+ # I can't really hope to suss out what they
644
+ # were going for, so just move it to CWD/output
645
+ final_dir = output_path
646
+ else:
647
+ # already under output, not moving anything
648
+ final_dir = filename_path.parent
649
+
650
+ input_name = filename_path.name
651
+ if add_instance_id:
652
+ current_instance_id = len(kim_edn.loads(self.__output_property_instances))
653
+ root, ext = os.path.splitext(input_name)
654
+ root = root + "-" + str(current_instance_id)
655
+ final_name = root + ext
656
+ else:
657
+ final_name = input_name
658
+
659
+ final_path = os.path.join(final_dir, final_name)
660
+
661
+ assert final_path.startswith(str(output_path))
662
+
663
+ shutil.move(filename, final_path)
664
+
665
+ # Filenames are reported relative to $CWD/output
666
+ self._add_key_to_current_property_instance(
667
+ name, os.path.relpath(final_path, output_path)
668
+ )
669
+
670
+ @property
671
+ def kim_model_name(self) -> Optional[str]:
672
+ """
673
+ Get the KIM model name, if present
674
+ """
675
+ return self.__kim_model_name
676
+
677
+ @property
678
+ def property_instances(self) -> Dict:
679
+ """
680
+ Get all property instances accumulated over all calls to the Test Driver so far
681
+ """
682
+ return kim_edn.loads(self.__output_property_instances)
683
+
684
+ @property
685
+ def _calc(self) -> Optional[Calculator]:
686
+ """
687
+ Get the ASE calculator
688
+ """
689
+ return self.__calc
690
+
691
+ def _get_serialized_property_instances(self) -> str:
692
+ """
693
+ Get the property instances computed so far in serialized EDN format
694
+ """
695
+ return self.__output_property_instances
696
+
697
+ def _set_serialized_property_instances(self, property_instances: str) -> None:
698
+ """
699
+ Set the property instances from a serialized EDN string
700
+ """
701
+ self.__output_property_instances = property_instances
702
+
703
+ def write_property_instances_to_file(self, filename="output/results.edn") -> None:
704
+ """
705
+ Write internal property instances (possibly accumulated over several calls to
706
+ the Test Driver) to a file at the requested path. Also dumps any cached files to
707
+ the same directory.
708
+
709
+ Args:
710
+ filename: path to write the file
711
+ """
712
+
713
+ with open(filename, "w") as f:
714
+ kim_property_dump(
715
+ self.__output_property_instances, f
716
+ ) # serialize the dictionary to string first
717
+
718
+
719
+ def _add_common_crystal_genome_keys_to_current_property_instance(
720
+ property_instances: str,
721
+ prototype_label: str,
722
+ stoichiometric_species: List[str],
723
+ a: float,
724
+ parameter_values: Optional[List[float]] = None,
725
+ library_prototype_label: Optional[Union[List[str], str]] = None,
726
+ short_name: Optional[Union[List[str], str]] = None,
727
+ cell_cauchy_stress: Optional[List[float]] = None,
728
+ temperature: Optional[float] = None,
729
+ crystal_genome_source_structure_id: Optional[List[List[str]]] = None,
730
+ a_unit: str = "angstrom",
731
+ cell_cauchy_stress_unit: str = "eV/angstrom^3",
732
+ temperature_unit: str = "K",
733
+ ) -> str:
734
+ """
735
+ Write common Crystal Genome keys to the last element of ``property_instances``. See
736
+ https://openkim.org/properties/show/crystal-structure-npt for definition of the
737
+ input keys. Note that the "parameter-names" key is inferred from the
738
+ ``prototype_label`` input and is not an input to this function.
739
+
740
+ Args:
741
+ property_instances:
742
+ An EDN-serialized list of dictionaries representing KIM Property Instances.
743
+ The key will be added to the last dictionary in the list
744
+
745
+ Returns:
746
+ Updated EDN-serialized list of property instances
747
+ """
748
+ property_instances = _add_key_to_current_property_instance(
749
+ property_instances, "prototype-label", prototype_label
750
+ )
751
+ property_instances = _add_key_to_current_property_instance(
752
+ property_instances, "stoichiometric-species", stoichiometric_species
753
+ )
754
+ property_instances = _add_key_to_current_property_instance(
755
+ property_instances, "a", a, a_unit
756
+ )
757
+
758
+ # get parameter names
759
+ aflow = AFLOW()
760
+ aflow_parameter_names = aflow.get_param_names_from_prototype(prototype_label)
761
+ if parameter_values is None:
762
+ if len(aflow_parameter_names) > 1:
763
+ raise KIMTestDriverError(
764
+ "The prototype label implies that parameter_values (i.e. dimensionless "
765
+ "parameters besides a) are required, but you provided None"
766
+ )
767
+ else:
768
+ if len(aflow_parameter_names) - 1 != len(parameter_values):
769
+ raise KIMTestDriverError(
770
+ "Incorrect number of parameter_values (i.e. dimensionless parameters "
771
+ "besides a) for the provided prototype"
772
+ )
773
+ property_instances = _add_key_to_current_property_instance(
774
+ property_instances, "parameter-names", aflow_parameter_names[1:]
775
+ )
776
+ property_instances = _add_key_to_current_property_instance(
777
+ property_instances, "parameter-values", parameter_values
778
+ )
779
+
780
+ if short_name is not None:
781
+ if not isinstance(short_name, list):
782
+ short_name = [short_name]
783
+ property_instances = _add_key_to_current_property_instance(
784
+ property_instances, "short-name", short_name
785
+ )
786
+
787
+ if library_prototype_label is not None:
788
+ property_instances = _add_key_to_current_property_instance(
789
+ property_instances, "library-prototype-label", library_prototype_label
790
+ )
791
+
792
+ if cell_cauchy_stress is not None:
793
+ if len(cell_cauchy_stress) != 6:
794
+ raise KIMTestDriverError(
795
+ "Please specify the Cauchy stress as a 6-dimensional vector in Voigt "
796
+ "order [xx, yy, zz, yz, xz, xy]"
797
+ )
798
+ property_instances = _add_key_to_current_property_instance(
799
+ property_instances,
800
+ "cell-cauchy-stress",
801
+ cell_cauchy_stress,
802
+ cell_cauchy_stress_unit,
803
+ )
804
+
805
+ if temperature is not None:
806
+ property_instances = _add_key_to_current_property_instance(
807
+ property_instances, "temperature", temperature, temperature_unit
808
+ )
809
+
810
+ if crystal_genome_source_structure_id is not None:
811
+ property_instances = _add_key_to_current_property_instance(
812
+ property_instances,
813
+ "crystal-genome-source-structure-id",
814
+ crystal_genome_source_structure_id,
815
+ )
816
+
817
+ return property_instances
818
+
819
+
820
+ def _add_property_instance_and_common_crystal_genome_keys(
821
+ property_name: str,
822
+ prototype_label: str,
823
+ stoichiometric_species: List[str],
824
+ a: float,
825
+ parameter_values: Optional[List[float]] = None,
826
+ library_prototype_label: Optional[Union[List[str], str]] = None,
827
+ short_name: Optional[Union[List[str], str]] = None,
828
+ cell_cauchy_stress: Optional[List[float]] = None,
829
+ temperature: Optional[float] = None,
830
+ crystal_genome_source_structure_id: Optional[List[List[str]]] = None,
831
+ a_unit: str = "angstrom",
832
+ cell_cauchy_stress_unit: str = "eV/angstrom^3",
833
+ temperature_unit: str = "K",
834
+ disclaimer: Optional[str] = None,
835
+ property_instances: Optional[str] = None,
836
+ ) -> str:
837
+ """
838
+ Initialize a new property instance to ``property_instances`` (an empty
839
+ ``property_instances`` will be initialized if not provided). It will automatically
840
+ get the an "instance-id" equal to the length of ``property_instances`` after it is
841
+ added. Then, write the common Crystal Genome keys to it. See
842
+ https://openkim.org/properties/show/crystal-structure-npt for definition of the
843
+ input keys. Note that the "parameter-names" key is inferred from the
844
+ ``prototype_label`` input and is not an input to this function.
845
+
846
+ Args:
847
+ property_name:
848
+ The property name, e.g.
849
+ "tag:staff@noreply.openkim.org,2023-02-21:property/binding-energy-crystal"
850
+ or "binding-energy-crystal"
851
+ disclaimer:
852
+ An optional disclaimer commenting on the applicability of this result, e.g.
853
+ "This relaxation did not reach the desired tolerance."
854
+ property_instances:
855
+ A pre-existing EDN-serialized list of KIM Property instances to add to
856
+
857
+ Returns:
858
+ Updated EDN-serialized list of property instances
859
+ """
860
+ property_instances = _add_property_instance(
861
+ property_name, disclaimer, property_instances
862
+ )
863
+ return _add_common_crystal_genome_keys_to_current_property_instance(
864
+ property_instances,
865
+ prototype_label,
866
+ stoichiometric_species,
867
+ a,
868
+ parameter_values,
869
+ library_prototype_label,
870
+ short_name,
871
+ cell_cauchy_stress,
872
+ temperature,
873
+ crystal_genome_source_structure_id,
874
+ a_unit,
875
+ cell_cauchy_stress_unit,
876
+ temperature_unit,
877
+ )
878
+
879
+
880
+ def get_crystal_structure_from_atoms(
881
+ atoms: Atoms, get_short_name: bool = True, prim: bool = True, aflow_np: int = 4
882
+ ) -> Dict:
883
+ """
884
+ By performing a symmetry analysis on an :class:`~ase.Atoms` object, generate a
885
+ dictionary that is a subset of the
886
+ `crystal-structure-npt <https://openkim.org/properties/show/crystal-structure-npt>`_
887
+ property. See https://openkim.org/doc/schema/properties-framework/ for more
888
+ information about KIM properties and how values and units are defined. The
889
+ dictionary returned by this function does not necessarily constitute a complete KIM
890
+ Property Instance, but the key-value pairs are in valid KIM Property format and can
891
+ be inserted as-is into any single crystal Crystal Genome property.
892
+
893
+ Args:
894
+ atoms: Configuration to analyze. It is assumed that the length unit is angstrom
895
+ get_short_name:
896
+ whether to compare against AFLOW prototype library to obtain short-name
897
+ prim: whether to primitivize the atoms object first
898
+ aflow_np: Number of processors to use with AFLOW executable
899
+
900
+ Returns:
901
+ A dictionary that has the following Property Keys (possibly optionally) defined.
902
+ See the
903
+ `crystal-structure-npt
904
+ <https://openkim.org/properties/show/crystal-structure-npt>`_
905
+ Property Definition for their meaning:
906
+
907
+ - "stoichiometric-species"
908
+ - "prototype-label"
909
+ - "a"
910
+ - "parameter-names" (inferred from "prototype-label")
911
+ - "parameter-values"
912
+ - "short-name"
913
+
914
+ """
915
+ aflow = AFLOW(np=aflow_np)
916
+
917
+ proto_des = aflow.get_prototype_designation_from_atoms(atoms, prim=prim)
918
+ library_prototype_label, short_name = (
919
+ aflow.get_library_prototype_label_and_shortname_from_atoms(atoms, prim=prim)
920
+ if get_short_name
921
+ else (None, None)
922
+ )
923
+
924
+ a = proto_des["aflow_prototype_params_values"][0]
925
+ parameter_values = (
926
+ proto_des["aflow_prototype_params_values"][1:]
927
+ if len(proto_des["aflow_prototype_params_values"]) > 1
928
+ else None
929
+ )
930
+
931
+ property_instances = _add_property_instance_and_common_crystal_genome_keys(
932
+ property_name="crystal-structure-npt",
933
+ prototype_label=proto_des["aflow_prototype_label"],
934
+ stoichiometric_species=sorted(list(set(atoms.get_chemical_symbols()))),
935
+ a=a,
936
+ parameter_values=parameter_values,
937
+ library_prototype_label=library_prototype_label,
938
+ short_name=short_name,
939
+ )
940
+
941
+ return kim_edn.loads(property_instances)[0]
942
+
943
+
944
+ def get_poscar_from_crystal_structure(
945
+ crystal_structure: Dict, output_file: Optional[str] = None, flat: bool = False
946
+ ) -> Optional[str]:
947
+ """
948
+ Write a POSCAR coordinate file (or output it as a multiline string) from the AFLOW
949
+ Prototype Designation obtained from a KIM Property Instance. The following keys from
950
+ https://openkim.org/properties/show/2023-02-21/staff@noreply.openkim.org/crystal-structure-npt
951
+ are used:
952
+
953
+ - "stoichiometric-species"
954
+ - "prototype-label"
955
+ - "a"
956
+ - "parameter-values"
957
+ (if "prototype-label" defines a crystal with free parameters besides "a")
958
+
959
+ Note that although the keys are required to follow the schema of a KIM Property
960
+ Instance (https://openkim.org/doc/schema/properties-framework/), there is no
961
+ requirement or check that the input dictionary is an instance of any specific KIM
962
+ Property, only that the keys required to build a crystal are present.
963
+
964
+ Args:
965
+ crystal_structure:
966
+ Dictionary containing the required keys in KIM Property Instance format
967
+ output_file:
968
+ Name of the output file. If not provided, the output is returned as a string
969
+ flat:
970
+ whether the input dictionary is flattened
971
+ Returns:
972
+ If ``output_file`` is not provided, a string in POSCAR format containg the
973
+ primitive unit cell of the crystal as defined in
974
+ http://doi.org/10.1016/j.commatsci.2017.01.017. Lengths are always in angstrom.
975
+ Raises:
976
+ AFLOW.ChangedSymmetryException: if the symmetry of the atoms object is different
977
+ from ``prototype_label``
978
+ """
979
+ if flat:
980
+ prototype_label = crystal_structure["prototype-label.source-value"]
981
+ a_unit = crystal_structure["a.source-unit"]
982
+ a_value = crystal_structure["a.source-value"]
983
+ parameter_values_key = "parameter-values.source-value"
984
+ stoichiometric_species = crystal_structure[
985
+ "stoichiometric-species.source-value"
986
+ ]
987
+ else:
988
+ prototype_label = crystal_structure["prototype-label"]["source-value"]
989
+ a_unit = crystal_structure["a"]["source-unit"]
990
+ a_value = crystal_structure["a"]["source-value"]
991
+ parameter_values_key = "parameter-values"
992
+ stoichiometric_species = crystal_structure["stoichiometric-species"][
993
+ "source-value"
994
+ ]
995
+
996
+ aflow = AFLOW()
997
+ aflow_parameter_names = aflow.get_param_names_from_prototype(prototype_label)
998
+
999
+ # Atoms objects are always in angstrom
1000
+ if a_unit == "angstrom":
1001
+ a_angstrom = a_value
1002
+ else:
1003
+ a_angstrom = convert_units(
1004
+ a_value,
1005
+ crystal_structure["a"]["source-unit"],
1006
+ a_unit,
1007
+ True,
1008
+ )
1009
+
1010
+ aflow_parameter_values = [a_angstrom]
1011
+
1012
+ if parameter_values_key in crystal_structure:
1013
+ if len(aflow_parameter_names) == 1:
1014
+ raise KIMTestDriverError(
1015
+ f'Prototype label {prototype_label} implies only "a" parameter, but '
1016
+ 'you provided "parameter-values"'
1017
+ )
1018
+ if flat:
1019
+ aflow_parameter_values += crystal_structure[parameter_values_key]
1020
+ else:
1021
+ aflow_parameter_values += crystal_structure[parameter_values_key][
1022
+ "source-value"
1023
+ ]
1024
+
1025
+ try:
1026
+ return aflow.write_poscar_from_prototype(
1027
+ prototype_label=prototype_label,
1028
+ species=stoichiometric_species,
1029
+ parameter_values=aflow_parameter_values,
1030
+ output_file=output_file,
1031
+ )
1032
+ except AFLOW.ChangedSymmetryException as e:
1033
+ # re-raise, just indicating that this function knows about this exception
1034
+ raise e
1035
+
1036
+
1037
+ def get_atoms_from_crystal_structure(
1038
+ crystal_structure: Dict, flat: bool = False
1039
+ ) -> Atoms:
1040
+ """
1041
+ Generate an :class:`~ase.Atoms` object from the AFLOW Prototype Designation obtained
1042
+ from a KIM Property Instance. The following keys from
1043
+ https://openkim.org/properties/show/2023-02-21/staff@noreply.openkim.org/crystal-structure-npt
1044
+ are used:
1045
+
1046
+ - "stoichiometric-species"
1047
+ - "prototype-label"
1048
+ - "a"
1049
+ - "parameter-values" (if "prototype-label" defines a crystal with free parameters
1050
+ besides "a")
1051
+
1052
+ Note that although the keys are required to follow the schema of a KIM Property
1053
+ Instance (https://openkim.org/doc/schema/properties-framework/),
1054
+ There is no requirement or check that the input dictionary is an instance of any
1055
+ specific KIM Property, only that the keys required to build a crystal are present.
1056
+
1057
+ Args:
1058
+ crystal_structure:
1059
+ Dictionary containing the required keys in KIM Property Instance format
1060
+ flat:
1061
+ whether the dictionary is flattened
1062
+
1063
+ Returns:
1064
+ Primitive unit cell of the crystal as defined in the
1065
+ `AFLOW prototype standard <http://doi.org/10.1016/j.commatsci.2017.01.017>`_.
1066
+ Lengths are always in angstrom
1067
+
1068
+ Raises:
1069
+ AFLOW.ChangedSymmetryException:
1070
+ if the symmetry of the atoms object is different from ``prototype_label``
1071
+ """
1072
+ try:
1073
+ poscar_string = get_poscar_from_crystal_structure(crystal_structure, flat=flat)
1074
+ except AFLOW.ChangedSymmetryException as e:
1075
+ # re-raise, just indicating that this function knows about this exception
1076
+ raise e
1077
+
1078
+ with NamedTemporaryFile(mode="w+") as f:
1079
+ f.write(poscar_string)
1080
+ f.seek(0)
1081
+ atoms = ase.io.read(f.name, format="vasp")
1082
+ atoms.wrap()
1083
+ return atoms
1084
+
1085
+
1086
+ class SingleCrystalTestDriver(KIMTestDriver):
1087
+ """
1088
+ A KIM test that computes property(s) of a single nominal crystal structure
1089
+
1090
+ Attributes:
1091
+ __nominal_crystal_structure_npt [Dict]:
1092
+ An instance of the
1093
+ `crystal-structure-npt
1094
+ <https://openkim.org/properties/show/crystal-structure-npt>`_
1095
+ property representing the nominal crystal structure and conditions of the
1096
+ current call to the Test Driver.
1097
+ """
1098
+
1099
+ def _setup(
1100
+ self,
1101
+ material: Union[Atoms, Dict],
1102
+ cell_cauchy_stress_eV_angstrom3: Optional[List[float]] = None,
1103
+ temperature_K: float = 0,
1104
+ **kwargs,
1105
+ ) -> None:
1106
+ """
1107
+ TODO: Consider allowing arbitrary units for temp and stress?
1108
+
1109
+ Args:
1110
+ material:
1111
+ An :class:`~ase.Atoms` object or a KIM Property Instance specifying the
1112
+ nominal crystal structure that this run of the test
1113
+ will use. Pass one of the two following types of objects:
1114
+
1115
+ Atoms object:
1116
+
1117
+ :class:`~ase.Atoms` object to use as the initial configuration. Note
1118
+ that a symmetry analysis will be performed on it and a primitive
1119
+ cell will be generated according to the conventions in
1120
+ http://doi.org/10.1016/j.commatsci.2017.01.017. This primitive cell
1121
+ may be rotated and translated relative to the configuration you
1122
+ provided.
1123
+
1124
+ Property instance:
1125
+
1126
+ Dictionary containing information about the nominal input crystal
1127
+ structure in KIM Property Instance format (e.g. from a query to the
1128
+ OpenKIM.org database)
1129
+ The following keys from
1130
+ https://openkim.org/properties/show/2023-02-21/staff@noreply.openkim.org/crystal-structure-npt
1131
+ are used:
1132
+
1133
+ - "stoichiometric-species"
1134
+ - "prototype-label"
1135
+ - "a"
1136
+ - "parameter-values"
1137
+ (if "prototype-label" defines a crystal with
1138
+ free parameters besides "a")
1139
+ - "short-name" (if present)
1140
+ - "crystal-genome-source-structure-id" (if present)
1141
+
1142
+ Note that although the keys are required to follow the schema of a
1143
+ KIM Property Instance
1144
+ (https://openkim.org/doc/schema/properties-framework/),
1145
+ There is no requirement or check that the input dictionary is an
1146
+ instance of any specific KIM Property, only that
1147
+ the keys required to build a crystal are present.
1148
+
1149
+ cell_cauchy_stress_eV_angstrom3:
1150
+ Cauchy stress on the cell in eV/angstrom^3 (ASE units) in
1151
+ [xx, yy, zz, yz, xz, xy] format. This is a nominal variable, and this
1152
+ class simply provides recordkeeping of it. It is up to derived classes
1153
+ to implement actually imposing this stress on the system.
1154
+ temperature_K:
1155
+ The temperature in Kelvin. This is a nominal variable, and this class
1156
+ simply provides recordkeeping of it. It is up to derived classes to
1157
+ implement actually setting the temperature of the system.
1158
+ """
1159
+ if cell_cauchy_stress_eV_angstrom3 is None:
1160
+ cell_cauchy_stress_eV_angstrom3 = [0, 0, 0, 0, 0, 0]
1161
+
1162
+ if isinstance(material, Atoms):
1163
+ crystal_structure = get_crystal_structure_from_atoms(material)
1164
+ msg = (
1165
+ "Rebuilding atoms object in a standard setting defined by "
1166
+ "doi.org/10.1016/j.commatsci.2017.01.017. See log file or computed "
1167
+ "properties for the (possibly re-oriented) primitive cell that "
1168
+ "computations will be based on."
1169
+ )
1170
+ logger.info(msg)
1171
+ print()
1172
+ print(msg)
1173
+ print()
1174
+ else:
1175
+ crystal_structure = material
1176
+
1177
+ # Pop the temperature and stress keys in case they came along with a query
1178
+ if "temperature" in crystal_structure:
1179
+ crystal_structure.pop("temperature")
1180
+ if "cell-cauchy-stress" in crystal_structure:
1181
+ crystal_structure.pop("cell-cauchy-stress")
1182
+
1183
+ crystal_structure["temperature"] = {
1184
+ "source-value": temperature_K,
1185
+ "source-unit": "K",
1186
+ }
1187
+ crystal_structure["cell-cauchy-stress"] = {
1188
+ "source-value": cell_cauchy_stress_eV_angstrom3,
1189
+ "source-unit": "eV/angstrom^3",
1190
+ }
1191
+ if "meta" in crystal_structure:
1192
+ # Carrying 'meta' around doesn't really make sense since we may have already
1193
+ # modified things and will modify in the future
1194
+ crystal_structure.pop("meta")
1195
+
1196
+ self.__nominal_crystal_structure_npt = crystal_structure
1197
+
1198
+ # Warn if atoms appear unrelaxed
1199
+ atoms_tmp = self._get_atoms()
1200
+ force_max = np.max(atoms_tmp.get_forces())
1201
+ if force_max > FMAX_INITIAL:
1202
+ msg = (
1203
+ "The configuration you provided has a maximum force component "
1204
+ f"{force_max} eV/angstrom. Unless the Test Driver you are running "
1205
+ "provides minimization, you may wish to relax the configuration."
1206
+ )
1207
+ print(f"\nNOTE: {msg}\n")
1208
+ logger.info(msg)
1209
+ if cell_cauchy_stress_eV_angstrom3 != [0, 0, 0, 0, 0, 0]:
1210
+ stress_max = np.max(atoms_tmp.get_stress())
1211
+ if stress_max > FMAX_INITIAL:
1212
+ msg = (
1213
+ "The configuration you provided has a maximum stress component "
1214
+ f"{stress_max} eV/angstrom^3 even though the nominal state of the "
1215
+ "system is unstressed. Unless the Test Driver you are running "
1216
+ "provides minimization, you may wish to relax the configuration."
1217
+ )
1218
+ print(f"\nNOTE: {msg}\n")
1219
+ logger.info(msg)
1220
+
1221
+ def _update_nominal_parameter_values(self, atoms: Atoms) -> None:
1222
+ """
1223
+ Update the nominal parameter values of the nominal crystal structure from the
1224
+ provided :class:`~ase.Atoms` object. It is assumed that the crystallographic
1225
+ symmetry (space group + occupied Wyckoff positions) have not changed from the
1226
+ initially provided structure.
1227
+
1228
+ The provided :class:`~ase.Atoms` object MUST be a primitive cell of the crystal
1229
+ as defined in http://doi.org/10.1016/j.commatsci.2017.01.017.
1230
+ The :class:`~ase.Atoms` object may be rotated, translated, and permuted, but the
1231
+ identity of the lattice vectors must be unchanged w.r.t. the crystallographic
1232
+ prototype. In other words, there must exist a permutation and translation of the
1233
+ fractional coordinates that enables them to match the equations defined by the
1234
+ prototype label.
1235
+
1236
+ In practical terms, this means that this function is designed to take as input a
1237
+ relaxed or time-averaged from MD (and folded back into the original primitive
1238
+ cell) copy of the :class:`~ase.Atoms` object originally obtained from
1239
+ :func:`~kim_tools.SingleCrystalTestDriver._get_atoms()`.
1240
+
1241
+ If finding the parameter fails, this function will raise an exception. This
1242
+ probably indicates a phase transition to a different symmetry, which is a normal
1243
+ occasional occurrence if the original structure is not stable under the
1244
+ interatomic potential and prescribed conditions. These exceptions should not be
1245
+ handled and that run of the Test Driver should be allowed to fail.
1246
+
1247
+ Args:
1248
+ atoms: Structure to analyze to get the new parameter values
1249
+
1250
+ Raises:
1251
+ AFLOW.FailedToMatchException:
1252
+ If the solution failed due to a failure to match two crystals that
1253
+ should be identical at some point in the solution process. This
1254
+ *usually* indicates a phase transformation
1255
+ AFLOW.ChangedSymmetryException:
1256
+ If a more definitive error indicating a phase transformation is
1257
+ encountered
1258
+ """
1259
+
1260
+ try:
1261
+ aflow_parameter_values = AFLOW().solve_for_params_of_known_prototype(
1262
+ atoms,
1263
+ self.__nominal_crystal_structure_npt["prototype-label"]["source-value"],
1264
+ )
1265
+ except (AFLOW.FailedToMatchException, AFLOW.ChangedSymmetryException) as e:
1266
+ raise type(e)(
1267
+ "Encountered an error that MAY be the result of the nominal crystal "
1268
+ "being unstable under the given potential and conditions. Stopping "
1269
+ "execution."
1270
+ ) from e
1271
+
1272
+ # Atoms objects always in angstrom
1273
+ self.__nominal_crystal_structure_npt["a"] = {
1274
+ "source-value": aflow_parameter_values[0],
1275
+ "source-unit": "angstrom",
1276
+ }
1277
+ if len(aflow_parameter_values) > 1:
1278
+ self.__nominal_crystal_structure_npt["parameter-values"] = {
1279
+ "source-value": aflow_parameter_values[1:]
1280
+ }
1281
+
1282
+ def _verify_unchanged_symmetry(self, atoms: Atoms) -> bool:
1283
+ """
1284
+ Without changing the nominal state of the system, check if the provided Atoms
1285
+ object has the same symmetry as the nominal crystal structure associated with
1286
+ the current state of the Test Driver. This is defined as having the same
1287
+ prototype label, except for possible changes in Wyckoff letters as permitted by
1288
+ the space group normalizer.
1289
+
1290
+ Args:
1291
+ atoms: The structure to compare to the current nominal structure of the Test
1292
+ Driver
1293
+
1294
+ Returns:
1295
+ Whether or not the symmetry is unchanged
1296
+
1297
+ """
1298
+ return prototype_labels_are_equivalent(
1299
+ AFLOW().get_prototype_designation_from_atoms(atoms)[
1300
+ "aflow_prototype_label"
1301
+ ],
1302
+ self.__nominal_crystal_structure_npt["prototype-label"]["source-value"],
1303
+ )
1304
+
1305
+ def __add_poscar_to_curr_prop_inst(
1306
+ self,
1307
+ change_of_basis: Union[str, ArrayLike],
1308
+ filename: os.PathLike,
1309
+ key_name: str,
1310
+ ) -> None:
1311
+ """
1312
+ Add a POSCAR file constructed from ``self.__nominal_crystal_structure_npt``
1313
+ to the current property instance.
1314
+
1315
+ Args:
1316
+ change_of_basis:
1317
+ Passed to :meth:`kim_tools.SingleCrystalTestDriver._get_atoms`
1318
+ filename:
1319
+ File to save to. Will be automatically moved and renamed,
1320
+ e.g. 'instance.poscar' -> 'output/instance-1.poscar'
1321
+ key_name:
1322
+ The property key to write to
1323
+ """
1324
+
1325
+ # `_get_atoms` always returns in Angstrom
1326
+ atoms_tmp = self._get_atoms(change_of_basis)
1327
+
1328
+ # will automatically be renamed
1329
+ # e.g. 'instance.poscar' -> 'output/instance-1.poscar'
1330
+ atoms_tmp.write(filename=filename, sort=True, format="vasp")
1331
+ self._add_file_to_current_property_instance(key_name, filename)
1332
+
1333
+ def _add_property_instance_and_common_crystal_genome_keys(
1334
+ self,
1335
+ property_name: str,
1336
+ write_stress: bool = False,
1337
+ write_temp: bool = False,
1338
+ disclaimer: Optional[str] = None,
1339
+ ) -> None:
1340
+ """
1341
+ Initialize a new property instance to ``self.property_instances``. It will
1342
+ automatically get the an "instance-id" equal to the length of
1343
+ ``self.property_instances`` after it is added. Then, write the common Crystal
1344
+ Genome keys to it from the attributes of this class.
1345
+
1346
+ Args:
1347
+ property_name:
1348
+ The property name, e.g.
1349
+ "tag:staff@noreply.openkim.org,2023-02-21:property/binding-energy-crystal"
1350
+ or "binding-energy-crystal"
1351
+ write_stress:
1352
+ Write the "cell-cauchy-stress" key
1353
+ write_temp:
1354
+ Write the "temperature" key
1355
+ disclaimer:
1356
+ An optional disclaimer commenting on the applicability of this result,
1357
+ e.g. "This relaxation did not reach the desired tolerance."
1358
+ """
1359
+ crystal_structure = self.__nominal_crystal_structure_npt
1360
+
1361
+ a = crystal_structure["a"]["source-value"]
1362
+
1363
+ a_unit = crystal_structure["a"]["source-unit"]
1364
+
1365
+ prototype_label = crystal_structure["prototype-label"]["source-value"]
1366
+
1367
+ stoichiometric_species = crystal_structure["stoichiometric-species"][
1368
+ "source-value"
1369
+ ]
1370
+
1371
+ parameter_values = _get_optional_source_value(
1372
+ crystal_structure, "parameter-values"
1373
+ )
1374
+
1375
+ library_prototype_label = _get_optional_source_value(
1376
+ crystal_structure, "library-prototype-label"
1377
+ )
1378
+
1379
+ short_name = _get_optional_source_value(crystal_structure, "short-name")
1380
+
1381
+ # stress and temperature are always there (default 0), but we don't always write
1382
+ if write_stress:
1383
+ cell_cauchy_stress = crystal_structure["cell-cauchy-stress"]["source-value"]
1384
+ else:
1385
+ cell_cauchy_stress = None
1386
+
1387
+ cell_cauchy_stress_unit = crystal_structure["cell-cauchy-stress"]["source-unit"]
1388
+
1389
+ if write_temp:
1390
+ temperature = crystal_structure["temperature"]["source-value"]
1391
+ else:
1392
+ temperature = None
1393
+
1394
+ temperature_unit = crystal_structure["temperature"]["source-unit"]
1395
+
1396
+ crystal_genome_source_structure_id = _get_optional_source_value(
1397
+ crystal_structure, "crystal-genome-source-structure-id"
1398
+ )
1399
+
1400
+ super()._set_serialized_property_instances(
1401
+ _add_property_instance_and_common_crystal_genome_keys(
1402
+ property_name=property_name,
1403
+ prototype_label=prototype_label,
1404
+ stoichiometric_species=stoichiometric_species,
1405
+ a=a,
1406
+ parameter_values=parameter_values,
1407
+ library_prototype_label=library_prototype_label,
1408
+ short_name=short_name,
1409
+ cell_cauchy_stress=cell_cauchy_stress,
1410
+ temperature=temperature,
1411
+ crystal_genome_source_structure_id=crystal_genome_source_structure_id,
1412
+ a_unit=a_unit,
1413
+ cell_cauchy_stress_unit=cell_cauchy_stress_unit,
1414
+ temperature_unit=temperature_unit,
1415
+ disclaimer=disclaimer,
1416
+ property_instances=super()._get_serialized_property_instances(),
1417
+ )
1418
+ )
1419
+
1420
+ self.__add_poscar_to_curr_prop_inst(
1421
+ "primitive", "instance.poscar", "coordinates-file"
1422
+ )
1423
+ self.__add_poscar_to_curr_prop_inst(
1424
+ "conventional",
1425
+ "conventional.instance.poscar",
1426
+ "coordinates-file-conventional",
1427
+ )
1428
+
1429
+ def _get_temperature(self, unit: str = "K") -> float:
1430
+ """
1431
+ Get the nominal temperature
1432
+
1433
+ Args:
1434
+ unit: The requested unit for the output. Must be understood by the GNU
1435
+ ``units`` utility
1436
+ """
1437
+ source_value = self.__nominal_crystal_structure_npt["temperature"][
1438
+ "source-value"
1439
+ ]
1440
+ source_unit = self.__nominal_crystal_structure_npt["temperature"]["source-unit"]
1441
+ if source_unit != unit:
1442
+ temp = convert_units(source_value, source_unit, unit, True)
1443
+ else:
1444
+ temp = source_value
1445
+ return temp
1446
+
1447
+ def _get_cell_cauchy_stress(self, unit: str = "eV/angstrom^3") -> List[float]:
1448
+ """
1449
+ Get the nominal stress
1450
+
1451
+ Args:
1452
+ unit: The requested unit for the output. Must be understood by the GNU
1453
+ ``units`` utility
1454
+ """
1455
+ source_value = self.__nominal_crystal_structure_npt["cell-cauchy-stress"][
1456
+ "source-value"
1457
+ ]
1458
+ source_unit = self.__nominal_crystal_structure_npt["cell-cauchy-stress"][
1459
+ "source-unit"
1460
+ ]
1461
+ if source_unit != unit:
1462
+ stress, _ = convert_list(source_value, source_unit, unit)
1463
+ else:
1464
+ stress = source_value
1465
+ return stress
1466
+
1467
+ def _get_mass_density(self, unit: str = "amu/angstrom^3") -> float:
1468
+ """
1469
+ Get the mass density of the current nominal state of the system,
1470
+ according to the masses defined in :data:`ase.data.atomic_masses`
1471
+
1472
+ Args:
1473
+ unit:
1474
+ The requested units
1475
+
1476
+ Returns:
1477
+ The mass density of the crystal
1478
+ """
1479
+ atoms = self._get_atoms() # always in angstrom
1480
+ vol_ang3 = atoms.get_volume()
1481
+ mass_amu = 0.0
1482
+ for atomic_number in atoms.get_atomic_numbers():
1483
+ mass_amu += atomic_masses[atomic_number]
1484
+ density_amu_ang3 = mass_amu / vol_ang3
1485
+ if unit != "amu/angstrom^3":
1486
+ density = convert_units(density_amu_ang3, "amu/angstrom^3", unit, True)
1487
+ else:
1488
+ density = density_amu_ang3
1489
+
1490
+ return density
1491
+
1492
+ def _get_nominal_crystal_structure_npt(self) -> Dict:
1493
+ """
1494
+ Get the dictionary returning the current nominal state of the system.
1495
+
1496
+ Returns:
1497
+ An instance of the
1498
+ `crystal-structure-npt
1499
+ <https://openkim.org/properties/show/crystal-structure-npt>`_
1500
+ OpenKIM property containing a symmetry-reduced description of the nominal
1501
+ crystal structure.
1502
+ """
1503
+ return self.__nominal_crystal_structure_npt
1504
+
1505
+ def deduplicate_property_instances(
1506
+ self,
1507
+ properties_to_deduplicate: Optional[List[str]] = None,
1508
+ allow_rotation: bool = True,
1509
+ ) -> None:
1510
+ """
1511
+ In the internally stored property instances,
1512
+ deduplicate any repeated crystal structures for each property id and merge
1513
+ their "crystal-genome-source-structure-id" keys.
1514
+
1515
+ WARNING: Only the crystal structures are checked. If you for some reason have a
1516
+ property that can reasonably report different non-structural values for the
1517
+ same atomic configuration, this will delete the extras!
1518
+
1519
+ Args:
1520
+ properties_to_deduplicate:
1521
+ A list of property names to pick out of ``property_instances`` to
1522
+ deduplicate. Each element can be the long or short name, e.g.
1523
+ "tag:staff@noreply.openkim.org,2023-02-21:property/binding-energy-crystal"
1524
+ or "binding-energy-crystal". If omitted, all properties will be
1525
+ deduplicated.
1526
+ allow_rotation:
1527
+ Whether or not structures that are rotated by a rotation that is not in
1528
+ the crystal's point group are considered identical
1529
+ """
1530
+ deduplicated_property_instances = get_deduplicated_property_instances(
1531
+ self.property_instances, properties_to_deduplicate, allow_rotation
1532
+ )
1533
+ logger.info(
1534
+ f"Deduplicated {len(self.property_instances)} Property Instances "
1535
+ f"down to {len(deduplicated_property_instances)}."
1536
+ )
1537
+ # Remove files
1538
+ for original_property_instance in self.property_instances:
1539
+ instance_id = original_property_instance["instance-id"]
1540
+ instance_id_still_there = False
1541
+ for deduplicated_property_instance in deduplicated_property_instances:
1542
+ if instance_id == deduplicated_property_instance["instance-id"]:
1543
+ instance_id_still_there = True
1544
+ break
1545
+ if not instance_id_still_there:
1546
+ for key in original_property_instance:
1547
+ value_dict = original_property_instance[key]
1548
+ if not isinstance(value_dict, dict):
1549
+ continue
1550
+ if "source-unit" in value_dict:
1551
+ continue
1552
+ value = value_dict["source-value"]
1553
+ if isinstance(value, str):
1554
+ # TODO: should really check the property def that this is a
1555
+ # "file" type key, but come on, how incredibly unlikely is it
1556
+ # that there just happends to be a string type property that
1557
+ # happens to have a valid file path in it
1558
+ candidate_filename = os.path.join("output", value)
1559
+ if os.path.isfile(candidate_filename):
1560
+ os.remove(candidate_filename)
1561
+
1562
+ super()._set_serialized_property_instances(
1563
+ kim_edn.dumps(deduplicated_property_instances)
1564
+ )
1565
+
1566
+ def _set_serialized_property_instances(self, property_instances) -> None:
1567
+ """
1568
+ An override to prevent SingleCrystalTestDriver derived classes from setting
1569
+ property instances directly
1570
+ """
1571
+ raise NotImplementedError(
1572
+ "Setting property instances directly not supported "
1573
+ "in Crystal Genome Test Drivers"
1574
+ )
1575
+
1576
+ def _get_atoms(self, change_of_basis: Union[str, ArrayLike] = "primitive") -> Atoms:
1577
+ """
1578
+ Get the atomic configuration representing the nominal crystal,
1579
+ with a calculator already attached.
1580
+
1581
+ Args:
1582
+ change_of_basis:
1583
+ Specify the desired unit cell. The default, ``"primitive"``, gives
1584
+ the cell as defined in the `AFLOW prototype standard
1585
+ <http://doi.org/10.1016/j.commatsci.2017.01.017>`_. ``"conventional"``
1586
+ gives the conventional cell defined therein.
1587
+
1588
+ Alternatively, provide an arbitrary change of basis matrix **P** as
1589
+ defined in ITA 1.5.1.2, with the above-defined primitive cell
1590
+ corresponding to the "old basis" and the returned ``Atoms`` object being
1591
+ in the "new basis".
1592
+
1593
+ See the docstring for :func:`kim_tools.change_of_basis_atoms` for
1594
+ more information on how to define the change of basis.
1595
+
1596
+ Returns:
1597
+ Unit cell of the crystal.
1598
+ Lengths are always in angstrom
1599
+ """
1600
+ crystal_structure = self.__nominal_crystal_structure_npt
1601
+ atoms_prim = get_atoms_from_crystal_structure(crystal_structure)
1602
+ if isinstance(change_of_basis, str):
1603
+ if change_of_basis.lower() == "primitive":
1604
+ change_of_basis_matrix = None
1605
+ elif change_of_basis.lower() == "conventional":
1606
+ prototype_label = crystal_structure["prototype-label"]["source-value"]
1607
+ sgnum = get_space_group_number_from_prototype(prototype_label)
1608
+ formal_bravais_lattice = get_formal_bravais_lattice_from_space_group(
1609
+ sgnum
1610
+ )
1611
+ change_of_basis_matrix = get_change_of_basis_matrix_to_conventional_cell_from_formal_bravais_lattice( # noqa: E501
1612
+ formal_bravais_lattice
1613
+ )
1614
+ else:
1615
+ raise KIMTestDriverError(
1616
+ 'Allowable string values for `change_of_basis` are "primitive" or '
1617
+ f'"conventional". You provided f{change_of_basis}'
1618
+ )
1619
+ else:
1620
+ change_of_basis_matrix = change_of_basis
1621
+
1622
+ if change_of_basis_matrix is None:
1623
+ atoms_tmp = atoms_prim
1624
+ else:
1625
+ atoms_tmp = change_of_basis_atoms(atoms_prim, change_of_basis_matrix)
1626
+
1627
+ atoms_tmp.calc = self._calc
1628
+ return atoms_tmp
1629
+
1630
+
1631
+ def query_crystal_structures(
1632
+ stoichiometric_species: List[str],
1633
+ prototype_label: Optional[str] = None,
1634
+ short_name: Optional[str] = None,
1635
+ cell_cauchy_stress_eV_angstrom3: Optional[List[float]] = None,
1636
+ temperature_K: float = 0,
1637
+ kim_model_name: Optional[str] = None,
1638
+ ) -> List[Dict]:
1639
+ """
1640
+ Query for all equilibrium parameter sets for this species combination and,
1641
+ optionally, crystal structure specified by ``prototype_label`` and/or ``short_name``
1642
+ in the KIM database. This is a utility function for running the test outside of the
1643
+ OpenKIM pipeline. In the OpenKIM pipeline, this information is delivered to the test
1644
+ driver through the ``runner`` script.
1645
+
1646
+ Args:
1647
+ stoichiometric_species:
1648
+ List of unique species in the crystal. Required part of the Crystal Genome
1649
+ designation.
1650
+ short_name:
1651
+ short name of the crystal, e.g. "Hexagonal Close Packed". This will be
1652
+ searched as a case-insensitive regex, so partial matches will be returned.
1653
+ The list of possible shortnames is taken by postprocessing README_PROTO.TXT
1654
+ from the AFLOW software and packaged with kim-tools for reproducibility. To
1655
+ see the exact list of possible short names, call
1656
+ :func:`kim_tools.read_shortnames` and inspect the values of the returned
1657
+ dictionary. Note that a given short name corresponds to an exact set of
1658
+ parameters (with some tolerance), except the overall scale of the crystal.
1659
+ For example, "Hexagonal Close Packed" will return only structures with a
1660
+ c/a close to 1.63 (actually close packed), not any structure with the same
1661
+ symmetry as HCP as is sometimes colloquially understood.
1662
+ TODO: consider adding the same expanded logic we have on the website that
1663
+ searches for any crystal with the symmetry corresponding to a given
1664
+ shortname, i.e. invalidating the last caveat given above.
1665
+ prototype_label:
1666
+ AFLOW prototype label for the crystal.
1667
+ cell_cauchy_stress_eV_angstrom3:
1668
+ Cauchy stress on the cell in eV/angstrom^3 (ASE units) in
1669
+ [xx,yy,zz,yz,xz,xy] format
1670
+ temperature_K:
1671
+ The temperature in Kelvin
1672
+ kim_model_name:
1673
+ KIM model name. If not provided, RD will be queried instead
1674
+
1675
+ Returns:
1676
+ List of kim property instances matching the query in the OpenKIM.org database
1677
+ """
1678
+ if cell_cauchy_stress_eV_angstrom3 is None:
1679
+ cell_cauchy_stress_eV_angstrom3 = [0, 0, 0, 0, 0, 0]
1680
+
1681
+ stoichiometric_species.sort()
1682
+
1683
+ # TODO: Some kind of generalized query interface for all tests, this is very
1684
+ # hand-made
1685
+ cell_cauchy_stress_Pa = [
1686
+ component * 1.6021766e11 for component in cell_cauchy_stress_eV_angstrom3
1687
+ ]
1688
+
1689
+ query = {
1690
+ "meta.type": "rd" if kim_model_name is None else "tr",
1691
+ "property-id": (
1692
+ "tag:staff@noreply.openkim.org,2023-02-21:property/crystal-structure-npt"
1693
+ ),
1694
+ "stoichiometric-species.source-value": {
1695
+ "$size": len(stoichiometric_species),
1696
+ "$all": stoichiometric_species,
1697
+ },
1698
+ "cell-cauchy-stress.si-value": cell_cauchy_stress_Pa,
1699
+ "temperature.si-value": temperature_K,
1700
+ }
1701
+
1702
+ if kim_model_name is not None:
1703
+ query["meta.subject.extended-id"] = kim_model_name
1704
+
1705
+ if prototype_label is not None:
1706
+ query["prototype-label.source-value"] = prototype_label
1707
+
1708
+ if short_name is not None:
1709
+ query["short-name.source-value"] = {"$regex": short_name, "$options": "$i"}
1710
+
1711
+ raw_query_args = {
1712
+ "query": query,
1713
+ "database": "data",
1714
+ "limit": 0,
1715
+ }
1716
+
1717
+ logger.info(f"Sending below query:\n{raw_query_args}")
1718
+
1719
+ query_result = raw_query(**raw_query_args)
1720
+
1721
+ len_msg = (
1722
+ f"Found {len(query_result)} equilibrium structures from "
1723
+ "query_crystal_genome_structures()"
1724
+ )
1725
+ logger.info(len_msg)
1726
+ logger.debug(f"Query result (length={len(query_result)}):\n{query_result}")
1727
+
1728
+ print(f"!!! {len_msg} !!!")
1729
+
1730
+ return query_result
1731
+
1732
+
1733
+ def detect_unique_crystal_structures(
1734
+ crystal_structures: Union[List[Dict], Dict],
1735
+ allow_rotation: bool = True,
1736
+ aflow_np: int = 4,
1737
+ ) -> Dict:
1738
+ """
1739
+ Detect which of the provided crystal structures is unique
1740
+
1741
+ Args:
1742
+ crystal_structures:
1743
+ A list of dictionaries in KIM Property format, each containing the Crystal
1744
+ Genome keys required to build a structure, namely: "stoichiometric-species",
1745
+ "prototype-label", "a", and, if the prototype has free parameters,
1746
+ "parameter-values". These dictionaries are not required to be complete KIM
1747
+ Property Instances, e.g. the keys "property-id", "instance-id" and "meta"
1748
+ can be absent. Alternatively, this can be a dictionary of dictionaries with
1749
+ integers as indices, for the recursive call.
1750
+ allow_rotation:
1751
+ Whether or not structures that are rotated by a rotation that is not in the
1752
+ crystal's point group are considered identical
1753
+ Returns:
1754
+ Dictionary with keys corresponding to indices of unique structures and values
1755
+ being lists of indices of their duplicates
1756
+ """
1757
+ if len(crystal_structures) == 0:
1758
+ return []
1759
+
1760
+ aflow = AFLOW(np=aflow_np)
1761
+
1762
+ with TemporaryDirectory() as tmpdirname:
1763
+ # I don't know if crystal_structurs is a list or a dict with integer keys
1764
+ for i in (
1765
+ range(len(crystal_structures))
1766
+ if isinstance(crystal_structures, list)
1767
+ else crystal_structures
1768
+ ):
1769
+ structure = crystal_structures[i]
1770
+ try:
1771
+ get_poscar_from_crystal_structure(
1772
+ structure, os.path.join(tmpdirname, str(i))
1773
+ )
1774
+ except AFLOW.ChangedSymmetryException:
1775
+ logger.info(
1776
+ f"Comparison structure {i} failed to write a POSCAR due to a "
1777
+ "detected higher symmetry"
1778
+ )
1779
+
1780
+ comparison = aflow.compare_materials_dir(tmpdirname)
1781
+
1782
+ unique_materials = {}
1783
+ for materials_group in comparison:
1784
+ i_repr = int(
1785
+ materials_group["structure_representative"]["name"].split("/")[-1]
1786
+ )
1787
+ unique_materials[i_repr] = []
1788
+ for structure_duplicate in materials_group["structures_duplicate"]:
1789
+ unique_materials[i_repr].append(
1790
+ int(structure_duplicate["name"].split("/")[-1])
1791
+ )
1792
+
1793
+ if not allow_rotation:
1794
+ for materials_group in comparison:
1795
+ # to preserve their ordering in the original input list, make this a
1796
+ # dictionary now
1797
+ repr_filename = materials_group["structure_representative"]["name"]
1798
+ rotated_structures = {}
1799
+ cell = get_cell_from_poscar(repr_filename)
1800
+ sgnum = materials_group["space_group"]
1801
+ for potential_rotated_duplicate in materials_group[
1802
+ "structures_duplicate"
1803
+ ]:
1804
+ cart_rot = potential_rotated_duplicate["rotation"]
1805
+ if not cartesian_rotation_is_in_point_group(cart_rot, sgnum, cell):
1806
+ i_rot_dup = int(
1807
+ potential_rotated_duplicate["name"].split("/")[-1]
1808
+ )
1809
+ rotated_structures[i_rot_dup] = crystal_structures[i_rot_dup]
1810
+ # Now that we know it's rotated, need to remove
1811
+ # it from the list of duplicates
1812
+ i_repr = int(repr_filename.split("/")[-1])
1813
+ unique_materials[i_repr].remove(i_rot_dup)
1814
+
1815
+ unique_materials.update(
1816
+ detect_unique_crystal_structures(
1817
+ rotated_structures, False, aflow_np
1818
+ )
1819
+ )
1820
+
1821
+ return unique_materials
1822
+
1823
+
1824
+ def get_deduplicated_property_instances(
1825
+ property_instances: List[Dict],
1826
+ properties_to_deduplicate: Optional[List[str]] = None,
1827
+ allow_rotation: bool = True,
1828
+ ) -> List[Dict]:
1829
+ """
1830
+ Given a list of dictionaries constituting KIM Property instances,
1831
+ deduplicate any repeated crystal structures for each property id and merge
1832
+ their "crystal-genome-source-structure-id" keys.
1833
+
1834
+ WARNING: Only the crystal structures are checked. If you for some reason have a
1835
+ property that can reasonably report different non-structural values for the
1836
+ same atomic configuration, this will delete the extras!
1837
+
1838
+ Args:
1839
+ property_instances:
1840
+ The list of KIM Property Instances to deduplicate
1841
+ properties_to_deduplicate:
1842
+ A list of property names to pick out of ``property_instances`` to
1843
+ deduplicate. Each element can be the long or short name, e.g.
1844
+ "tag:staff@noreply.openkim.org,2023-02-21:property/binding-energy-crystal"
1845
+ or "binding-energy-crystal". If omitted, all properties will be
1846
+ deduplicated.
1847
+ allow_rotation:
1848
+ Whether or not structures that are rotated by a rotation that is not in the
1849
+ crystal's point group are considered identical
1850
+
1851
+ Returns:
1852
+ The deduplicated property instances
1853
+ """
1854
+ if properties_to_deduplicate is None:
1855
+ properties_set = set()
1856
+ for property_instance in property_instances:
1857
+ properties_set.add(property_instance["property-id"])
1858
+ properties_to_deduplicate = list(properties_set)
1859
+
1860
+ property_instances_deduplicated = []
1861
+ for property_name in properties_to_deduplicate:
1862
+ # Pick out property instances with the relevant name
1863
+ property_instances_curr_name = []
1864
+ for property_instance in property_instances:
1865
+ property_id = property_instance["property-id"]
1866
+ if (
1867
+ property_id == property_name
1868
+ or get_property_id_path(property_id)[3] == property_name
1869
+ ):
1870
+ property_instances_curr_name.append(deepcopy(property_instance))
1871
+ if len(property_instances_curr_name) == 0:
1872
+ raise KIMTestDriverError(
1873
+ "The property you asked to deduplicate "
1874
+ "is not in the property instances you provided"
1875
+ )
1876
+
1877
+ # Get unique-duplicate dictionary
1878
+ unique_crystal_structures = detect_unique_crystal_structures(
1879
+ property_instances_curr_name, allow_rotation
1880
+ )
1881
+
1882
+ # Put together the list of unique instances for the current
1883
+ # name only
1884
+ property_instances_curr_name_deduplicated = []
1885
+ for i_unique in unique_crystal_structures:
1886
+ property_instances_curr_name_deduplicated.append(
1887
+ property_instances_curr_name[i_unique]
1888
+ )
1889
+ # Put together a list of "crystal-genome-source-structure-id"
1890
+ # to gather into the deduplicated structure
1891
+ additional_source_structure_id = []
1892
+ for i_dup in unique_crystal_structures[i_unique]:
1893
+ source_structure_id = _get_optional_source_value(
1894
+ property_instances_curr_name[i_dup],
1895
+ "crystal-genome-source-structure-id",
1896
+ )
1897
+ if source_structure_id is not None:
1898
+ # "crystal-genome-source-structure-id" is a 2D list
1899
+ # but only the first row should be populated
1900
+ additional_source_structure_id += source_structure_id[0]
1901
+
1902
+ if len(additional_source_structure_id) != 0:
1903
+ if (
1904
+ "crystal-genome-source-structure-id"
1905
+ not in property_instances_curr_name_deduplicated[-1]
1906
+ ):
1907
+ property_instances_curr_name_deduplicated[-1][
1908
+ "crystal-genome-source-structure-id"
1909
+ ] = [[]]
1910
+ property_instances_curr_name_deduplicated[-1][
1911
+ "crystal-genome-source-structure-id"
1912
+ ]["source-value"][0] += additional_source_structure_id
1913
+
1914
+ property_instances_deduplicated += property_instances_curr_name_deduplicated
1915
+
1916
+ # Add any instances of properties that weren't deduplicated
1917
+ for property_instance in property_instances:
1918
+ property_id = property_instance["property-id"]
1919
+ if (
1920
+ property_id not in properties_to_deduplicate
1921
+ and get_property_id_path(property_id)[3] not in properties_to_deduplicate
1922
+ ):
1923
+ property_instances_deduplicated.append(deepcopy(property_instance))
1924
+
1925
+ property_instances_deduplicated.sort(key=lambda a: a["instance-id"])
1926
+
1927
+ return property_instances_deduplicated
1928
+
1929
+
1930
+ # If called directly, do nothing
1931
+ if __name__ == "__main__":
1932
+ pass