1111from arc .job .adapter import JobAdapter
1212from arc .job .adapters .common import _initialize_adapter
1313from arc .job .factory import register_job_adapter
14- from arc .imports import settings
14+ from arc .imports import ase_submit , settings
1515from 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+
1721if 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 :
0 commit comments