Skip to content

Commit 259912e

Browse files
committed
Add UMA as a calculator in the ASE job adapter
Build UMA (Meta FAIR fairchem-core, uma-s-1p1, task omol) on top of the generic ASE adapter (PR #836) instead of a standalone adapter: - ase_script.py: add a uma/fairchem branch to get_calculator; set total charge and spin (=multiplicity) on atoms.info (omol conditioning); use Sella order=1 for TS saddle-point searches when is_ts; add an irc job type via Sella IRC. - ase.py: derive the calculator from the level method (so method='uma' works with no args), resolve UMA defaults (latest model, omol, cpu) via determine_settings, pass is_ts and irc_direction to the script, and warn on a UMA single point for an isolated atom or triplet O2 (unreliable absolute energy). - settings.py: UMA_PYTHON=find_executable('uma_env'), ASE_CALCULATORS_ENV['uma'], and UMA_LATEST_MODEL. - level.py: route method 'uma'/'uma-s-1'/'uma-s-1p1' to the 'ase' software. - yaml.py: implement parse_irc_traj and parse_1d_scan_coords so UMA IRC/scan outputs round-trip. Rotor scans run through ARC's directed_scan (constrained opt), already supported by the ASE adapter. fairchem/Sella-IRC API points only confirmable inside uma_env are marked with # VERIFY. Adds env-independent unit tests (routing, calculator/settings resolution, input writing, sp warning, output round-trip) plus skip-guarded model tests.
1 parent 495c535 commit 259912e

6 files changed

Lines changed: 329 additions & 16 deletions

File tree

arc/job/adapters/ase.py

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from arc.job.adapters.common import _initialize_adapter
1313
from arc.job.factory import register_job_adapter
1414
from arc.imports import settings
15-
from arc.settings.settings import ARC_PYTHON, find_executable
15+
from arc.settings.settings import ARC_PYTHON, UMA_LATEST_MODEL, find_executable
1616

1717
if TYPE_CHECKING:
1818
from arc.level import Level
@@ -25,8 +25,12 @@
2525
DEFAULT_ASE_ENV = {
2626
'torchani': 'TANI_PYTHON',
2727
'xtb': 'XTB_PYTHON',
28+
'uma': 'UMA_PYTHON',
2829
}
2930

31+
# Level methods that select the UMA calculator. 'uma' resolves to the latest model.
32+
UMA_METHODS = ('uma', 'uma-s-1', 'uma-s-1p1')
33+
3034
class ASEAdapter(JobAdapter):
3135
"""
3236
A generic adapter for ASE (Atomic Simulation Environment) jobs.
@@ -77,12 +81,13 @@ def __init__(self,
7781
self.job_adapter = 'ase'
7882
self.execution_type = execution_type or 'incore'
7983
self.incore_capacity = 100
80-
84+
8185
self.sp = None
8286
self.opt_xyz = None
8387
self.freqs = None
8488

8589
self.args = args or dict()
90+
self.level = level # also set by _initialize_adapter; needed early by get_python_executable
8691
self.python_executable = self.get_python_executable()
8792
self.script_path = os.path.join(os.path.dirname(__file__), 'scripts', 'ase_script.py')
8893

@@ -128,11 +133,46 @@ def __init__(self,
128133
xyz=xyz,
129134
)
130135

136+
def determine_calculator_name(self) -> str:
137+
"""
138+
Determine the ASE calculator name, from ``args['keyword']['calculator']`` if given,
139+
otherwise inferred from the level method (e.g., a 'uma' method selects the UMA calculator).
140+
141+
Returns:
142+
str: The lowercased calculator name (empty string if undetermined).
143+
"""
144+
calc = (self.args or dict()).get('keyword', dict()).get('calculator', '')
145+
if not calc and self.level is not None and getattr(self.level, 'method', None) \
146+
and self.level.method.lower() in UMA_METHODS:
147+
calc = 'uma'
148+
return calc.lower()
149+
150+
def determine_settings(self) -> dict:
151+
"""
152+
Build the ``settings`` block passed to ase_script.py: the user's ``args['keyword']`` plus
153+
a resolved ``calculator`` and, for UMA, default ``model`` (the level method, with 'uma'
154+
resolving to the latest model), ``task``, and ``device``.
155+
156+
Returns:
157+
dict: The resolved ASE run settings.
158+
"""
159+
settings_dict = dict((self.args or dict()).get('keyword', dict()))
160+
calc = self.determine_calculator_name()
161+
if calc:
162+
settings_dict.setdefault('calculator', calc)
163+
if calc == 'uma':
164+
if 'model' not in settings_dict:
165+
method = self.level.method.lower() if self.level is not None and self.level.method else 'uma'
166+
settings_dict['model'] = UMA_LATEST_MODEL if method == 'uma' else method
167+
settings_dict.setdefault('task', 'omol')
168+
settings_dict.setdefault('device', 'cpu')
169+
return settings_dict
170+
131171
def get_python_executable(self) -> str:
132172
"""
133173
Identify the correct Python executable based on the calculator.
134174
"""
135-
calc = self.args.get('keyword', {}).get('calculator', '').lower()
175+
calc = self.determine_calculator_name()
136176
env_mapping = settings.get('ASE_CALCULATORS_ENV', DEFAULT_ASE_ENV)
137177
env_var_name = env_mapping.get(calc)
138178

@@ -157,15 +197,35 @@ def write_input_file(self) -> None:
157197
'xyz': self.xyz,
158198
'charge': self.charge,
159199
'multiplicity': self.multiplicity,
200+
'is_ts': self.species[0].is_ts if self.species else False,
160201
'constraints': self.constraints,
161-
'settings': self.args.get('keyword', {}),
202+
'irc_direction': self.irc_direction,
203+
'settings': self.determine_settings(),
162204
}
163205
save_yaml_file(os.path.join(self.local_path, 'input.yml'), input_dict)
164206

207+
def warn_if_unreliable_uma_sp(self) -> None:
208+
"""
209+
Warn if this is a UMA single point on a species whose absolute UMA energy is unreliable
210+
(an isolated atom or triplet O2). UMA's geometries/frequencies are fine; only the absolute
211+
energy of these under-represented species is off, so a DFT single point is preferable.
212+
"""
213+
if self.job_type not in ['sp', 'conf_sp'] or self.determine_calculator_name() != 'uma':
214+
return
215+
symbols = self.xyz['symbols'] if self.xyz is not None else tuple()
216+
is_atom = len(symbols) == 1
217+
is_triplet_o2 = len(symbols) == 2 and all(s == 'O' for s in symbols) and self.multiplicity == 3
218+
if is_atom or is_triplet_o2:
219+
label = self.species[0].label if self.species else 'species'
220+
logger.warning(f'Computing a UMA single point for {label} (an isolated atom or triplet O2). '
221+
f'UMA absolute energies are unreliable for these under-represented species; '
222+
f'consider using a DFT single point instead.')
223+
165224
def execute_incore(self) -> None:
166225
"""
167226
Execute the job incore.
168227
"""
228+
self.warn_if_unreliable_uma_sp()
169229
self.write_input_file()
170230
cmd = [self.python_executable, self.script_path, '--yml_path', self.local_path]
171231
process = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)

arc/job/adapters/scripts/ase_script.py

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,18 @@ def get_calculator(calc_config: dict, charge: int = 0, multiplicity: int = 1):
8888
elif name == 'mopac':
8989
from ase.calculators.mopac import MOPAC
9090
return MOPAC(**kwargs)
91-
91+
92+
elif name in ('uma', 'fairchem'):
93+
# UMA (Meta FAIR fairchem-core). Total charge and spin (= multiplicity) are conditioned on
94+
# the ase.Atoms via atoms.info in main(); they are not calculator kwargs.
95+
from fairchem.core import FAIRChemCalculator, pretrained_mlip
96+
model = calc_config.get('model', 'uma-s-1p1')
97+
device = calc_config.get('device', 'cpu')
98+
task = calc_config.get('task', 'omol')
99+
predictor = pretrained_mlip.get_predict_unit(model, device=device)
100+
return FAIRChemCalculator(predictor, task_name=task)
101+
102+
92103
from ase.calculators.calculator import get_calculator_class
93104
try:
94105
calc_class = get_calculator_class(name)
@@ -279,8 +290,10 @@ def main():
279290
settings = input_dict.get('settings', {})
280291
charge = input_dict.get('charge', 0)
281292
multiplicity = input_dict.get('multiplicity', 1)
282-
293+
is_ts = input_dict.get('is_ts', False)
294+
283295
atoms = Atoms(symbols=xyz['symbols'], positions=xyz['coords'])
296+
atoms.info.update({'charge': charge, 'spin': multiplicity}) # UMA (omol) conditions on these
284297
calc = get_calculator(settings, charge, multiplicity)
285298
atoms.calc = calc
286299

@@ -308,13 +321,14 @@ def save_current_geometry(out_dict, atoms_obj, input_xyz):
308321
'scipyfminbfgs': SciPyFminBFGS, 'scipyfmincg': SciPyFminCG,
309322
'sella': None,
310323
}
311-
if engine_name == 'sella':
324+
logfile = os.path.join(os.path.dirname(input_path), 'opt.log')
325+
if is_ts or engine_name == 'sella':
326+
# A TS search needs a saddle-point optimizer; UMA ships none, so use Sella.
312327
from sella import Sella
313-
opt_class = Sella
328+
opt = Sella(atoms, order=1 if is_ts else 0, logfile=logfile)
314329
else:
315-
opt_class = engine_dict.get(engine_name, BFGS)
316-
opt = opt_class(atoms, logfile=os.path.join(os.path.dirname(input_path), 'opt.log'))
317-
330+
opt = engine_dict.get(engine_name, BFGS)(atoms, logfile=logfile)
331+
318332
try:
319333
opt.run(fmax=fmax, steps=steps)
320334
save_current_geometry(output, atoms, xyz)
@@ -326,6 +340,26 @@ def save_current_geometry(out_dict, atoms_obj, input_xyz):
326340
# For non-optimization jobs, still save the geometry
327341
save_current_geometry(output, atoms, xyz)
328342

343+
if job_type == 'irc':
344+
from sella import IRC # VERIFY the Sella IRC API in the installed sella
345+
from ase.io import read
346+
fmax = float(settings.get('fmax', 0.001))
347+
steps = int(settings.get('steps', 1000))
348+
direction = input_dict.get('irc_direction', 'forward')
349+
traj_path = os.path.join(os.path.dirname(input_path), 'irc.traj')
350+
try:
351+
irc = IRC(atoms, logfile=os.path.join(os.path.dirname(input_path), 'irc.log'),
352+
trajectory=traj_path)
353+
irc.run(fmax=fmax, steps=steps, direction=direction)
354+
images = read(traj_path, index=':')
355+
output['irc_traj'] = [
356+
{'coords': tuple(map(tuple, image.get_positions().tolist())),
357+
'symbols': xyz['symbols'],
358+
'isotopes': xyz.get('isotopes') or tuple([None] * len(xyz['symbols']))}
359+
for image in images]
360+
except Exception as exc:
361+
output['error'] = f"IRC failed: {exc}"
362+
329363
if job_type in ['freq', 'optfreq']:
330364
try:
331365
freq_results = run_vibrational_analysis(atoms, settings)

0 commit comments

Comments
 (0)