Skip to content

Commit cb346da

Browse files
committed
Make queue-executed ASE jobs runnable on a cluster
ASEAdapter.write_submit_script() emitted a bare two-line bash script: no scheduler directives, no queue, no environment activation, and the ARC host's conda python path, which does not exist on the server. It now composes a PBS or Slurm script from args['block'] (queue, env_setup, gpu_resource, python), pins the thread pools to the granted core count so torch cannot oversubscribe a shared node, requests the job's walltime so a long scan is not killed at the queue default, and stamps initial_time/final_time so ARC can report a run time. The script body lives in a server-independent ase_submit template in arc/settings/submit.py (keyed by cluster software, mirroring pipe_submit and wired through arc.imports with the same local-override hook), so it can be customized per cluster like every other submit script; the adapter only fills in placeholders. The script is written under the scheduler's submit filename (submit.sl for Slurm), which is the name submit_job() invokes, and cd's into the submission directory (the local path for a 'local' server). The resolved queue is recorded in attempted_queues, as JobAdapter does, so a failed submission moves on to the next queue instead of retrying the same one. Incore jobs keep the bare script. A queue-executed ASE job also never wrote its submit script or input.yml. set_files() only listed them for upload, but JobAdapter.execute() uploads before it calls execute_queue(), so the upload died with "InputError: Cannot upload a non-existing file". Write them in set_files(), where Gaussian, Orca and xTB write theirs; the incore path still writes its input in execute_incore(). execute_queue() then never submitted anything either: it guarded on self.server_adapter, an attribute nothing sets. Use legacy_queue_execution(), as every other adapter does, which also records the job id and status. Directed scans were also not constrained: Scheduler.run_job() always passes constraints=None and hands the adapter torsions + dihedrals instead, so every point of a brute_force_opt scan optimized freely and relaxed to the same minimum. ASEAdapter.determine_constraints() derives the constraint, and apply_constraints() converts ARC's 1-indexed atom indices to ASE's 0-indexed FixInternals. The shared Scheduler/Gaussian side of that defect is left alone here; a Gaussian directed_scan job needs its own fix.
1 parent d285b1e commit cb346da

5 files changed

Lines changed: 288 additions & 20 deletions

File tree

arc/imports.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
find_rits_ckpt,
1313
queue_deferred_warning)
1414
from arc.settings.inputs import input_files
15-
from arc.settings.submit import incore_commands, pipe_submit, submit_scripts
15+
from arc.settings.submit import ase_submit, incore_commands, pipe_submit, submit_scripts
1616

1717
logger = logging.getLogger('arc')
1818

@@ -81,6 +81,7 @@ def resolve_overridden_dependents(settings: dict, local_settings_dict: dict) ->
8181
local_arc_submit_path = os.path.join(local_arc_path, 'submit.py')
8282
if os.path.isfile(local_arc_submit_path):
8383
local_incore_commands, local_pipe_submit, local_submit_scripts = dict(), dict(), dict()
84+
local_ase_submit = dict()
8485
if local_arc_path not in sys.path:
8586
sys.path.insert(1, local_arc_path)
8687
try:
@@ -91,6 +92,10 @@ def resolve_overridden_dependents(settings: dict, local_settings_dict: dict) ->
9192
from submit import pipe_submit as local_pipe_submit
9293
except ImportError:
9394
pass
95+
try:
96+
from submit import ase_submit as local_ase_submit
97+
except ImportError:
98+
pass
9499
try:
95100
from submit import submit_scripts as local_submit_scripts
96101
except ImportError:
@@ -99,6 +104,8 @@ def resolve_overridden_dependents(settings: dict, local_settings_dict: dict) ->
99104
incore_commands.update(local_incore_commands)
100105
if local_pipe_submit:
101106
pipe_submit.update(local_pipe_submit)
107+
if local_ase_submit:
108+
ase_submit.update(local_ase_submit)
102109
if local_submit_scripts:
103110
submit_scripts.update(local_submit_scripts)
104111

arc/job/adapters/ase_adapter.py

Lines changed: 147 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@
1111
from arc.job.adapter import JobAdapter
1212
from arc.job.adapters.common import _initialize_adapter
1313
from arc.job.factory import register_job_adapter
14-
from arc.imports import settings
14+
from arc.imports import ase_submit, settings
1515
from arc.settings.settings import ARC_PYTHON, UMA_LATEST_MODEL, find_executable
1616

17+
servers = settings['servers']
18+
submit_filenames = settings['submit_filenames']
19+
t_max_format = settings['t_max_format']
20+
1721
if TYPE_CHECKING:
1822
from arc.level import Level
1923
from arc.species.species import ARCSpecies
@@ -188,6 +192,25 @@ def get_python_executable(self) -> str:
188192

189193
return ARC_PYTHON or 'python'
190194

195+
def determine_constraints(self) -> List[Tuple[List[int], float]]:
196+
"""
197+
Determine the internal coordinate constraints to apply.
198+
199+
A directed rotor scan is spawned by the Scheduler as one constrained optimization per
200+
dihedral point, but ``Scheduler.run_job()`` always passes ``constraints=None`` and hands the
201+
adapter ``torsions`` + ``dihedrals`` instead. Without translating those into a constraint the
202+
"scan" is an unconstrained optimization repeated at every point, and every point relaxes back
203+
to the same minimum, giving a flat V(phi). Torsions are 0-indexed; ARC constraints are
204+
1-indexed (as in the xTB and Gaussian adapters).
205+
206+
Returns:
207+
List[Tuple[List[int], float]]: The constraints, as (1-indexed atom indices, value) pairs.
208+
"""
209+
if self.constraints or self.job_type != 'directed_scan' or not self.torsions or not self.dihedrals:
210+
return self.constraints
211+
return [([index + 1 for index in torsion], dihedral)
212+
for torsion, dihedral in zip(self.torsions, self.dihedrals)]
213+
191214
def write_input_file(self) -> None:
192215
"""
193216
Write the input file for ase_script.py.
@@ -198,7 +221,7 @@ def write_input_file(self) -> None:
198221
'charge': self.charge,
199222
'multiplicity': self.multiplicity,
200223
'is_ts': self.species[0].is_ts if self.species else False,
201-
'constraints': self.constraints,
224+
'constraints': self.determine_constraints(),
202225
'irc_direction': self.irc_direction,
203226
'settings': self.determine_settings(),
204227
}
@@ -242,23 +265,26 @@ def execute_incore(self) -> None:
242265
def execute_queue(self) -> None:
243266
"""
244267
Execute a job to the server's queue.
268+
269+
``set_files()`` wrote the files and ``JobAdapter.execute()`` uploaded them, so all that is
270+
left here is the submission itself, through the same path every other adapter uses.
245271
"""
246-
self.write_input_file()
247-
self.write_submit_script()
248-
self.set_files()
249-
if self.server_adapter is not None:
250-
for file_dict in self.files_to_upload:
251-
self.server_adapter.upload_file(remote_path=file_dict['remote'],
252-
local_path=file_dict['local'])
253-
self.server_adapter.submit_job(self.remote_path)
272+
self.legacy_queue_execution()
254273

255274
def set_files(self) -> None:
256275
"""
257-
Set files to be uploaded and downloaded.
276+
Set files to be uploaded and downloaded. Writes the files if needed.
258277
"""
259278
# 1. Upload
260279
if self.execution_type != 'incore':
261-
self.files_to_upload.append(self.get_file_property_dictionary(file_name='submit.sh'))
280+
# ``JobAdapter.execute()`` calls ``upload_files()`` *before* ``execute_queue()``, and
281+
# ``_initialize_adapter()`` calls this method while the job is being constructed, so a
282+
# queue job's files have to be written here - as the Gaussian, Orca and xTB adapters do
283+
# - or the upload raises "InputError: Cannot upload a non-existing file".
284+
# An incore job is not uploaded and writes its input in ``execute_incore()``.
285+
self.write_submit_script()
286+
self.files_to_upload.append(self.get_file_property_dictionary(file_name=self.determine_submit_filename()))
287+
self.write_input_file()
262288
self.files_to_upload.append(self.get_file_property_dictionary(file_name='input.yml'))
263289
self.files_to_upload.append(self.get_file_property_dictionary(file_name='ase_script.py',
264290
local=self.script_path))
@@ -277,14 +303,119 @@ def set_input_file_memory(self) -> None:
277303
"""
278304
pass
279305

306+
def determine_submit_config(self) -> dict:
307+
"""
308+
Determine the cluster submission knobs for this job, taken from the level's ``args['block']``.
309+
310+
Recognized keys (all optional):
311+
312+
- ``env_setup``: shell lines to run on the compute node before the ASE script, e.g.
313+
``conda activate uma_env``. Note that ARC lowercases level args, so a case-sensitive
314+
module name must be sourced from a file on the server rather than written inline.
315+
- ``gpu_resource``: a scheduler GPU request, appended to the PBS ``select`` statement
316+
(e.g. ``ngpus=1``) or used as the Slurm ``--gres`` value (e.g. ``gpu:1``).
317+
- ``python``: the python executable **on the server**. ``self.python_executable`` is
318+
resolved against the ARC host's conda envs and generally does not exist on a remote server.
319+
- ``queue``: the queue to submit to, if not already set on the job or in the server settings.
320+
321+
Returns:
322+
dict: The resolved submit configuration.
323+
"""
324+
block = (self.args or dict()).get('block', dict()) or dict()
325+
default_queue, _ = next(iter(servers.get(self.server, dict()).get('queues', dict()).items()), (None, None))
326+
return {'queue': self.queue or block.get('queue') or default_queue,
327+
'env_setup': block.get('env_setup', ''),
328+
'gpu_resource': block.get('gpu_resource', ''),
329+
'python': block.get('python', ''),
330+
}
331+
332+
def determine_submit_filename(self) -> str:
333+
"""
334+
Return the filename ARC will submit for this job.
335+
336+
A queue-executed PBS/Slurm job must be written under the scheduler-specific name that
337+
``submit_job()`` invokes (``submit_filenames``, e.g. ``submit.sl`` for Slurm), or the
338+
submission fails because the file it names is not on disk. Everything else uses the plain
339+
``submit.sh`` the bare script is written to.
340+
341+
Returns:
342+
str: The submit-script filename.
343+
"""
344+
cluster_soft = servers.get(self.server, dict()).get('cluster_soft', '') if self.server is not None else ''
345+
if self.execution_type != 'incore' and cluster_soft.lower() in ('pbs', 'slurm'):
346+
return submit_filenames[cluster_soft]
347+
return 'submit.sh'
348+
349+
def get_queue_submit_script(self, command: str, config: dict, cluster_soft: str) -> str:
350+
"""
351+
Compose a cluster submit script for a queue-executed ASE job.
352+
353+
Formats the server-independent ``ase_submit`` template (in ``arc/settings/submit.py``,
354+
keyed by cluster software) with this job's resources and submit config. The thread-pool
355+
exports pin the numerical libraries (torch, NumPy) to the cores the scheduler granted, so a
356+
shared node is not oversubscribed.
357+
358+
Args:
359+
command (str): The command running the ASE script on the compute node.
360+
config (dict): The output of ``determine_submit_config()``.
361+
cluster_soft (str): The lowercased cluster software name ('pbs' or 'slurm').
362+
363+
Returns:
364+
str: The submit script content.
365+
"""
366+
if cluster_soft not in ase_submit:
367+
raise NotImplementedError(f"No ASE submit template for cluster software '{cluster_soft}'. "
368+
f"Available templates: {list(ase_submit.keys())}")
369+
memory = int(self.submit_script_memory) if isinstance(self.submit_script_memory, (int, float)) \
370+
else self.submit_script_memory
371+
time_format = next((v for k, v in t_max_format.items() if k.lower() == cluster_soft), 'hours')
372+
pwd = self.local_path if self.server is None or str(self.server).lower() == 'local' else self.remote_path
373+
queue, gpu_resource = config['queue'], config['gpu_resource']
374+
format_kwargs = {'name': self.job_server_name, 'cpus': self.cpu_cores, 'memory': memory,
375+
't_max': self.format_max_job_time(time_format=time_format), 'pwd': pwd,
376+
'env_setup': config['env_setup'], 'command': command}
377+
if cluster_soft == 'pbs':
378+
format_kwargs['queue_directive'] = f'#PBS -q {queue}\n' if queue else ''
379+
format_kwargs['gpu_select'] = f':{gpu_resource}' if gpu_resource else ''
380+
else:
381+
format_kwargs['queue_directive'] = f'#SBATCH -p {queue}\n' if queue else ''
382+
format_kwargs['gpu_directive'] = f'#SBATCH --gres={gpu_resource}\n' if gpu_resource else ''
383+
return ase_submit[cluster_soft].format(**format_kwargs)
384+
280385
def write_submit_script(self) -> None:
281386
"""
282387
Write the submission script.
388+
389+
An incore job only has to invoke the ASE script. A queue job additionally needs cluster
390+
scheduler directives, an environment setup preamble (``conda activate uma_env`` for UMA), a
391+
server-side python executable, and - for a GPU run - a GPU resource request; a bare
392+
``#!/bin/bash`` script carries none of those and lands on the queue's defaults with a python
393+
path that only exists on the ARC host. See ``determine_submit_config()`` for the knobs.
283394
"""
284-
remote_script_path = os.path.join(self.remote_path, 'ase_script.py')
285-
command = f"{self.python_executable} {remote_script_path} --yml_path {self.remote_path}"
286-
content = f"#!/bin/bash\n\n{command}\n"
287-
with open(os.path.join(self.local_path, 'submit.sh'), 'w') as f:
395+
config = self.determine_submit_config()
396+
cluster_soft = servers.get(self.server, dict()).get('cluster_soft', '').lower() \
397+
if self.server is not None else ''
398+
queue_job = self.execution_type != 'incore' and cluster_soft in ('pbs', 'slurm')
399+
if queue_job and not config['python']:
400+
logger.warning(f"Job {self.job_name} is submitted to {self.server}, but no server-side python "
401+
f"was given in args['block']['python']; falling back to {self.python_executable}, "
402+
f"which was resolved on this machine and may not exist there.")
403+
python_executable = config['python'] or self.python_executable
404+
if queue_job:
405+
# trsh_job_queue() skips queues already in attempted_queues; record the one we submit to
406+
# here, as JobAdapter.write_submit_script() does, so a failed submission moves on.
407+
if config['queue'] and config['queue'] not in self.attempted_queues:
408+
self.attempted_queues.append(config['queue'])
409+
# The script cd's into the job directory, so address the ASE script relative to it.
410+
command = f'{python_executable} "$JOB_DIR/ase_script.py" --yml_path "$JOB_DIR"'
411+
content = self.get_queue_submit_script(command=command, config=config, cluster_soft=cluster_soft)
412+
else:
413+
# A job with no server (and hence no remote path) runs out of its local directory.
414+
path = self.remote_path or self.local_path
415+
remote_script_path = os.path.join(path, 'ase_script.py')
416+
command = f"{python_executable} {remote_script_path} --yml_path {path}"
417+
content = f"#!/bin/bash\n\n{command}\n"
418+
with open(os.path.join(self.local_path, self.determine_submit_filename()), 'w') as f:
288419
f.write(content)
289420

290421
def parse_results(self) -> None:

arc/job/adapters/ase_test.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
import numpy as np
1414

1515
from arc.common import ARC_TESTING_PATH, read_yaml_file, save_yaml_file
16-
from arc.job.adapters.ase_adapter import ASEAdapter
16+
from arc.job.adapters.ase_adapter import ASEAdapter, servers
1717
from arc.species.species import ARCSpecies
18-
from arc.job.adapters.scripts.ase_script import to_kJmol, numpy_vibrational_analysis, is_linear
18+
from arc.job.adapters.scripts.ase_script import apply_constraints, to_kJmol, numpy_vibrational_analysis, is_linear
1919

2020

2121
class TestASEAdapter(unittest.TestCase):
@@ -102,6 +102,71 @@ def test_set_files(self):
102102
self.assertTrue(any('ase_script.py' in f['local'] for f in self.job_2.files_to_upload))
103103
self.assertTrue(any('output.yml' in f['local'] for f in self.job_2.files_to_download))
104104

105+
def test_set_files_writes_the_files_of_a_queue_job(self):
106+
"""Test that constructing a queue job already writes its submit script and input file"""
107+
# JobAdapter.execute() calls upload_files() before execute_queue(), so the files must be on
108+
# disk once the job object exists, not when it is executed.
109+
xyz = {'symbols': ('O', 'H', 'H'),
110+
'isotopes': (16, 1, 1),
111+
'coords': ((0.0, 0.0, 0.0), (0.0, 0.75, 0.58), (0.0, -0.75, 0.58))}
112+
fake_server = {'test_server': {'cluster_soft': 'PBS', 'un': 'test_user'}}
113+
# arc.job.adapter and arc.job.adapters.ase_adapter share this one dict object.
114+
with patch.dict(servers, fake_server):
115+
job_3 = ASEAdapter(execution_type='queue',
116+
job_type='directed_scan',
117+
project='test_3',
118+
project_directory=os.path.join(self.project_directory, 'test_3'),
119+
species=[ARCSpecies(label='H2O', xyz=xyz)],
120+
args={'keyword': {'calculator': 'xtb'},
121+
'block': {'queue': 'test_q',
122+
'env_setup': 'conda activate uma_env',
123+
'python': '/remote/python'}},
124+
server='test_server',
125+
testing=True)
126+
submit_path = os.path.join(job_3.local_path, 'submit.sh')
127+
self.assertTrue(os.path.isfile(submit_path))
128+
self.assertTrue(os.path.isfile(os.path.join(job_3.local_path, 'input.yml')))
129+
# Every file the job says it will upload must exist, or ssh.upload_file() raises an InputError.
130+
for file_dict in job_3.files_to_upload:
131+
self.assertTrue(os.path.isfile(file_dict['local']), msg=f"missing {file_dict['file_name']}")
132+
with open(submit_path, 'r') as f:
133+
content = f.read()
134+
self.assertIn('#PBS -q test_q', content)
135+
self.assertIn('conda activate uma_env', content)
136+
self.assertIn('/remote/python', content)
137+
138+
def test_set_files_does_not_write_for_an_incore_job(self):
139+
"""Test that an incore job writes no submit script (it writes its input when it executes)"""
140+
self.assertFalse(os.path.isfile(os.path.join(self.job_1.local_path, 'submit.sh')))
141+
self.assertTrue(all('submit.sh' not in f['local'] for f in self.job_1.files_to_upload))
142+
143+
def test_determine_constraints(self):
144+
"""Test that a directed scan derives a 1-indexed dihedral constraint from torsions/dihedrals"""
145+
xyz = {'symbols': ('O', 'H', 'H'),
146+
'isotopes': (16, 1, 1),
147+
'coords': ((0.0, 0.0, 0.0), (0.0, 0.75, 0.58), (0.0, -0.75, 0.58))}
148+
job = ASEAdapter(execution_type='incore',
149+
job_type='directed_scan',
150+
project='test_c',
151+
project_directory=os.path.join(self.project_directory, 'test_c'),
152+
species=[ARCSpecies(label='H2O', xyz=xyz)],
153+
torsions=[[0, 1, 2, 3]],
154+
dihedrals=[60.0],
155+
args={'keyword': {'calculator': 'xtb'}},
156+
testing=True)
157+
# torsions are 0-indexed; the returned constraint must be 1-indexed, as xTB/Gaussian expect.
158+
self.assertEqual(job.determine_constraints(), [([1, 2, 3, 4], 60.0)])
159+
160+
def test_apply_constraints_converts_to_zero_indexed(self):
161+
"""Test that apply_constraints translates ARC's 1-indexed dihedral to ASE's 0-indexed FixInternals"""
162+
from ase import Atoms
163+
atoms = Atoms('C4', positions=[(0.0, 0.0, 0.0), (1.5, 0.0, 0.0),
164+
(2.0, 1.4, 0.0), (3.5, 1.4, 0.0)])
165+
apply_constraints(atoms, [([1, 2, 3, 4], 90.0)])
166+
self.assertEqual(len(atoms.constraints), 1)
167+
dihedrals = atoms.constraints[0].todict()['kwargs']['dihedrals_deg']
168+
self.assertEqual(dihedrals, [[90.0, [0, 1, 2, 3]]])
169+
105170
def test_parse_results(self):
106171
"""Test parsing dummy output YAML back into object attributes"""
107172
output_data = {

arc/job/adapters/scripts/ase_script.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,15 @@ def get_calculator(calc_config: dict, charge: int = 0, multiplicity: int = 1):
116116
def apply_constraints(atoms: Atoms, constraints_data: list):
117117
"""
118118
Apply internal constraints to the Atoms object.
119+
120+
ARC's constraint atom indices are 1-indexed (that is what Gaussian's modredundant section and
121+
xTB's $constrain block consume); ASE's FixInternals is 0-indexed.
119122
"""
120123
if not constraints_data:
121124
return
122125
bonds, angles, dihedrals = list(), list(), list()
123126
for constraint in constraints_data:
124-
indices = constraint[0]
127+
indices = [index - 1 for index in constraint[0]]
125128
if len(indices) == 2:
126129
bonds.append([constraint[1], indices])
127130
elif len(indices) == 3:

0 commit comments

Comments
 (0)