Skip to content

Commit 2a4236a

Browse files
alongdclaude
andcommitted
scheduler: keep reaction levels consistent under adaptive_levels
Levels were chosen per species from its own heavy-atom count, so a reaction's large TS and its small wells could fall on different grains, mixing levels of theory across the barrier. Key the whole reaction by its (conserved) heavy-atom count, and for any well on a coarser grain make an autonomous relabeled copy that the reaction uses at the reaction-wide level. The new per-species thermo_at_own_level flag (default True) keeps each species' own granular level for thermo; set it False to evaluate the species at the reaction-wide level with no copy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 840543c commit 2a4236a

5 files changed

Lines changed: 276 additions & 20 deletions

File tree

arc/scheduler.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from arc.level import Level
5353
from arc.species.species import (ARCSpecies,
5454
are_coords_compliant_with_graph,
55+
check_label,
5556
determine_rotor_symmetry,
5657
TSGuess)
5758
from arc.species.converter import (check_isomorphism,
@@ -331,6 +332,8 @@ def __init__(self,
331332
self.save_restart = False
332333

333334
if len(self.rxn_list):
335+
if self.adaptive_levels is not None:
336+
self._apply_adaptive_reaction_levels()
334337
rxn_info_path = self.make_reaction_labels_info_file()
335338
for rxn in self.rxn_list:
336339
logger.info('\n\n')
@@ -920,8 +923,10 @@ def run_job(self,
920923
torsions = species.rotors_dict[rotor_index]['torsion']
921924
torsions = [torsions] if not isinstance(torsions[0], list) else torsions
922925
if self.adaptive_levels is not None and label is not None:
926+
spc = self.species_dict[label]
927+
heavy_atoms = spc.adaptive_lot_n_heavy if spc.adaptive_lot_n_heavy is not None else spc.number_of_heavy_atoms
923928
level_of_theory = self.determine_adaptive_level(original_level_of_theory=level_of_theory, job_type=job_type,
924-
heavy_atoms=self.species_dict[label].number_of_heavy_atoms)
929+
heavy_atoms=heavy_atoms)
925930
job_adapter = job_adapter.lower() if job_adapter is not None else \
926931
self.deduce_job_adapter(level=Level(repr=level_of_theory), job_type=job_type)
927932
args = {'keyword': {}, 'block': {}}
@@ -3936,6 +3941,78 @@ def determine_adaptive_level(self,
39363941
# for any other job type use the original level of theory regardless of the number of heavy atoms
39373942
return original_level_of_theory
39383943

3944+
def _adaptive_atom_range(self, heavy_atoms: int) -> tuple | None:
3945+
"""
3946+
Determine the adaptive levels atom range (the LOT grain) that a heavy-atom count falls into.
3947+
3948+
Args:
3949+
heavy_atoms (int): The number of heavy atoms.
3950+
3951+
Returns:
3952+
tuple | None: The ``(min, max)`` atom range key from ``self.adaptive_levels``, or ``None`` if not found.
3953+
"""
3954+
for atom_range in self.adaptive_levels.keys():
3955+
if (atom_range[1] == 'inf' and heavy_atoms >= atom_range[0]) \
3956+
or (atom_range[1] != 'inf' and atom_range[0] <= heavy_atoms <= atom_range[1]):
3957+
return atom_range
3958+
return None
3959+
3960+
def _apply_adaptive_reaction_levels(self):
3961+
"""
3962+
Make every reaction's energetics internally consistent under adaptive levels of theory.
3963+
3964+
Heavy atoms are conserved across a reaction, so the reaction-wide heavy-atom count (the sum over the reactants,
3965+
equal to the TS supermolecule count) keys a single adaptive level for the whole reaction. A reaction
3966+
participant whose own heavy-atom count lands it on a *different* (finer) adaptive grain than the reaction
3967+
would otherwise be evaluated at an inconsistent level, mixing levels of theory across the barrier. To avoid
3968+
this, each such participant whose ``thermo_at_own_level`` is ``False`` (the default) is itself evaluated at
3969+
the reaction-wide level (no copy). If ``thermo_at_own_level`` is ``True``, an autonomous relabeled copy of
3970+
the species is created from the outset and used by the reaction (evaluated at the reaction-wide level),
3971+
while the original species is left to compute its own thermochemistry at its own granular level. A copy is
3972+
also created for a no-copy participant that is shared across reactions landing on different grains, since a
3973+
single per-species override cannot keep both reactions internally consistent.
3974+
3975+
This runs once during setup, before the reactions are processed, so each reaction is defined with its copies
3976+
from the start (its label, reactants, and products are kept mutually consistent for ``check_attributes`` and
3977+
restart).
3978+
"""
3979+
for rxn_i, rxn in enumerate(self.rxn_list):
3980+
rxn_index = rxn.index if rxn.index is not None else rxn_i
3981+
reactant_species = [self.species_dict[label] for label in rxn.reactants if label in self.species_dict]
3982+
if len(reactant_species) != len(rxn.reactants) \
3983+
or any(spc.number_of_heavy_atoms is None for spc in reactant_species):
3984+
logger.warning(f'Could not determine reaction-wide adaptive levels for {rxn.label}, '
3985+
f'using per-species levels.')
3986+
continue
3987+
reaction_n_heavy = sum(spc.number_of_heavy_atoms for spc in reactant_species)
3988+
reaction_range = self._adaptive_atom_range(reaction_n_heavy)
3989+
for participants in (rxn.reactants, rxn.products):
3990+
for pos, label in enumerate(participants):
3991+
spc = self.species_dict.get(label)
3992+
if spc is None or spc.number_of_heavy_atoms is None \
3993+
or self._adaptive_atom_range(spc.number_of_heavy_atoms) == reaction_range:
3994+
continue
3995+
if not spc.thermo_at_own_level:
3996+
if spc.adaptive_lot_n_heavy is None \
3997+
or self._adaptive_atom_range(spc.adaptive_lot_n_heavy) == reaction_range:
3998+
spc.adaptive_lot_n_heavy = reaction_n_heavy
3999+
continue
4000+
logger.warning(f'Species {label} participates in reactions on different adaptive level '
4001+
f'grains, creating a dedicated copy of it for reaction {rxn.label} to keep '
4002+
f'the reaction internally consistent.')
4003+
copy_label = check_label(f'{label}_TS{rxn_index}')[0]
4004+
if copy_label not in self.species_dict:
4005+
copy_spc = spc.copy()
4006+
copy_spc.label = copy_label
4007+
copy_spc.adaptive_lot_n_heavy = reaction_n_heavy
4008+
copy_spc.compute_thermo = False
4009+
copy_spc.include_in_thermo_lib = False
4010+
self.species_list.append(copy_spc)
4011+
self.species_dict[copy_label] = copy_spc
4012+
self.initialize_output_dict(copy_label)
4013+
participants[pos] = copy_label
4014+
rxn.label = rxn.arrow.join([rxn.plus.join(rxn.reactants), rxn.plus.join(rxn.products)])
4015+
39394016
def initialize_output_dict(self, label: str | None = None):
39404017
"""
39414018
Initialize self.output.

arc/scheduler_test.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1241,5 +1241,125 @@ def test_spawn_ts_jobs_unknown_family_admission_predicate(self):
12411241
self.assertEqual(admit_unknown_family, expected_admission)
12421242

12431243

1244+
class TestSchedulerAdaptiveReactionLevels(unittest.TestCase):
1245+
"""
1246+
Contains unit tests for the reaction-wide adaptive levels of theory logic
1247+
(Scheduler._apply_adaptive_reaction_levels and the spawn_job override).
1248+
"""
1249+
@classmethod
1250+
def setUpClass(cls):
1251+
"""A method that is run before all unit tests in this class."""
1252+
cls.ess_settings = {'gaussian': ['server1'], 'molpro': ['server2', 'server1'], 'qchem': ['server1']}
1253+
# A grain at exactly 1 heavy atom, and another for everything larger, so single-heavy-atom wells
1254+
# (e.g. CH4, OH) land on a different grain than a 2-heavy-atom reaction.
1255+
cls.adaptive_levels = {(1, 1): {('opt', 'freq'): Level(repr='wb97xd/def2tzvp'),
1256+
('sp',): Level(repr='ccsd(t)-f12/cc-pvtz-f12')},
1257+
(2, 'inf'): {('opt', 'freq'): Level(repr='b3lyp/6-31g(d,p)'),
1258+
('sp',): Level(repr='b3lyp/6-311+g(d,p)')}}
1259+
1260+
def build_scheduler(self, rxn, species_list, name):
1261+
"""
1262+
Build a testing Scheduler for a single reaction under the class adaptive levels.
1263+
1264+
Args:
1265+
rxn (ARCReaction): The reaction.
1266+
species_list (list): The species list.
1267+
name (str): A unique project name.
1268+
1269+
Returns:
1270+
Scheduler: The constructed (testing) scheduler.
1271+
"""
1272+
project_directory = os.path.join(ARC_PATH, 'Projects', f'{name}_delete')
1273+
self.addCleanup(shutil.rmtree, project_directory, ignore_errors=True)
1274+
return Scheduler(project=name,
1275+
ess_settings=self.ess_settings,
1276+
species_list=species_list,
1277+
rxn_list=[rxn],
1278+
opt_level=Level(repr='b3lyp/6-31g(d,p)'),
1279+
sp_level=Level(repr='b3lyp/6-311+g(d,p)'),
1280+
freq_level=Level(repr='b3lyp/6-31g(d,p)'),
1281+
adaptive_levels=self.adaptive_levels,
1282+
project_directory=project_directory,
1283+
job_types=initialize_job_types(),
1284+
testing=True)
1285+
1286+
def test_bimolecular_creates_copies(self):
1287+
"""Test that a reaction with wells on a different grain than the reaction gets relabeled copies"""
1288+
r = [ARCSpecies(label='CH4', smiles='C'), ARCSpecies(label='OH', smiles='[OH]')]
1289+
p = [ARCSpecies(label='CH3', smiles='[CH3]'), ARCSpecies(label='H2O', smiles='O')]
1290+
rxn = ARCReaction(label='CH4 + OH <=> CH3 + H2O', r_species=r, p_species=p)
1291+
sched = self.build_scheduler(rxn, r + p, 'adaptive_bimol')
1292+
1293+
# The reaction is now defined with the copies, and stays self-consistent.
1294+
self.assertEqual(rxn.reactants, ['CH4_TS0', 'OH_TS0'])
1295+
self.assertEqual(rxn.products, ['CH3_TS0', 'H2O_TS0'])
1296+
self.assertEqual([s.label for s in rxn.r_species], ['CH4_TS0', 'OH_TS0'])
1297+
self.assertEqual(rxn.label, 'CH4_TS0 + OH_TS0 <=> CH3_TS0 + H2O_TS0')
1298+
rxn.check_attributes() # Must not raise.
1299+
1300+
# The copies are autonomous, evaluated at the reaction-wide level, and not part of the thermo library.
1301+
for copy_label in ['CH4_TS0', 'OH_TS0', 'CH3_TS0', 'H2O_TS0']:
1302+
self.assertIn(copy_label, sched.species_dict)
1303+
self.assertIn(copy_label, sched.output)
1304+
copy_spc = sched.species_dict[copy_label]
1305+
self.assertEqual(copy_spc.adaptive_lot_n_heavy, 2)
1306+
self.assertFalse(copy_spc.compute_thermo)
1307+
self.assertFalse(copy_spc.include_in_thermo_lib)
1308+
1309+
# The originals are untouched - they keep their own granular level and their own thermo.
1310+
for original_label in ['CH4', 'OH', 'CH3', 'H2O']:
1311+
original = sched.species_dict[original_label]
1312+
self.assertIsNone(original.adaptive_lot_n_heavy)
1313+
self.assertTrue(original.compute_thermo)
1314+
1315+
def test_thermo_at_own_level_false_no_copy(self):
1316+
"""Test that with thermo_at_own_level=False the species itself takes the reaction-wide level, with no copy"""
1317+
r = [ARCSpecies(label='CH4', smiles='C', thermo_at_own_level=False),
1318+
ARCSpecies(label='OH', smiles='[OH]', thermo_at_own_level=False)]
1319+
p = [ARCSpecies(label='CH3', smiles='[CH3]', thermo_at_own_level=False),
1320+
ARCSpecies(label='H2O', smiles='O', thermo_at_own_level=False)]
1321+
rxn = ARCReaction(label='CH4 + OH <=> CH3 + H2O', r_species=r, p_species=p)
1322+
sched = self.build_scheduler(rxn, r + p, 'adaptive_noflag')
1323+
1324+
self.assertEqual(rxn.label, 'CH4 + OH <=> CH3 + H2O')
1325+
self.assertFalse(any('_TS' in label for label in sched.species_dict))
1326+
self.assertEqual(sched.species_dict['CH4'].adaptive_lot_n_heavy, 2)
1327+
1328+
def test_unimolecular_no_copy(self):
1329+
"""Test that a reaction whose well shares the reaction's grain gets no copies"""
1330+
r = [ARCSpecies(label='nC3H7', smiles='[CH2]CC')]
1331+
p = [ARCSpecies(label='iC3H7', smiles='C[CH]C')]
1332+
rxn = ARCReaction(label='nC3H7 <=> iC3H7', r_species=r, p_species=p)
1333+
sched = self.build_scheduler(rxn, r + p, 'adaptive_unimol')
1334+
1335+
self.assertEqual(rxn.label, 'nC3H7 <=> iC3H7')
1336+
self.assertFalse(any('_TS' in label for label in sched.species_dict))
1337+
self.assertIsNone(sched.species_dict['nC3H7'].adaptive_lot_n_heavy)
1338+
1339+
def test_spawn_job_uses_override(self):
1340+
"""Test that determine_adaptive_level is driven by adaptive_lot_n_heavy when set"""
1341+
spc = ARCSpecies(label='CH4', smiles='C')
1342+
sched = Scheduler(project='adaptive_override',
1343+
ess_settings=self.ess_settings,
1344+
species_list=[spc],
1345+
opt_level=Level(repr='b3lyp/6-31g(d,p)'),
1346+
sp_level=Level(repr='b3lyp/6-311+g(d,p)'),
1347+
freq_level=Level(repr='b3lyp/6-31g(d,p)'),
1348+
adaptive_levels=self.adaptive_levels,
1349+
project_directory=os.path.join(ARC_PATH, 'Projects', 'adaptive_override_delete'),
1350+
job_types=initialize_job_types(),
1351+
testing=True)
1352+
self.addCleanup(shutil.rmtree, os.path.join(ARC_PATH, 'Projects', 'adaptive_override_delete'),
1353+
ignore_errors=True)
1354+
original = Level(method='CBS-QB3')
1355+
# 1 heavy atom (own count) -> the (1, 1) grain.
1356+
self.assertEqual(sched.determine_adaptive_level(original, 'sp', spc.number_of_heavy_atoms).simple(),
1357+
'ccsd(t)-f12/cc-pvtz-f12')
1358+
# With the override set to a 2-heavy-atom reaction, the (2, 'inf') grain is used instead.
1359+
spc.adaptive_lot_n_heavy = 2
1360+
heavy = spc.adaptive_lot_n_heavy if spc.adaptive_lot_n_heavy is not None else spc.number_of_heavy_atoms
1361+
self.assertEqual(sched.determine_adaptive_level(original, 'sp', heavy).simple(), 'b3lyp/6-311+g(d,p)')
1362+
1363+
12441364
if __name__ == '__main__':
12451365
unittest.main(testRunner=unittest.TextTestRunner(verbosity=2))

arc/species/species.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ class ARCSpecies(object):
109109
bond_corrections (dict, optional): The bond additivity corrections (BAC) to be used. Determined from the
110110
structure if not directly given.
111111
compute_thermo (bool, optional): Whether to calculate thermodynamic properties for this species.
112+
thermo_at_own_level (bool, optional): Relevant only when ``adaptive_levels`` are used. If ``True`` (default),
113+
the species' thermochemistry is computed at its own size-appropriate (granular) adaptive level even when
114+
it participates in a reaction; the reaction's energetics then use a separate relabeled copy of the species
115+
evaluated at the reaction-consistent level. If ``False``, the species itself is evaluated at the
116+
reaction-consistent (coarser) level and no copy is made.
112117
include_in_thermo_lib (bool, optional): Whether to include in the output RMG library.
113118
e0_only (bool, optional): Whether to only run statmech (w/o thermo) to compute E0.
114119
species_dict (dict, optional): A dictionary to create this object from (used when restarting ARC).
@@ -231,6 +236,11 @@ class ARCSpecies(object):
231236
t1 (float): The T1 diagnostic parameter from Molpro.
232237
neg_freqs_trshed (list): A list of negative frequencies this species was troubleshooted for.
233238
compute_thermo (bool): Whether to calculate thermodynamic properties for this species.
239+
thermo_at_own_level (bool): Whether to compute this species' thermochemistry at its own granular adaptive
240+
level even when it participates in a reaction (see the constructor argument; relevant for ``adaptive_levels``).
241+
adaptive_lot_n_heavy (int): An internal override for adaptive-levels selection - the heavy-atom count to key the
242+
level by instead of this species' own count (set to the reaction-wide count for reaction participants).
243+
``None`` means use the species' own heavy-atom count.
234244
include_in_thermo_lib (bool): Whether to include in the output RMG library.
235245
e0_only (bool): Whether to only run statmech (w/o thermo) to compute E0.
236246
thermo (ThermoData): The thermo data calculated by ARC with 'H298' in kJ/mol and 'S298' in J/mol*K.
@@ -307,6 +317,8 @@ def __init__(self,
307317
charge: int | None = None,
308318
checkfile: str | None = None,
309319
compute_thermo: bool | None = None,
320+
thermo_at_own_level: bool = True,
321+
adaptive_lot_n_heavy: int | None = None,
310322
include_in_thermo_lib: bool | None = True,
311323
consider_all_diastereomers: bool = True,
312324
directed_rotors: dict | None = None,
@@ -409,6 +421,8 @@ def __init__(self,
409421
self.chosen_ts_method = None
410422
self.chosen_ts_list = list()
411423
self.compute_thermo = compute_thermo if compute_thermo is not None else not self.is_ts
424+
self.thermo_at_own_level = thermo_at_own_level
425+
self.adaptive_lot_n_heavy = adaptive_lot_n_heavy
412426
self.include_in_thermo_lib = include_in_thermo_lib
413427
self.e0_only = e0_only
414428
self.long_thermo_description = ''
@@ -688,6 +702,10 @@ def as_dict(self,
688702
species_dict['charge'] = self.charge
689703
if not self.compute_thermo and not self.is_ts:
690704
species_dict['compute_thermo'] = self.compute_thermo
705+
if not self.thermo_at_own_level:
706+
species_dict['thermo_at_own_level'] = self.thermo_at_own_level
707+
if self.adaptive_lot_n_heavy is not None:
708+
species_dict['adaptive_lot_n_heavy'] = self.adaptive_lot_n_heavy
691709
if not self.include_in_thermo_lib:
692710
species_dict['include_in_thermo_lib'] = self.include_in_thermo_lib
693711
species_dict['number_of_rotors'] = self.number_of_rotors
@@ -884,6 +902,8 @@ def from_dict(self, species_dict):
884902
self.multi_species = species_dict['multi_species'] if 'multi_species' in species_dict else None
885903
self.charge = species_dict['charge'] if 'charge' in species_dict else 0
886904
self.compute_thermo = species_dict['compute_thermo'] if 'compute_thermo' in species_dict else not self.is_ts
905+
self.thermo_at_own_level = species_dict['thermo_at_own_level'] if 'thermo_at_own_level' in species_dict else True
906+
self.adaptive_lot_n_heavy = species_dict['adaptive_lot_n_heavy'] if 'adaptive_lot_n_heavy' in species_dict else None
887907
self.include_in_thermo_lib = species_dict['include_in_thermo_lib'] if 'include_in_thermo_lib' in species_dict else True
888908
self.e0_only = species_dict['e0_only'] if 'e0_only' in species_dict else False
889909
self.number_of_radicals = species_dict['number_of_radicals'] if 'number_of_radicals' in species_dict else None

0 commit comments

Comments
 (0)