Skip to content

Commit 79e48a1

Browse files
feat: add cli scripts to quickly run a calculation (#69)
* add a script to quickly make single-point prediction * add a script to quickly make single-point prediction * add cli script for structure relaxation * remove fix encoding hook as it is not necessary in python 3 * add comments * mark the class method relax_structures as deprecated * add examples structures for testing * add init * add cli script to compute phonons * fixed results * remove scripts in cli folder * add CLI entrypoint to mattersim applications * wrap up function * rename * add relax subcommand * removed unused arguments * fixed the return type of relax * update phonon * reorganize the cli codes * add command line for molecular dynamics --------- Co-authored-by: yanghan-microsoft <[email protected]>
1 parent 8b7ffb6 commit 79e48a1

12 files changed

+837
-3
lines changed

.pre-commit-config.yaml

+1-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ repos:
33
rev: v4.2.0
44
hooks:
55
- id: end-of-file-fixer
6-
- id: fix-encoding-pragma
76
- id: mixed-line-ending
87
- id: trailing-whitespace
98
- id: check-json
@@ -24,4 +23,4 @@ repos:
2423
rev: 6.0.0
2524
hooks:
2625
- id: flake8
27-
args: ["--max-line-length=88", "--ignore=E203,W503"]
26+
args: ["--max-line-length=88", "--ignore=E203,W503"]

src/mattersim/applications/relax.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from ase.optimize import BFGS, FIRE
99
from ase.optimize.optimize import Optimizer
1010
from ase.units import GPa
11+
from deprecated import deprecated
1112

1213

1314
class Relaxer(object):
@@ -55,7 +56,7 @@ def relax(
5556
fmax: float = 0.01,
5657
params_filter: dict = {},
5758
**kwargs,
58-
) -> Atoms:
59+
) -> Tuple[bool, Atoms]:
5960
"""
6061
Relax the atoms object.
6162
@@ -108,6 +109,7 @@ def relax(
108109
return converged, atoms
109110

110111
@classmethod
112+
@deprecated(reason="Use cli/applications/relax_structure.py instead.")
111113
def relax_structures(
112114
cls,
113115
atoms: Union[Atoms, Iterable[Atoms]],

src/mattersim/cli/__init__.py

Whitespace-only changes.

src/mattersim/cli/applications/__init__.py

Whitespace-only changes.
+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import os
2+
import re
3+
import uuid
4+
from collections import defaultdict
5+
from typing import List
6+
7+
import pandas as pd
8+
from ase import Atoms
9+
from ase.io import read
10+
from loguru import logger
11+
from pymatgen.io.ase import AseAtomsAdaptor
12+
13+
from mattersim.applications.moldyn import MolecularDynamics
14+
15+
16+
def moldyn(
17+
atoms_list: List[Atoms],
18+
*,
19+
temperature: float = 300,
20+
timestep: float = 1,
21+
steps: int = 1000,
22+
ensemble: str = "nvt_nose_hoover",
23+
logfile: str = "-",
24+
loginterval: int = 10,
25+
trajectory: str = None,
26+
taut: float = None,
27+
work_dir: str = str(uuid.uuid4()),
28+
save_csv: str = "results.csv.gz",
29+
**kwargs,
30+
) -> dict:
31+
moldyn_results = defaultdict(list)
32+
33+
for atoms in atoms_list:
34+
# check if the atoms object has non-zero values in the lower triangle
35+
# of the cell. If so, the cell will be rotated and permuted to upper
36+
# triangular form. This is to avoid numerical issues in the MD simulation.
37+
print(atoms.cell.array)
38+
if any(atoms.cell.array[2, 0:2]) or atoms.cell.array[1, 0] != 0:
39+
logger.warning(
40+
"The lower triangle of the cell is not zero. "
41+
"The cell will be rotated and permuted to upper triangular form."
42+
)
43+
44+
# The following code is from the PR
45+
# https://gitlab.com/ase/ase/-/merge_requests/3277.
46+
# It will be removed once the PR is merged.
47+
# This part of the codes rotates the cell and permutes the axes
48+
# such that the cell will be in upper triangular form.
49+
50+
from ase.build import make_supercell
51+
52+
_calc = atoms.calc
53+
logger.info(f"Initial cell: {atoms.cell.array}")
54+
55+
atoms.set_cell(atoms.cell.standard_form()[0], scale_atoms=True)
56+
57+
# Permute a and c axes
58+
atoms = make_supercell(atoms, [[0, 0, 1], [0, 1, 0], [1, 0, 0]])
59+
60+
atoms.rotate(90, "y", rotate_cell=True)
61+
62+
# set the lower triangle of the cell to be exactly zero
63+
# to avoid numerical issues
64+
atoms.cell.array[1, 0] = 0
65+
atoms.cell.array[2, 0] = 0
66+
atoms.cell.array[2, 1] = 0
67+
68+
logger.info(f"Cell after rotation/permutation: {atoms.cell.array}")
69+
atoms.calc = _calc
70+
71+
if not os.path.exists(work_dir):
72+
os.makedirs(work_dir)
73+
74+
md = MolecularDynamics(
75+
atoms,
76+
ensemble=ensemble,
77+
temperature=temperature,
78+
timestep=timestep,
79+
loginterval=loginterval,
80+
logfile=os.path.join(work_dir, logfile),
81+
trajectory=os.path.join(work_dir, trajectory),
82+
taut=taut,
83+
)
84+
md.run(steps)
85+
86+
# parse the logfile
87+
88+
# Read the file into a pandas DataFrame
89+
df = pd.read_csv(
90+
os.path.join(work_dir, logfile),
91+
sep="\\s+",
92+
names=["time", "temperature", "energy", "pressure"],
93+
skipfooter=1,
94+
)
95+
df.columns = list(
96+
map(lambda x: re.sub(r"\[.*?\]", "", x).strip().lower(), df.columns)
97+
)
98+
traj = read(os.path.join(work_dir, trajectory), index=":")
99+
print(df.shape)
100+
print(len(traj))
101+
structure_list = [AseAtomsAdaptor.get_structure(atoms) for atoms in traj]
102+
103+
# Add the structure list to the DataFrame
104+
df["structure"] = [structure.to_json() for structure in structure_list]
105+
106+
# Print the DataFrame
107+
print(df)
108+
109+
# Save the DataFrame to a CSV file
110+
df.to_csv(os.path.join(work_dir, save_csv))
111+
112+
moldyn_results = df.to_dict()
113+
114+
return moldyn_results
+142
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import os
2+
import uuid
3+
from collections import defaultdict
4+
from typing import List
5+
6+
import numpy as np
7+
import pandas as pd
8+
import yaml
9+
from ase import Atoms
10+
from loguru import logger
11+
from pymatgen.core.structure import Structure
12+
from pymatgen.io.ase import AseAtomsAdaptor
13+
from tqdm import tqdm
14+
15+
from mattersim.applications.phonon import PhononWorkflow
16+
from mattersim.cli.applications.relax import relax
17+
18+
19+
def phonon(
20+
atoms_list: List[Atoms],
21+
*,
22+
find_prim: bool = False,
23+
work_dir: str = str(uuid.uuid4()),
24+
save_csv: str = "results.csv.gz",
25+
amplitude: float = 0.01,
26+
supercell_matrix: np.ndarray = None,
27+
qpoints_mesh: np.ndarray = None,
28+
max_atoms: int = None,
29+
enable_relax: bool = False,
30+
**kwargs,
31+
) -> dict:
32+
"""
33+
Predict phonon properties for a list of atoms.
34+
35+
Args:
36+
atoms_list (List[Atoms]): List of ASE Atoms objects.
37+
find_prim (bool, optional): If find the primitive cell and use it
38+
to calculate phonon. Default to False.
39+
work_dir (str, optional): workplace path to contain phonon result.
40+
Defaults to data + chemical_symbols + 'phonon'
41+
amplitude (float, optional): Magnitude of the finite difference to
42+
displace in force constant calculation, in Angstrom. Defaults
43+
to 0.01 Angstrom.
44+
supercell_matrix (nd.array, optional): Supercell matrix for constr
45+
-uct supercell, priority over than max_atoms. Defaults to None.
46+
qpoints_mesh (nd.array, optional): Qpoint mesh for IBZ integral,
47+
priority over than max_atoms. Defaults to None.
48+
max_atoms (int, optional): Maximum atoms number limitation for the
49+
supercell generation. If not set, will automatic generate super
50+
-cell based on symmetry. Defaults to None.
51+
enable_relax (bool, optional): Whether to relax the structure before
52+
predicting phonon properties. Defaults to False.
53+
"""
54+
phonon_results = defaultdict(list)
55+
56+
for atoms in tqdm(
57+
atoms_list, total=len(atoms_list), desc="Predicting phonon properties"
58+
):
59+
if enable_relax:
60+
relaxed_results = relax(
61+
[atoms],
62+
constrain_symmetry=True,
63+
work_dir=work_dir,
64+
save_csv=save_csv.replace(".csv", "_relax.csv"),
65+
)
66+
structure = Structure.from_str(relaxed_results["structure"][0], fmt="json")
67+
_atoms = AseAtomsAdaptor.get_atoms(structure)
68+
_atoms.calc = atoms.calc
69+
atoms = _atoms
70+
ph = PhononWorkflow(
71+
atoms=atoms,
72+
find_prim=find_prim,
73+
work_dir=work_dir,
74+
amplitude=amplitude,
75+
supercell_matrix=supercell_matrix,
76+
qpoints_mesh=qpoints_mesh,
77+
max_atoms=max_atoms,
78+
)
79+
has_imaginary, phonon = ph.run()
80+
phonon_results["has_imaginary"].append(has_imaginary)
81+
# phonon_results["phonon"].append(phonon)
82+
phonon_results["phonon_band_plot"].append(
83+
os.path.join(os.path.abspath(work_dir), f"{atoms.symbols}_phonon_band.png")
84+
)
85+
phonon_results["phonon_dos_plot"].append(
86+
os.path.join(os.path.abspath(work_dir), f"{atoms.symbols}_phonon_dos.png")
87+
)
88+
os.rename(
89+
os.path.join(os.path.abspath(work_dir), "band.yaml"),
90+
os.path.join(os.path.abspath(work_dir), f"{atoms.symbols}_band.yaml"),
91+
)
92+
os.rename(
93+
os.path.join(os.path.abspath(work_dir), "phonopy_params.yaml"),
94+
os.path.join(
95+
os.path.abspath(work_dir), f"{atoms.symbols}_phonopy_params.yaml"
96+
),
97+
)
98+
os.rename(
99+
os.path.join(os.path.abspath(work_dir), "total_dos.dat"),
100+
os.path.join(os.path.abspath(work_dir), f"{atoms.symbols}_total_dos.dat"),
101+
)
102+
phonon_results["phonon_band"].append(
103+
yaml.safe_load(
104+
open(
105+
os.path.join(
106+
os.path.abspath(work_dir), f"{atoms.symbols}_band.yaml"
107+
),
108+
"r",
109+
)
110+
)
111+
)
112+
phonon_results["phonopy_params"].append(
113+
yaml.safe_load(
114+
open(
115+
os.path.join(
116+
os.path.abspath(work_dir),
117+
f"{atoms.symbols}_phonopy_params.yaml",
118+
),
119+
"r",
120+
)
121+
)
122+
)
123+
phonon_results["total_dos"].append(
124+
np.loadtxt(
125+
os.path.join(
126+
os.path.abspath(work_dir), f"{atoms.symbols}_total_dos.dat"
127+
),
128+
comments="#",
129+
)
130+
)
131+
132+
if not os.path.exists(work_dir):
133+
os.makedirs(work_dir)
134+
135+
logger.info(f"Saving the results to {os.path.join(work_dir, save_csv)}")
136+
df = pd.DataFrame(phonon_results)
137+
df.to_csv(
138+
os.path.join(work_dir, save_csv.replace(".csv", "_phonon.csv")),
139+
index=False,
140+
mode="a",
141+
)
142+
return phonon_results
+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import os
2+
import uuid
3+
from collections import defaultdict
4+
from typing import List, Union
5+
6+
import pandas as pd
7+
from ase import Atoms
8+
from ase.constraints import Filter
9+
from ase.optimize.optimize import Optimizer
10+
from ase.units import GPa
11+
from loguru import logger
12+
from pymatgen.io.ase import AseAtomsAdaptor
13+
from tqdm import tqdm
14+
15+
from mattersim.applications.relax import Relaxer
16+
17+
18+
def relax(
19+
atoms_list: List[Atoms],
20+
*,
21+
optimizer: Union[str, Optimizer] = "FIRE",
22+
filter: Union[str, Filter, None] = None,
23+
constrain_symmetry: bool = False,
24+
fix_axis: Union[bool, List[bool]] = False,
25+
pressure_in_GPa: float = None,
26+
fmax: float = 0.01,
27+
steps: int = 500,
28+
work_dir: str = str(uuid.uuid4()),
29+
save_csv: str = "results.csv.gz",
30+
**kwargs,
31+
) -> dict:
32+
"""
33+
Relax a list of atoms structures.
34+
35+
Args:
36+
atoms_list (List[Atoms]): List of ASE Atoms objects.
37+
optimizer (Union[str, Optimizer]): The optimizer to use. Default is "FIRE".
38+
filter (Union[str, Filter, None]): The filter to use.
39+
constrain_symmetry (bool): Whether to constrain symmetry. Default is False.
40+
fix_axis (Union[bool, List[bool]]): Whether to fix the axis. Default is False.
41+
pressure_in_GPa (float): Pressure in GPa to use for relaxation.
42+
fmax (float): Maximum force tolerance for relaxation. Default is 0.01.
43+
steps (int): Maximum number of steps for relaxation. Default is 500.
44+
work_dir (str): Working directory for the calculations.
45+
Default is a UUID with timestamp.
46+
save_csv (str): Save the results to a CSV file. Default is `results.csv.gz`.
47+
48+
Returns:
49+
pd.DataFrame: DataFrame containing the relaxed results.
50+
"""
51+
params_filter = {}
52+
53+
if pressure_in_GPa:
54+
params_filter["scalar_pressure"] = (
55+
pressure_in_GPa * GPa
56+
) # convert GPa to eV/Angstrom^3
57+
filter = "ExpCellFilter" if filter is None else filter
58+
elif filter:
59+
params_filter["scalar_pressure"] = 0.0
60+
61+
relaxer = Relaxer(
62+
optimizer=optimizer,
63+
filter=filter,
64+
constrain_symmetry=constrain_symmetry,
65+
fix_axis=fix_axis,
66+
)
67+
68+
relaxed_results = defaultdict(list)
69+
for atoms in tqdm(atoms_list, total=len(atoms_list), desc="Relaxing structures"):
70+
converged, relaxed_atoms = relaxer.relax(
71+
atoms,
72+
params_filter=params_filter,
73+
fmax=fmax,
74+
steps=steps,
75+
)
76+
relaxed_results["converged"].append(converged)
77+
relaxed_results["structure"].append(
78+
AseAtomsAdaptor.get_structure(relaxed_atoms).to_json()
79+
)
80+
relaxed_results["energy"].append(relaxed_atoms.get_potential_energy())
81+
relaxed_results["energy_per_atom"].append(
82+
relaxed_atoms.get_potential_energy() / len(relaxed_atoms)
83+
)
84+
relaxed_results["forces"].append(relaxed_atoms.get_forces())
85+
relaxed_results["stress"].append(relaxed_atoms.get_stress(voigt=False))
86+
relaxed_results["stress_GPa"].append(
87+
relaxed_atoms.get_stress(voigt=False) / GPa
88+
)
89+
90+
logger.info(f"Relaxed structure: {relaxed_atoms}")
91+
92+
if not os.path.exists(work_dir):
93+
os.makedirs(work_dir)
94+
95+
logger.info(f"Saving the results to {os.path.join(work_dir, save_csv)}")
96+
df = pd.DataFrame(relaxed_results)
97+
df.to_csv(os.path.join(work_dir, save_csv), index=False, mode="a")
98+
return relaxed_results

0 commit comments

Comments
 (0)