Skip to content

Commit f080b54

Browse files
ASE MD NPT bug fixes + housekeeping (#1255)
* Close #1254: Ensure ASE NPT MD runs correctly transform structure to upper triangular matrix * Close #1249: Default MatPES flow reflects flow used in paper * Close #1252: Openff tests use consistent values of the atomic masses
1 parent e7969e9 commit f080b54

File tree

7 files changed

+93
-38
lines changed

7 files changed

+93
-38
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ strict-openff = [
121121
"monty==2025.3.3",
122122
"openmm-mdanalysis-reporter==0.1.0",
123123
"openmm==8.1.1",
124-
"pymatgen==2025.6.14", # TODO: open ff is extremely sensitive to pymatgen version
124+
"pymatgen==2025.6.14", # EXERCISE CAUTION WHEN UPDATING - open ff is extremely sensitive to pymatgen version
125125
]
126126
strict-forcefields = [
127127
"calorine==3.1",

src/atomate2/ase/md.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
from pymatgen.core.structure import Molecule, Structure
2929
from pymatgen.io.ase import AseAtomsAdaptor
3030
from scipy.interpolate import interp1d
31-
from scipy.linalg import schur
3231

3332
from atomate2.ase.jobs import _ASE_DATA_OBJECTS, AseMaker
3433
from atomate2.ase.schemas import AseResult, AseTaskDoc
@@ -237,11 +236,31 @@ def _get_ensemble_defaults(self) -> None:
237236
self.ase_md_kwargs.pop("temperature_K", None)
238237
self.ase_md_kwargs.pop("externalstress", None)
239238
elif self.ensemble == MDEnsemble.nvt:
240-
self.ase_md_kwargs["temperature_K"] = self.t_schedule[0]
239+
self.ase_md_kwargs["temperature_K"] = self.ase_md_kwargs.get(
240+
"temperature_K", self.t_schedule[0]
241+
)
241242
self.ase_md_kwargs.pop("externalstress", None)
242243
elif self.ensemble == MDEnsemble.npt:
243-
self.ase_md_kwargs["temperature_K"] = self.t_schedule[0]
244-
self.ase_md_kwargs["externalstress"] = self.p_schedule[0] * 1e3 * units.bar
244+
self.ase_md_kwargs["temperature_K"] = self.ase_md_kwargs.get(
245+
"temperature_K", self.t_schedule[0]
246+
)
247+
248+
# These use different kwargs for pressure
249+
if (
250+
isinstance(self.dynamics, DynamicsPresets)
251+
and DynamicsPresets(self.dynamics) == DynamicsPresets.npt_berendsen
252+
) or (
253+
isinstance(self.dynamics, type)
254+
and issubclass(self.dynamics, MolecularDynamics)
255+
and self.dynamics.__name__ == "NPTBerendsen"
256+
):
257+
stress_kwarg = "pressure_au"
258+
else:
259+
stress_kwarg = "externalstress"
260+
261+
self.ase_md_kwargs[stress_kwarg] = self.ase_md_kwargs.get(
262+
stress_kwarg, self.p_schedule[0] * 1e3 * units.bar
263+
)
245264

246265
if isinstance(self.dynamics, str) and self.dynamics.lower() == "langevin":
247266
self.ase_md_kwargs["friction"] = self.ase_md_kwargs.get(
@@ -338,8 +357,7 @@ def run_ase(
338357
# `isinstance(dynamics,NPT)` is False
339358

340359
# ASE NPT implementation requires upper triangular cell
341-
schur_decomp, _ = schur(atoms.get_cell(complete=True), output="complex")
342-
atoms.set_cell(schur_decomp.real, scale_atoms=True)
360+
atoms.set_cell(atoms.cell.standard_form(form="upper")[0])
343361

344362
if initial_velocities:
345363
atoms.set_velocities(initial_velocities)

src/atomate2/openff/utils.py

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@
1111
from emmet.core.openff import MoleculeSpec
1212
from pymatgen.core import Element, Molecule
1313
from pymatgen.io.openff import create_openff_mol
14+
from scipy.constants import Avogadro
1415

1516
if TYPE_CHECKING:
1617
import pathlib
1718

19+
DEFAULT_ATOMIC_MASSES = {el.Z: el.atomic_mass for el in Element}
20+
1821

1922
def create_mol_spec(
2023
smiles: str,
@@ -145,6 +148,7 @@ def calculate_elyte_composition(
145148
salts: dict[str, float],
146149
solvent_densities: dict = None,
147150
solvent_ratio_dimension: Literal["mass", "volume"] = "mass",
151+
atomic_masses: dict[int, float] | None = None,
148152
) -> dict[str, float]:
149153
"""Calculate the normalized mass ratios of an electrolyte solution.
150154
@@ -158,6 +162,9 @@ def calculate_elyte_composition(
158162
Dictionary of solvent SMILES strings and their densities (g/ml).
159163
solvent_ratio_dimension: optional, str
160164
Whether the solvents are included with a ratio of "mass" or "volume"
165+
atomic_masses : dict[int,float] or None (Default)
166+
The mass of each element in the species dict. Defaults to the
167+
most recent data from pymatgen.core.periodic_table.Element
161168
162169
Returns
163170
-------
@@ -186,11 +193,11 @@ def calculate_elyte_composition(
186193
}
187194

188195
# Calculate the molecular weights of the solvent
189-
masses = {el.Z: el.atomic_mass for el in Element}
196+
atomic_masses = atomic_masses or DEFAULT_ATOMIC_MASSES
190197
salt_mws = {}
191198
for smile in salts:
192199
mol = tk.Molecule.from_smiles(smile, allow_undefined_stereo=True)
193-
salt_mws[smile] = sum(masses[atom.atomic_number] for atom in mol.atoms)
200+
salt_mws[smile] = sum(atomic_masses[atom.atomic_number] for atom in mol.atoms)
194201

195202
# Convert salt mole ratios to mass ratios
196203
salt_mass_ratio = {
@@ -207,7 +214,9 @@ def calculate_elyte_composition(
207214
return {species: mass / total_mass for species, mass in combined_mass_ratio.items()}
208215

209216

210-
def counts_from_masses(species: dict[str, float], n_mol: int) -> dict[str, float]:
217+
def counts_from_masses(
218+
species: dict[str, float], n_mol: int, atomic_masses: dict[int, float] | None = None
219+
) -> dict[str, float]:
211220
"""Calculate the number of mols needed to yield a given mass ratio.
212221
213222
Parameters
@@ -216,19 +225,21 @@ def counts_from_masses(species: dict[str, float], n_mol: int) -> dict[str, float
216225
Dictionary of species SMILES strings and their relative mass fractions.
217226
n_mol : float
218227
Total number of mols. Returned array will sum to near n_mol.
219-
228+
atomic_masses : dict[int,float] or None (Default)
229+
The mass of each element in the species dict. Defaults to the
230+
most recent data from pymatgen.core.periodic_table.Element
220231
221232
Returns
222233
-------
223234
numpy.ndarray
224235
n_mols: Number of each SMILES needed for the given mass ratio.
225236
"""
226-
masses = {el.Z: el.atomic_mass for el in Element}
237+
atomic_masses = atomic_masses or DEFAULT_ATOMIC_MASSES
227238

228239
mol_weights = []
229240
for smile in species:
230241
mol = tk.Molecule.from_smiles(smile, allow_undefined_stereo=True)
231-
mol_weights.append(sum(masses[atom.atomic_number] for atom in mol.atoms))
242+
mol_weights.append(sum(atomic_masses[atom.atomic_number] for atom in mol.atoms))
232243

233244
mol_ratio = np.array(list(species.values())) / np.array(mol_weights)
234245
mol_ratio /= sum(mol_ratio)
@@ -239,7 +250,10 @@ def counts_from_masses(species: dict[str, float], n_mol: int) -> dict[str, float
239250

240251

241252
def counts_from_box_size(
242-
species: dict[str, float], side_length: float, density: float = 0.8
253+
species: dict[str, float],
254+
side_length: float,
255+
density: float = 0.8,
256+
atomic_masses: dict[int, float] | None = None,
243257
) -> dict[str, float]:
244258
"""Calculate the number of molecules needed to fill a box.
245259
@@ -251,25 +265,27 @@ def counts_from_box_size(
251265
Side length of the cubic simulation box in nm.
252266
density : int, optional
253267
Density of the system in g/cm^3. Default is 1 g/cm^3.
268+
atomic_masses : dict[int,float] or None (Default)
269+
The mass of each element in the species dict. Defaults to the
270+
most recent data from pymatgen.core.periodic_table.Element
254271
255272
Returns
256273
-------
257274
dict of str, float
258275
Number of each species needed to fill the box with the given density.
259276
"""
260-
masses = {el.Z: el.atomic_mass for el in Element}
277+
atomic_masses = atomic_masses or DEFAULT_ATOMIC_MASSES
261278

262-
na = 6.02214076e23 # Avogadro's number
263279
volume = (side_length * 1e-7) ** 3 # Convert from nm3 to cm^3
264280
total_mass = volume * density # grams
265281

266282
# Calculate molecular weights
267283
mol_weights = []
268284
for smile in species:
269285
mol = tk.Molecule.from_smiles(smile, allow_undefined_stereo=True)
270-
mol_weights.append(sum(masses[atom.atomic_number] for atom in mol.atoms))
286+
mol_weights.append(sum(atomic_masses[atom.atomic_number] for atom in mol.atoms))
271287
mean_mw = np.mean(mol_weights)
272-
n_mol = (total_mass / mean_mw) * na
288+
n_mol = (total_mass / mean_mw) * Avogadro
273289

274290
# Calculate the number of moles needed for each species
275291
mol_ratio = np.array(list(species.values())) / np.array(mol_weights)

src/atomate2/vasp/flows/matpes.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,7 @@ class MatPesStaticFlowMaker(Maker):
5858
copy_vasp_kwargs={"additional_vasp_files": ("WAVECAR",)}
5959
)
6060
)
61-
# optional 3rd PBE+U static in case structure contains elements with +U corrections
62-
static3: Maker | None = field(
63-
default_factory=lambda: MatPesGGAStaticMaker(
64-
name="MatPES GGA+U static",
65-
input_set_generator=MatPESStaticSet(
66-
user_incar_settings={"LDAU:": True}, # enable +U corrections
67-
),
68-
copy_vasp_kwargs={"additional_vasp_files": ("WAVECAR",)},
69-
)
70-
)
61+
static3: Maker | None = None
7162

7263
def __post_init__(self) -> None:
7364
"""Validate flow."""

tests/conftest.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from unittest import mock
88

99
import pytest
10-
from fireworks import LaunchPad
1110
from jobflow import JobStore
1211
from jobflow.settings import JobflowSettings
1312
from maggma.stores import MemoryStore
@@ -69,6 +68,12 @@ def debug_mode():
6968

7069
@pytest.fixture(scope="session")
7170
def lpad(database, debug_mode):
71+
try:
72+
from fireworks import LaunchPad
73+
except ImportError as exc:
74+
raise ImportError(
75+
"Please pip install fireworks to use this test fixture."
76+
) from exc
7277
lpad = LaunchPad(name=database)
7378
lpad.reset("", require_password=False)
7479
yield lpad

tests/openff_md/test_utils.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@
2626
mol_graph_to_openff_mol,
2727
)
2828

29+
# Ensure consistency of test results by fixing atomic masses (no updates)
30+
LEGACY_ATOMIC_MASSES = {
31+
1: 1.00794,
32+
3: 6.941,
33+
6: 12.0107,
34+
7: 14.0067,
35+
8: 15.9994,
36+
9: 18.9984032,
37+
15: 30.973762,
38+
}
39+
2940

3041
def test_molgraph_to_openff_pf6(mol_files):
3142
"""transform a water MoleculeGraph to a OpenFF water molecule"""
@@ -229,10 +240,14 @@ def test_calculate_elyte_composition():
229240
solvent_densities = {"O": 1.0, "CCO": 0.8}
230241

231242
comp_dict = calculate_elyte_composition(
232-
vol_ratio, salts, solvent_densities, "volume"
243+
vol_ratio,
244+
salts,
245+
solvent_densities,
246+
"volume",
247+
atomic_masses=LEGACY_ATOMIC_MASSES,
233248
)
234-
counts = counts_from_masses(comp_dict, 100)
235-
assert sum(counts.values()) == 100
249+
counts = counts_from_masses(comp_dict, 100, atomic_masses=LEGACY_ATOMIC_MASSES)
250+
assert counts == {"CCO": 23, "F[P-](F)(F)(F)(F)F": 3, "O": 72, "[Li+]": 3}
236251

237252
mol_ratio = {
238253
"[Li+]": 0.00616,
@@ -242,16 +257,21 @@ def test_calculate_elyte_composition():
242257
"CC#N": 0.130,
243258
"FC1COC(=O)O1": 0.028,
244259
}
245-
counts2 = counts_from_masses(mol_ratio, 1000)
260+
counts2 = counts_from_masses(mol_ratio, 1000, atomic_masses=LEGACY_ATOMIC_MASSES)
246261
assert np.allclose(sum(counts2.values()), 1000, atol=5)
247262

248263

249264
def test_counts_calculators():
250265
mass_fractions = {"O": 0.5, "CCO": 0.5}
251266

252-
counts_size = counts_from_box_size(mass_fractions, 3)
253-
counts_number = counts_from_masses(mass_fractions, 324)
267+
counts_size = counts_from_box_size(
268+
mass_fractions, 3, atomic_masses=LEGACY_ATOMIC_MASSES
269+
)
270+
271+
assert sum(counts_size.values()) == 406
254272

255-
assert 200 < sum(counts_size.values()) < 500
273+
counts_number = counts_from_masses(
274+
mass_fractions, 406, atomic_masses=LEGACY_ATOMIC_MASSES
275+
)
256276

257277
assert counts_size == counts_number

tests/vasp/flows/test_matpes.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pymatgen.core import Structure
77

88
from atomate2.vasp.flows.matpes import MatPesStaticFlowMaker
9+
from atomate2.vasp.jobs.matpes import MatPesGGAStaticMaker
910

1011

1112
def test_matpes_static_flow_maker(mock_vasp, clean_dir, vasp_test_dir):
@@ -59,7 +60,9 @@ def test_matpes_static_flow_maker(mock_vasp, clean_dir, vasp_test_dir):
5960
assert flow[0].name == "MatPES GGA static"
6061

6162
# Test setting two makers to None
62-
flow_maker = MatPesStaticFlowMaker(static1=None, static2=None)
63+
flow_maker = MatPesStaticFlowMaker(
64+
static1=None, static2=None, static3=MatPesGGAStaticMaker()
65+
)
6366
flow = flow_maker.make(si_struct)
6467
assert len(flow) == 0
6568

@@ -68,7 +71,9 @@ def test_matpes_static_flow_maker(mock_vasp, clean_dir, vasp_test_dir):
6871
MatPesStaticFlowMaker(static1=None, static2=None, static3=None)
6972

7073
# Test no static3 if structure requires no Hubbard U corrections
71-
flow_maker = MatPesStaticFlowMaker(static1=None, static2=None)
74+
flow_maker = MatPesStaticFlowMaker(
75+
static1=None, static2=None, static3=MatPesGGAStaticMaker()
76+
)
7277
flow = flow_maker.make(si_struct)
7378
assert len(flow) == 0
7479
assert flow.output == {}

0 commit comments

Comments
 (0)