-
Notifications
You must be signed in to change notification settings - Fork 497
Expand file tree
/
Copy pathtest_lammps_fc.py
More file actions
298 lines (253 loc) · 9.61 KB
/
Copy pathtest_lammps_fc.py
File metadata and controls
298 lines (253 loc) · 9.61 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
"""
Copyright (c) Meta Platforms, Inc. and affiliates.
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
"""
from __future__ import annotations
import os
import sys
import tempfile
import hydra
import numpy as np
import pytest
from ase import Atoms
pytest.importorskip("lammps")
from fairchem.lammps import lammps_fc # noqa: E402
from fairchem.lammps.lammps_fc import restricted_cell_from_lammps_box # noqa: E402
def create_lammps_data_file(filepath, positions, cell, atom_types, masses):
"""
Create a LAMMPS data file for a triclinic box.
Args:
filepath: Path to write the data file
positions: Nx3 array of atom positions in Cartesian coordinates
cell: 3x3 cell matrix (rows are lattice vectors)
atom_types: List of atom type IDs (1-indexed for LAMMPS)
masses: Dict mapping atom type ID to mass
"""
n_atoms = len(positions)
n_types = len(masses)
# Extract cell parameters for LAMMPS triclinic box
# Cell rows are: a = cell[0], b = cell[1], c = cell[2]
a_vec = cell[0]
b_vec = cell[1]
c_vec = cell[2]
# LAMMPS restricted triclinic parameters
# See: https://docs.lammps.org/Howto_triclinic.html
xlo, ylo, zlo = 0.0, 0.0, 0.0
xhi = a_vec[0] # lx
xy = b_vec[0]
yhi = b_vec[1] # ly
xz = c_vec[0]
yz = c_vec[1]
zhi = c_vec[2] # lz
with open(filepath, "w") as f:
f.write("LAMMPS data file\n\n")
f.write(f"{n_atoms} atoms\n")
f.write(f"{n_types} atom types\n\n")
f.write(f"{xlo} {xhi} xlo xhi\n")
f.write(f"{ylo} {yhi} ylo yhi\n")
f.write(f"{zlo} {zhi} zlo zhi\n")
f.write(f"{xy} {xz} {yz} xy xz yz\n\n")
f.write("Masses\n\n")
for type_id, mass in masses.items():
f.write(f"{type_id} {mass}\n")
f.write("\n")
f.write("Atoms\n\n")
for i, (pos, atype) in enumerate(zip(positions, atom_types), start=1):
f.write(f"{i} {atype} {pos[0]} {pos[1]} {pos[2]}\n")
@pytest.mark.parametrize(
"cell_name,cell,fractional_positions",
[
(
"cubic",
np.array(
[[5.0, 0.0, 0.0], [0.0, 5.0, 0.0], [0.0, 0.0, 5.0]], dtype=np.float64
),
np.array(
[[0.1, 0.2, 0.3], [0.7, 0.8, 0.1], [0.5, 0.5, 0.5]], dtype=np.float64
),
),
(
"orthorhombic",
np.array(
[[3.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 5.0]], dtype=np.float64
),
np.array(
[[0.1, 0.2, 0.3], [0.7, 0.8, 0.1], [0.5, 0.5, 0.5]], dtype=np.float64
),
),
(
"monoclinic",
np.array(
[[4.0, 0.0, 0.0], [-1.5, 4.5, 0.0], [0.0, 0.0, 6.0]], dtype=np.float64
),
np.array(
[[0.1, 0.2, 0.3], [0.7, 0.8, 0.1], [0.5, 0.5, 0.5]], dtype=np.float64
),
),
(
"triclinic",
np.array(
[[5.0, 0.0, 0.0], [1.2, 4.8, 0.0], [0.8, 0.5, 5.5]], dtype=np.float64
),
np.array(
[[0.1, 0.2, 0.3], [0.7, 0.8, 0.1], [0.5, 0.5, 0.5]], dtype=np.float64
),
),
(
"triclinic_with_boundary_atoms",
np.array(
[[5.0, 0.0, 0.0], [1.0, 4.0, 0.0], [0.5, 0.3, 6.0]], dtype=np.float64
),
np.array(
[
[0.0, 0.0, 0.0],
[0.5, 0.0, 0.0],
[0.0, 0.5, 0.0],
[0.0, 0.0, 0.5],
[0.5, 0.5, 0.5],
[0.25, 0.75, 0.25],
],
dtype=np.float64,
),
),
],
)
def test_scaled_positions_lammps_vs_ase(cell_name, cell, fractional_positions):
"""
Test that scaled atomic positions computed by ASE match those from LAMMPS.
This test:
1. Creates a LAMMPS simulation with atoms in a triclinic box
2. Extracts box parameters and Cartesian positions from LAMMPS
3. Uses restricted_cell_from_lammps_box to get the ASE cell
4. Creates an ASE Atoms object with the positions and cell
5. Verifies that scaled (fractional) positions match
"""
lammps = pytest.importorskip("lammps")
# Convert fractional to Cartesian: pos = frac @ cell
cartesian_positions = fractional_positions @ cell
# Atom types (all type 1 = Carbon for simplicity)
atom_types = [1] * len(cartesian_positions)
masses = {1: 12.011} # Carbon mass
with tempfile.TemporaryDirectory() as tmpdir:
data_file = os.path.join(tmpdir, "test.data")
create_lammps_data_file(
data_file, cartesian_positions, cell, atom_types, masses
)
# Create LAMMPS instance and read the data file
lmp = lammps.lammps(cmdargs=["-screen", "none", "-log", "none"])
lmp.command("units metal")
lmp.command("atom_style atomic")
lmp.command("boundary p p p")
lmp.command(f"read_data {data_file}")
# Extract box parameters from LAMMPS
boxlo, boxhi, xy_lmp, yz_lmp, xz_lmp, periodicity, box_change = (
lmp.extract_box()
)
# Extract atom positions from LAMMPS
nlocal = lmp.get_natoms()
x_lammps = lmp.numpy.extract_atom("x")[:nlocal].copy()
# Get ASE cell from LAMMPS box parameters
cell_from_lammps = restricted_cell_from_lammps_box(
boxlo, boxhi, xy_lmp, yz_lmp, xz_lmp
)
cell_np = cell_from_lammps.squeeze().numpy()
# Create ASE Atoms object
ase_atoms = Atoms(
symbols=["C"] * nlocal, positions=x_lammps, cell=cell_np, pbc=True
)
# Get scaled positions from ASE (wrap=False to avoid [0,1) wrapping issues)
ase_scaled_positions = ase_atoms.get_scaled_positions(wrap=False)
# The key validation: scaled positions from ASE should match our original
# fractional positions. We compare modulo 1 to handle periodic boundary effects.
def normalize_fractional(frac):
"""Normalize fractional coordinates to [0, 1) handling numerical precision."""
normalized = frac % 1.0
# Handle values very close to 1.0 that should wrap to 0.0
normalized = np.where(np.abs(normalized - 1.0) < 1e-6, 0.0, normalized)
return normalized
wrapped_original = normalize_fractional(fractional_positions)
wrapped_ase = normalize_fractional(ase_scaled_positions)
assert np.allclose(
wrapped_ase, wrapped_original, atol=1e-5
), f"Cell {cell_name}: Scaled positions don't match.\nASE: {wrapped_ase}\nOriginal: {wrapped_original}"
lmp.close()
@pytest.mark.parametrize(
"box_name,boxlo,boxhi,xy,yz,xz",
[
("cubic", [0.0, 0.0, 0.0], [5.0, 5.0, 5.0], 0.0, 0.0, 0.0),
("orthorhombic", [0.0, 0.0, 0.0], [3.0, 4.0, 5.0], 0.0, 0.0, 0.0),
("xy_tilt", [0.0, 0.0, 0.0], [4.0, 5.0, 6.0], 1.0, 0.0, 0.0),
("yz_tilt", [0.0, 0.0, 0.0], [4.0, 5.0, 6.0], 0.0, 0.5, 0.0),
("xz_tilt", [0.0, 0.0, 0.0], [4.0, 5.0, 6.0], 0.0, 0.0, 0.3),
("full_triclinic", [0.0, 0.0, 0.0], [4.0, 5.0, 6.0], 1.0, 0.5, 0.3),
("nonzero_boxlo", [1.0, 2.0, 3.0], [6.0, 7.0, 8.0], 0.5, 0.3, 0.2),
],
)
def test_cell_conversion_preserves_volume(box_name, boxlo, boxhi, xy, yz, xz):
"""
Test that restricted_cell_from_lammps_box preserves cell volume.
"""
cell = restricted_cell_from_lammps_box(boxlo, boxhi, xy, yz, xz)
cell_np = cell.squeeze().numpy()
# Expected volume: lx * ly * lz (for restricted triclinic)
lx = boxhi[0] - boxlo[0]
ly = boxhi[1] - boxlo[1]
lz = boxhi[2] - boxlo[2]
expected_volume = lx * ly * lz
actual_volume = np.abs(np.linalg.det(cell_np))
assert np.isclose(expected_volume, actual_volume, atol=1e-6), (
f"Volume mismatch for {box_name}: boxlo={boxlo}, boxhi={boxhi}, xy={xy}, yz={yz}, xz={xz}.\n"
f"Expected: {expected_volume}, Actual: {actual_volume}"
)
def test_lammps_hydra_entrypoint_startup(monkeypatch, tmp_path):
"""
Test that the Hydra-decorated LAMMPS entry point composes config and starts.
"""
hydra.core.global_hydra.GlobalHydra.instance().clear()
lammps_state = {}
run_args = {}
def fake_instantiate(_cfg):
return object()
def fake_run_lammps_with_fairchem(
predictor,
lammps_input_path,
task_name,
charge=0,
spin=0,
):
run_args.update(
{
"predictor": predictor,
"lammps_input_path": lammps_input_path,
"task_name": task_name,
"charge": charge,
"spin": spin,
}
)
class DummyLammps:
pass
lmp = DummyLammps()
lmp._predictor = predictor
lammps_state["lmp"] = lmp
return lmp
monkeypatch.setattr(lammps_fc.hydra.utils, "instantiate", fake_instantiate)
monkeypatch.setattr(
lammps_fc, "run_lammps_with_fairchem", fake_run_lammps_with_fairchem
)
old_argv = sys.argv[:]
try:
sys.argv = [
"lammps_fc.py",
f"hydra.run.dir={tmp_path}",
"hydra.output_subdir=null",
]
lammps_fc.main()
finally:
sys.argv = old_argv
hydra.core.global_hydra.GlobalHydra.instance().clear()
assert run_args["lammps_input_path"] == "lammps_in_example.file"
assert run_args["task_name"] == "omol"
assert run_args["charge"] == 0
assert run_args["spin"] == 0
assert not hasattr(lammps_state["lmp"], "_predictor")