diff --git a/src/mattersim/cli/__init__.py b/src/mattersim/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mattersim/cli/applications/__init__.py b/src/mattersim/cli/applications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mattersim/cli/applications/phonon.py b/src/mattersim/cli/applications/phonon.py new file mode 100644 index 0000000..090cc81 --- /dev/null +++ b/src/mattersim/cli/applications/phonon.py @@ -0,0 +1,142 @@ +import os +import uuid +from collections import defaultdict +from typing import List + +import numpy as np +import pandas as pd +import yaml +from ase import Atoms +from loguru import logger +from pymatgen.core.structure import Structure +from pymatgen.io.ase import AseAtomsAdaptor +from tqdm import tqdm + +from mattersim.applications.phonon import PhononWorkflow +from mattersim.cli.applications.relax import relax + + +def phonon( + atoms_list: List[Atoms], + *, + find_prim: bool = False, + work_dir: str = str(uuid.uuid4()), + save_csv: str = "results.csv.gz", + amplitude: float = 0.01, + supercell_matrix: np.ndarray = None, + qpoints_mesh: np.ndarray = None, + max_atoms: int = None, + enable_relax: bool = False, + **kwargs, +) -> dict: + """ + Predict phonon properties for a list of atoms. + + Args: + atoms_list (List[Atoms]): List of ASE Atoms objects. + find_prim (bool, optional): If find the primitive cell and use it + to calculate phonon. Default to False. + work_dir (str, optional): workplace path to contain phonon result. + Defaults to data + chemical_symbols + 'phonon' + amplitude (float, optional): Magnitude of the finite difference to + displace in force constant calculation, in Angstrom. Defaults + to 0.01 Angstrom. + supercell_matrix (nd.array, optional): Supercell matrix for constr + -uct supercell, priority over than max_atoms. Defaults to None. + qpoints_mesh (nd.array, optional): Qpoint mesh for IBZ integral, + priority over than max_atoms. Defaults to None. + max_atoms (int, optional): Maximum atoms number limitation for the + supercell generation. If not set, will automatic generate super + -cell based on symmetry. Defaults to None. + enable_relax (bool, optional): Whether to relax the structure before + predicting phonon properties. Defaults to False. + """ + phonon_results = defaultdict(list) + + for atoms in tqdm( + atoms_list, total=len(atoms_list), desc="Predicting phonon properties" + ): + if enable_relax: + relaxed_results = relax( + [atoms], + constrain_symmetry=True, + work_dir=work_dir, + save_csv=save_csv.replace(".csv", "_relax.csv"), + ) + structure = Structure.from_str(relaxed_results["structure"][0], fmt="json") + _atoms = AseAtomsAdaptor.get_atoms(structure) + _atoms.calc = atoms.calc + atoms = _atoms + ph = PhononWorkflow( + atoms=atoms, + find_prim=find_prim, + work_dir=work_dir, + amplitude=amplitude, + supercell_matrix=supercell_matrix, + qpoints_mesh=qpoints_mesh, + max_atoms=max_atoms, + ) + has_imaginary, phonon = ph.run() + phonon_results["has_imaginary"].append(has_imaginary) + # phonon_results["phonon"].append(phonon) + phonon_results["phonon_band_plot"].append( + os.path.join(os.path.abspath(work_dir), f"{atoms.symbols}_phonon_band.png") + ) + phonon_results["phonon_dos_plot"].append( + os.path.join(os.path.abspath(work_dir), f"{atoms.symbols}_phonon_dos.png") + ) + os.rename( + os.path.join(os.path.abspath(work_dir), "band.yaml"), + os.path.join(os.path.abspath(work_dir), f"{atoms.symbols}_band.yaml"), + ) + os.rename( + os.path.join(os.path.abspath(work_dir), "phonopy_params.yaml"), + os.path.join( + os.path.abspath(work_dir), f"{atoms.symbols}_phonopy_params.yaml" + ), + ) + os.rename( + os.path.join(os.path.abspath(work_dir), "total_dos.dat"), + os.path.join(os.path.abspath(work_dir), f"{atoms.symbols}_total_dos.dat"), + ) + phonon_results["phonon_band"].append( + yaml.safe_load( + open( + os.path.join( + os.path.abspath(work_dir), f"{atoms.symbols}_band.yaml" + ), + "r", + ) + ) + ) + phonon_results["phonopy_params"].append( + yaml.safe_load( + open( + os.path.join( + os.path.abspath(work_dir), + f"{atoms.symbols}_phonopy_params.yaml", + ), + "r", + ) + ) + ) + phonon_results["total_dos"].append( + np.loadtxt( + os.path.join( + os.path.abspath(work_dir), f"{atoms.symbols}_total_dos.dat" + ), + comments="#", + ) + ) + + if not os.path.exists(work_dir): + os.makedirs(work_dir) + + logger.info(f"Saving the results to {os.path.join(work_dir, save_csv)}") + df = pd.DataFrame(phonon_results) + df.to_csv( + os.path.join(work_dir, save_csv.replace(".csv", "_phonon.csv")), + index=False, + mode="a", + ) + return phonon_results diff --git a/src/mattersim/cli/applications/relax.py b/src/mattersim/cli/applications/relax.py new file mode 100644 index 0000000..d477f45 --- /dev/null +++ b/src/mattersim/cli/applications/relax.py @@ -0,0 +1,98 @@ +import os +import uuid +from collections import defaultdict +from typing import List, Union + +import pandas as pd +from ase import Atoms +from ase.constraints import Filter +from ase.optimize.optimize import Optimizer +from ase.units import GPa +from loguru import logger +from pymatgen.io.ase import AseAtomsAdaptor +from tqdm import tqdm + +from mattersim.applications.relax import Relaxer + + +def relax( + atoms_list: List[Atoms], + *, + optimizer: Union[str, Optimizer] = "FIRE", + filter: Union[str, Filter, None] = None, + constrain_symmetry: bool = False, + fix_axis: Union[bool, List[bool]] = False, + pressure_in_GPa: float = None, + fmax: float = 0.01, + steps: int = 500, + work_dir: str = str(uuid.uuid4()), + save_csv: str = "results.csv.gz", + **kwargs, +) -> dict: + """ + Relax a list of atoms structures. + + Args: + atoms_list (List[Atoms]): List of ASE Atoms objects. + optimizer (Union[str, Optimizer]): The optimizer to use. Default is "FIRE". + filter (Union[str, Filter, None]): The filter to use. + constrain_symmetry (bool): Whether to constrain symmetry. Default is False. + fix_axis (Union[bool, List[bool]]): Whether to fix the axis. Default is False. + pressure_in_GPa (float): Pressure in GPa to use for relaxation. + fmax (float): Maximum force tolerance for relaxation. Default is 0.01. + steps (int): Maximum number of steps for relaxation. Default is 500. + work_dir (str): Working directory for the calculations. + Default is a UUID with timestamp. + save_csv (str): Save the results to a CSV file. Default is `results.csv.gz`. + + Returns: + pd.DataFrame: DataFrame containing the relaxed results. + """ + params_filter = {} + + if pressure_in_GPa: + params_filter["scalar_pressure"] = ( + pressure_in_GPa * GPa + ) # convert GPa to eV/Angstrom^3 + filter = "ExpCellFilter" if filter is None else filter + elif filter: + params_filter["scalar_pressure"] = 0.0 + + relaxer = Relaxer( + optimizer=optimizer, + filter=filter, + constrain_symmetry=constrain_symmetry, + fix_axis=fix_axis, + ) + + relaxed_results = defaultdict(list) + for atoms in tqdm(atoms_list, total=len(atoms_list), desc="Relaxing structures"): + converged, relaxed_atoms = relaxer.relax( + atoms, + params_filter=params_filter, + fmax=fmax, + steps=steps, + ) + relaxed_results["converged"].append(converged) + relaxed_results["structure"].append( + AseAtomsAdaptor.get_structure(relaxed_atoms).to_json() + ) + relaxed_results["energy"].append(relaxed_atoms.get_potential_energy()) + relaxed_results["energy_per_atom"].append( + relaxed_atoms.get_potential_energy() / len(relaxed_atoms) + ) + relaxed_results["forces"].append(relaxed_atoms.get_forces()) + relaxed_results["stress"].append(relaxed_atoms.get_stress(voigt=False)) + relaxed_results["stress_GPa"].append( + relaxed_atoms.get_stress(voigt=False) / GPa + ) + + logger.info(f"Relaxed structure: {relaxed_atoms}") + + if not os.path.exists(work_dir): + os.makedirs(work_dir) + + logger.info(f"Saving the results to {os.path.join(work_dir, save_csv)}") + df = pd.DataFrame(relaxed_results) + df.to_csv(os.path.join(work_dir, save_csv), index=False, mode="a") + return relaxed_results diff --git a/src/mattersim/cli/applications/singlepoint.py b/src/mattersim/cli/applications/singlepoint.py new file mode 100644 index 0000000..9c0153f --- /dev/null +++ b/src/mattersim/cli/applications/singlepoint.py @@ -0,0 +1,46 @@ +import os +import uuid +from collections import defaultdict +from typing import List + +import pandas as pd +from ase import Atoms +from ase.units import GPa +from loguru import logger +from pymatgen.io.ase import AseAtomsAdaptor +from tqdm import tqdm + + +def singlepoint( + atoms_list: List[Atoms], + *, + work_dir: str = str(uuid.uuid4()), + save_csv: str = "results.csv.gz", + **kwargs, +) -> dict: + """ + Predict single point properties for a list of atoms. + + """ + logger.info("Predicting single point properties.") + predicted_properties = defaultdict(list) + for atoms in tqdm( + atoms_list, total=len(atoms_list), desc="Predicting single point properties" + ): + predicted_properties["structure"].append(AseAtomsAdaptor.get_structure(atoms)) + predicted_properties["energy"].append(atoms.get_potential_energy()) + predicted_properties["energy_per_atom"].append( + atoms.get_potential_energy() / len(atoms) + ) + predicted_properties["forces"].append(atoms.get_forces()) + predicted_properties["stress"].append(atoms.get_stress(voigt=False)) + predicted_properties["stress_GPa"].append(atoms.get_stress(voigt=False) / GPa) + + if not os.path.exists(work_dir): + os.makedirs(work_dir) + + logger.info(f"Saving the results to {os.path.join(work_dir, save_csv)}") + + df = pd.DataFrame(predicted_properties) + df.to_csv(os.path.join(work_dir, save_csv), index=False, mode="a") + return predicted_properties diff --git a/src/mattersim/cli/mattersim_app.py b/src/mattersim/cli/mattersim_app.py index 56ced86..a026467 100644 --- a/src/mattersim/cli/mattersim_app.py +++ b/src/mattersim/cli/mattersim_app.py @@ -1,64 +1,18 @@ import argparse -import os import uuid from collections import defaultdict from datetime import datetime from typing import List, Union -import numpy as np -import pandas as pd -import yaml from ase import Atoms -from ase.constraints import Filter from ase.io import read as ase_read -from ase.optimize.optimize import Optimizer -from ase.units import GPa from loguru import logger -from pymatgen.core.structure import Structure -from pymatgen.io.ase import AseAtomsAdaptor -from tqdm import tqdm -from mattersim.applications.phonon import PhononWorkflow -from mattersim.applications.relax import Relaxer +from mattersim.cli.applications.phonon import phonon +from mattersim.cli.applications.relax import relax +from mattersim.cli.applications.singlepoint import singlepoint from mattersim.forcefield import MatterSimCalculator -__all__ = ["singlepoint", "phonon", "relax", "moldyn"] - - -def singlepoint( - atoms_list: List[Atoms], - *, - work_dir: str = str(uuid.uuid4()), - save_csv: str = "results.csv.gz", - **kwargs, -) -> dict: - """ - Predict single point properties for a list of atoms. - - """ - logger.info("Predicting single point properties.") - predicted_properties = defaultdict(list) - for atoms in tqdm( - atoms_list, total=len(atoms_list), desc="Predicting single point properties" - ): - predicted_properties["structure"].append(AseAtomsAdaptor.get_structure(atoms)) - predicted_properties["energy"].append(atoms.get_potential_energy()) - predicted_properties["energy_per_atom"].append( - atoms.get_potential_energy() / len(atoms) - ) - predicted_properties["forces"].append(atoms.get_forces()) - predicted_properties["stress"].append(atoms.get_stress(voigt=False)) - predicted_properties["stress_GPa"].append(atoms.get_stress(voigt=False) / GPa) - - if not os.path.exists(work_dir): - os.makedirs(work_dir) - - logger.info(f"Saving the results to {os.path.join(work_dir, save_csv)}") - - df = pd.DataFrame(predicted_properties) - df.to_csv(os.path.join(work_dir, save_csv), index=False, mode="a") - return predicted_properties - def singlepoint_cli(args: argparse.Namespace) -> dict: """ @@ -82,259 +36,103 @@ def singlepoint_cli(args: argparse.Namespace) -> dict: return singlepoint(atoms_list, **singlepoint_args) -def relax( - atoms_list: List[Atoms], - *, - optimizer: Union[str, Optimizer] = "FIRE", - filter: Union[str, Filter, None] = None, - constrain_symmetry: bool = False, - fix_axis: Union[bool, List[bool]] = False, - pressure_in_GPa: float = None, - fmax: float = 0.01, - steps: int = 500, - work_dir: str = str(uuid.uuid4()), - save_csv: str = "results.csv.gz", - **kwargs, -) -> dict: +def relax_cli(args: argparse.Namespace) -> dict: """ - Relax a list of atoms structures. + CLI wrapper for relax function. Args: - atoms_list (List[Atoms]): List of ASE Atoms objects. - optimizer (Union[str, Optimizer]): The optimizer to use. Default is "FIRE". - filter (Union[str, Filter, None]): The filter to use. - constrain_symmetry (bool): Whether to constrain symmetry. Default is False. - fix_axis (Union[bool, List[bool]]): Whether to fix the axis. Default is False. - pressure_in_GPa (float): Pressure in GPa to use for relaxation. - fmax (float): Maximum force tolerance for relaxation. Default is 0.01. - steps (int): Maximum number of steps for relaxation. Default is 500. - work_dir (str): Working directory for the calculations. - Default is a UUID with timestamp. - save_csv (str): Save the results to a CSV file. Default is `results.csv.gz`. + args (argparse.Namespace): Command line arguments. Returns: - pd.DataFrame: DataFrame containing the relaxed results. + dict: Dictionary containing the relaxed results """ - params_filter = {} - - if pressure_in_GPa: - params_filter["scalar_pressure"] = ( - pressure_in_GPa * GPa - ) # convert GPa to eV/Angstrom^3 - filter = "ExpCellFilter" if filter is None else filter - elif filter: - params_filter["scalar_pressure"] = 0.0 - - relaxer = Relaxer( - optimizer=optimizer, - filter=filter, - constrain_symmetry=constrain_symmetry, - fix_axis=fix_axis, - ) - - relaxed_results = defaultdict(list) - for atoms in tqdm(atoms_list, total=len(atoms_list), desc="Relaxing structures"): - converged, relaxed_atoms = relaxer.relax( - atoms, - params_filter=params_filter, - fmax=fmax, - steps=steps, - ) - relaxed_results["converged"].append(converged) - relaxed_results["structure"].append( - AseAtomsAdaptor.get_structure(relaxed_atoms).to_json() - ) - relaxed_results["energy"].append(relaxed_atoms.get_potential_energy()) - relaxed_results["energy_per_atom"].append( - relaxed_atoms.get_potential_energy() / len(relaxed_atoms) - ) - relaxed_results["forces"].append(relaxed_atoms.get_forces()) - relaxed_results["stress"].append(relaxed_atoms.get_stress(voigt=False)) - relaxed_results["stress_GPa"].append( - relaxed_atoms.get_stress(voigt=False) / GPa - ) - - logger.info(f"Relaxed structure: {relaxed_atoms}") - - if not os.path.exists(work_dir): - os.makedirs(work_dir) - - logger.info(f"Saving the results to {os.path.join(work_dir, save_csv)}") - df = pd.DataFrame(relaxed_results) - df.to_csv(os.path.join(work_dir, save_csv), index=False, mode="a") - return relaxed_results + atoms_list = parse_atoms_list( + args.structure_file, args.mattersim_model, args.device + ) + relax_args = { + k: v + for k, v in vars(args).items() + if k not in ["structure_file", "mattersim_model", "device"] + } + return relax(atoms_list, **relax_args) -def relax_cli(args: argparse.Namespace) -> dict: +def phonon_cli(args: argparse.Namespace) -> dict: """ - CLI wrapper for relax function. + CLI wrapper for phonon function. Args: args (argparse.Namespace): Command line arguments. Returns: - dict: Dictionary containing the relaxed results + dict: Dictionary containing the phonon properties. """ atoms_list = parse_atoms_list( args.structure_file, args.mattersim_model, args.device ) - relax_args = { + phonon_args = { k: v for k, v in vars(args).items() if k not in ["structure_file", "mattersim_model", "device"] } - return relax(atoms_list, **relax_args) + return phonon(atoms_list, **phonon_args) -def phonon( +def moldyn( atoms_list: List[Atoms], *, - find_prim: bool = False, + temperature: float = 300, + timestep: float = 1, + steps: int = 1000, + ensemble: str = "nvt_nose_hoover", + logfile: str = "-", + loginterval: int = 10, + trajectory: str = None, + taut: float = None, work_dir: str = str(uuid.uuid4()), save_csv: str = "results.csv.gz", - amplitude: float = 0.01, - supercell_matrix: np.ndarray = None, - qpoints_mesh: np.ndarray = None, - max_atoms: int = None, - enable_relax: bool = False, **kwargs, ) -> dict: - """ - Predict phonon properties for a list of atoms. + moldyn_results = defaultdict(list) - Args: - atoms_list (List[Atoms]): List of ASE Atoms objects. - find_prim (bool, optional): If find the primitive cell and use it - to calculate phonon. Default to False. - work_dir (str, optional): workplace path to contain phonon result. - Defaults to data + chemical_symbols + 'phonon' - amplitude (float, optional): Magnitude of the finite difference to - displace in force constant calculation, in Angstrom. Defaults - to 0.01 Angstrom. - supercell_matrix (nd.array, optional): Supercell matrix for constr - -uct supercell, priority over than max_atoms. Defaults to None. - qpoints_mesh (nd.array, optional): Qpoint mesh for IBZ integral, - priority over than max_atoms. Defaults to None. - max_atoms (int, optional): Maximum atoms number limitation for the - supercell generation. If not set, will automatic generate super - -cell based on symmetry. Defaults to None. - enable_relax (bool, optional): Whether to relax the structure before - predicting phonon properties. Defaults to False. - """ - phonon_results = defaultdict(list) - - for atoms in tqdm( - atoms_list, total=len(atoms_list), desc="Predicting phonon properties" - ): - if enable_relax: - relaxed_results = relax( - [atoms], - constrain_symmetry=True, - work_dir=work_dir, - save_csv=save_csv.replace(".csv", "_relax.csv"), - ) - structure = Structure.from_str(relaxed_results["structure"][0], fmt="json") - _atoms = AseAtomsAdaptor.get_atoms(structure) - _atoms.calc = atoms.calc - atoms = _atoms - ph = PhononWorkflow( - atoms=atoms, - find_prim=find_prim, - work_dir=work_dir, - amplitude=amplitude, - supercell_matrix=supercell_matrix, - qpoints_mesh=qpoints_mesh, - max_atoms=max_atoms, - ) - has_imaginary, phonon = ph.run() - phonon_results["has_imaginary"].append(has_imaginary) - # phonon_results["phonon"].append(phonon) - phonon_results["phonon_band_plot"].append( - os.path.join(os.path.abspath(work_dir), f"{atoms.symbols}_phonon_band.png") - ) - phonon_results["phonon_dos_plot"].append( - os.path.join(os.path.abspath(work_dir), f"{atoms.symbols}_phonon_dos.png") - ) - os.rename( - os.path.join(os.path.abspath(work_dir), "band.yaml"), - os.path.join(os.path.abspath(work_dir), f"{atoms.symbols}_band.yaml"), - ) - os.rename( - os.path.join(os.path.abspath(work_dir), "phonopy_params.yaml"), - os.path.join( - os.path.abspath(work_dir), f"{atoms.symbols}_phonopy_params.yaml" - ), - ) - os.rename( - os.path.join(os.path.abspath(work_dir), "total_dos.dat"), - os.path.join(os.path.abspath(work_dir), f"{atoms.symbols}_total_dos.dat"), - ) - phonon_results["phonon_band"].append( - yaml.safe_load( - open( - os.path.join( - os.path.abspath(work_dir), f"{atoms.symbols}_band.yaml" - ), - "r", - ) - ) - ) - phonon_results["phonopy_params"].append( - yaml.safe_load( - open( - os.path.join( - os.path.abspath(work_dir), - f"{atoms.symbols}_phonopy_params.yaml", - ), - "r", - ) - ) - ) - phonon_results["total_dos"].append( - np.loadtxt( - os.path.join( - os.path.abspath(work_dir), f"{atoms.symbols}_total_dos.dat" - ), - comments="#", - ) - ) - - if not os.path.exists(work_dir): - os.makedirs(work_dir) - - logger.info(f"Saving the results to {os.path.join(work_dir, save_csv)}") - df = pd.DataFrame(phonon_results) - df.to_csv( - os.path.join(work_dir, save_csv.replace(".csv", "_phonon.csv")), - index=False, - mode="a", - ) - return phonon_results + # for atoms in atoms_list: + # md = MolecularDynamics( + # atoms, + # ensemble=ensemble, + # temperature=temperature, + # timestep=timestep, + # logfile=logfile, + # loginterval=loginterval, + # trajectory=trajectory, + # taut=taut + # ) + # md.run(steps) + return moldyn_results -def phonon_cli(args: argparse.Namespace) -> dict: + +def moldyn_cli(args: argparse.Namespace) -> dict: """ - CLI wrapper for phonon function. + CLI wrapper for moldyn function. Args: args (argparse.Namespace): Command line arguments. Returns: - dict: Dictionary containing the phonon properties. + dict: Dictionary containing the molecular dynamics properties. """ atoms_list = parse_atoms_list( args.structure_file, args.mattersim_model, args.device ) - phonon_args = { + if len(atoms_list) > 1: + logger.error("Molecular dynamics may take too long for multiple structures.") + + moldyn_args = { k: v for k, v in vars(args).items() if k not in ["structure_file", "mattersim_model", "device"] } - return phonon(atoms_list, **phonon_args) - - -def moldyn(): - pass + return moldyn(atoms_list, **moldyn_args) def add_common_args(parser: argparse.ArgumentParser): @@ -374,7 +172,7 @@ def add_common_args(parser: argparse.ArgumentParser): ) -def add_relax_common_args(parser: argparse.ArgumentParser): +def add_relax_args(parser: argparse.ArgumentParser): parser.add_argument( "--optimizer", type=str, @@ -419,7 +217,7 @@ def add_relax_common_args(parser: argparse.ArgumentParser): ) -def add_phonon_common_args(parser: argparse.ArgumentParser): +def add_phonon_args(parser: argparse.ArgumentParser): parser.add_argument( "--find-prim", action="store_true", @@ -459,6 +257,52 @@ def add_phonon_common_args(parser: argparse.ArgumentParser): ) +def add_moldyn_args(parser: argparse.ArgumentParser): + parser.add_argument( + "--temperature", + type=float, + default=300, + help="Temperature in Kelvin.", + ) + parser.add_argument( + "--timestep", + type=float, + default=1, + help="Timestep in femtoseconds.", + ) + parser.add_argument( + "--steps", + type=int, + default=1000, + help="Number of steps for the molecular dynamics simulation.", + ) + parser.add_argument( + "--ensemble", + type=str, + choices=["nvt_berendsen", "nvt_nose_hoover"], + default="nvt_nose_hoover", + help="Simulation ensemble to use.", + ) + parser.add_argument( + "--logfile", + type=str, + default="-", + help="Logfile to write the output to. Default is stdout.", + ) + parser.add_argument( + "--loginterval", + type=int, + default=10, + help="Log interval for writing the output.", + ) + parser.add_argument( + "--trajectory", + type=str, + default=None, + help="Path to the trajectory file.", + ) + + def parse_atoms_list( structure_file_list: Union[str, List[str]], mattersim_model: str, @@ -496,7 +340,7 @@ def main(): "relax", help="Relax a list of atoms structures." ) add_common_args(relax_parser) - add_relax_common_args(relax_parser) + add_relax_args(relax_parser) relax_parser.set_defaults(func=relax_cli) # Sub-command for phonon @@ -505,10 +349,19 @@ def main(): help="Predict phonon properties for a list of structures.", ) add_common_args(phonon_parser) - add_relax_common_args(phonon_parser) - add_phonon_common_args(phonon_parser) + add_relax_args(phonon_parser) + add_phonon_args(phonon_parser) phonon_parser.set_defaults(func=phonon_cli) + # Sub-command for molecular dynamics + moldyn_parser = subparsers.add_parser( + "moldyn", + help="Perform molecular dynamics simulation for a list of structures.", + ) + add_common_args(moldyn_parser) + add_moldyn_args(moldyn_parser) + moldyn_parser.set_defaults(func=moldyn_cli) + # Parse arguments args = argparser.parse_args() print(args)