diff --git a/kliff/dataset/dataset.py b/kliff/dataset/dataset.py index dedbf425..c7f0918b 100644 --- a/kliff/dataset/dataset.py +++ b/kliff/dataset/dataset.py @@ -430,7 +430,7 @@ def fingerprint(self, fingerprint): """ Set the fingerprint of the configuration. Args: - fingerprint: Numpy array which is the fingerprint of the configuration. + fingerprint: Object which is the fingerprint of the configuration. """ self._fingerprint = fingerprint diff --git a/kliff/transforms/configuration_transforms/configuration_transform.py b/kliff/transforms/configuration_transforms/configuration_transform.py index 95791bc1..fa3bfb92 100644 --- a/kliff/transforms/configuration_transforms/configuration_transform.py +++ b/kliff/transforms/configuration_transforms/configuration_transform.py @@ -32,7 +32,7 @@ def forward(self, configuration: Configuration) -> Any: def __call__(self, configuration: Configuration) -> Any: fingerprint = self.forward(configuration) if self.copy_to_config: - configuration.fingerprint(fingerprint) + configuration.fingerprint = fingerprint return fingerprint def inverse(self, *args, **kargs) -> Configuration: diff --git a/kliff/transforms/property_transforms.py b/kliff/transforms/property_transforms.py index add27d72..67f88a18 100644 --- a/kliff/transforms/property_transforms.py +++ b/kliff/transforms/property_transforms.py @@ -96,7 +96,7 @@ def _get_property_values( def _set_property_values( self, dataset: Union[List[Configuration], Dataset], - property_values: Union[np.ndarray, List[float, int]], + property_values: Union[np.ndarray, List[Union[float, int]]], ): """ Set the property values of all the configurations in a dataset. This method diff --git a/kliff/utils.py b/kliff/utils.py index 65e8f63c..5cddf2d0 100644 --- a/kliff/utils.py +++ b/kliff/utils.py @@ -194,9 +194,9 @@ def stress_to_voigt(input_stress: np.ndarray) -> list: stress[0] = input_stress[0, 0] stress[1] = input_stress[1, 1] stress[2] = input_stress[2, 2] - stress[3] = input_stress[0, 1] + stress[3] = input_stress[1, 2] stress[4] = input_stress[0, 2] - stress[5] = input_stress[1, 2] + stress[5] = input_stress[0, 1] else: raise ValueError("input_stress must be a 2D array") @@ -218,7 +218,7 @@ def stress_to_tensor(input_stress: list) -> np.ndarray: stress[1, 1] = input_stress[1] stress[2, 2] = input_stress[2] stress[1, 2] = stress[2, 1] = input_stress[3] - stress[0, 2] = stress[0, 2] = input_stress[4] + stress[0, 2] = stress[2, 0] = input_stress[4] stress[0, 1] = stress[1, 0] = input_stress[5] return stress diff --git a/setup.py b/setup.py index f9af7839..f89573e8 100644 --- a/setup.py +++ b/setup.py @@ -124,6 +124,8 @@ def get_readme(): "torch", "numpy", "ase", + "libdescriptor", + "torch_geometric", ], "docs": [ "sphinx", diff --git a/tests/dataset/test_ase_parser.py b/tests/dataset/test_ase_parser.py index 89d575a0..5e777eaa 100644 --- a/tests/dataset/test_ase_parser.py +++ b/tests/dataset/test_ase_parser.py @@ -2,12 +2,13 @@ import numpy as np import pytest +from ase import io from kliff.dataset import Dataset -def test_ase_parser(): - """Test ASE parser.""" +def test_dataset_from_ase(): + """Test ASE parser reading from file using ASE parser""" # training set filename = Path(__file__).parents[1].joinpath("test_data/configs/Si_4.xyz") @@ -34,6 +35,54 @@ def test_ase_parser(): assert np.allclose(configs[1].stress, np.array([9.9, 5.5, 1.1, 8.8, 7.7, 4.4])) +def test_dataset_add_from_ase(): + """Test adding configurations to dataset using ASE parser.""" + filename = Path(__file__).parents[1].joinpath("test_data/configs/Si_4.xyz") + data = Dataset() + data.add_from_ase(filename, energy_key="Energy", forces_key="force") + configs = data.get_configs() + + assert len(configs) == 4 + assert configs[0].species == ["Si" for _ in range(4)] + assert configs[0].coords.shape == (4, 3) + assert configs[0].energy == 123.45 + assert np.allclose(configs[0].stress, np.array([1.1, 5.5, 9.9, 8.8, 7.7, 4.4])) + assert configs[1].forces.shape == (8, 3) + + +def test_dataset_from_ase_atoms_list(): + """Test ASE parser reading from file using ASE atoms list.""" + filename = Path(__file__).parents[1].joinpath("test_data/configs/Si_4.xyz") + atoms = io.read(filename, index=":") + data = Dataset.from_ase( + ase_atoms_list=atoms, energy_key="Energy", forces_key="force" + ) + configs = data.get_configs() + + assert len(configs) == 4 + assert configs[0].species == ["Si" for _ in range(4)] + assert configs[0].coords.shape == (4, 3) + assert configs[0].energy == 123.45 + assert np.allclose(configs[0].stress, np.array([1.1, 5.5, 9.9, 8.8, 7.7, 4.4])) + assert configs[1].forces.shape == (8, 3) + + +def test_dataset_add_from_ase_atoms_list(): + """Test adding configurations to dataset using ASE atoms list.""" + filename = Path(__file__).parents[1].joinpath("test_data/configs/Si_4.xyz") + atoms = io.read(filename, index=":") + data = Dataset() + data.add_from_ase(ase_atoms_list=atoms, energy_key="Energy", forces_key="force") + configs = data.get_configs() + + assert len(configs) == 4 + assert configs[0].species == ["Si" for _ in range(4)] + assert configs[0].coords.shape == (4, 3) + assert configs[0].energy == 123.45 + assert np.allclose(configs[0].stress, np.array([1.1, 5.5, 9.9, 8.8, 7.7, 4.4])) + assert configs[1].forces.shape == (8, 3) + + # def test_colabfit_parser(): # TODO: figure minimal colabfit example to run tests on # pass diff --git a/tests/dataset/test_configurations.py b/tests/dataset/test_configurations.py new file mode 100644 index 00000000..8ba1489d --- /dev/null +++ b/tests/dataset/test_configurations.py @@ -0,0 +1,62 @@ +from pathlib import Path + +import numpy as np +import pytest +from ase import io + +from kliff.dataset import Configuration +from kliff.dataset.dataset import ConfigurationError +from kliff.utils import stress_to_voigt + + +def test_configuration_from_ase(): + """Test initializing Configuration from ASE atoms object""" + + filename = Path(__file__).parents[1].joinpath("test_data/configs/Si_4.xyz") + atoms = io.read(filename, index=":") + config = Configuration.from_ase_atoms( + atoms[0], energy_key="Energy", forces_key="force" + ) + + assert config.species == ["Si" for _ in range(4)] + assert config.coords.shape == (4, 3) + assert config.energy == 123.45 + assert np.allclose(config.stress, np.array([1.1, 5.5, 9.9, 8.8, 7.7, 4.4])) + assert config.forces.shape == (4, 3) + assert config.stress.shape == (6,) + + +def test_configuration_to_ase(): + """Test converting Configuration to ASE atoms object""" + + filename = Path(__file__).parents[1].joinpath("test_data/configs/Si_4.xyz") + atoms = io.read(filename, index=":") + config = Configuration.from_ase_atoms( + atoms[0], energy_key="Energy", forces_key="force" + ) + + atoms = config.to_ase_atoms() + assert np.allclose(atoms.get_positions(), config.coords) + assert atoms.info["energy"] == config.energy + assert np.allclose(atoms.arrays["forces"], config.forces) + assert np.allclose(stress_to_voigt(atoms.info["stress"]), config.stress) + # TODO: As per Marcos' suggestion, we should use the dict + # method to get the ASE atoms properties. It solves the issue + # of associated calculator. + + +def test_configuration_from_file(): + """Test initializing Configuration from file""" + + filename = ( + Path(__file__).parents[1].joinpath("test_data/configs/Si_4/Si_T300_step_0.xyz") + ) + config = Configuration.from_file(filename) + + assert config.species == ["Si" for _ in range(64)] + assert config.coords.shape == (64, 3) + assert config.energy == 0.0 + assert config.forces.shape == (64, 3) + # stress should raise exception + with pytest.raises(ConfigurationError): + stress = config.stress diff --git a/tests/misc/test_utils.py b/tests/misc/test_utils.py new file mode 100644 index 00000000..a43543ac --- /dev/null +++ b/tests/misc/test_utils.py @@ -0,0 +1,25 @@ +import numpy as np +import pytest + +from kliff.utils import stress_to_tensor, stress_to_voigt + + +def test_stress_conversion(): + stress_tensor = np.random.rand(3, 3) + stress_tensor = stress_tensor + stress_tensor.T # make it symmetric + stress_voigt = stress_to_voigt(stress_tensor) + assert np.allclose( + stress_voigt, + np.array( + [ + stress_tensor[0, 0], + stress_tensor[1, 1], + stress_tensor[2, 2], + stress_tensor[1, 2], + stress_tensor[0, 2], + stress_tensor[0, 1], + ] + ), + ) + print(stress_tensor, stress_to_tensor(stress_voigt)) + assert np.allclose(stress_tensor, stress_to_tensor(stress_voigt)) diff --git a/tests/test_data/precomputed_numpy_arrays/cm_si.npy b/tests/test_data/precomputed_numpy_arrays/cm_si.npy new file mode 100644 index 00000000..853e7bd0 Binary files /dev/null and b/tests/test_data/precomputed_numpy_arrays/cm_si.npy differ diff --git a/tests/transformations/test_configuration_transform.py b/tests/transformations/test_configuration_transform.py new file mode 100644 index 00000000..ff3e9837 --- /dev/null +++ b/tests/transformations/test_configuration_transform.py @@ -0,0 +1,74 @@ +import numpy as np +from ase.data import atomic_numbers + +from kliff.dataset import Configuration +from kliff.transforms.configuration_transforms import ConfigurationTransform + + +class GlobalCoulombMatrix(ConfigurationTransform): + """ + Coulomb matrix representation of the configuration. + """ + + def __init__(self, max_atoms: int = 5, copy_to_config: bool = False): + super().__init__(copy_to_config) + self.max_atoms = max_atoms + + def forward(self, configuration: Configuration): + """ + Generate the Coulomb matrix for the configuration. + + Args: + configuration: Instance of ~:class:`kliff.dataset.Configuration`. For which the + Coulomb matrix is to be generated. + + Returns: + Coulomb matrix of the configuration. + """ + coords = configuration.coords + n_atoms = configuration.get_num_atoms() + coulomb_mat = np.zeros((self.max_atoms, self.max_atoms)) + species = [atomic_numbers[elem] for elem in configuration.species] + for i in range(n_atoms): + for j in range(i + 1): + if i == j: + coulomb_mat[i, j] = 0.5 * (species[i] ** 2.4) + else: + r = np.linalg.norm(coords[i] - coords[j]) + coulomb_mat[i, j] = species[i] * species[j] / r + coulomb_mat[j, i] = coulomb_mat[i, j] + return coulomb_mat + + def backward(self, fingerprint, configuration): + """ + Inverse mapping of the transform. This is not implemented for any of the transforms, + but is there for future use. + """ + NotImplementedError( + "Any of the implemented transforms do not support inverse mapping.\n" + "For computing jacobian-vector product use `backward` function." + ) + + def collate_fn(self, config_list): + """ + Collate function for the Coulomb matrix transform. + """ + return [self.forward(config) for config in config_list] + + +def test_configuration_transform(test_data_dir): + config = Configuration.from_file(test_data_dir / "configs/Si.xyz") + transform = GlobalCoulombMatrix(max_atoms=8) + fingerprint = transform(config) + assert fingerprint.shape == (8, 8) + assert np.allclose(fingerprint, fingerprint.T) + assert np.allclose( + fingerprint, np.load(test_data_dir / "precomputed_numpy_arrays/cm_si.npy") + ) + assert config.fingerprint is None + dataset = [config, config] + fingerprints = transform.collate_fn(dataset) + assert len(fingerprints) == 2 + assert fingerprints[0].shape == (8, 8) + assert fingerprints[1].shape == (8, 8) + assert np.array(fingerprints).shape == (2, 8, 8) diff --git a/tests/transformations/test_descriptors.py b/tests/transformations/test_descriptors.py new file mode 100644 index 00000000..f8551b9a --- /dev/null +++ b/tests/transformations/test_descriptors.py @@ -0,0 +1,122 @@ +import numpy as np +import pytest + +from kliff.dataset import Configuration +from kliff.descriptors import Bispectrum, SymmetryFunction +from kliff.transforms.configuration_transforms.descriptors import ( + Descriptor, + bispectrum_default, + symmetry_functions_set30, + symmetry_functions_set51, +) + + +def test_symmetry_functions(test_data_dir): + config = Configuration.from_file( + test_data_dir / "configs" / "Si_4" / "Si_T300_step_0.xyz" + ) + + # Forward pass + # initialize kliff older desc + desc_30 = SymmetryFunction( + cut_dists={"Si-Si": 4.5}, + cut_name="cos", + hyperparams=symmetry_functions_set30(), + normalize=False, + ) + descriptor_old_30 = desc_30.transform(config, fit_forces=True, fit_stress=False) + + # initialize kliff new desc + libdesc_30 = Descriptor( + cutoff=4.5, + species=["Si"], + descriptor="SymmetryFunctions", + hyperparameters=symmetry_functions_set30(), + ) + descriptor_new_30 = libdesc_30.forward(config) + + assert np.allclose(descriptor_old_30[0], descriptor_new_30) + + # Backward pass + libdesc_jvp_30 = libdesc_30.backward(config, np.ones_like(descriptor_new_30)) + desc_jvp_30 = np.tensordot( + np.ones_like(descriptor_old_30[0]), descriptor_old_30[1], ([0, 1], [0, 1]) + ).reshape(-1, 3) + assert np.allclose(desc_jvp_30, libdesc_jvp_30) + + # Set 51 + desc_51 = SymmetryFunction( + cut_dists={"Si-Si": 4.5}, + cut_name="cos", + hyperparams=symmetry_functions_set51(), + normalize=False, + ) + descriptor_old_51 = desc_51.transform(config, fit_forces=True, fit_stress=False) + + # initialize kliff new desc + libdesc_51 = Descriptor( + cutoff=4.5, + species=["Si"], + descriptor="SymmetryFunctions", + hyperparameters=symmetry_functions_set51(), + ) + descriptor_new_51 = libdesc_51.forward(config) + + assert np.allclose(descriptor_old_51[0], descriptor_new_51) + + # Backward pass + libdesc_jvp_51 = libdesc_51.backward(config, np.ones_like(descriptor_new_51)) + desc_jvp_51 = np.tensordot( + np.ones_like(descriptor_old_51[0]), descriptor_old_51[1], ([0, 1], [0, 1]) + ).reshape(-1, 3) + assert np.allclose(desc_jvp_51, libdesc_jvp_51) + + +def test_bispectrum(test_data_dir): + config = Configuration.from_file( + test_data_dir / "configs" / "Si_4" / "Si_T300_step_0.xyz" + ) + + # Forward pass + # initialize kliff older desc + hyperparams = { + "jmax": 4, + "rfac0": 0.99363, + "diagonalstyle": 3, + "rmin0": 0, + "switch_flag": 1, + "bzero_flag": 0, + } + + desc = Bispectrum(cut_dists={"Si-Si": 4.5}, cut_name="cos", hyperparams=hyperparams) + + descriptor_old = desc.transform(config, grad=False) + + # initialize kliff new desc + libdesc = Descriptor( + cutoff=4.5, + species=["Si"], + descriptor="Bispectrum", + hyperparameters=bispectrum_default(), + ) + descriptor_new = libdesc.forward(config) + + assert np.allclose(descriptor_old[0], descriptor_new) + # Current Bispectrum implementation has wrong grads. So, we can't compare grads + + +def test_implicit_copying(test_data_dir): + config = Configuration.from_file( + test_data_dir / "configs" / "Si_4" / "Si_T300_step_0.xyz" + ) + + libdesc = Descriptor( + cutoff=4.5, + species=["Si"], + descriptor="SymmetryFunctions", + hyperparameters=symmetry_functions_set30(), + copy_to_config=True, + ) + desc = libdesc(config) + assert np.allclose(desc, libdesc.forward(config)) + assert desc is config.fingerprint diff --git a/tests/transformations/test_graphs.py b/tests/transformations/test_graphs.py new file mode 100644 index 00000000..a8de81d9 --- /dev/null +++ b/tests/transformations/test_graphs.py @@ -0,0 +1,39 @@ +import numpy as np +import pytest +from ase import Atoms +from torch import tensor + +from kliff.dataset import Configuration +from kliff.transforms.configuration_transforms.graphs import KIMDriverGraph + + +def test_implicit_copying(test_data_dir): + config = Configuration.from_file( + test_data_dir / "configs" / "Si_4" / "Si_T300_step_0.xyz" + ) + + graph_generator = KIMDriverGraph( + species=["Si"], cutoff=4.5, n_layers=2, copy_to_config=True + ) + graph = graph_generator(config) + + assert graph is config.fingerprint + + +def test_graph_generation(test_data_dir): + config = Configuration.from_file(test_data_dir / "configs" / "Si.xyz") + graph_generator = KIMDriverGraph(species=["Si"], cutoff=3.7, n_layers=2) + graph = graph_generator(config) + assert np.allclose( + graph.edge_index0.numpy(), + np.array( + [ + [0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 4, 4, 4, 4, 5, 6, 6, 6, 7], + [1, 4, 0, 2, 4, 6, 1, 3, 4, 6, 2, 0, 1, 2, 5, 4, 1, 2, 7, 6], + ] + ), + ) + assert np.allclose(graph.edge_index1.numpy(), graph.edge_index0.numpy()) + assert graph.n_layers == 2 + assert np.allclose(graph.species.numpy(), np.array([0, 0, 0, 0, 0, 0, 0, 0])) + assert np.allclose(graph.z.numpy(), np.array([14, 14, 14, 14, 14, 14, 14, 14])) diff --git a/tests/transformations/test_property_transforms.py b/tests/transformations/test_property_transforms.py new file mode 100644 index 00000000..cc08c91c --- /dev/null +++ b/tests/transformations/test_property_transforms.py @@ -0,0 +1,27 @@ +import numpy as np + +from kliff.dataset import Configuration, Dataset +from kliff.transforms.property_transforms import NormalizedPropertyTransform + + +def test_normalized_property_transforms(test_data_dir): + # Test NormalizedPropertyTransform + dataset = Dataset.from_ase(test_data_dir / "configs/Si_4.xyz", energy_key="Energy") + pt = NormalizedPropertyTransform() + + assert pt._get_property_values(dataset).tolist() == [123.45, 0.0, 0.0, 100.0] + pt.transform(dataset) + assert pt.mean == 55.8625 + assert np.abs(pt.std - 56.474389936943986) < 10**-10 + transformed_energy = np.array( + [ + 1.1967814096879006, + -0.9891651784529732, + -0.9891651784529732, + 0.7815489472180464, + ] + ) + assert np.allclose(pt._get_property_values(dataset), transformed_energy) + + pt.inverse(dataset) + assert np.allclose(pt._get_property_values(dataset), [123.45, 0.0, 0.0, 100.0])