Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ DEVTOOLS_DIR := devtools

.PHONY: all help clean test test-unittests test-functional test-all \
install-all install-ci install-rmg install-rmgdb install-autotst install-gcn \
install-gcn-cpu install-kinbot install-sella install-xtb install-torchani install-ob \
install-gcn-cpu install-kinbot install-sella install-xtb install-torchani install-uma install-ob \
lite check-env compile


Expand Down Expand Up @@ -36,6 +36,7 @@ help:
@echo " install-sella Install Sella"
@echo " install-xtb Install xTB"
@echo " install-torchani Install TorchANI"
@echo " install-uma Install UMA (fairchem MLIP, gated model; users only, not CI)"
@echo " install-ob Install OpenBabel"
@echo ""
@echo "Maintenance:"
Expand Down Expand Up @@ -99,6 +100,11 @@ install-xtb:
install-torchani:
bash $(DEVTOOLS_DIR)/install_torchani.sh

# UMA (fairchem MLIP). Not part of install-ci: the model is gated (Meta license + HuggingFace
# token) and heavy, so this is a manual, user-driven setup. See devtools/install_uma.sh.
install-uma:
bash $(DEVTOOLS_DIR)/install_uma.sh

install-ob:
bash $(DEVTOOLS_DIR)/install_ob.sh

Expand Down
74 changes: 70 additions & 4 deletions arc/job/adapters/ase_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from arc.job.adapters.common import _initialize_adapter
from arc.job.factory import register_job_adapter
from arc.imports import settings
from arc.settings.settings import ARC_PYTHON, find_executable
from arc.settings.settings import ARC_PYTHON, UMA_LATEST_MODEL, find_executable

if TYPE_CHECKING:
from arc.level import Level
Expand All @@ -25,8 +25,12 @@
DEFAULT_ASE_ENV = {
'torchani': 'TANI_PYTHON',
'xtb': 'XTB_PYTHON',
'uma': 'UMA_PYTHON',
}

# Level methods that select the UMA calculator. 'uma' resolves to the latest model.
UMA_METHODS = ('uma', 'uma-s-1', 'uma-s-1p1')

class ASEAdapter(JobAdapter):
"""
A generic adapter for ASE (Atomic Simulation Environment) jobs.
Expand Down Expand Up @@ -77,12 +81,13 @@ def __init__(self,
self.job_adapter = 'ase'
self.execution_type = execution_type or 'incore'
self.incore_capacity = 100

self.sp = None
self.opt_xyz = None
self.freqs = None

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

Expand Down Expand Up @@ -128,11 +133,46 @@ def __init__(self,
xyz=xyz,
)

def determine_calculator_name(self) -> str:
"""
Determine the ASE calculator name, from ``args['keyword']['calculator']`` if given,
otherwise inferred from the level method (e.g., a 'uma' method selects the UMA calculator).

Returns:
str: The lowercased calculator name (empty string if undetermined).
"""
calc = (self.args or dict()).get('keyword', dict()).get('calculator', '')
if not calc and self.level is not None and getattr(self.level, 'method', None) \
and self.level.method.lower() in UMA_METHODS:
calc = 'uma'
return calc.lower()

def determine_settings(self) -> dict:
"""
Build the ``settings`` block passed to ase_script.py: the user's ``args['keyword']`` plus
a resolved ``calculator`` and, for UMA, default ``model`` (the level method, with 'uma'
resolving to the latest model), ``task``, and ``device``.

Returns:
dict: The resolved ASE run settings.
"""
settings_dict = dict((self.args or dict()).get('keyword', dict()))
calc = self.determine_calculator_name()
if calc:
settings_dict.setdefault('calculator', calc)
if calc == 'uma':
if 'model' not in settings_dict:
method = self.level.method.lower() if self.level is not None and self.level.method else 'uma'
settings_dict['model'] = UMA_LATEST_MODEL if method == 'uma' else method
settings_dict.setdefault('task', 'omol')
settings_dict.setdefault('device', 'cpu')
return settings_dict

def get_python_executable(self) -> str:
"""
Identify the correct Python executable based on the calculator.
"""
calc = self.args.get('keyword', {}).get('calculator', '').lower()
calc = self.determine_calculator_name()
env_mapping = settings.get('ASE_CALCULATORS_ENV', DEFAULT_ASE_ENV)
env_var_name = env_mapping.get(calc)

Expand All @@ -157,15 +197,41 @@ def write_input_file(self) -> None:
'xyz': self.xyz,
'charge': self.charge,
'multiplicity': self.multiplicity,
'is_ts': self.species[0].is_ts if self.species else False,
'constraints': self.constraints,
'settings': self.args.get('keyword', {}),
'irc_direction': self.irc_direction,
'settings': self.determine_settings(),
}
save_yaml_file(os.path.join(self.local_path, 'input.yml'), input_dict)

def warn_if_unreliable_uma_sp(self) -> bool:
"""
Warn if this is a UMA single point on a species whose absolute UMA energy is unreliable
(an isolated atom or triplet O2). UMA's geometries/frequencies are fine; only the absolute
energy of these under-represented species is off, so a DFT single point is preferable.

Reference: This is a known issue for general machine learning interatomic potentials (MLIPs)
such as UMA (https://arxiv.org/abs/2405.20235), where atomic energy offsets do not accurately
model isolated non-bonded atoms or highly specific spin states like triplet O2.
"""
if self.job_type not in ['sp', 'conf_sp'] or self.determine_calculator_name() != 'uma':
return False
symbols = self.xyz['symbols'] if self.xyz is not None else tuple()
is_atom = len(symbols) == 1
is_triplet_o2 = len(symbols) == 2 and all(s == 'O' for s in symbols) and self.multiplicity == 3
if is_atom or is_triplet_o2:
label = self.species[0].label if self.species else 'species'
logger.warning(f'Computing a UMA single point for {label} (an isolated atom or triplet O2). '
f'UMA absolute energies are unreliable for these under-represented species; '

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a source for that please? Is that like a known issue?

f'consider using a DFT single point instead.')
return True
return False

def execute_incore(self) -> None:
"""
Execute the job incore.
"""
self.warn_if_unreliable_uma_sp()
self.write_input_file()
cmd = [self.python_executable, self.script_path, '--yml_path', self.local_path]
process = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
Expand Down
46 changes: 41 additions & 5 deletions arc/job/adapters/scripts/ase_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,18 @@ def get_calculator(calc_config: dict, charge: int = 0, multiplicity: int = 1):
if multiplicity > 1:
raise ValueError("ARC's integration with MOPAC vua the ASE calculator does not support multiplicity > 1.")
return MOPAC(**kwargs)


elif name in ('uma', 'fairchem'):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does fairchem has a model that is not uma? Do we want to leave the option to call fairchem in the input?

# UMA (Meta FAIR fairchem-core). Total charge and spin (= multiplicity) are conditioned on
# the ase.Atoms via atoms.info in main(); they are not calculator kwargs.
from fairchem.core import FAIRChemCalculator, pretrained_mlip
model = calc_config.get('model', 'uma-s-1p1')
device = calc_config.get('device', 'cpu')
task = calc_config.get('task', 'omol')
predictor = pretrained_mlip.get_predict_unit(model, device=device)
return FAIRChemCalculator(predictor, task_name=task)


from ase.calculators.calculator import get_calculator_class
try:
calc_class = get_calculator_class(name)
Expand Down Expand Up @@ -313,8 +324,10 @@ def main():
settings = input_dict.get('settings', {})
charge = input_dict.get('charge', 0)
multiplicity = input_dict.get('multiplicity', 1)

is_ts = input_dict.get('is_ts', False)

atoms = Atoms(symbols=xyz['symbols'], positions=xyz['coords'])
atoms.info.update({'charge': charge, 'spin': multiplicity}) # UMA (omol) conditions on these
calc = get_calculator(settings, charge, multiplicity)
Comment on lines 329 to 331
atoms.calc = calc

Expand Down Expand Up @@ -342,13 +355,16 @@ def save_current_geometry(out_dict, atoms_obj, input_xyz):
'scipyfminbfgs': SciPyFminBFGS, 'scipyfmincg': SciPyFminCG,
'sella': None,
}
if engine_name == 'sella':
logfile = os.path.join(os.path.dirname(input_path), 'opt.log')
if is_ts or engine_name == 'sella':
# A TS search needs a saddle-point optimizer; UMA ships none, so use Sella.
from sella import Sella
opt_class = Sella
opt = opt_class(atoms, order=1 if is_ts else 0, logfile=logfile)
else:
opt_class = engine_dict.get(engine_name, BFGS)
opt = opt_class(atoms, logfile=os.path.join(os.path.dirname(input_path), 'opt.log'))
opt = opt_class(atoms, logfile=logfile)

try:
opt.run(fmax=fmax, steps=steps)
save_current_geometry(output, atoms, xyz)
Expand All @@ -360,6 +376,26 @@ def save_current_geometry(out_dict, atoms_obj, input_xyz):
# For non-optimization jobs, still save the geometry
save_current_geometry(output, atoms, xyz)

if job_type == 'irc':
from sella import IRC
from ase.io import read
fmax = float(settings.get('fmax', 0.001))
steps = int(settings.get('steps', 1000))
direction = input_dict.get('irc_direction', 'forward')
traj_path = os.path.join(os.path.dirname(input_path), 'irc.traj')
try:
irc = IRC(atoms, logfile=os.path.join(os.path.dirname(input_path), 'irc.log'),
trajectory=traj_path)
irc.run(fmax=fmax, steps=steps, direction=direction)
images = read(traj_path, index=':')
output['irc_traj'] = [
{'coords': tuple(map(tuple, image.get_positions().tolist())),
'symbols': xyz['symbols'],
'isotopes': xyz.get('isotopes') or tuple([None] * len(xyz['symbols']))}
for image in images]
except Exception as exc:
output['error'] = f"IRC failed: {exc}"

if job_type in ['freq', 'optfreq']:
try:
freq_results = run_vibrational_analysis(atoms, settings)
Expand Down
Loading