diff --git a/src/abacusagent/modules/band.py b/src/abacusagent/modules/band.py index 71ab934..2e5214e 100644 --- a/src/abacusagent/modules/band.py +++ b/src/abacusagent/modules/band.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Literal, Dict, List, Union +from typing import Literal, Dict, List, Union, Any from abacusagent.init_mcp import mcp from abacusagent.modules.submodules.band import abacus_cal_band as _abacus_cal_band @@ -12,7 +12,7 @@ def abacus_cal_band(abacus_inputs_dir: Path, energy_min: float = -10, energy_max: float = 10, insert_point_nums: int = 30 -) -> Dict[str, float|str]: +) -> Dict[str, Any]: """ Calculate band using ABACUS based on prepared directory containing the INPUT, STRU, KPT, and pseudopotential or orbital files. PYATB or ABACUS NSCF calculation will be used according to parameters in INPUT. diff --git a/src/abacusagent/modules/structure_editor.py b/src/abacusagent/modules/structure_editor.py index 7a7a486..4293cda 100644 --- a/src/abacusagent/modules/structure_editor.py +++ b/src/abacusagent/modules/structure_editor.py @@ -1,18 +1,22 @@ import os from pathlib import Path -from typing import Literal, Tuple +from typing import Literal, Tuple, Dict, Any, Optional from abacusagent.init_mcp import mcp from abacusagent.modules.submodules.structure_editor import build_slab as _build_slab +from abacusagent.modules.submodules.structure_editor import convert_to_primitive as _convert_to_primitive +from abacusagent.modules.submodules.structure_editor import convert_to_conventional as _convert_to_conventional @mcp.tool() -def build_slab(stru_file: Path, - stru_type: Literal["cif", "poscar", "abacus/stru"] = "cif", - miller_indices: Tuple[int, int, int] = (1, 0, 0), - layers: int = 3, - surface_supercell: Tuple[int, int] = (1, 1), - vacuum: float = 15.0, - vacuum_direction: Literal['a', 'b', 'c'] = 'b'): +def build_slab( + stru_file: Path, + stru_type: Literal["cif", "poscar", "abacus/stru"] = "cif", + miller_indices: Tuple[int, int, int] = (1, 0, 0), + layers: int = 3, + surface_supercell: Tuple[int, int] = (1, 1), + vacuum: float = 15.0, + vacuum_direction: Literal["a", "b", "c"] = "b", +): """ Build slab from given structure file. @@ -22,7 +26,7 @@ def build_slab(stru_file: Path, miller_indices (Tuple[int, int, int]): Miller indices of the surface. Defaults to (1, 0, 0), which means (100) surface of the structure. layers (int, optional): Number of layers of the surface. Note that the layers is number of equivalent layers, not number of layers of atoms. Defaults to 3. surface_supercell (Tuple[int, int], optional): Supercell size of the surface. Default is (1, 1), which means no supercell. - vacuum (float, optional): Vacuum space between the cleaved surface and its periodic image. The total vacuum size will be twice this value. Units in Angstrom. Defaults to 15.0. + vacuum (float, optional): Vacuum space between the cleaved surface and its periodic image. The total vacuum size will be twice this value. Units in Angstrom. Defaults to 15.0. vacuum_direction (Literal['a', 'b', 'c']): The direction of the vacuum space. Defaults to 'b'. Returns: A dictionary containing the path to the surface structure file. @@ -32,3 +36,94 @@ def build_slab(stru_file: Path, ValueError: If stru_type is not supported. """ return _build_slab(stru_file, stru_type, miller_indices, layers, surface_supercell, vacuum, vacuum_direction) + +@mcp.tool() +def convert_to_primitive( + stru_file: Path, + stru_type: Literal["cif", "poscar", "abacus/stru"] = "cif", + output_format: Optional[Literal["cif", "poscar", "abacus/stru"]] = None, + tolerance: float = 0.25, +) -> Dict[str, Any]: + """ + Convert a crystal structure to its primitive cell. + + This function takes a crystal structure in CIF, POSCAR, or ABACUS STRU format + and converts it to its primitive cell using pymatgen's symmetry analysis. + + Args: + stru_file: Path to the input structure file. + stru_type: Type of the input structure file. Options are: + - 'cif': Crystallographic Information File format + - 'poscar': VASP POSCAR format + - 'abacus/stru': ABACUS STRU format + output_format: Format of the output file. If not specified, uses the same + format as the input. Options are: 'cif', 'poscar', 'abacus/stru'. + tolerance: Tolerance for symmetry detection in Angstroms. Default is 0.25. + Structures with atoms closer than this distance are considered symmetric. + + Returns: + A dictionary containing: + - 'output_file': Path to the generated primitive structure file. + - 'num_atoms': Number of atoms in the primitive cell. + - 'cell': Cell parameters of the primitive cell as a 3x3 list of lists. + - 'spacegroup': Space group symbol of the structure. + + Raises: + FileNotFoundError: If the input structure file does not exist. + ValueError: If the structure file type or output format is not supported. + + Examples: + >>> # Convert a CIF file to primitive cell in POSCAR format + >>> convert_structure_to_primitive("Si.cif", stru_type="cif", output_format="poscar") + + >>> # Convert ABACUS STRU to primitive cell in CIF format + >>> convert_structure_to_primitive("STRU", stru_type="abacus/stru", output_format="cif") + """ + return _convert_to_primitive(stru_file, stru_type, output_format, tolerance) + + +@mcp.tool() +def convert_to_conventional( + stru_file: Path, + stru_type: Literal["cif", "poscar", "abacus/stru"] = "cif", + output_format: Optional[Literal["cif", "poscar", "abacus/stru"]] = None, + tolerance: float = 0.01, +) -> Dict[str, Any]: + """ + Convert a crystal structure to its conventional standard cell. + + This function takes a crystal structure in CIF, POSCAR, or ABACUS STRU format + and converts it to its conventional standard cell using pymatgen's SpacegroupAnalyzer. + The conventional cell follows the standard conventions for each space group, + ensuring proper cell orientation and lattice parameter assignment. + + Args: + stru_file: Path to the input structure file. + stru_type: Type of the input structure file. Options are: + - 'cif': Crystallographic Information File format + - 'poscar': VASP POSCAR format + - 'abacus/stru': ABACUS STRU format + output_format: Format of the output file. If not specified, uses the same + format as the input. Options are: 'cif', 'poscar', 'abacus/stru'. + tolerance: Tolerance for symmetry detection in Angstroms. Default is 0.01. + Lower values are more strict in detecting symmetry. + + Returns: + A dictionary containing: + - 'output_file': Path to the generated conventional structure file. + - 'num_atoms': Number of atoms in the conventional cell. + - 'cell': Cell parameters of the conventional cell as a 3x3 list of lists. + - 'spacegroup': Space group symbol of the structure. + + Raises: + FileNotFoundError: If the input structure file does not exist. + ValueError: If the structure file type or output format is not supported. + + Examples: + >>> # Convert a CIF file to conventional cell in POSCAR format + >>> convert_structure_to_conventional("Si.cif", stru_type="cif", output_format="poscar") + + >>> # Convert ABACUS STRU to conventional cell in CIF format + >>> convert_structure_to_conventional("STRU", stru_type="abacus/stru", output_format="cif") + """ + return _convert_to_conventional(stru_file, stru_type, output_format, tolerance) diff --git a/src/abacusagent/modules/submodules/structure_editor.py b/src/abacusagent/modules/submodules/structure_editor.py index 8ad2b4a..9ff73b5 100644 --- a/src/abacusagent/modules/submodules/structure_editor.py +++ b/src/abacusagent/modules/submodules/structure_editor.py @@ -1,7 +1,10 @@ +import os from pathlib import Path -from typing import Literal, Tuple +from typing import Literal, Tuple, Dict, Any from ase.build import surface, make_supercell from ase.io import read +from pymatgen.core import Structure +from pymatgen.symmetry.analyzer import SpacegroupAnalyzer from abacustest.constant import A2BOHR from abacustest.lib_prepare.stru import AbacusSTRU @@ -22,7 +25,7 @@ def build_slab(stru_file: Path, miller_indices (Tuple[int, int, int]): Miller indices of the surface. Defaults to (1, 0, 0), which means (100) surface of the structure. layers (int, optional): Number of layers of the surface. Note that the layers is number of equivalent layers, not number of layers of atoms. Defaults to 3. surface_supercell (Tuple[int, int], optional): Supercell size of the surface. Default is (1, 1), which means no supercell. - vacuum (float, optional): Vacuum space between the cleaved surface and its periodic image. The total vacuum size will be twice this value. Units in Angstrom. Defaults to 15.0. + vacuum (float, optional): Vacuum space between the cleaved surface and its periodic image. The total vacuum size will be twice this value. Units in Angstrom. Defaults to 15.0. vacuum_direction (Literal['a', 'b', 'c']): The direction of the vacuum space. Defaults to 'b'. Returns: A dictionary containing the path to the surface structure file. @@ -31,22 +34,28 @@ def build_slab(stru_file: Path, Raises: ValueError: If stru_type is not supported. """ - if stru_type == 'abacus/stru': + if stru_type == "abacus/stru": stru = AbacusSTRU.read(stru_file, fmt="stru") stru_ase = stru.to("ase") - elif stru_type in ['cif', 'poscar']: + elif stru_type in ["cif", "poscar"]: stru_ase = read(stru_file, format=stru_type) else: raise ValueError(f"Unsupported structure file type: {stru_type}") - - stru_surface = surface(stru_ase, miller_indices, layers, vacuum=vacuum/2, periodic=True) - stru_surface = make_supercell(stru_surface, [[surface_supercell[0], 0, 0], [0, surface_supercell[1], 0], [0, 0, 1]]) - stru_surface_abacusstru = AbacusSTRU.from_ase(stru_surface, metadata={ - "lattice_constant": A2BOHR, - "atom_type": "cartesian", - }) + + stru_surface = surface(stru_ase, miller_indices, layers, vacuum=vacuum / 2, periodic=True) + stru_surface = make_supercell( + stru_surface, + [[surface_supercell[0], 0, 0], [0, surface_supercell[1], 0], [0, 0, 1]], + ) + stru_surface_abacusstru = AbacusSTRU.from_ase( + stru_surface, + metadata={ + "lattice_constant": A2BOHR, + "atom_type": "cartesian", + }, + ) stru_surface_abacusstru.sort() - + # Permute axis to set vacuum direction along given axis. The vacuum direction create by ase.build.surface is always along z axis. if vacuum_direction == "a": stru_surface_abacusstru.permute_lat_vec(mode="cab", rotate_cart_coord=True) @@ -56,8 +65,224 @@ def build_slab(stru_file: Path, pass h, k, l = miller_indices - suffix = 'STRU' if stru_type == 'abacus/stru' else stru_type + suffix = "STRU" if stru_type == "abacus/stru" else stru_type surface_stru_file = Path(f"./{stru_file.stem}_{h}{k}{l}_{layers}layer.{suffix}").absolute() stru_surface_abacusstru.write(surface_stru_file, fmt=stru_type) - return {'surface_stru_file': surface_stru_file} + return {"surface_stru_file": surface_stru_file} + +def _read_structure( + stru_file: Path, stru_type: Literal["cif", "poscar", "abacus/stru"] +) -> Structure: + """ + Read a structure file and convert it to pymatgen Structure object. + + Args: + stru_file: Path to the structure file. + stru_type: Type of structure file ('cif', 'poscar', or 'abacus/stru'). + + Returns: + pymatgen Structure object. + + Raises: + FileNotFoundError: If the structure file does not exist. + ValueError: If the structure file type is not supported. + """ + if not os.path.isfile(stru_file): + raise FileNotFoundError(f"Structure file {stru_file} does not exist.") + + if stru_type == "abacus/stru": + stru = AbacusSTRU.read(stru_file, fmt="stru") + return Structure( + lattice=stru.cell, + species=stru.labels, + coords=stru.coords_direct, + coords_are_cartesian=False, + ) + elif stru_type == "cif": + return Structure.from_file(stru_file) + elif stru_type == "poscar": + from pymatgen.io.vasp import Poscar + + return Poscar.from_file(stru_file).structure + else: + raise ValueError(f"Unsupported structure file type: {stru_type}") + +def _write_structure( + structure: Structure, + output_file: Path, + output_format: Literal["cif", "poscar", "abacus/stru"], +) -> Path: + """ + Write a pymatgen Structure to file in the specified format. + + Args: + structure: pymatgen Structure object. + output_file: Path to the output file. + output_format: Format of the output file ('cif', 'poscar', or 'abacus/stru'). + + Returns: + Path to the output file. + """ + if output_format == "cif": + structure.to(filename=output_file, fmt="cif") + elif output_format == "poscar": + structure.to(filename=output_file, fmt="poscar") + elif output_format == "abacus/stru": + from pymatgen.io.ase import AseAtomsAdaptor + + ase_atoms = AseAtomsAdaptor.get_atoms(structure) + abacus_stru = AbacusSTRU.from_ase(ase_atoms, metadata={"lattice_constant": A2BOHR}) + abacus_stru.write(output_file, fmt="stru") + else: + raise ValueError(f"Unsupported output format: {output_format}") + + return output_file + +def convert_to_primitive( + stru_file: Path, + stru_type: Literal["cif", "poscar", "abacus/stru"] = "cif", + output_format: Literal["cif", "poscar", "abacus/stru"] = None, + tolerance: float = 0.25, +) -> Dict[str, Any]: + """ + Convert a crystal structure to its primitive cell. + + This function takes a crystal structure in CIF, POSCAR, or ABACUS STRU format + and converts it to its primitive cell using pymatgen's symmetry analysis. + + Args: + stru_file: Path to the input structure file. + stru_type: Type of the input structure file. Options are: + - 'cif': Crystallographic Information File format + - 'poscar': VASP POSCAR format + - 'abacus/stru': ABACUS STRU format + output_format: Format of the output file. If not specified, uses the same + format as the input. Options are: 'cif', 'poscar', 'abacus/stru'. + tolerance: Tolerance for symmetry detection in Angstroms. Default is 0.25. + Structures with atoms closer than this distance are considered symmetric. + + Returns: + A dictionary containing: + - 'output_file': Path to the generated primitive structure file. + - 'num_atoms': Number of atoms in the primitive cell. + - 'cell': Cell parameters of the primitive cell as a 3x3 list of lists. + - 'spacegroup': Space group symbol of the structure. + + Raises: + FileNotFoundError: If the input structure file does not exist. + ValueError: If the structure file type or output format is not supported. + + Examples: + >>> # Convert a CIF file to primitive cell in POSCAR format + >>> convert_to_primitive("Si.cif", stru_type="cif", output_format="poscar") + + >>> # Convert ABACUS STRU to primitive cell in CIF format + >>> convert_to_primitive("STRU", stru_type="abacus/stru", output_format="cif") + """ + try: + # Set default output format if not specified + if output_format is None: + output_format = stru_type + + # Read the structure + structure = _read_structure(stru_file, stru_type) + + # Get primitive structure + primitive_structure = structure.get_primitive_structure(tolerance=tolerance) + + # Get space group + sga = SpacegroupAnalyzer(primitive_structure, symprec=tolerance) + spacegroup = sga.get_space_group_symbol() + + # Generate output filename + input_path = Path(stru_file) + suffix_map = {"cif": ".cif", "poscar": ".vasp", "abacus/stru": ".stru"} + output_suffix = suffix_map.get(output_format, ".cif") + output_file = Path(f"{input_path.stem}_primitive{output_suffix}").absolute() + + # Write the primitive structure + _write_structure(primitive_structure, output_file, output_format) + + return { + "output_file": output_file, + "num_atoms": len(primitive_structure), + "cell": primitive_structure.lattice.matrix.tolist(), + "spacegroup": spacegroup, + } + except Exception as e: + return {"message": f"Converting to primitive cell failed: {e}"} + +def convert_to_conventional( + stru_file: Path, + stru_type: Literal["cif", "poscar", "abacus/stru"] = "cif", + output_format: Literal["cif", "poscar", "abacus/stru"] = None, + tolerance: float = 0.01, +) -> Dict[str, Any]: + """ + Convert a crystal structure to its conventional standard cell. + + This function takes a crystal structure in CIF, POSCAR, or ABACUS STRU format + and converts it to its conventional standard cell using pymatgen's SpacegroupAnalyzer. + The conventional cell follows the standard conventions for each space group, + ensuring proper cell orientation and lattice parameter assignment. + + Args: + stru_file: Path to the input structure file. + stru_type: Type of the input structure file. Options are: + - 'cif': Crystallographic Information File format + - 'poscar': VASP POSCAR format + - 'abacus/stru': ABACUS STRU format + output_format: Format of the output file. If not specified, uses the same + format as the input. Options are: 'cif', 'poscar', 'abacus/stru'. + tolerance: Tolerance for symmetry detection in Angstroms. Default is 0.01. + Lower values are more strict in detecting symmetry. + + Returns: + A dictionary containing: + - 'output_file': Path to the generated conventional structure file. + - 'num_atoms': Number of atoms in the conventional cell. + - 'cell': Cell parameters of the conventional cell as a 3x3 list of lists. + - 'spacegroup': Space group symbol of the structure. + + Raises: + FileNotFoundError: If the input structure file does not exist. + ValueError: If the structure file type or output format is not supported. + + Examples: + >>> # Convert a CIF file to conventional cell in POSCAR format + >>> convert_to_conventional("Si.cif", stru_type="cif", output_format="poscar") + + >>> # Convert ABACUS STRU to conventional cell in CIF format + >>> convert_to_conventional("STRU", stru_type="abacus/stru", output_format="cif") + """ + try: + # Set default output format if not specified + if output_format is None: + output_format = stru_type + + # Read the structure + structure = _read_structure(stru_file, stru_type) + + # Get conventional standard structure + sga = SpacegroupAnalyzer(structure, symprec=tolerance) + conventional_structure = sga.get_conventional_standard_structure() + spacegroup = sga.get_space_group_symbol() + + # Generate output filename + input_path = Path(stru_file) + suffix_map = {"cif": ".cif", "poscar": ".vasp", "abacus/stru": ".stru"} + output_suffix = suffix_map.get(output_format, ".cif") + output_file = Path(f"{input_path.stem}_conventional{output_suffix}").absolute() + + # Write the conventional structure + _write_structure(conventional_structure, output_file, output_format) + + return { + "output_file": output_file, + "num_atoms": len(conventional_structure), + "cell": conventional_structure.lattice.matrix.tolist(), + "spacegroup": spacegroup, + } + except Exception as e: + return {"message": f"Converting to conventional cell failed: {e}"} diff --git a/tests/test_structure_conversion.py b/tests/test_structure_conversion.py new file mode 100644 index 0000000..e441e72 --- /dev/null +++ b/tests/test_structure_conversion.py @@ -0,0 +1,255 @@ +import unittest +import os +import tempfile +import glob +from pathlib import Path + +os.environ["ABACUSAGENT_MODEL"] = "test" + +from abacusagent.modules.structure_editor import ( + convert_to_primitive, + convert_to_conventional, +) + + +class TestStructureConversion(unittest.TestCase): + """Tests for structure conversion functions with proper cleanup.""" + + def setUp(self): + """Create test structure files and set up temporary directory.""" + self.test_dir = tempfile.TemporaryDirectory() + self.test_path = Path(self.test_dir.name) + self.original_cwd = os.getcwd() + + # Change to test directory so output files are created there + os.chdir(self.test_path) + + # Create test structure files + from ase.build import bulk + from ase.io import write + + # FCC conventional cell (4 atoms) + fcc_conventional = bulk("Al", "fcc", a=4.05, cubic=True) + self.fcc_conventional_file = self.test_path / "Al_fcc_conventional.cif" + write(self.fcc_conventional_file, fcc_conventional, format="cif") + + # FCC primitive cell (1 atom) + fcc_primitive = bulk("Al", "fcc", a=4.05, cubic=False) + self.fcc_primitive_file = self.test_path / "Al_fcc_primitive.cif" + write(self.fcc_primitive_file, fcc_primitive, format="cif") + + # BCC conventional cell (2 atoms) + bcc_conventional = bulk("Fe", "bcc", a=2.87, cubic=True) + self.bcc_conventional_file = self.test_path / "Fe_bcc_conventional.cif" + write(self.bcc_conventional_file, bcc_conventional, format="cif") + + # BCC primitive cell (1 atom) + bcc_primitive = bulk("Fe", "bcc", a=2.87, cubic=False) + self.bcc_primitive_file = self.test_path / "Fe_bcc_primitive.cif" + write(self.bcc_primitive_file, bcc_primitive, format="cif") + + def tearDown(self): + """Clean up and return to original directory.""" + # Clean up any remaining generated files + for pattern in ["*_primitive*", "*_conventional*"]: + for filepath in glob.glob(pattern): + try: + os.remove(filepath) + except (OSError, PermissionError): + pass + + # Return to original directory + os.chdir(self.original_cwd) + + # Clean up temporary directory + self.test_dir.cleanup() + + def test_primitive_conversion(self): + """Test converting conventional cells to primitive cells.""" + test_cases = [ + ("FCC", self.fcc_conventional_file, 1, "Fm-3m"), + ("BCC", self.bcc_conventional_file, 1, "Im-3m"), + ] + + for name, input_file, expected_atoms, expected_spacegroup in test_cases: + with self.subTest(structure=name): + result = convert_to_primitive( + input_file, + stru_type="cif", + output_format="cif", + tolerance=0.25, + ) + + self.assertIn("output_file", result) + self.assertIn("num_atoms", result) + self.assertIn("cell", result) + self.assertIn("spacegroup", result) + + self.assertEqual(result["num_atoms"], expected_atoms) + self.assertTrue(os.path.exists(result["output_file"])) + self.assertEqual(result["spacegroup"], expected_spacegroup) + + # Check cell format + cell = result["cell"] + self.assertIsInstance(cell, list) + self.assertEqual(len(cell), 3) + for row in cell: + self.assertIsInstance(row, list) + self.assertEqual(len(row), 3) + + def test_conventional_conversion(self): + """Test converting primitive cells to conventional cells.""" + test_cases = [ + ("FCC", self.fcc_primitive_file, 4, "Fm-3m"), + ("BCC", self.bcc_primitive_file, 2, "Im-3m"), + ] + + for name, input_file, expected_atoms, expected_spacegroup in test_cases: + with self.subTest(structure=name): + result = convert_to_conventional( + input_file, + stru_type="cif", + output_format="cif", + tolerance=0.01, + ) + + self.assertIn("output_file", result) + self.assertIn("num_atoms", result) + self.assertIn("cell", result) + self.assertIn("spacegroup", result) + + self.assertEqual(result["num_atoms"], expected_atoms) + self.assertTrue(os.path.exists(result["output_file"])) + self.assertEqual(result["spacegroup"], expected_spacegroup) + + def test_round_trip_conversion(self): + """Test round-trip conversion: conventional -> primitive -> conventional.""" + test_cases = [ + ("FCC", self.fcc_conventional_file, 4), + ("BCC", self.bcc_conventional_file, 2), + ] + + for name, input_file, expected_atoms in test_cases: + with self.subTest(structure=name): + # Convert conventional to primitive + primitive_result = convert_to_primitive( + input_file, + stru_type="cif", + output_format="cif", + tolerance=0.25, + ) + + # Convert primitive back to conventional + conventional_result = convert_to_conventional( + primitive_result["output_file"], + stru_type="cif", + output_format="cif", + tolerance=0.01, + ) + + # Should get back to original number of atoms + self.assertEqual(conventional_result["num_atoms"], expected_atoms) + + # Space group should be consistent + self.assertEqual( + primitive_result["spacegroup"], conventional_result["spacegroup"] + ) + + def test_output_formats(self): + """Test conversion with different output formats.""" + output_formats = [ + ("cif", ".cif"), + ("poscar", ".vasp"), + ("abacus/stru", ".stru"), + ] + + for format_name, file_extension in output_formats: + with self.subTest(format=format_name): + # Test primitive conversion + result = convert_to_primitive( + self.fcc_conventional_file, + stru_type="cif", + output_format=format_name, + tolerance=0.25, + ) + + self.assertIn("output_file", result) + self.assertTrue(os.path.exists(result["output_file"])) + self.assertTrue(str(result["output_file"]).endswith(file_extension)) + + # Test conventional conversion + result = convert_to_conventional( + self.fcc_primitive_file, + stru_type="cif", + output_format=format_name, + tolerance=0.01, + ) + + self.assertIn("output_file", result) + self.assertTrue(os.path.exists(result["output_file"])) + self.assertTrue(str(result["output_file"]).endswith(file_extension)) + + def test_error_handling(self): + """Test error handling for non-existent files.""" + # Need to be in test directory for this test + non_existent = Path("non_existent_file.cif") + + # Test primitive conversion + result = convert_to_primitive( + non_existent, + stru_type="cif", + output_format="cif", + tolerance=0.25, + ) + self.assertIn("message", result) + self.assertIn("failed", result["message"].lower()) + + # Test conventional conversion + result = convert_to_conventional( + non_existent, + stru_type="cif", + output_format="cif", + tolerance=0.01, + ) + self.assertIn("message", result) + self.assertIn("failed", result["message"].lower()) + + def test_tolerance_parameter(self): + """Test that tolerance parameter affects symmetry detection.""" + # Test with different tolerance values + tolerances = [0.001, 0.25, 1.0] + + for tolerance in tolerances: + with self.subTest(tolerance=tolerance): + result = convert_to_primitive( + self.fcc_conventional_file, + stru_type="cif", + output_format="cif", + tolerance=tolerance, + ) + + self.assertIn("output_file", result) + self.assertIn("num_atoms", result) + self.assertIn("spacegroup", result) + + # Should succeed and give correct number of atoms + self.assertEqual(result["num_atoms"], 1) + self.assertEqual(result["spacegroup"], "Fm-3m") + + def test_same_input_output_format(self): + """Test conversion when input and output formats are the same.""" + # Test with explicit same format + result = convert_to_primitive( + self.fcc_conventional_file, + stru_type="cif", + output_format="cif", + tolerance=0.25, + ) + + self.assertIn("output_file", result) + self.assertTrue(os.path.exists(result["output_file"])) + self.assertTrue(str(result["output_file"]).endswith(".cif")) + + +if __name__ == "__main__": + unittest.main()