diff --git a/news/ase-adapter.rst b/news/ase-adapter.rst new file mode 100644 index 0000000..2cf7507 --- /dev/null +++ b/news/ase-adapter.rst @@ -0,0 +1,31 @@ +**Added:** + +* Added ``Structure.get_lattice_vectors()`` method to return the lattice vectors. +* Added ``Structure.get_lattice_vector_angles()`` method to return the angles between the lattice vectors. +* Added ``Structure.get_isotropic_displacement_parameters()`` method to return the isotropic displacement parameters. +* Added ``Structure.get_anisotropic_displacement_parameters()`` method to return the anisotropic displacement parameters. +* Added ``Structure.get_occupancies()`` method to return the occupancies of the sites. +* Added ``Structure.get_cartesian_coordinates()`` method to return the Cartesian coordinates of the sites. +* Added ``Structure.get_fractional_coordinates()`` method to return the fractional coordinates of the sites. +* Added ``Structure.get_chemical_symbols()`` method to return the chemical symbols of the sites. +* Added ``Structure.convert_ase_to_diffpy_structure()`` method to convert an ASE Atoms object to a DiffPy Structure object. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/requirements/conda.txt b/requirements/conda.txt index 4c4ff50..bb3d289 100644 --- a/requirements/conda.txt +++ b/requirements/conda.txt @@ -1,3 +1,4 @@ numpy pycifrw diffpy.utils +ase diff --git a/requirements/pip.txt b/requirements/pip.txt index 4c4ff50..bb3d289 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -1,3 +1,4 @@ numpy pycifrw diffpy.utils +ase diff --git a/src/diffpy/structure/structure.py b/src/diffpy/structure/structure.py index ccba404..0b42344 100644 --- a/src/diffpy/structure/structure.py +++ b/src/diffpy/structure/structure.py @@ -17,6 +17,7 @@ import copy as copymod import numpy +from ase import Atoms as ASEAtoms from diffpy.structure.atom import Atom from diffpy.structure.lattice import Lattice @@ -173,6 +174,230 @@ def getLastAtom(self): last_atom = self[-1] return last_atom + def get_chemical_symbols(self): + """Return list of chemical symbols for all `Atoms` in this + structure. + + Returns + ------- + list of str + The list of chemical symbols for all `Atoms` in this structure. + """ + symbols_with_charge = [a.element for a in self] + symbols = [atomBareSymbol(sym) for sym in symbols_with_charge] + return symbols + + def get_fractional_coordinates(self): + """Return array of fractional coordinates of all `Atoms` in this + structure. + + Returns + ------- + numpy.ndarray + The array of fractional coordinates of all `Atoms` in this structure + in the same order as `Structure.get_chemical_symbols()`. + """ + coords = numpy.array([a.xyz for a in self]) + return coords + + def get_cartesian_coordinates(self): + """Return array of Cartesian coordinates of all `Atoms` in this + structure. + + Returns + ------- + numpy.ndarray + The array of Cartesian coordinates of all `Atoms` in this structure + in the same order as `Structure.get_chemical_symbols()`. + """ + cartn_coords = numpy.array([a.xyz_cartn for a in self]) + return cartn_coords + + def get_anisotropic_displacement_parameters(self, return_array=False): + """Return the anisotropic displacement parameters for all atoms. + + Parameters + ---------- + return_array : bool, optional + If True, return anisotropic displacement parameters as a numpy array instead of a dictionary. + + Returns + ------- + dict + The dictionary of anisotropic displacement parameters for all atoms in this structure. + Keys are of the form 'Element_i_Ujk', e.g. 'C_0_11', 'C_0_12'. + """ + if return_array: + aniso_adps = numpy.array([a.U for a in self]) + return aniso_adps + else: + adp_dict = {} + for i, atom in enumerate(self): + element = atomBareSymbol(atom.element) + adp_dict[f"{element}_{i}_11"] = self.U11[i] + adp_dict[f"{element}_{i}_22"] = self.U22[i] + adp_dict[f"{element}_{i}_33"] = self.U33[i] + adp_dict[f"{element}_{i}_12"] = self.U12[i] + adp_dict[f"{element}_{i}_13"] = self.U13[i] + adp_dict[f"{element}_{i}_23"] = self.U23[i] + return adp_dict + + def get_isotropic_displacement_parameters(self, return_array=False): + """Return a the isotropic displacement parameters for all atoms. + + Parameters + ---------- + return_array : bool, optional + If True, return isotropic displacement parameters as a numpy array instead of a dictionary. + Default is False. + + Returns + ------- + dict + The dictionary of isotropic displacement parameters for all atoms in this structure. + Keys are of the form 'Element_i_Uiso', e.g. 'C_0_Uiso'. + """ + if return_array: + iso_adps = numpy.array([a.Uisoequiv for a in self]) + return iso_adps + else: + iso_dict = {} + for i, atom in enumerate(self): + element = atomBareSymbol(atom.element) + iso_dict[f"{element}_{i+1}_Uiso"] = self.Uisoequiv[i] + return iso_dict + + def get_occupancies(self): + """Return array of occupancies of all `Atoms` in this structure. + + Returns + ------- + numpy.ndarray + The array of occupancies of all `Atoms` in this structure. + """ + occupancies = numpy.array([a.occupancy for a in self]) + return occupancies + + def get_lattice_vectors(self): + """Return array of lattice vectors for this structure. + + Returns + ------- + numpy.ndarray + The array of lattice vectors for this structure. + """ + lattice_vectors = self.lattice.base + return lattice_vectors + + def get_lattice_vector_angles(self): + """Return array of lattice vector angles for this structure. + + Returns + ------- + numpy.ndarray + The array of lattice vector angles for this structure. + """ + a, b, c = self.lattice.base + alpha = self.lattice.angle(b, c) + beta = self.lattice.angle(a, c) + gamma = self.lattice.angle(a, b) + return numpy.array([alpha, beta, gamma]) + + def convert_ase_to_diffpy_structure( + self, + ase_atoms: ASEAtoms, + lost_info: list[str] | None = None, + ) -> Structure | tuple[Structure, dict]: # noqa + """Convert ASE `Atoms` object to this `Structure` instance. + + The conversion process involves extracting the lattice parameters, chemical symbols, + and fractional coordinates from the ASE `Atoms` object and populating the corresponding + attributes of this `Structure` instance. The `lattice` attribute is set based on the + cell parameters of the ASE `Atoms` object, and each `Atom` in this `Structure` is created + with the chemical symbol and fractional coordinates extracted from the ASE `Atoms`. + + Parameters + ---------- + ase_structure : ase.Atoms + The ASE `Atoms` object to be converted. + lost_info : str or list of str, optional + The method(s) or attribute(s) to extract from the ASE `Atoms` + object that do not have a direct equivalent in the `Structure` class. + This will be provided in a dictionary format where keys are the + method/attribute name(s) and value(s) are the corresponding data + extracted from the ASE `Atoms` object. + Default is None. See `Examples` for usage. + + Returns + ------- + lost_info : dict, optional + If specified, the dictionary containing any information from the ASE `Atoms` + object that is not currently available in the `Structure` class. + Default behavior is to return `None`. + If `lost_info` is provided, a dictionary containing + any information from the ASE `Atoms` will be returned. + This may include information such as magnetic moments, charge states, + or other ASE-specific properties that do not have a direct equivalent + in the `Structure` class. + + Raises + ------ + TypeError + If the input `ase_structure` is not an instance of `ase.Atoms`. + ValueError + If any of the specified `lost_info` attributes are not present in the ASE `Atoms` object. + + Examples + -------- + An example of converting an `ASE.Atoms` instance to a `Structure` instance, + + .. code-block:: python + from ase import Atoms + from diffpy.structure import Structure + + # Create an ASE Atoms object + ase_atoms = Atoms('H2O', positions=[[0, 0, 0], [0, 0, 1], [1, 0, 0]]) + + # Convert to a diffpy Structure object + structure = Structure() + structure.convert_ase_to_diffpy(ase_atoms) + + + To extract additional information from the ASE `Atoms` object that is not + directly represented in the `Structure` class, such as magnetic moments, + you can specify an attribute or method of `ASE.Atoms` as + a string or list of strings in `lost_info` list. For example, + + .. code-block:: python + lost_info = structure.convert_ase_to_diffpy( + ase_atoms, + lost_info='get_magnetic_moments' + ) + + will return a dictionary with the magnetic moments of the atoms in the ASE `Atoms` object. + """ + # clear structure before populating it with new atoms + del self[:] + if not isinstance(ase_atoms, ASEAtoms): + raise TypeError(f"Input must be an instance of ase.Atoms but got type {type(ase_atoms)}.") + cell = ase_atoms.get_cell() + self.lattice = Lattice(base=numpy.array(cell)) + symbols = ase_atoms.get_chemical_symbols() + scaled_positions = ase_atoms.get_scaled_positions() + for atom_symbol, frac_coord in zip(symbols, scaled_positions): + self.append(Atom(atom_symbol, xyz=frac_coord)) + if lost_info is None: + return + extracted_info = {} + if isinstance(lost_info, str): + lost_info = [lost_info] + for name in lost_info: + if not hasattr(ase_atoms, name): + raise ValueError(f"ASE.Atoms object has no attribute '{name}'.") + attr = getattr(ase_atoms, name) + extracted_info[name] = attr() if callable(attr) else attr + return extracted_info + def assign_unique_labels(self): """Set a unique label string for each `Atom` in this structure. diff --git a/tests/conftest.py b/tests/conftest.py index 5471acc..abe71b6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,11 @@ import json from pathlib import Path +import numpy as np import pytest +from ase import Atoms + +from diffpy.structure import Atom, Lattice, Structure @pytest.fixture @@ -27,3 +31,45 @@ def _load(filename): return "tests/testdata/" + filename return _load + + +@pytest.fixture +def build_ase_atom_object(): + """Helper function to build an ASE.Atoms object for testing.""" + a = 5.409 + frac_coords = np.array( + [ + [0.0, 0.0, 0.0], + [0.5, 0.5, 0.5], + [0.25, 0.25, 0.25], + [0.75, 0.75, 0.75], + ] + ) + cart_coords = frac_coords * a + symbols = ["Zn", "Zn", "S", "S"] + ase_zb = Atoms(symbols=symbols, positions=cart_coords, cell=[[a, 0, 0], [0, a, 0], [0, 0, a]], pbc=True) + return ase_zb + + +@pytest.fixture +def build_diffpy_structure_object(): + """Helper function to build a diffpy.structure.Structure object for + testing.""" + a = 5.409 + frac_coords = np.array( + [ + [0.0, 0.0, 0.0], + [0.5, 0.5, 0.5], + [0.25, 0.25, 0.25], + [0.75, 0.75, 0.75], + ] + ) + lattice = Lattice(base=[[a, 0, 0], [0, a, 0], [0, 0, a]]) + atoms = [ + Atom("Zn", frac_coords[0]), + Atom("Zn", frac_coords[1]), + Atom("S", frac_coords[2]), + Atom("S", frac_coords[3]), + ] + diffpy_zb = Structure(atoms=atoms, lattice=lattice) + return diffpy_zb diff --git a/tests/test_structure.py b/tests/test_structure.py index 818cc26..aa6e6ca 100644 --- a/tests/test_structure.py +++ b/tests/test_structure.py @@ -19,7 +19,7 @@ import pickle import unittest -import numpy +import numpy as np import pytest from diffpy.structure import Atom, Lattice, Structure @@ -87,8 +87,8 @@ def __copy__(self): pbte = self.pbte pbte2 = pbte.copy() self.assertFalse(pbte2.lattice is pbte.lattice) - self.assertTrue(numpy.array_equal(pbte.xyz_cartn, pbte2.xyz_cartn)) - self.assertTrue(numpy.array_equal(pbte.U, pbte2.U)) + self.assertTrue(np.array_equal(pbte.xyz_cartn, pbte2.xyz_cartn)) + self.assertTrue(np.array_equal(pbte.U, pbte2.U)) stru = MyDerivedStructure() stru += pbte2[pbte2.element.startswith("Pb")] pb3 = stru.copy() @@ -163,9 +163,9 @@ def test_placeInLattice(self): new_lattice = Lattice(0.5, 0.5, 0.5, 90, 90, 60) stru.placeInLattice(new_lattice) a0 = stru[0] - self.assertTrue(numpy.allclose(a0.xyz, [0.0, 0.0, 0.0])) + self.assertTrue(np.allclose(a0.xyz, [0.0, 0.0, 0.0])) a1 = stru[1] - self.assertTrue(numpy.allclose(a1.xyz, [2.0, 0.0, 2.0])) + self.assertTrue(np.allclose(a1.xyz, [2.0, 0.0, 2.0])) # def test_read(self): # """check Structure.read()""" @@ -199,7 +199,7 @@ def test_append(self): self.assertEqual(3, len(self.stru)) self.assertEqual("Si", alast.element) self.assertTrue(lat is alast.lattice) - self.assertTrue(numpy.array_equal(a.xyz, alast.xyz)) + self.assertTrue(np.array_equal(a.xyz, alast.xyz)) self.assertFalse(a is alast) self.assertFalse(lat is a.lattice) return @@ -213,7 +213,7 @@ def test_insert(self): self.assertEqual(3, len(self.stru)) self.assertEqual("Si", a1.element) self.assertTrue(lat is a1.lattice) - self.assertTrue(numpy.array_equal(a.xyz, a1.xyz)) + self.assertTrue(np.array_equal(a.xyz, a1.xyz)) self.assertFalse(a is a1) self.assertFalse(lat is a.lattice) return @@ -236,7 +236,7 @@ def test___getitem__(self): self.assertTrue(stru[0] is stru.tolist()[0]) intidx = list(range(len(stru)))[::-1] self.assertEqual(stru[intidx].tolist(), stru.tolist()[::-1]) - flagidx = numpy.arange(len(stru)) > 0 + flagidx = np.arange(len(stru)) > 0 self.assertEqual(stru[flagidx].tolist(), stru.tolist()[1:]) cdse = Structure(self.cdse) self.assertEqual([cdse[0], cdse[-2]], cdse[0, -2].tolist()) @@ -273,7 +273,7 @@ def test___setitem__(self): self.assertEqual(2, len(self.stru)) self.assertEqual("Si", a1.element) self.assertTrue(lat is a1.lattice) - self.assertTrue(numpy.array_equal(a.xyz, a1.xyz)) + self.assertTrue(np.array_equal(a.xyz, a1.xyz)) self.assertFalse(a is a1) self.assertFalse(lat is a.lattice) return @@ -287,7 +287,7 @@ def test___setitem__slice(self): self.assertEqual(1, len(self.stru)) self.assertEqual("Si", a0.element) self.assertTrue(lat is a0.lattice) - self.assertTrue(numpy.array_equal(a.xyz, a0.xyz)) + self.assertTrue(np.array_equal(a.xyz, a0.xyz)) self.assertFalse(a is a0) self.assertFalse(lat is a.lattice) return @@ -301,9 +301,9 @@ def test___add__(self): ta0 = total[0] tam1 = total[-1] self.assertEqual("C", ta0.element) - self.assertTrue(numpy.array_equal(stru[0].xyz, ta0.xyz)) + self.assertTrue(np.array_equal(stru[0].xyz, ta0.xyz)) self.assertEqual("Se", tam1.element) - self.assertTrue(numpy.array_equal(cdse[-1].xyz, tam1.xyz)) + self.assertTrue(np.array_equal(cdse[-1].xyz, tam1.xyz)) self.assertFalse(total.lattice in (stru.lattice, cdse.lattice)) self.assertTrue(all([a.lattice is total.lattice for a in total])) return @@ -319,7 +319,7 @@ def test___iadd__(self): self.assertEqual(lst, stru[:2].tolist()) am1 = stru[-1] self.assertEqual("Se", am1.element) - self.assertTrue(numpy.array_equal(cdse[-1].xyz, am1.xyz)) + self.assertTrue(np.array_equal(cdse[-1].xyz, am1.xyz)) self.assertTrue(lat0 is stru.lattice) self.assertFalse(stru.lattice is cdse.lattice) self.assertTrue(all([a.lattice is stru.lattice for a in stru])) @@ -332,8 +332,8 @@ def test___sub__(self): self.assertEqual(2, len(cadmiums)) self.assertEqual("Cd", cadmiums[0].element) self.assertEqual("Cd", cadmiums[1].element) - self.assertTrue(numpy.array_equal(cdse[0].xyz, cadmiums[0].xyz)) - self.assertTrue(numpy.array_equal(cdse[1].xyz, cadmiums[1].xyz)) + self.assertTrue(np.array_equal(cdse[0].xyz, cadmiums[0].xyz)) + self.assertTrue(np.array_equal(cdse[1].xyz, cadmiums[1].xyz)) self.assertFalse(cdse[0] is cadmiums[0]) self.assertFalse(cdse.lattice is cadmiums.lattice) return @@ -360,7 +360,7 @@ def test___mul__(self): cdsex3 = 3 * cdse self.assertEqual(12, len(cdsex3)) self.assertEqual(3 * "Cd Cd Se Se".split(), [a.element for a in cdsex3]) - self.assertTrue(numpy.array_equal(3 * [a.xyz for a in cdse], [a.xyz for a in cdsex3])) + self.assertTrue(np.array_equal(3 * [a.xyz for a in cdse], [a.xyz for a in cdsex3])) self.assertFalse(set(cdse).intersection(cdsex3)) self.assertFalse(cdse.lattice is cdsex3.lattice) return @@ -375,8 +375,8 @@ def test___imul__(self): cdse *= 2 self.assertEqual(8, len(cdse)) self.assertEqual(lst, cdse[:4].tolist()) - self.assertEqual(numpy.tile(els, 2).tolist(), cdse.element.tolist()) - self.assertTrue(numpy.array_equal(numpy.tile(xyz, (2, 1)), cdse.xyz)) + self.assertEqual(np.tile(els, 2).tolist(), cdse.element.tolist()) + self.assertTrue(np.array_equal(np.tile(xyz, (2, 1)), cdse.xyz)) self.assertEqual(8, len(set(cdse))) self.assertEqual(8 * [lat], [a.lattice for a in cdse]) self.stru *= -3 @@ -423,18 +423,18 @@ def test_xyz(self): """Check Structure.xyz.""" stru = self.stru self.assertEqual((2, 3), stru.xyz.shape) - self.assertTrue(numpy.array_equal([1, 1, 1], stru.xyz[1])) + self.assertTrue(np.array_equal([1, 1, 1], stru.xyz[1])) stru.xyz += 0.1 - self.assertTrue(numpy.array_equal([0.1, 0.1, 0.1], stru[0].xyz)) - self.assertTrue(numpy.array_equal([1.1, 1.1, 1.1], stru[1].xyz)) + self.assertTrue(np.array_equal([0.1, 0.1, 0.1], stru[0].xyz)) + self.assertTrue(np.array_equal([1.1, 1.1, 1.1], stru[1].xyz)) stru.xyz = 0 stru[1].xyz[:] = 1 - self.assertTrue(numpy.array_equal([0, 0, 0], stru[0].xyz)) - self.assertTrue(numpy.array_equal([1, 1, 1], stru[1].xyz)) + self.assertTrue(np.array_equal([0, 0, 0], stru[0].xyz)) + self.assertTrue(np.array_equal([1, 1, 1], stru[1].xyz)) # verify noop when changing empty slice - xyz0 = numpy.copy(stru.xyz) + xyz0 = np.copy(stru.xyz) stru[1:1].xyz += 1 - self.assertTrue(numpy.array_equal(xyz0, stru.xyz)) + self.assertTrue(np.array_equal(xyz0, stru.xyz)) return def test_x(self): @@ -483,7 +483,7 @@ def test_label(self): def test_occupancy(self): """Check Structure.occupancy.""" cdse = self.cdse - self.assertTrue(numpy.array_equal(numpy.ones(4), cdse.occupancy)) + self.assertTrue(np.array_equal(np.ones(4), cdse.occupancy)) self.stru.occupancy *= 0.5 self.assertEqual(1.0, sum([a.occupancy for a in self.stru])) cdse.occupancy = 1 @@ -494,25 +494,25 @@ def test_xyz_cartn(self): """Check Structure.xyz_cartn.""" pbte = copy.copy(self.pbte) self.assertEqual((8, 3), pbte.xyz_cartn.shape) - self.assertTrue(numpy.allclose(6.461 / 2.0 * numpy.ones(3), pbte.xyz_cartn[0])) - pbte.xyz_cartn += numpy.array([0.1, 0.2, 0.3]) * 6.461 - self.assertTrue(numpy.allclose([0.6, 0.7, 0.8], pbte[0].xyz)) - self.assertTrue(numpy.allclose([0.6, 0.7, 0.3], pbte[7].xyz)) + self.assertTrue(np.allclose(6.461 / 2.0 * np.ones(3), pbte.xyz_cartn[0])) + pbte.xyz_cartn += np.array([0.1, 0.2, 0.3]) * 6.461 + self.assertTrue(np.allclose([0.6, 0.7, 0.8], pbte[0].xyz)) + self.assertTrue(np.allclose([0.6, 0.7, 0.3], pbte[7].xyz)) return def test_anisotropy(self): """Check Structure.anisotropy.""" self.assertEqual((2,), self.stru.anisotropy.shape) - self.assertFalse(numpy.any(self.stru.anisotropy)) + self.assertFalse(np.any(self.stru.anisotropy)) tei = copy.copy(self.tei) - self.assertTrue(numpy.all(tei.anisotropy)) + self.assertTrue(np.all(tei.anisotropy)) tei.anisotropy = False - self.assertFalse(numpy.any(tei.anisotropy)) + self.assertFalse(np.any(tei.anisotropy)) self.assertAlmostEqual(0.019227, tei[0].U11, 6) self.assertAlmostEqual(0.019227, tei[0].U22, 6) self.assertAlmostEqual(0.019227, tei[0].U33, 6) self.assertAlmostEqual(0.0, tei[0].U12, 6) - self.assertAlmostEqual(0.019227 * -numpy.cos(numpy.radians(128.09)), tei[0].U13, 6) + self.assertAlmostEqual(0.019227 * -np.cos(np.radians(128.09)), tei[0].U13, 6) self.assertAlmostEqual(0.0, tei[0].U23, 6) self.assertAlmostEqual(0.019227, tei[0].Uisoequiv, 6) return @@ -521,22 +521,22 @@ def test_U(self): """Check Structure.U.""" stru = self.stru self.assertEqual((2, 3, 3), stru.U.shape) - self.assertFalse(numpy.any(stru.anisotropy)) - stru.U = numpy.identity(3) + self.assertFalse(np.any(stru.anisotropy)) + stru.U = np.identity(3) self.assertEqual(2, len(set([id(a.U) for a in stru]))) isou = stru.lattice.isotropicunit - self.assertTrue(numpy.array_equal(2 * [isou], stru.U)) - self.assertFalse(numpy.any(stru.anisotropy)) + self.assertTrue(np.array_equal(2 * [isou], stru.U)) + self.assertFalse(np.any(stru.anisotropy)) stru.anisotropy = True - stru.U = numpy.identity(3) - self.assertTrue(numpy.array_equal(2 * [numpy.identity(3)], stru.U)) - self.assertTrue(numpy.all(stru.anisotropy)) + stru.U = np.identity(3) + self.assertTrue(np.array_equal(2 * [np.identity(3)], stru.U)) + self.assertTrue(np.all(stru.anisotropy)) stru.U = 0 - self.assertTrue(numpy.all(stru.anisotropy)) - self.assertFalse(numpy.any(stru.U != 0.0)) + self.assertTrue(np.all(stru.anisotropy)) + self.assertFalse(np.any(stru.U != 0.0)) stru[1].U[:] = 1 - self.assertTrue(numpy.all(stru[0].U == 0.0)) - self.assertTrue(numpy.all(stru[1].U == 1.0)) + self.assertTrue(np.all(stru[0].U == 0.0)) + self.assertTrue(np.all(stru[1].U == 1.0)) return def test_Uisoequiv(self): @@ -557,19 +557,19 @@ def test_Uij(self): stru = self.stru stru[1].anisotropy = True stru[1].U = [[1.1, 0.12, 0.13], [0.12, 2.2, 0.23], [0.13, 0.23, 3.3]] - self.assertTrue(numpy.array_equal([0, 1.1], stru.U11)) - self.assertTrue(numpy.array_equal([0, 2.2], stru.U22)) - self.assertTrue(numpy.array_equal([0, 3.3], stru.U33)) - self.assertTrue(numpy.array_equal([0, 0.12], stru.U12)) - self.assertTrue(numpy.array_equal([0, 0.13], stru.U13)) - self.assertTrue(numpy.array_equal([0, 0.23], stru.U23)) + self.assertTrue(np.array_equal([0, 1.1], stru.U11)) + self.assertTrue(np.array_equal([0, 2.2], stru.U22)) + self.assertTrue(np.array_equal([0, 3.3], stru.U33)) + self.assertTrue(np.array_equal([0, 0.12], stru.U12)) + self.assertTrue(np.array_equal([0, 0.13], stru.U13)) + self.assertTrue(np.array_equal([0, 0.23], stru.U23)) stru.U11 = stru.U22 = stru.U33 = stru.U12 = stru.U13 = stru.U23 = 0.0 - self.assertFalse(numpy.any(stru.U != 0.0)) + self.assertFalse(np.any(stru.U != 0.0)) return def test_Bisoequiv(self): """Check Structure.Bisoequiv.""" - utob = 8 * numpy.pi**2 + utob = 8 * np.pi**2 tei = copy.copy(self.tei) self.assertEqual((16,), tei.Bisoequiv.shape) self.assertAlmostEqual(utob * 0.019227, tei.Bisoequiv[0], 4) @@ -586,15 +586,15 @@ def test_Bij(self): stru = self.stru stru[1].anisotropy = True stru[1].U = [[1.1, 0.12, 0.13], [0.12, 2.2, 0.23], [0.13, 0.23, 3.3]] - stru[1].U /= 8 * numpy.pi**2 - self.assertTrue(numpy.allclose([0, 1.1], stru.B11)) - self.assertTrue(numpy.allclose([0, 2.2], stru.B22)) - self.assertTrue(numpy.allclose([0, 3.3], stru.B33)) - self.assertTrue(numpy.allclose([0, 0.12], stru.B12)) - self.assertTrue(numpy.allclose([0, 0.13], stru.B13)) - self.assertTrue(numpy.allclose([0, 0.23], stru.B23)) + stru[1].U /= 8 * np.pi**2 + self.assertTrue(np.allclose([0, 1.1], stru.B11)) + self.assertTrue(np.allclose([0, 2.2], stru.B22)) + self.assertTrue(np.allclose([0, 3.3], stru.B33)) + self.assertTrue(np.allclose([0, 0.12], stru.B12)) + self.assertTrue(np.allclose([0, 0.13], stru.B13)) + self.assertTrue(np.allclose([0, 0.23], stru.B23)) stru.B11 = stru.B22 = stru.B33 = stru.B12 = stru.B13 = stru.B23 = 0.0 - self.assertFalse(numpy.any(stru.U != 0.0)) + self.assertFalse(np.any(stru.U != 0.0)) return def test_pickling(self): @@ -609,6 +609,326 @@ def test_pickling(self): # End of class TestStructure + +def test_get_chemical_symbols(datafile): + """Check Structure.get_chemical_symbols()""" + pbte_stru = Structure(filename=datafile("PbTe.cif")) + actual_chemical_symbols = pbte_stru.get_chemical_symbols() + expected_chemical_symbols = ["Pb"] * 4 + ["Te"] * 4 + assert actual_chemical_symbols == expected_chemical_symbols + + +def test_get_fractional_coordinates(datafile): + """Check Structure.get_fractional_coordinates()""" + pbte_stru = Structure(filename=datafile("PbTe.cif")) + actual_fractional_coords = pbte_stru.get_fractional_coordinates() + expected_fractional_coords = np.array( + [ + [0.5, 0.5, 0.5], + [0.5, 0.0, 0.0], + [0.0, 0.5, 0.0], + [0.0, 0.0, 0.5], + [0.0, 0.0, 0.0], + [0.0, 0.5, 0.5], + [0.5, 0.0, 0.5], + [0.5, 0.5, 0.0], + ] + ) + assert np.allclose(actual_fractional_coords, expected_fractional_coords) + + +def test_get_cartesian_coordinates(datafile): + """Check Structure.get_cartesian_coordinates()""" + cdse_stru = Structure(filename=datafile("CdSe_bulk.stru")) + actual_cartesian_coords = cdse_stru.get_cartesian_coordinates() + expected_cartesian_coords = np.array( + [ + [1.22284264, 2.11760202, 0.0], + [2.44495161, 0.0, 3.4530135], + [1.22284264, 2.11760202, 2.60129319], + [2.44495161, 0.0, 6.05430669], + ] + ) + assert np.allclose(actual_cartesian_coords, expected_cartesian_coords, atol=1e-6) + + +@pytest.mark.parametrize( + "return_array", + [ # case: user wants ADPs as an array + # expected: a 3D array of shape (num_atoms, 3, 3) with the Uij values + True, + # case: user wants ADPs as a dictionary + # expected: a dictionary with keys like "I_8_11" and values as the corresponding Uij values + False, + ], +) +def test_get_anisotropic_displacement_parameters(datafile, return_array): + """Check Structure.get_anisotropic_displacement_parameters()""" + tei_stru = Structure(filename=datafile("TeI.cif")) + actual_displacement = tei_stru.get_anisotropic_displacement_parameters(return_array=return_array) + if return_array: + expected_one_atom = np.array( + [ + [[0.0211, 0.0, 0.0109], [0.0, 0.0195, 0.0], [0.0109, 0.0, 0.016]], + [[0.0223, 0.0, 0.0179], [0.0, 0.018, 0.0], [0.0179, 0.0, 0.0254]], + [[0.025, 0.0, 0.0226], [0.0, 0.0234, 0.0], [0.0226, 0.0, 0.0345]], + [[0.0234, 0.0, 0.0138], [0.0, 0.0295, 0.0], [0.0138, 0.0, 0.0253]], + ] + ) + expected_displacement = np.repeat(expected_one_atom, 4, axis=0) + assert np.allclose(actual_displacement, expected_displacement) + else: + expected_displacement = { + # Iodine + "I_8_11": np.float64(0.025), + "I_8_12": np.float64(0.0), + "I_8_13": np.float64(0.0226), + "I_8_22": np.float64(0.0234), + "I_8_23": np.float64(0.0), + "I_8_33": np.float64(0.0345), + "I_9_11": np.float64(0.025), + "I_9_12": np.float64(0.0), + "I_9_13": np.float64(0.0226), + "I_9_22": np.float64(0.0234), + "I_9_23": np.float64(0.0), + "I_9_33": np.float64(0.0345), + "I_10_11": np.float64(0.025), + "I_10_12": np.float64(0.0), + "I_10_13": np.float64(0.0226), + "I_10_22": np.float64(0.0234), + "I_10_23": np.float64(0.0), + "I_10_33": np.float64(0.0345), + "I_11_11": np.float64(0.025), + "I_11_12": np.float64(0.0), + "I_11_13": np.float64(0.0226), + "I_11_22": np.float64(0.0234), + "I_11_23": np.float64(0.0), + "I_11_33": np.float64(0.0345), + "I_12_11": np.float64(0.0234), + "I_12_12": np.float64(0.0), + "I_12_13": np.float64(0.0138), + "I_12_22": np.float64(0.0295), + "I_12_23": np.float64(0.0), + "I_12_33": np.float64(0.0253), + "I_13_11": np.float64(0.0234), + "I_13_12": np.float64(0.0), + "I_13_13": np.float64(0.0138), + "I_13_22": np.float64(0.0295), + "I_13_23": np.float64(0.0), + "I_13_33": np.float64(0.0253), + "I_14_11": np.float64(0.0234), + "I_14_12": np.float64(0.0), + "I_14_13": np.float64(0.0138), + "I_14_22": np.float64(0.0295), + "I_14_23": np.float64(0.0), + "I_14_33": np.float64(0.0253), + "I_15_11": np.float64(0.0234), + "I_15_12": np.float64(0.0), + "I_15_13": np.float64(0.0138), + "I_15_22": np.float64(0.0295), + "I_15_23": np.float64(0.0), + "I_15_33": np.float64(0.0253), + # Tellurium + "Te_0_11": np.float64(0.0211), + "Te_0_12": np.float64(0.0), + "Te_0_13": np.float64(0.0109), + "Te_0_22": np.float64(0.0195), + "Te_0_23": np.float64(0.0), + "Te_0_33": np.float64(0.016), + "Te_1_11": np.float64(0.0211), + "Te_1_12": np.float64(0.0), + "Te_1_13": np.float64(0.0109), + "Te_1_22": np.float64(0.0195), + "Te_1_23": np.float64(0.0), + "Te_1_33": np.float64(0.016), + "Te_2_11": np.float64(0.0211), + "Te_2_12": np.float64(0.0), + "Te_2_13": np.float64(0.0109), + "Te_2_22": np.float64(0.0195), + "Te_2_23": np.float64(0.0), + "Te_2_33": np.float64(0.016), + "Te_3_11": np.float64(0.0211), + "Te_3_12": np.float64(0.0), + "Te_3_13": np.float64(0.0109), + "Te_3_22": np.float64(0.0195), + "Te_3_23": np.float64(0.0), + "Te_3_33": np.float64(0.016), + "Te_4_11": np.float64(0.0223), + "Te_4_12": np.float64(0.0), + "Te_4_13": np.float64(0.0179), + "Te_4_22": np.float64(0.018), + "Te_4_23": np.float64(0.0), + "Te_4_33": np.float64(0.0254), + "Te_5_11": np.float64(0.0223), + "Te_5_12": np.float64(0.0), + "Te_5_13": np.float64(0.0179), + "Te_5_22": np.float64(0.018), + "Te_5_23": np.float64(0.0), + "Te_5_33": np.float64(0.0254), + "Te_6_11": np.float64(0.0223), + "Te_6_12": np.float64(0.0), + "Te_6_13": np.float64(0.0179), + "Te_6_22": np.float64(0.018), + "Te_6_23": np.float64(0.0), + "Te_6_33": np.float64(0.0254), + "Te_7_11": np.float64(0.0223), + "Te_7_12": np.float64(0.0), + "Te_7_13": np.float64(0.0179), + "Te_7_22": np.float64(0.018), + "Te_7_23": np.float64(0.0), + "Te_7_33": np.float64(0.0254), + } + assert actual_displacement == expected_displacement + + +@pytest.mark.parametrize( + "return_array", + [ # case: user wants isotropic displacement parameters as an array + # expected: a 1D array of shape (num_atoms,) with the Uiso values + True, + # case: user wants isotropic displacement parameters as a dictionary + # expected: a dictionary with keys like "I_Uiso" and values as the corresponding Uiso values + False, + ], +) +def test_get_isotropic_displacement_parameters(datafile, return_array): + """Check Structure.get_isotropic_displacement_parameters()""" + pbte_stru = Structure(filename=datafile("PbTe.cif")) + actual_isotropic_displacement = pbte_stru.get_isotropic_displacement_parameters(return_array=return_array) + if return_array: + expected_isotropic_displacement = np.array( + [0.0225566, 0.0225566, 0.0225566, 0.0225566, 0.0155528, 0.0155528, 0.0155528, 0.0155528] + ) + assert np.allclose(actual_isotropic_displacement, expected_isotropic_displacement) + else: + expected_isotropic_displacement = { + "Pb_1_Uiso": np.float64(0.0225566), + "Pb_2_Uiso": np.float64(0.0225566), + "Pb_3_Uiso": np.float64(0.0225566), + "Pb_4_Uiso": np.float64(0.0225566), + "Te_5_Uiso": np.float64(0.0155528), + "Te_6_Uiso": np.float64(0.0155528), + "Te_7_Uiso": np.float64(0.0155528), + "Te_8_Uiso": np.float64(0.0155528), + } + assert actual_isotropic_displacement == expected_isotropic_displacement + + +def test_get_occupancies(datafile): + """Check Structure.get_occupancies()""" + pbte_stru = Structure(filename=datafile("PbTe.cif")) + actual_occupancies = pbte_stru.get_occupancies() + expected_occupancies = np.ones(8) + assert np.allclose(actual_occupancies, expected_occupancies) + + +def test_get_lattice_vectors(datafile): + """Check Structure.get_lattice_vectors()""" + pbte_stru = Structure(filename=datafile("PbTe.cif")) + actual_lattice_vectors = pbte_stru.get_lattice_vectors() + expected_lattice_vectors = np.array([[6.461, 0.0, 0.0], [0.0, 6.461, 0.0], [0.0, 0.0, 6.461]]) + assert np.allclose(actual_lattice_vectors, expected_lattice_vectors) + + +def test_get_lattice_vector_angles(datafile): + """Check Structure.get_lattice_vector_angles()""" + pbte_stru = Structure(filename=datafile("PbTe.cif")) + actual_lattice_vector_angles = pbte_stru.get_lattice_vector_angles() + expected_lattice_vector_angles = np.array([90.0, 90.0, 90.0]) + assert np.allclose(actual_lattice_vector_angles, expected_lattice_vector_angles) + + +@pytest.mark.parametrize( + "input", + [ + # case: user calls the conversion function on a Structure object that already contains + # a structure + # expected: the structure is wiped clean and replaced with the converted structure + # we use the fixture to create a Structure object that already contains a structure. + "use_diffpy_structure_fixture", + # case: user calls the conversion function on an empty Structure object + # expected: the converted structure is added to the empty Structure object without issue + Structure(), + ], +) +def test_convert_ase_to_diffpy_structure(input, build_ase_atom_object, build_diffpy_structure_object): + """Check convert_ase_to_diffpy_structure()""" + # input: User wants to convert an ASE.Atoms object to a diffpy.structure.Structure object + # expected: All similar data is transferred correctly, + # including chemical symbols, fractional coordinates, and lattice parameters. + + # Create an ASE.Atoms object + ase_zb = build_ase_atom_object + # Create an identical expected diffpy Structure object + expected_structure = build_diffpy_structure_object + + # Create new Structure object and convert ase to diffpy structure. + # Use the string input to determine which type of Structure object to create for the test + if isinstance(input, str): + actual_structure = build_diffpy_structure_object + else: + actual_structure = input + # set the lost_info variable, which gets the attribute of method from ASE.Atoms object, gets the values + # and stores it in a dict. This is used because ASE.Atoms stores more/different + # info that a diffpy.structure object + lost_info_dict = actual_structure.convert_ase_to_diffpy_structure(ase_zb, lost_info="get_masses") + actual_masses = lost_info_dict["get_masses"] + expected_masses = ase_zb.get_masses() + assert np.allclose(actual_masses, expected_masses) + + # Compare the actual and expected values + expected_lattice_vectors = expected_structure.get_lattice_vectors() + actual_lattice_vectors = actual_structure.get_lattice_vectors() + assert np.allclose(expected_lattice_vectors, actual_lattice_vectors) + + expected_lattice_angle = expected_structure.get_lattice_vector_angles() + actual_lattice_angle = actual_structure.get_lattice_vector_angles() + assert np.allclose(expected_lattice_angle, actual_lattice_angle) + + expected_symbols = expected_structure.get_chemical_symbols() + actual_symbols = actual_structure.get_chemical_symbols() + assert actual_symbols == expected_symbols + + expected_coords = expected_structure.get_fractional_coordinates() + actual_coords = actual_structure.get_fractional_coordinates() + assert np.allclose(actual_coords, expected_coords) + + +def test_convert_ase_to_diffpy_structure_bad_typeerror(): + """Check convert_ase_to_diffpy_structure() with bad input.""" + bad_input = "string" # pass a string instead of ase.Atoms + expected_error_msg = "Input must be an instance of ase.Atoms but got type ." + actual_structure = Structure() + with pytest.raises(TypeError, match=expected_error_msg): + actual_structure.convert_ase_to_diffpy_structure(bad_input) + + +@pytest.mark.parametrize( + "bad_lost_info,error,expected_error_msg", + [ # case: User provides an ASE.Atoms object but requests lost_info that is not an attribute of ASE.Atoms + # expected: A ValueError is raised with a clear error message indicating the requested lost_info + # attribute is invalid. + (["invalid_method"], ValueError, "ASE.Atoms object has no attribute 'invalid_method'"), + # case: User provides an ASE.Atoms object but requests lost_info that is an attribute of ASE.Atoms + # but has not been set yet. + # expected: The error message from ase is raised indicating the specific issue with the + # requested lost_info attribute. + # We set the expected error message to None because this expectation is + # out of our control, but it is good to make sure that we are + # raising the error from ASE. + (["get_magnetic_moments"], RuntimeError, None), + ], +) +def test_convert_ase_to_diffpy_structure_bad_valueerror( + bad_lost_info, error, expected_error_msg, build_ase_atom_object +): + """Check convert_ase_to_diffpy_structure() with bad lost_info.""" + ase_zb = build_ase_atom_object + actual_structure = Structure() + with pytest.raises(error, match=expected_error_msg): + actual_structure.convert_ase_to_diffpy_structure(ase_zb, lost_info=bad_lost_info) + + # ---------------------------------------------------------------------------- if __name__ == "__main__":