@@ -1775,10 +1775,50 @@ def tearDownClass(cls):
17751775 shutil .rmtree (project_directory , ignore_errors = True )
17761776
17771777
1778+ class StubJob (object ):
1779+ """
1780+ A stand-in for a job object, so that Scheduler.run_job() can be driven without executing anything.
1781+ A job of an unavailable adapter raises FileNotFoundError from execute(), as the KinBot and
1782+ AutoTST adapters do when their backend is not installed.
1783+ """
1784+
1785+ def __init__ (self , job_type : str , job_adapter : str | None , unavailable : bool = False ):
1786+ self .job_type = job_type
1787+ self .job_adapter = job_adapter
1788+ self .unavailable = unavailable
1789+ self .job_name = f'{ job_type } _{ job_adapter } '
1790+ self .job_id = None
1791+ self .server = None
1792+
1793+ def as_dict (self ) -> dict :
1794+ """Return a dictionary representation of the job, used when saving the restart file."""
1795+ return {'job_type' : self .job_type , 'job_adapter' : self .job_adapter }
1796+
1797+ def execute (self ) -> None :
1798+ """Execute the job, raising if its external backend is not installed."""
1799+ if self .unavailable :
1800+ raise FileNotFoundError (f'The { self .job_adapter } python executable was not found.' )
1801+
1802+
17781803class TestSpawnTsJobsAdmission (unittest .TestCase ):
17791804 """
17801805 Contains unit tests for the TS adapter admission logic of Scheduler.spawn_ts_jobs().
17811806 """
1807+ @classmethod
1808+ def setUpClass (cls ):
1809+ """
1810+ A method that is run before all unit tests in this class.
1811+ """
1812+ cls .ess_settings = {'gaussian' : ['server1' ]}
1813+ cls .projects = [f'arc_project_for_testing_delete_after_usage_tsg_{ i } ' for i in range (1 , 6 )]
1814+
1815+ @classmethod
1816+ def tearDownClass (cls ):
1817+ """
1818+ A method that is run after all unit tests in this class, deleting the project directories.
1819+ """
1820+ for project in cls .projects :
1821+ shutil .rmtree (os .path .join (ARC_PATH , 'Projects' , project ), ignore_errors = True )
17821822
17831823 def test_spawn_ts_jobs_unknown_family_admission_predicate (self ):
17841824 """Test the admission predicate for TS adapters of reactions with an unknown family."""
@@ -1819,6 +1859,145 @@ def test_spawn_ts_jobs_unknown_family_admission_predicate(self):
18191859 and rxn .is_unimolecular ())
18201860 self .assertEqual (admit_unknown_family , expected_admission )
18211861
1862+ def setup_hocho_scheduler (self ,
1863+ ts_adapters : list ,
1864+ project : str ,
1865+ unavailable_adapters : list | None = None ,
1866+ ) -> tuple :
1867+ """
1868+ Set up a Scheduler holding HOCHO <=> H2O + CO (family 1,2_Insertion_CO) with optimized wells.
1869+
1870+ Only ``job_factory()`` is replaced, by one handing back ``StubJob``, so that
1871+ ``spawn_ts_jobs()`` can be driven end to end without executing anything while ``run_job()``
1872+ still does its real bookkeeping. A job for an adapter named in ``unavailable_adapters``
1873+ raises ``FileNotFoundError`` from ``execute()``, exactly as the KinBot and AutoTST adapters
1874+ do when their backend is not installed -- that is, after ``run_job()`` has already
1875+ registered the job in ``running_jobs`` and in ``job_dict``.
1876+
1877+ Args:
1878+ ts_adapters (list): The TS adapters to configure the Scheduler with.
1879+ project (str): The project name, also the project directory name.
1880+ unavailable_adapters (list, optional): Adapters whose job raises ``FileNotFoundError``.
1881+
1882+ Returns:
1883+ tuple: The Scheduler, its ARCReaction, and the list accumulating the
1884+ (job type, adapter) pairs jobs were created for.
1885+ """
1886+ unavailable_adapters = unavailable_adapters or list ()
1887+ r_species = [ARCSpecies (label = 'HOCHO' , smiles = 'OC=O' )]
1888+ p_species = [ARCSpecies (label = 'H2O' , smiles = 'O' ), ARCSpecies (label = 'CO' , smiles = '[C-]#[O+]' )]
1889+ for spc in r_species + p_species :
1890+ spc .final_xyz = spc .get_xyz () # Marks the well as optimized, gating spawn_ts_jobs().
1891+ rxn = ARCReaction (r_species = r_species , p_species = p_species )
1892+ self .assertEqual (rxn .family , '1,2_Insertion_CO' )
1893+ sched = Scheduler (project = project ,
1894+ ess_settings = self .ess_settings ,
1895+ species_list = r_species + p_species ,
1896+ rxn_list = [rxn ],
1897+ project_directory = os .path .join (ARC_PATH , 'Projects' , project ),
1898+ ts_adapters = ts_adapters ,
1899+ job_types = initialize_job_types (),
1900+ conformer_opt_level = Level (repr = default_levels_of_theory ['conformer' ]),
1901+ opt_level = Level (repr = default_levels_of_theory ['opt' ]),
1902+ testing = True ,
1903+ )
1904+ spawned = list ()
1905+
1906+ def stub_job_factory (job_type , job_adapter = None , ** kwargs ):
1907+ """Record the job being created and hand back a StubJob instead of a real job."""
1908+ spawned .append ((job_type , job_adapter ))
1909+ return StubJob (job_type = job_type , job_adapter = job_adapter ,
1910+ unavailable = job_adapter in unavailable_adapters )
1911+
1912+ patcher = patch ('arc.scheduler.job_factory' , new = stub_job_factory )
1913+ patcher .start ()
1914+ self .addCleanup (patcher .stop )
1915+ return sched , rxn , spawned
1916+
1917+ def test_spawn_ts_jobs_warns_when_no_adapter_is_eligible (self ):
1918+ """Test that a reaction with no eligible configured adapter is reported, not skipped silently.
1919+
1920+ Regression test for a real campaign failure: ``~/.arc/settings.py`` pinned
1921+ ``ts_adapters = ['heuristics', 'AutoTST', 'GCN', 'xtb_gsm', 'orca_neb']`` (predating 'linear'
1922+ joining ARC's own default list), while the reaction's family, ``1,2_Insertion_CO``, admits
1923+ only ``['kinbot', 'goflow', 'rits', 'linear']``. The intersection was empty, so zero TS guess
1924+ jobs were spawned -- silently. ``tsg_spawned`` latches True regardless, so nothing retried,
1925+ and the TS guess report came back with successful_methods AND unsuccessful_methods both
1926+ empty. The only user-visible symptom was 'TS did not converge', hours later.
1927+ """
1928+ configured = ['heuristics' , 'autotst' , 'gcn' , 'xtb_gsm' , 'orca_neb' ]
1929+ sched , rxn , spawned = self .setup_hocho_scheduler (ts_adapters = configured ,
1930+ project = 'arc_project_for_testing_delete_after_usage_tsg_1' )
1931+ eligible = [a .lower () for a in ts_adapters_by_rmg_family [rxn .family ]]
1932+ self .assertEqual ([m for m in configured if m in eligible ], list ())
1933+ self .assertIn ('linear' , eligible ) # 'linear' bridges them, a correctly-configured run is unaffected.
1934+
1935+ with self .assertLogs ('arc' , level = 'WARNING' ) as captured :
1936+ sched .spawn_ts_jobs ()
1937+ self .assertEqual ([adapter for job_type , adapter in spawned if job_type == 'tsg' ], list ())
1938+ self .assertTrue (any ('Not spawning any TS search job' in record .getMessage ()
1939+ and rxn .family in record .getMessage () for record in captured .records ))
1940+ self .assertTrue (rxn .ts_species .tsg_spawned ) # Latched, hence the single chance to report this.
1941+
1942+ def test_spawn_ts_jobs_warns_when_ts_adapters_is_empty (self ):
1943+ """Test that an empty ts_adapters list warns instead of raising."""
1944+ sched , rxn , spawned = self .setup_hocho_scheduler (ts_adapters = list (),
1945+ project = 'arc_project_for_testing_delete_after_usage_tsg_2' )
1946+ with self .assertLogs ('arc' , level = 'WARNING' ) as captured :
1947+ sched .spawn_ts_jobs ()
1948+ self .assertEqual ([adapter for job_type , adapter in spawned if job_type == 'tsg' ], list ())
1949+ self .assertTrue (any ('Not spawning any TS search job' in record .getMessage ()
1950+ for record in captured .records ))
1951+
1952+ def test_spawn_ts_jobs_does_not_warn_when_a_user_guess_exists (self ):
1953+ """Test that a user-supplied TS guess suppresses the 'no TS guess will be generated' warning."""
1954+ sched , rxn , spawned = self .setup_hocho_scheduler (ts_adapters = ['heuristics' ],
1955+ project = 'arc_project_for_testing_delete_after_usage_tsg_3' )
1956+ rxn .ts_species .ts_guesses .append (TSGuess (method = 'user guess 0' ,
1957+ xyz = rxn .r_species [0 ].get_xyz (),
1958+ index = 0 ))
1959+ with self .assertNoLogs ('arc' , level = 'WARNING' ):
1960+ sched .spawn_ts_jobs ()
1961+ self .assertEqual ([adapter for job_type , adapter in spawned if job_type == 'tsg' ], list ())
1962+ self .assertTrue (len (spawned )) # The user guess itself does proceed, so nothing is lost here.
1963+ self .assertEqual (len (rxn .ts_species .ts_guesses ), 1 )
1964+
1965+ def test_spawn_ts_jobs_survives_an_uninstalled_optional_adapter (self ):
1966+ """Test that an adapter whose backend is missing is recorded and skipped, not fatal.
1967+
1968+ Regression test for a real campaign failure. ``ts_adapters`` listed 'kinbot', whose backend
1969+ is optional and not installed on that host, so ``KinBotAdapter.execute_incore`` raised
1970+ ``FileNotFoundError('The KinBot python executable was not found...')``. That propagated
1971+ through ``run_job`` -> ``spawn_ts_jobs`` -> ``spawn_post_opt_jobs`` and terminated the whole
1972+ ARC run -- discarding the 7 TS guesses 'linear' had successfully produced for the same
1973+ reaction moments earlier. A missing optional dependency must cost that adapter, not the run.
1974+ """
1975+ sched , rxn , spawned = self .setup_hocho_scheduler (ts_adapters = ['kinbot' , 'linear' ],
1976+ project = 'arc_project_for_testing_delete_after_usage_tsg_4' ,
1977+ unavailable_adapters = ['kinbot' ])
1978+ sched .spawn_ts_jobs () # Must not raise: that is the whole point.
1979+ # 'linear' still ran, the run was not lost.
1980+ self .assertEqual ([adapter for job_type , adapter in spawned if job_type == 'tsg' ], ['kinbot' , 'linear' ])
1981+ self .assertEqual (rxn .ts_species .unsuccessful_methods , ['kinbot' ])
1982+ # run_job() registers a job before executing it, so the failed adapter left bookkeeping behind
1983+ # that must be rolled back: exactly one 'tsg0' entry, holding the job that actually ran.
1984+ self .assertEqual (sched .running_jobs [rxn .ts_label ], ['tsg0' ])
1985+ self .assertEqual (list (sched .job_dict [rxn .ts_label ]['tsg' ].keys ()), [0 ])
1986+ self .assertEqual (sched .job_dict [rxn .ts_label ]['tsg' ][0 ].job_adapter , 'linear' )
1987+
1988+ def test_spawn_ts_jobs_warns_when_every_eligible_adapter_is_unavailable (self ):
1989+ """Test that a reaction whose every eligible adapter is uninstalled is reported, and leaves no state."""
1990+ sched , rxn , spawned = self .setup_hocho_scheduler (ts_adapters = ['kinbot' , 'linear' ],
1991+ project = 'arc_project_for_testing_delete_after_usage_tsg_5' ,
1992+ unavailable_adapters = ['kinbot' , 'linear' ])
1993+ with self .assertLogs ('arc' , level = 'WARNING' ) as captured :
1994+ sched .spawn_ts_jobs ()
1995+ self .assertEqual (rxn .ts_species .unsuccessful_methods , ['kinbot' , 'linear' ])
1996+ self .assertTrue (any ("all of its eligible adapters ['kinbot', 'linear'] are unavailable" in record .getMessage ()
1997+ for record in captured .records ))
1998+ self .assertEqual (sched .running_jobs .get (rxn .ts_label , list ()), list ())
1999+ self .assertEqual (sched .job_dict [rxn .ts_label ].get ('tsg' , dict ()), dict ())
2000+
18222001
18232002class TestSchedulerAdaptiveReactionLevels (unittest .TestCase ):
18242003 """
0 commit comments