kim-tools 0.3.13__py3-none-any.whl → 0.4.3__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 CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "0.3.13"
1
+ __version__ = "0.4.3"
2
2
 
3
3
  from .aflow_util import *
4
4
  from .aflow_util import __all__ as aflow_all
@@ -17,7 +17,6 @@ import numpy as np
17
17
  import numpy.typing as npt
18
18
  from ase import Atoms
19
19
  from ase.cell import Cell
20
- from ase.neighborlist import natural_cutoffs, neighbor_list
21
20
  from semver import Version
22
21
  from sympy import Symbol, linear_eq_to_matrix, matrix2numpy, parse_expr
23
22
 
@@ -30,6 +29,7 @@ from ..symmetry_util import (
30
29
  cartesian_rotation_is_in_point_group,
31
30
  get_possible_primitive_shifts,
32
31
  get_primitive_wyckoff_multiplicity,
32
+ get_smallest_nn_dist,
33
33
  get_wyck_pos_xform_under_normalizer,
34
34
  space_group_numbers_are_enantiomorphic,
35
35
  )
@@ -1605,20 +1605,12 @@ class AFLOW:
1605
1605
  """
1606
1606
  # If max_resid not provided, determine it from neighborlist
1607
1607
  if max_resid is None:
1608
- nl_len = 0
1609
- cov_mult = 1
1610
- while nl_len == 0:
1611
- logger.info(
1612
- "Attempting to find NN distance by searching "
1613
- f"within covalent radii times {cov_mult}"
1614
- )
1615
- nl = neighbor_list("d", atoms, natural_cutoffs(atoms, mult=cov_mult))
1616
- nl_len = nl.size
1617
- cov_mult += 1
1618
1608
  # set the maximum error to 1% of NN distance to follow AFLOW convention
1619
1609
  # rescale by cube root of cell volume to get rough conversion from
1620
1610
  # cartesian to fractional
1621
- max_resid = nl.min() * 0.01 * atoms.get_volume() ** (-1 / 3)
1611
+ max_resid = (
1612
+ get_smallest_nn_dist(atoms) * 0.01 * atoms.get_volume() ** (-1 / 3)
1613
+ )
1622
1614
  logger.info(
1623
1615
  "Automatically set max fractional residual for solving position "
1624
1616
  f"equations to {max_resid}"
kim_tools/kimunits.py CHANGED
@@ -12,6 +12,8 @@ import re
12
12
  import subprocess
13
13
  import warnings
14
14
 
15
+ import numpy as np
16
+
15
17
  warnings.simplefilter("ignore")
16
18
 
17
19
 
@@ -24,17 +26,31 @@ _units_output_expression = re.compile(
24
26
  )
25
27
 
26
28
 
27
- def check_units_util():
29
+ def check_units_util() -> str:
28
30
  """
29
- Check that units util can be found
31
+ Figure out if units (first choice) or gunits (second choice) works
32
+ with the options that we use
30
33
  """
34
+ args = ["-o", r"%1.15e", "-qt1", "0.0 eV/angstrom^3 bar"]
31
35
  try:
32
- subprocess.check_output(["units", "--help"])
36
+ output = subprocess.check_output(["units"] + args, encoding="utf-8")
37
+ assert np.isclose(float(output), 0)
38
+ units_command = "units"
33
39
  except Exception:
34
- raise UnitConversion(
35
- "Failed to run a 'units' test command. It is likely "
36
- "that the 'units' executable was not found."
37
- )
40
+ try:
41
+ output = subprocess.check_output(["gunits"] + args, encoding="utf-8")
42
+ assert np.isclose(float(output), 0)
43
+ units_command = "gunits"
44
+ except Exception:
45
+ raise UnitConversion(
46
+ "Neither "
47
+ r"units -o %1.15e -qt1 '0.0 eV/angstrom^3 bar'"
48
+ " nor "
49
+ r"gunits -o %1.15e -qt1 '0.0 eV/angstrom^3 bar'"
50
+ " successfully ran and returned 0.e0. Please install a "
51
+ "compatible version of units."
52
+ )
53
+ return units_command
38
54
 
39
55
 
40
56
  def linear_fit(x, y):
@@ -68,7 +84,7 @@ def islinear(unit, to_unit=None):
68
84
 
69
85
  def convert_units(from_value, from_unit, wanted_unit=None, suppress_unit=False):
70
86
  """Works with 'units' utility"""
71
- check_units_util()
87
+ units_util = check_units_util()
72
88
  from_sign = from_value < 0
73
89
  from_value = str(abs(from_value))
74
90
  from_unit = str(from_unit)
@@ -77,7 +93,7 @@ def convert_units(from_value, from_unit, wanted_unit=None, suppress_unit=False):
77
93
 
78
94
  if from_unit in TEMPERATURE_FUNCTION_UNITS:
79
95
  args = [
80
- "units",
96
+ units_util,
81
97
  "-o",
82
98
  "%1.15e",
83
99
  "-qt1",
@@ -85,7 +101,7 @@ def convert_units(from_value, from_unit, wanted_unit=None, suppress_unit=False):
85
101
  ]
86
102
 
87
103
  else:
88
- args = ["units", "-o", "%1.15e", "-qt1", " ".join((from_value, from_unit))]
104
+ args = [units_util, "-o", "%1.15e", "-qt1", " ".join((from_value, from_unit))]
89
105
 
90
106
  if wanted_unit:
91
107
  args.append(wanted_unit)
@@ -9,7 +9,6 @@ from itertools import product
9
9
  from math import ceil
10
10
  from typing import Dict, List, Optional, Tuple, Union
11
11
 
12
- import matplotlib.pyplot as plt
13
12
  import numpy as np
14
13
  import numpy.typing as npt
15
14
  import sympy as sp
@@ -17,11 +16,9 @@ from ase import Atoms
17
16
  from ase.cell import Cell
18
17
  from ase.constraints import FixSymmetry
19
18
  from ase.geometry import get_distances, get_duplicate_atoms
20
- from matplotlib.backends.backend_pdf import PdfPages
19
+ from ase.neighborlist import natural_cutoffs, neighbor_list
21
20
  from pymatgen.core.operations import SymmOp
22
21
  from pymatgen.core.tensors import Tensor
23
- from scipy.stats import kstest
24
- from sklearn.decomposition import PCA
25
22
  from sympy import Matrix, cos, matrix2numpy, sin, sqrt, symbols
26
23
  from sympy.tensor.array.expressions import ArrayContraction, ArrayTensorProduct
27
24
 
@@ -50,6 +47,7 @@ __all__ = [
50
47
  "change_of_basis_atoms",
51
48
  "get_possible_primitive_shifts",
52
49
  "get_primitive_genpos_ops",
50
+ "get_smallest_nn_dist",
53
51
  ]
54
52
 
55
53
  C_CENTERED_ORTHORHOMBIC_GROUPS = (20, 21, 35, 36, 37, 63, 64, 65, 66, 67, 68)
@@ -516,7 +514,9 @@ def get_change_of_basis_matrix_to_conventional_cell_from_formal_bravais_lattice(
516
514
  return np.round(change_of_basis_matrix)
517
515
 
518
516
 
519
- def change_of_basis_atoms(atoms: Atoms, change_of_basis: npt.ArrayLike) -> Atoms:
517
+ def change_of_basis_atoms(
518
+ atoms: Atoms, change_of_basis: npt.ArrayLike, cutoff: Optional[float] = None
519
+ ) -> Atoms:
520
520
  """
521
521
  Perform an arbitrary basis change on an ``Atoms`` object, duplicating or cropping
522
522
  atoms as needed. A basic check is made that the determinant of ``change_of_basis``
@@ -524,7 +524,7 @@ def change_of_basis_atoms(atoms: Atoms, change_of_basis: npt.ArrayLike) -> Atoms
524
524
  that ``change_of_basis`` is appropriate for the particuar crystal described by
525
525
  ``atoms``, which is up to the user.
526
526
 
527
- TODO: Incorporate :func:`kstest_reduced_distances` into this function
527
+ TODO: Incorporate period extension test into this function
528
528
 
529
529
  Args:
530
530
  atoms:
@@ -543,6 +543,9 @@ def change_of_basis_atoms(atoms: Atoms, change_of_basis: npt.ArrayLike) -> Atoms
543
543
 
544
544
  Relationship between fractional coordinates in each basis:
545
545
  **x** = **P** **x**'
546
+ cutoff:
547
+ The cutoff to use for deleting duplicate atoms. If not specified,
548
+ the AFLOW tolerance of 0.01*(smallest NN distance) is used.
546
549
 
547
550
  Returns:
548
551
  The transformed ``Atoms`` object, containing the original number of
@@ -577,7 +580,9 @@ def change_of_basis_atoms(atoms: Atoms, change_of_basis: npt.ArrayLike) -> Atoms
577
580
  new_atoms = atoms.repeat(repeat)
578
581
  new_atoms.set_cell(new_cell)
579
582
  new_atoms.wrap()
580
- get_duplicate_atoms(new_atoms, delete=True)
583
+ if cutoff is None:
584
+ cutoff = get_smallest_nn_dist(atoms) * 0.01
585
+ get_duplicate_atoms(new_atoms, cutoff=cutoff, delete=True)
581
586
 
582
587
  volume_change = np.linalg.det(change_of_basis)
583
588
  if not np.isclose(len(atoms) * volume_change, len(new_atoms)):
@@ -634,14 +639,16 @@ def transform_atoms(atoms: Atoms, op: Dict) -> Atoms:
634
639
  return atoms_transformed
635
640
 
636
641
 
637
- def reduce_and_avg(
638
- atoms: Atoms, repeat: Tuple[int, int, int]
639
- ) -> Tuple[Atoms, npt.ArrayLike]:
642
+ def reduce_and_avg(atoms: Atoms, repeat: Tuple[int, int, int]) -> Atoms:
640
643
  """
641
644
  TODO: Upgrade :func:`change_of_basis_atoms` to provide the distances
642
645
  array, obviating this function
643
646
 
644
- Function to reduce all atoms to the original unit cell position.
647
+ Function to reduce all atoms to the original unit cell position,
648
+ assuming the supercell is built from contiguous repeats of the unit cell
649
+ (i.e. atoms 0 to N-1 in the supercell are the original unit cell, atoms N to
650
+ 2*[N-1] are the original unit cell shifted by an integer multiple of
651
+ the lattice vectors, and so on)
645
652
 
646
653
  Args:
647
654
  atoms:
@@ -651,10 +658,13 @@ def reduce_and_avg(
651
658
  provided supercell
652
659
 
653
660
  Returns:
654
- * The reduced unit cell
655
- * An array of displacement vectors. First dimension: index of reference atom
656
- in reduced cell. Second dimension: index of atom in provided supercell.
657
- Third dimension: x, y, z
661
+ The reduced unit cell
662
+
663
+ Raises:
664
+ PeriodExtensionException:
665
+ If two atoms that should be identical by translational symmetry
666
+ are further than 0.01*(smallest NN distance) apart when
667
+ reduced to the unit cell
658
668
  """
659
669
  new_atoms = atoms.copy()
660
670
 
@@ -685,126 +695,49 @@ def reduce_and_avg(
685
695
  # Start from end of the atoms
686
696
  # because we will remove all atoms except the reference ones.
687
697
  for i in reversed(range(number_atoms)):
698
+ reference_atom_index = i % original_number_atoms
688
699
  if i >= original_number_atoms:
689
700
  # Get the distance to the reference atom in the original unit cell with the
690
701
  # minimum image convention.
691
702
  distance = new_atoms.get_distance(
692
- i % original_number_atoms, i, mic=True, vector=True
703
+ reference_atom_index, i, mic=True, vector=True
693
704
  )
694
705
  # Get the position that has the closest distance to
695
706
  # the reference atom in the original unit cell.
696
- position_i = positions[i % original_number_atoms] + distance
707
+ position_i = positions[reference_atom_index] + distance
697
708
  # Remove atom from atoms object.
698
709
  new_atoms.pop()
699
710
  else:
700
711
  # Atom was part of the original unit cell.
701
712
  position_i = positions[i]
702
713
  # Average
703
- avg_positions_in_prim_cell[i % original_number_atoms] += position_i / M
714
+ avg_positions_in_prim_cell[reference_atom_index] += position_i / M
704
715
  positions_in_prim_cell[i] = position_i
705
716
 
706
717
  new_atoms.set_positions(avg_positions_in_prim_cell)
707
718
 
708
- # Calculate the distances.
709
- distances = np.empty((original_number_atoms, M, 3))
710
- for i in range(number_atoms):
711
- dr, _ = get_distances(
712
- positions_in_prim_cell[i],
713
- avg_positions_in_prim_cell[i % original_number_atoms],
719
+ # Check that all atoms are within tolerance of their translational images
720
+ cutoff = get_smallest_nn_dist(new_atoms) * 0.01
721
+ logger.info(f"Cutoff for period extension test is {cutoff}")
722
+ for i in range(original_number_atoms):
723
+ positions_of_all_images_of_atom_i = [
724
+ positions_in_prim_cell[j * original_number_atoms + i] for j in range(M)
725
+ ]
726
+ _, r = get_distances(
727
+ positions_of_all_images_of_atom_i,
714
728
  cell=new_atoms.get_cell(),
715
729
  pbc=True,
716
730
  )
717
- # dr is a distance matrix, here we only have one distance
718
- assert dr.shape == (1, 1, 3)
719
- distances[i % original_number_atoms, i // original_number_atoms] = dr[0][0]
720
-
721
- return new_atoms, distances
722
-
723
-
724
- def kstest_reduced_distances(
725
- reduced_distances: npt.ArrayLike,
726
- significance_level: float = 0.05,
727
- plot_filename: Optional[str] = None,
728
- number_bins: Optional[int] = None,
729
- ) -> None:
730
- """
731
- TODO: Incorporate this into :func:`change_of_basis_atoms`
732
-
733
- Function to test whether the reduced atom positions are normally distributed
734
- around their average.
735
-
736
- Args:
737
- reduced_distances:
738
- Distance array provided by :func:`reduce_and_avg`
739
- significance_level:
740
- Significance level for Kolmogorov-Smirnov
741
- plot_filename:
742
- number_bins:
743
- Number of bins for plot
744
-
745
- Raises:
746
- PeriodExtensionException:
747
- If a non-normal distribution is detected
748
- """
749
- assert len(reduced_distances.shape) == 3
750
- assert reduced_distances.shape[2] == 3
751
-
752
- if plot_filename is not None:
753
- if number_bins is None:
754
- raise ValueError(
755
- "number_bins must be specified if plot_filename is specified"
731
+ # Checking full MxM matrix, could probably speed up by
732
+ # checking upper triangle only. Could also save memory
733
+ # by looping over individual distances instead of
734
+ # checking the max of a giant matrix
735
+ assert r.shape == (M, M)
736
+ if r.max() > cutoff:
737
+ raise PeriodExtensionException(
738
+ f"At least one image of atom {i} is outside of tolerance"
756
739
  )
757
- if not plot_filename.endswith(".pdf"):
758
- raise ValueError(f"{plot_filename} is not a PDF file")
759
- with PdfPages(plot_filename) as pdf:
760
- for i in range(reduced_distances.shape[0]):
761
- fig, axs = plt.subplots(1, 3, figsize=(10.0, 4.0))
762
- for j in range(reduced_distances.shape[2]):
763
- axs[j].hist(reduced_distances[i, :, j], bins=number_bins)
764
- axs[j].set_xlabel(f"$x_{j}$")
765
- axs[0].set_ylabel("Counts")
766
- fig.suptitle(f"Atom {i}")
767
- pdf.savefig()
768
- else:
769
- if number_bins is not None:
770
- raise ValueError(
771
- "number_bins must not be specified if plot_filename is not specified"
772
- )
773
-
774
- p_values = np.empty((reduced_distances.shape[0], reduced_distances.shape[2]))
775
- for i in range(reduced_distances.shape[0]):
776
- atom_distances = reduced_distances[i]
777
-
778
- # Perform PCA on the xyz distribution.
779
- pca = PCA(n_components=atom_distances.shape[1])
780
- pca_components = pca.fit_transform(atom_distances)
781
- assert (
782
- pca_components.shape == atom_distances.shape == reduced_distances.shape[1:]
783
- )
784
-
785
- # Test each component with a KS test.
786
- for j in range(pca_components.shape[1]):
787
- component = pca_components[:, j]
788
- component_mean = np.mean(component)
789
- assert abs(component_mean) < 1.0e-7
790
- component_std = np.std(component)
791
- # Normalize component
792
- normalized_component = (component - component_mean) / component_std
793
- assert abs(np.mean(normalized_component)) < 1.0e-7
794
- assert abs(np.std(normalized_component) - 1.0) < 1.0e-7
795
- res = kstest(normalized_component, "norm")
796
- p_values[i, j] = res.pvalue
797
-
798
- if np.any(p_values <= significance_level):
799
- raise PeriodExtensionException(
800
- "Detected non-normal distribution of reduced atom positions around their "
801
- f"average (smallest p value {np.min(p_values)})."
802
- )
803
- else:
804
- print(
805
- "Detected normal distribution or reduced atom positions around their "
806
- f"average (smallest p value {np.min(p_values)})."
807
- )
740
+ return new_atoms
808
741
 
809
742
 
810
743
  def voigt_to_full_symb(voigt_input: sp.Array) -> sp.MutableDenseNDimArray:
@@ -1073,6 +1006,23 @@ def fit_voigt_tensor_to_cell_and_space_group(
1073
1006
  return t_symmetrized.voigt
1074
1007
 
1075
1008
 
1009
+ def get_smallest_nn_dist(atoms: Atoms) -> float:
1010
+ """
1011
+ Get the smallest NN distance in an Atoms object
1012
+ """
1013
+ nl_len = 0
1014
+ cov_mult = 1
1015
+ while nl_len == 0:
1016
+ logger.info(
1017
+ "Attempting to find NN distance by searching "
1018
+ f"within covalent radii times {cov_mult}"
1019
+ )
1020
+ nl = neighbor_list("d", atoms, natural_cutoffs(atoms, mult=cov_mult))
1021
+ nl_len = nl.size
1022
+ cov_mult += 1
1023
+ return nl.min()
1024
+
1025
+
1076
1026
  class FixProvidedSymmetry(FixSymmetry):
1077
1027
  """
1078
1028
  A modification of :obj:`~ase.constraints.FixSymmetry` that takes
@@ -1,4 +1,4 @@
1
- ################################################################################
1
+ ###############################################################################
2
2
  #
3
3
  # CDDL HEADER START
4
4
  #
@@ -30,14 +30,17 @@
30
30
  Helper classes for KIM Test Drivers
31
31
 
32
32
  """
33
- import glob
34
33
  import json
35
34
  import logging
36
35
  import os
37
36
  import shutil
37
+ import tarfile
38
38
  from abc import ABC, abstractmethod
39
39
  from copy import deepcopy
40
+ from fnmatch import fnmatch
41
+ from glob import glob
40
42
  from pathlib import Path
43
+ from secrets import token_bytes
41
44
  from tempfile import NamedTemporaryFile, TemporaryDirectory
42
45
  from typing import IO, Any, Dict, List, Optional, Union
43
46
 
@@ -109,6 +112,23 @@ PROP_SEARCH_PATHS_INFO = (
109
112
  "- $PWD/local-props/**/\n"
110
113
  "- $PWD/local_props/**/"
111
114
  )
115
+ TOKEN_NAME = "kim-tools.token"
116
+ TOKENPATH = os.path.join("output", TOKEN_NAME)
117
+ PIPELINE_EXCEPTIONS = ["output/pipeline.*"]
118
+
119
+
120
+ def _glob_with_exceptions(
121
+ pattern: str, exceptions: List[str], recursive: bool = False
122
+ ) -> List[os.PathLike]:
123
+ """
124
+ Return a list of paths that match the glob "pattern" but not
125
+ the glob "exceptions"
126
+ """
127
+ match = glob(pattern, recursive=recursive)
128
+ # If we are willing to make Python 3.14 minimum, can use filterfalse
129
+ for exception in exceptions:
130
+ match = [n for n in match if not fnmatch(n, exception)]
131
+ return match
112
132
 
113
133
 
114
134
  def get_supported_lammps_atom_style(model: str) -> str:
@@ -395,8 +415,15 @@ def _add_property_instance(
395
415
  property_name = path_str
396
416
  found_custom_property = True
397
417
  break
398
- except Exception:
399
- pass
418
+ except Exception as e:
419
+ msg = (
420
+ "MESSAGE: Trying to load a property from the .edn file at\n"
421
+ f"{path}\n"
422
+ "failed with the following exception:\n"
423
+ f"{repr(e)}"
424
+ )
425
+ logger.info(msg)
426
+ print(msg)
400
427
 
401
428
  if not found_custom_property:
402
429
  raise KIMTestDriverError(
@@ -550,6 +577,22 @@ class KIMTestDriver(ABC):
550
577
  __output_property_instances (str):
551
578
  Property instances, possibly accumulated over multiple invocations of
552
579
  the Test Driver
580
+ __files_to_keep_in_output (List[PathLike]):
581
+ List of files that were written by this class explicitly, that we won't
582
+ touch when cleaning and backing up the output directory. Specified
583
+ relative to 'output' directory.
584
+ __files_to_ignore_in_output (List[PathLike]):
585
+ List of globs of files to ignore when handling the output directory.
586
+ By default, this is set to the constant PIPELINE_EXCEPTIONS,
587
+ which contains files that need to be left untouched for the
588
+ OpenKIM pipeline. Top-level dotfiles are always ignored.
589
+ __token (Optional[bytes]):
590
+ Token that is written to TOKENPATH upon first evaluation. This
591
+ is used to check that multiple Test Drivers are not being called
592
+ concurrently, causing potential conflicts in the output directory
593
+ __times_called (Optional[int]):
594
+ Count of number of times the instance has been __call__'ed,
595
+ for numbering aux file archives
553
596
  """
554
597
 
555
598
  class NonKIMModelError(Exception):
@@ -560,7 +603,10 @@ class KIMTestDriver(ABC):
560
603
  """
561
604
 
562
605
  def __init__(
563
- self, model: Union[str, Calculator], suppr_sm_lmp_log: bool = False
606
+ self,
607
+ model: Union[str, Calculator],
608
+ suppr_sm_lmp_log: bool = False,
609
+ files_to_ignore_in_output: List[str] = PIPELINE_EXCEPTIONS,
564
610
  ) -> None:
565
611
  """
566
612
  Args:
@@ -568,6 +614,9 @@ class KIMTestDriver(ABC):
568
614
  ASE calculator or KIM model name to use
569
615
  suppr_sm_lmp_log:
570
616
  Suppress writing a lammps.log
617
+ files_to_ignore_in_output (List[PathLike]):
618
+ List of globs of files to ignore when handling the output directory.
619
+ Top-level dotfiles are always ignored.
571
620
  """
572
621
  if isinstance(model, Calculator):
573
622
  self.__calc = model
@@ -582,6 +631,10 @@ class KIMTestDriver(ABC):
582
631
  self.__calc.parameters.log_file = None
583
632
 
584
633
  self.__output_property_instances = "[]"
634
+ self.__files_to_keep_in_output = []
635
+ self.__files_to_ignore_in_output = files_to_ignore_in_output
636
+ self.__token = None
637
+ self.__times_called = None
585
638
 
586
639
  def _setup(self, material, **kwargs) -> None:
587
640
  """
@@ -589,6 +642,123 @@ class KIMTestDriver(ABC):
589
642
  """
590
643
  pass
591
644
 
645
+ def _init_output_dir(self) -> None:
646
+ """
647
+ Initialize the output directory
648
+ """
649
+ if self.__token is None:
650
+ # First time we've called this instance of the class
651
+ assert len(self.property_instances) == 0
652
+ assert self.__times_called is None
653
+
654
+ self.__times_called = 0
655
+
656
+ os.makedirs("output", exist_ok=True)
657
+
658
+ # Move all top-level non-hidden files and directories
659
+ # to backup
660
+ output_glob = _glob_with_exceptions(
661
+ "output/*", self.__files_to_ignore_in_output
662
+ )
663
+ if len(output_glob) > 0:
664
+ i = 0
665
+ while os.path.exists(f"output.{i}"):
666
+ i += 1
667
+ output_bak_name = f"output.{i}"
668
+ msg = (
669
+ "'output' directory has files besides dotfiles and allowed "
670
+ "exceptions, backing up all "
671
+ f"non-hidden files and directories to {output_bak_name}"
672
+ )
673
+ print(msg)
674
+ logger.info(msg)
675
+ os.mkdir(output_bak_name)
676
+ for file_in_output in output_glob:
677
+ shutil.move(file_in_output, output_bak_name)
678
+
679
+ # Create token
680
+ self.__token = token_bytes(16)
681
+ with open(TOKENPATH, "wb") as f:
682
+ f.write(self.__token)
683
+ self.__files_to_keep_in_output.append(TOKEN_NAME)
684
+ else:
685
+ # Token is stored, check that it matches the token file
686
+ if not os.path.isfile(TOKENPATH):
687
+ raise KIMTestDriverError(
688
+ f"Token file at {TOKENPATH} was not found,"
689
+ "can't confirm non-interference of Test Drivers. Did something "
690
+ "edit the 'output' directory between calls to this Test Driver?"
691
+ )
692
+ else:
693
+ with open(TOKENPATH, "rb") as f:
694
+ if self.__token != f.read():
695
+ raise KIMTestDriverError(
696
+ f"Token file at {TOKENPATH} does not match this object's "
697
+ "token. This likely means that a different KIMTestDriver "
698
+ "instance was called between calls to this one. In order to"
699
+ " prevent conflicts in the output directory, this is not "
700
+ "allowed."
701
+ )
702
+ self.__times_called += 1
703
+
704
+ # We should have a record of all non-hidden files in output. If any
705
+ # untracked files are present, raise an error
706
+ output_glob = _glob_with_exceptions(
707
+ "output/**", self.__files_to_ignore_in_output, True
708
+ )
709
+ for filepath in output_glob:
710
+ if os.path.isfile(filepath): # not tracking directories
711
+ if (
712
+ os.path.relpath(filepath, "output")
713
+ not in self.__files_to_keep_in_output
714
+ ):
715
+ raise KIMTestDriverError(
716
+ f"Unknown file {filepath} in 'output' directory appeared "
717
+ "between calls to this Test Driver. This is not allowed "
718
+ "because stray files can cause issues."
719
+ )
720
+
721
+ def _archive_aux_files(self) -> None:
722
+ """
723
+ Archive aux files after a run
724
+ """
725
+ # Archive untracked files as aux files
726
+ tar_prefix = f"aux_files.{self.__times_called}"
727
+ archive_name = f"output/{tar_prefix}.txz"
728
+ assert not os.path.isfile(tar_prefix)
729
+ output_glob = _glob_with_exceptions(
730
+ "output/**", self.__files_to_ignore_in_output, True
731
+ )
732
+ archived_files = [] # For deleting them later, and checking that any exist
733
+ for filepath in output_glob:
734
+ if os.path.isfile(filepath): # not tracking directories
735
+ output_relpath = os.path.relpath(filepath, "output")
736
+ if output_relpath not in self.__files_to_keep_in_output:
737
+ archived_files.append(filepath)
738
+
739
+ if len(archived_files) > 0:
740
+ msg = f"Auxiliary files found after call, archiving them to {archive_name}"
741
+ print(msg)
742
+ logger.info(msg)
743
+
744
+ with tarfile.open(archive_name, "w:xz") as tar:
745
+ for filepath in archived_files:
746
+ output_relpath = os.path.relpath(filepath, "output")
747
+ tar.add(
748
+ os.path.join(filepath),
749
+ os.path.join(tar_prefix, output_relpath),
750
+ )
751
+ self.__files_to_keep_in_output.append(f"{tar_prefix}.txz")
752
+ for filepath in archived_files:
753
+ os.remove(filepath)
754
+ try:
755
+ os.removedirs(os.path.dirname(filepath))
756
+ except OSError:
757
+ pass # might not be empty yet
758
+
759
+ # should not have removed output dir in any situation
760
+ assert os.path.isdir("output")
761
+
592
762
  @abstractmethod
593
763
  def _calculate(self, **kwargs) -> None:
594
764
  """
@@ -601,10 +771,12 @@ class KIMTestDriver(ABC):
601
771
 
602
772
  Main operation of a Test Driver:
603
773
 
774
+ * Call :func:`~KIMTestDriver._init_output_dir`
604
775
  * Run :func:`~KIMTestDriver._setup` (the base class provides a barebones
605
776
  version, derived classes may override)
606
777
  * Call :func:`~KIMTestDriver._calculate` (implemented by each individual
607
778
  Test Driver)
779
+ * Call :func:`~KIMTestDriver._archive_aux_files`
608
780
 
609
781
  Args:
610
782
  material:
@@ -614,20 +786,26 @@ class KIMTestDriver(ABC):
614
786
  Returns:
615
787
  The property instances calculated during the current run
616
788
  """
789
+
617
790
  # count how many instances we had before we started
618
- previous_properties_end = len(kim_edn.loads(self.__output_property_instances))
791
+ previous_properties_end = len(self.property_instances)
619
792
 
620
- os.makedirs("output", exist_ok=True)
793
+ # Set up the output directory
794
+ self._init_output_dir()
621
795
 
622
- # _setup is likely overridden by an derived class
623
- self._setup(material, **kwargs)
796
+ try:
797
+ # _setup is likely overridden by an derived class
798
+ self._setup(material, **kwargs)
624
799
 
625
- # implemented by each individual Test Driver
626
- self._calculate(**kwargs)
800
+ # implemented by each individual Test Driver
801
+ self._calculate(**kwargs)
802
+ finally:
803
+ # Postprocess output directory for this invocation
804
+ self._archive_aux_files()
627
805
 
628
806
  # The current invocation returns a Python list of dictionaries containing all
629
807
  # properties computed during this run
630
- return kim_edn.loads(self.__output_property_instances)[previous_properties_end:]
808
+ return self.property_instances[previous_properties_end:]
631
809
 
632
810
  def _add_property_instance(
633
811
  self, property_name: str, disclaimer: Optional[str] = None
@@ -745,7 +923,7 @@ class KIMTestDriver(ABC):
745
923
 
746
924
  input_name = filename_path.name
747
925
  if add_instance_id:
748
- current_instance_id = len(kim_edn.loads(self.__output_property_instances))
926
+ current_instance_id = len(self.property_instances)
749
927
  root, ext = os.path.splitext(input_name)
750
928
  root = root + "-" + str(current_instance_id)
751
929
  final_name = root + ext
@@ -758,10 +936,10 @@ class KIMTestDriver(ABC):
758
936
 
759
937
  shutil.move(filename, final_path)
760
938
 
939
+ output_relpath = os.path.relpath(final_path, output_path)
761
940
  # Filenames are reported relative to $CWD/output
762
- self._add_key_to_current_property_instance(
763
- name, os.path.relpath(final_path, output_path)
764
- )
941
+ self._add_key_to_current_property_instance(name, output_relpath)
942
+ self.__files_to_keep_in_output.append(output_relpath)
765
943
 
766
944
  def _get_supported_lammps_atom_style(self) -> str:
767
945
  """
@@ -834,17 +1012,14 @@ class KIMTestDriver(ABC):
834
1012
  os.makedirs(filename_parent, exist_ok=True)
835
1013
  kim_property_dump(self._get_serialized_property_instances(), filename)
836
1014
  if filename_parent != Path("output").resolve():
837
- for file_in_output in glob.glob("output/*"):
838
- file_in_output_name = str(Path(file_in_output).name)
839
- for instance in self.property_instances:
840
- for key in instance:
841
- if isinstance(instance[key], dict):
842
- if file_in_output_name == instance[key]["source-value"]:
843
- shutil.move(file_in_output, filename_parent)
844
- elif isinstance(instance[key]["source-value"], list):
845
- for file_in_property in instance[key]["source-value"]:
846
- if file_in_output_name == file_in_property:
847
- shutil.move(file_in_output, filename_parent)
1015
+ msg = (
1016
+ f"Writing properties .edn file to non-standard location {filename}. "
1017
+ "note that all other files remain in 'output' directory."
1018
+ )
1019
+ print(msg)
1020
+ logger.info(msg)
1021
+ else:
1022
+ self.__files_to_keep_in_output.append(os.path.relpath(filename, "output"))
848
1023
 
849
1024
  def get_isolated_energy_per_atom(self, symbol: str) -> float:
850
1025
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kim-tools
3
- Version: 0.3.13
3
+ Version: 0.4.3
4
4
  Summary: Base classes and helper routines for writing KIM Tests
5
5
  Author-email: ilia Nikiforov <nikif002@umn.edu>, Ellad Tadmor <tadmor@umn.edu>, Claire Waters <bwaters@umn.edu>, "Daniel S. Karls" <karl0100umn@gmail.com>, Matt Bierbaum <matt.bierbaum@gmail.com>, Eric Fuemmeler <efuemmel@umn.edu>, Philipp Hoellmer <ph2484@nyu.edu>, Guanming Zhang <gz2241@nyu.edu>, Tom Egg <tje3676@nyu.edu>, Navaneeth Mohan <mohan227@umn.edu>
6
6
  Maintainer-email: ilia Nikiforov <nikif002@umn.edu>
@@ -35,6 +35,7 @@ Dynamic: license-file
35
35
  [![Testing](https://github.com/openkim/kim-tools/actions/workflows/testing.yml/badge.svg)](https://github.com/openkim/kim-tools/actions/workflows/testing.yml)
36
36
  [![docs](https://app.readthedocs.org/projects/kim-tools/badge/?version=latest)](https://kim-tools.readthedocs.io/en/latest/)
37
37
  [![PyPI](https://img.shields.io/pypi/v/kim-tools.svg)](https://pypi.org/project/kim-tools/)
38
+ [![codecov](https://codecov.io/gh/openkim/kim-tools/graph/badge.svg?token=G57VDZYY0F)](https://codecov.io/gh/openkim/kim-tools)
38
39
 
39
40
  KIMTestDriver and SingleCrystalTestDriver classes for creating OpenKIM Test Drivers, and helper routines for writing
40
41
  KIM Tests and Verification Checks. Documentation at https://kim-tools.readthedocs.io.
@@ -1,7 +1,7 @@
1
- kim_tools/__init__.py,sha256=z9M4HWOpT0qCOUbGIKeUY-Oskrw_ZG6xeXli9iLTyzo,434
2
- kim_tools/kimunits.py,sha256=jOxBv9gRVhxPE6ygAIUxOzCAfPI6tT6sBaF_FNl9m-M,5387
1
+ kim_tools/__init__.py,sha256=Tw-dFo4IjQt_u8V_veHj6byvwl1KfmazTL1GdoWwcgM,433
2
+ kim_tools/kimunits.py,sha256=aCouh7z6fQDR1rcbRTO72l-_RuccuznrKrNF18F-cEQ,6076
3
3
  kim_tools/aflow_util/__init__.py,sha256=lJnQ8fZCma80QVRQeKvY4MQ87oCWu-9KATV3dKJfpDc,80
4
- kim_tools/aflow_util/core.py,sha256=mMS2r9ayJJ6ApOFTAIkCZ72Dg3g1EnREbrqe2YEipMo,81273
4
+ kim_tools/aflow_util/core.py,sha256=OmU4-m2jl81jfs0ni4bSECpC8GYt7-hEEH7cfKmrdoM,80886
5
5
  kim_tools/aflow_util/aflow_prototype_encyclopedia/data/A108B24C11D24_cP334_222_h4i_i_bf_i-001/info.json,sha256=IsFiO9X2Ko7yoq2QkDurUVP7k1BE4WFgblu7oxl6iZs,2013
6
6
  kim_tools/aflow_util/aflow_prototype_encyclopedia/data/A10B11_tI84_139_dehim_eh2n-001/info.json,sha256=f1EdtouuSL2y9NNw40Rvz2J9ZZcsqQBcyEmlHj6XoW8,1186
7
7
  kim_tools/aflow_util/aflow_prototype_encyclopedia/data/A10B2C_hP39_171_5c_c_a-001/info.json,sha256=vD1xjZKWShL0E6XNsSlmIhilGcGNefl56oQDLQlHO1M,1596
@@ -2004,7 +2004,7 @@ kim_tools/aflow_util/aflow_prototype_encyclopedia/data/A_tP50_134_a2m2n-001/info
2004
2004
  kim_tools/ase/__init__.py,sha256=1i6ko5tNr0VZC3T7hoEzq4fnSU0DdxNpxXcSaWMcJWc,76
2005
2005
  kim_tools/ase/core.py,sha256=umJY0LV3_zrEZLOXAFoz6AFigxA69sAOBzAGoBTDzxA,34335
2006
2006
  kim_tools/symmetry_util/__init__.py,sha256=uu-ZSUDUTe2P81rkAS3tXverx31s_uZ3wL4SD_dn5aI,86
2007
- kim_tools/symmetry_util/core.py,sha256=eMGgVt9MPcaLi_8jVMJ5UyQHvYbiiAFQ4HdJotnp8dk,43277
2007
+ kim_tools/symmetry_util/core.py,sha256=y9LGnUYxWE5e5KMxzM96t_UzrC-B_aT3XnntW8p_jRA,41392
2008
2008
  kim_tools/symmetry_util/elasticity.py,sha256=VxJ8wUcsSOPyJ8id6OpKmVlRAbIUIWxtzYJtkvVJIVs,13328
2009
2009
  kim_tools/symmetry_util/data/elast_cubic.svg,sha256=UpN4XcoLoOwv8a7KE0WyINkSH5AU2DPVZ08eGQf-Cds,8953
2010
2010
  kim_tools/symmetry_util/data/elast_hexagonal.svg,sha256=wOkw5IO3fZy039tLHhvtwrkatVYs5XzigUormLKtRlw,8705
@@ -2023,11 +2023,11 @@ kim_tools/symmetry_util/data/wyck_pos_xform_under_normalizer.json,sha256=6g1YuYh
2023
2023
  kim_tools/symmetry_util/data/wyckoff_multiplicities.json,sha256=qG2RPBd_-ejDIfz-E4ZhkHyRpIboxRy7oiXkdDf5Eg8,32270
2024
2024
  kim_tools/symmetry_util/data/wyckoff_sets.json,sha256=f5ZpHKDHo6_JWki1b7KUGoYLlhU-44Qikw_-PtbLssw,9248
2025
2025
  kim_tools/test_driver/__init__.py,sha256=KOiceeZNqkfrgZ66CiRiUdniceDrCmmDXQkOw0wXaCQ,92
2026
- kim_tools/test_driver/core.py,sha256=vHmhSWix5t95SCmJEapdpZXfJS0ZlbG-8bcmV9O9v9M,101261
2026
+ kim_tools/test_driver/core.py,sha256=MtoJgGWPbqaMG1yOhNZd83Waw5qKwI4jJKkXfP1rMJc,108701
2027
2027
  kim_tools/vc/__init__.py,sha256=zXjhxXCKVMLBMXXWYG3if7VOpBnsFrn_RjVpnohDm5c,74
2028
2028
  kim_tools/vc/core.py,sha256=BIjzEExnQAL2S90a_npptRm3ACqAo4fZBtvTDBMWMdw,13963
2029
- kim_tools-0.3.13.dist-info/licenses/LICENSE.CDDL,sha256=I2luEED_SHjuZ01B4rYG-AF_135amL24JpHvZ1Jhqe8,16373
2030
- kim_tools-0.3.13.dist-info/METADATA,sha256=7ldqSmsLFyzrEtIeV-wJacw1BEJoj55DgMOupJlycIs,2069
2031
- kim_tools-0.3.13.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
2032
- kim_tools-0.3.13.dist-info/top_level.txt,sha256=w_YCpJ5ERigj9te74ln7k64tqj1VumOzM_s9dsalIWY,10
2033
- kim_tools-0.3.13.dist-info/RECORD,,
2029
+ kim_tools-0.4.3.dist-info/licenses/LICENSE.CDDL,sha256=I2luEED_SHjuZ01B4rYG-AF_135amL24JpHvZ1Jhqe8,16373
2030
+ kim_tools-0.4.3.dist-info/METADATA,sha256=8bkatoFLO7BAGC-C6gGwIoCjI4s7HNqNLv-SmM10rZw,2196
2031
+ kim_tools-0.4.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
2032
+ kim_tools-0.4.3.dist-info/top_level.txt,sha256=w_YCpJ5ERigj9te74ln7k64tqj1VumOzM_s9dsalIWY,10
2033
+ kim_tools-0.4.3.dist-info/RECORD,,