From 1967b43c8178d233551bfe06fbe03cb93c378e6c Mon Sep 17 00:00:00 2001 From: Calvin Pieters Date: Wed, 15 Jul 2026 13:22:35 +0300 Subject: [PATCH] Run TS E0 checks for monoatomic participants --- arc/scheduler.py | 17 +++++-- arc/scheduler_test.py | 102 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 5 deletions(-) diff --git a/arc/scheduler.py b/arc/scheduler.py index 3c27829cf5..49b4d249f3 100644 --- a/arc/scheduler.py +++ b/arc/scheduler.py @@ -2952,7 +2952,7 @@ def check_negative_freq(self, def check_rxn_e0_by_spc(self, label: str): """ Check the E0 (electronic energy + ZPE) of reactions related to a specific species. - Requires all opt + freq computations to be converged for all species (and TS) participating in each reaction. + Requires SP energies for all participants and frequencies for all non-monoatomic participants. Args: label (str): A label representing a species. @@ -2960,8 +2960,8 @@ def check_rxn_e0_by_spc(self, label: str): for rxn in self.rxn_list: labels = rxn.reactants + rxn.products + [rxn.ts_label] if label in labels and rxn.ts_species.ts_checks['E0'] is None \ - and all([species_has_sp_and_freq(output_dict, self.species_dict[spc_label].yml_path) - for spc_label, output_dict in self.output.items() if spc_label in labels]): + and all([species_is_ready_for_e0(self.output[spc_label], self.species_dict[spc_label]) + for spc_label in set(labels)]): check_ts(reaction=rxn, checks=['energy'], species_dict=self.species_dict, @@ -3090,7 +3090,7 @@ def post_sp_actions(self, self.output[label]['paths']['sp_no_sol'] = sp_path self.output[label]['paths']['sp'] = original_sp_path # restore the original path - if species_has_freq(self.output[label], self.species_dict[label].yml_path): + if species_is_ready_for_e0(self.output[label], self.species_dict[label]): self.check_rxn_e0_by_spc(label) if self.report_e_elect: @@ -4516,3 +4516,12 @@ def species_has_sp_and_freq(species_output_dict: dict, Whether a species has a valid converged single-point energy and frequencies. """ return species_has_sp(species_output_dict, yml_path) and species_has_freq(species_output_dict, yml_path) + + +def species_is_ready_for_e0(species_output_dict: dict, + species: ARCSpecies, + ) -> bool: + """Check whether a species has the SP energy and, when applicable, frequencies needed to compute its E0.""" + if species.is_monoatomic(): + return species_has_sp(species_output_dict, species.yml_path) + return species_has_sp_and_freq(species_output_dict, species.yml_path) diff --git a/arc/scheduler_test.py b/arc/scheduler_test.py index 05ab83dc41..7d09c7dffa 100644 --- a/arc/scheduler_test.py +++ b/arc/scheduler_test.py @@ -20,7 +20,8 @@ from arc.level import Level from arc.plotter import save_conformers_file from arc.scheduler import (Scheduler, SchedulerError, species_has_freq, species_has_geo, species_has_sp, - species_has_sp_and_freq, tsg_method_matches_adapter) + species_has_sp_and_freq, species_is_ready_for_e0, + tsg_method_matches_adapter) from arc.imports import settings from arc.reaction import ARCReaction from arc.species.converter import str_to_xyz @@ -894,6 +895,105 @@ def test_species_has_geo_sp_freq(self): self.assertTrue(species_has_sp(species_output_dict=species_output_dict, yml_path=yml_path)) self.assertTrue(species_has_sp_and_freq(species_output_dict=species_output_dict, yml_path=yml_path)) + def test_species_is_ready_for_e0(self): + """Monoatomic species require an SP energy but legitimately have no frequencies.""" + output = {'paths': {'sp': 'sp.out', 'freq': '', 'composite': ''}} + monoatomic = ARCSpecies(label='O', smiles='[O]') + molecular = ARCSpecies(label='OH', smiles='[OH]') + self.assertTrue(species_is_ready_for_e0(output, monoatomic)) + self.assertFalse(species_is_ready_for_e0(output, molecular)) + output['paths']['sp'] = '' + self.assertFalse(species_is_ready_for_e0(output, monoatomic)) + + @patch('arc.scheduler.check_ts') + def test_monoatomic_participant_reaches_e0_check_and_switches_ts(self, mock_check_ts): + """A failed E0 check switches guesses even when one reaction participant is monoatomic.""" + scheduler = object.__new__(Scheduler) + species = { + 'R': ARCSpecies(label='R', smiles='OO'), + 'O': ARCSpecies(label='O', smiles='[O]'), + 'P': ARCSpecies(label='P', smiles='O=O'), + 'TS0': ARCSpecies(label='TS0', is_ts=True), + } + species['TS0'].ts_guesses_exhausted = False + species['TS0'].chosen_ts = 1 + rxn = MagicMock() + rxn.reactants = ['R', 'O'] + rxn.products = ['P'] + rxn.ts_label = 'TS0' + rxn.ts_species = species['TS0'] + rxn.label = 'R + O <=> P' + scheduler.rxn_list = [rxn] + scheduler.species_dict = species + scheduler.output = { + label: {'paths': {'sp': 'sp.out', 'freq': '' if label == 'O' else 'freq.out', 'composite': ''}, + 'convergence': True} + for label in species + } + scheduler.project_directory = '/tmp' + scheduler.kinetics_adapter = 'arkane' + scheduler.sp_level = Level('gfn2') + scheduler.composite_method = None + scheduler.freq_scale_factor = 1.0 + scheduler.switch_ts = MagicMock() + + def fail_e0(**kwargs): + kwargs['reaction'].ts_species.ts_checks['E0'] = False + + mock_check_ts.side_effect = fail_e0 + scheduler.check_rxn_e0_by_spc('O') + + mock_check_ts.assert_called_once() + scheduler.switch_ts.assert_called_once_with('TS0') + + @patch('arc.scheduler.check_ts') + @patch('arc.scheduler.parser.parse_e_elect', return_value=-75.0) + def test_atomic_sp_completion_triggers_e0_check_and_switches_ts(self, mock_parse_e_elect, mock_check_ts): + """Completing the last atomic SP triggers the E0 check and switches a failed TS.""" + scheduler = object.__new__(Scheduler) + species = { + 'R': ARCSpecies(label='R', smiles='OO'), + 'O': ARCSpecies(label='O', smiles='[O]'), + 'P': ARCSpecies(label='P', smiles='O=O'), + 'TS0': ARCSpecies(label='TS0', is_ts=True), + } + species['TS0'].ts_guesses_exhausted = False + species['TS0'].chosen_ts = 1 + rxn = MagicMock() + rxn.reactants = ['R', 'O'] + rxn.products = ['P'] + rxn.ts_label = 'TS0' + rxn.ts_species = species['TS0'] + rxn.label = 'R + O <=> P' + scheduler.rxn_list = [rxn] + scheduler.species_dict = species + scheduler.output = { + label: {'paths': {'sp': '' if label == 'O' else 'sp.out', + 'freq': '' if label == 'O' else 'freq.out', + 'composite': ''}, + 'job_types': {'sp': False}, + 'info': '', + 'convergence': True} + for label in species + } + scheduler.project_directory = '/tmp' + scheduler.kinetics_adapter = 'arkane' + scheduler.sp_level = Level('gfn2') + scheduler.composite_method = None + scheduler.freq_scale_factor = 1.0 + scheduler.report_e_elect = False + scheduler.switch_ts = MagicMock() + + def fail_e0(**kwargs): + kwargs['reaction'].ts_species.ts_checks['E0'] = False + + mock_check_ts.side_effect = fail_e0 + scheduler.post_sp_actions('O', 'atomic-sp.out') + + mock_parse_e_elect.assert_called_once_with('atomic-sp.out') + mock_check_ts.assert_called_once() + scheduler.switch_ts.assert_called_once_with('TS0') + def test_add_label_to_unique_species_labels(self): """Test the add_label_to_unique_species_labels() method.""" self.assertEqual(self.sched2.unique_species_labels, ['methylamine', 'C2H6', 'CtripCO'])