From 7d706b34e82c4bfb9f5ab994ecec91cf0c3041aa Mon Sep 17 00:00:00 2001 From: Calvin Pieters Date: Wed, 19 Aug 2026 10:49:29 +0300 Subject: [PATCH] Clear the TS energy cache and checkfile when switching to the next TS guess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `switch_ts()` discards a TS guess and re-optimizes the next one, and it already invalidates every piece of state the new geometry makes meaningless: the chosen guess, the running jobs, the output paths and job_types (via `delete_all_species_jobs()`), the copied `freq.out`, the TS checks dict, and the rotors dict. It did not invalidate the energy data cached on the TS species itself, nor the checkfile pointing at the abandoned guess's wavefunction. All three energy fields are read straight off the species object, which is exactly where `delete_all_species_jobs()` cannot reach: * `e0` decides the E0 check. `compute_rxn_e0()` skips any species that already carries an `e0`, and `ARCReaction.copy_e0_values()` only fills an empty one, so the TS E0 was computed once, for the first guess, and every later guess was judged against it. `e0` is also written to the restart file, so the stale value survived a restart. Measured on a benchmark run, two guesses whose own E0 was 2.51 kJ/mol above the reactants were rejected on the first guess's 121.88 kJ/mol. * `e_elect` decides a second gate by the same mechanism, and this is the strongest reason to clear it. `check_rxn_e_elect()` — the check that runs when E0 cannot be determined — reads `reaction.ts_species.e_elect` directly off the species object, so a retained value silently answered that gate for the new guess too. * `freqs` is the primary source of the reported imaginary frequency. `arc/output.py::_get_ts_imag_freq()` takes the most negative entry of `spc.freqs` and only falls back to the chosen guess's `imaginary_freqs` when `spc.freqs` is empty, so without this clear the run reports the abandoned guess's imaginary frequency for the accepted TS. Caching is correct for the reactants and the products, whose geometries do not change here; it is wrong for the TS, whose geometry changes on every switch. Until the new sp and freq jobs replace them, `e_elect` and `freqs` are also saved to the restart file and reported in output.yml as if they belonged to the new guess. `checkfile` is the same defect class, reached through the route builder rather than through a check. `run_job()` reads the checkfile off the species and hands it to every job it spawns, and `GaussianAdapter` resolves the SCF initial guess for any polyatomic species as ` guess=read` when the checkfile exists and ` guess=mix` when it does not — the fallback is not TS-specific. Left uncleared, the new guess is seeded from the discarded geometry's converged orbitals instead of the symmetry-broken default that a fresh species gets. That branch is right when the checkfile is current; what is wrong is the stale pointer. Deliberately left alone: * `opt_level` — a level of theory, not a geometry-derived quantity. * `external_symmetry` and `optical_isomers` — also sticky, but only ever written onto the scheduler's species by the final Arkane rate/thermo run, after all switching is over. Excluding them is safe only because `compute_rxn_e0()` operates on `reaction.copy()`, which round-trips through `as_dict`/`from_dict` — a genuine deep copy — and merges only `e0` back onto the live reaction; were `copy()` ever to become shallow, this exclusion would become a bug of the same shape as the ones fixed here. * `t1` — a wavefunction diagnostic that no check reads and that output.yml does not carry. * `active` — considered, and deliberately not cleared. It has the identical sticky shape: set only when it is `None`, persisted to the restart file, and consumed by the Orca and Molpro CASSCF route builders. Whether an active space is guess-dependent is a chemistry question rather than a caching one, so it is left for a deliberate decision. The regression test asserts the invalidation of all four fields and, for `e0`, its consequence: that an E0 computed for the new guess is adopted by `copy_e0_values()` rather than masked by the old one. --- arc/scheduler.py | 8 +++++ arc/scheduler_test.py | 73 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/arc/scheduler.py b/arc/scheduler.py index 3c27829cf5..7c029a0c25 100644 --- a/arc/scheduler.py +++ b/arc/scheduler.py @@ -2989,6 +2989,10 @@ def switch_ts(self, label: str): """ Try the next optimized TS guess in line if a previous TS guess was found to be wrong. + The energy data cached on the TS species (``e0``, ``e_elect`` and ``freqs``) and the + ``checkfile`` are discarded: all four belong to the abandoned guess's geometry and + wavefunction, and are repopulated by the jobs run for the new guess. + Args: label (str): The TS species label. """ @@ -2999,6 +3003,10 @@ def switch_ts(self, label: str): if os.path.isfile(freq_path): os.remove(freq_path) self.species_dict[label].populate_ts_checks() # Restart the TS checks dict. + self.species_dict[label].e0 = None + self.species_dict[label].e_elect = None + self.species_dict[label].freqs = None + self.species_dict[label].checkfile = None if self.job_types['rotors'] and self.species_dict[label].rotors_dict is not None: # Reset rotors so they are re-determined from the new TS geometry. # rotors_dict=None is a sentinel meaning "skip rotor scans"; preserve it. diff --git a/arc/scheduler_test.py b/arc/scheduler_test.py index 62b03efc14..b2b06cab74 100644 --- a/arc/scheduler_test.py +++ b/arc/scheduler_test.py @@ -1214,6 +1214,79 @@ def test_switch_ts_rotors_reset(self, mock_run_opt): # rotors_dict=None must be preserved — do not re-enable rotor scans. self.assertIsNone(sched2.species_dict[ts_label2].rotors_dict) + @patch('arc.scheduler.Scheduler.run_opt_job') + def test_switch_ts_clears_stale_ts_energy(self, mock_run_opt): + """Test that switch_ts discards the energy data computed for the TS guess being abandoned.""" + ts_xyz = str_to_xyz("""N 0.91779059 0.51946178 0.00000000 + H 1.81402049 1.03819414 0.00000000 + H 0.00000000 0.00000000 0.00000000 + H 0.91779059 1.22790192 0.72426890""") + + ts_spc = ARCSpecies(label='TS_e0', is_ts=True, xyz=ts_xyz, multiplicity=1, charge=0, + compute_thermo=False) + ts_spc.ts_guesses = [ + TSGuess(index=0, method='heuristics', success=True, energy=100.0, xyz=ts_xyz, + execution_time='0:00:01'), + TSGuess(index=1, method='heuristics', success=True, energy=110.0, xyz=ts_xyz, + execution_time='0:00:01'), + ] + ts_spc.ts_guesses[0].opt_xyz = ts_xyz + ts_spc.ts_guesses[0].imaginary_freqs = [-798.8] + ts_spc.ts_guesses[1].opt_xyz = ts_xyz + ts_spc.ts_guesses[1].imaginary_freqs = [-784.0] + ts_spc.chosen_ts = 0 + ts_spc.chosen_ts_list = [0] + ts_spc.ts_guesses_exhausted = False + # Energy data computed for guess 0, all of it specific to that geometry. + ts_spc.e0 = 121.88 + ts_spc.e_elect = -148340.0 + ts_spc.freqs = [-798.8, 1042.3, 1626.7, 1642.1, 3396.4, 3512.9] + + project_directory = os.path.join(ARC_PATH, 'Projects', + 'arc_project_for_testing_delete_after_usage22') + self.addCleanup(shutil.rmtree, project_directory, ignore_errors=True) + checkfile_path = os.path.join(project_directory, 'calcs', 'TSs', 'TS_e0', 'opt_a0', + 'check.chk') + ts_spc.checkfile = checkfile_path + sched = Scheduler(project='test_switch_ts_e0', ess_settings=self.ess_settings, + species_list=[ts_spc], + opt_level=Level(repr=default_levels_of_theory['opt']), + freq_level=Level(repr=default_levels_of_theory['freq']), + sp_level=Level(repr=default_levels_of_theory['sp']), + ts_guess_level=Level(repr=default_levels_of_theory['ts_guesses']), + project_directory=project_directory, + testing=True, + job_types=self.job_types1, + ) + + ts_label = 'TS_e0' + sched.output[ts_label]['job_types']['opt'] = True + sched.output[ts_label]['job_types']['freq'] = True + sched.output[ts_label]['job_types']['sp'] = True + sched.job_dict[ts_label] = {'opt': {}, 'freq': {}, 'sp': {}} + sched.running_jobs[ts_label] = [] + + sched.switch_ts(ts_label) + + self.assertEqual(sched.species_dict[ts_label].chosen_ts, 1) + self.assertIsNone(sched.species_dict[ts_label].e0) + self.assertIsNone(sched.species_dict[ts_label].e_elect) + self.assertIsNone(sched.species_dict[ts_label].freqs) + self.assertIsNone(sched.species_dict[ts_label].checkfile) + + # The consequence: an E0 computed for the new guess must be adopted, not masked by the + # value belonging to guess 0. ``compute_rxn_e0`` returns a copy of the reaction that + # ``copy_e0_values`` merges back, and it only fills an E0 that is empty. + rxn = ARCReaction(label='NH2 + H <=> NH3', + r_species=[ARCSpecies(label='NH2', smiles='[NH2]'), + ARCSpecies(label='H', smiles='[H]')], + p_species=[ARCSpecies(label='NH3', smiles='N')]) + rxn.ts_species = sched.species_dict[ts_label] + rxn_copy = rxn.copy() + rxn_copy.ts_species.e0 = 245.0 + rxn.copy_e0_values(rxn_copy) + self.assertEqual(rxn.ts_species.e0, 245.0) + def setup_ts_scheduler_for_freq_check(self, project, chosen_ts, chosen_ts_list=None): """ Set up a Scheduler with a single TS species whose TSGuess ``index`` (identity) and