diff --git a/.gitignore b/.gitignore index 1a531fbb1f..c52175385c 100644 --- a/.gitignore +++ b/.gitignore @@ -76,13 +76,17 @@ build/* *.log *.xml +# Test fixtures: real ESS log files / XML are tracked under arc/testing/ +!arc/testing/**/*.log +!arc/testing/**/*.xml -# AI Agent files +# AI Agent files and folders AGENTS.md spec.md +.vexb/* # Other AI things .agents ARC.egg* uv* -*graphify* \ No newline at end of file +*graphify* diff --git a/ARC.py b/ARC.py index 0707ec2130..b02a5e6776 100644 --- a/ARC.py +++ b/ARC.py @@ -12,6 +12,13 @@ from arc.common import read_yaml_file from arc.main import ARC +try: + from tckdb_arc.config import TCKDBConfig + from tckdb_arc.sweep import run_upload_sweep +except ImportError: # in-tree fallback until Phase 4 removes arc/tckdb/ + from arc.tckdb.config import TCKDBConfig + from arc.tckdb.sweep import run_upload_sweep + def parse_command_line_arguments(command_line_args=None): """ @@ -59,8 +66,35 @@ def main(): input_dict['verbose'] = input_dict['verbose'] if 'verbose' in input_dict else verbose if 'project_directory' not in input_dict or not input_dict['project_directory']: input_dict['project_directory'] = project_directory + + tckdb_config = TCKDBConfig.from_dict(input_dict.pop('tckdb', None)) + arc_object = ARC(**input_dict) - arc_object.execute() + arc_object.tckdb_config = tckdb_config + if tckdb_config is not None: + print(f'TCKDB integration enabled: {tckdb_config.base_url}') + + # Persistent SSH pool lives for the duration of the run; close it + # explicitly on every exit path (success, error, ctrl-C) so we don't + # leave paramiko Transports orphaned. Lazily instantiated on first + # remote-queue job, so this is a no-op for fully-local runs. + try: + arc_object.execute() + + if tckdb_config is not None: + try: + from tckdb_arc.adapter import TCKDBAdapter + except ImportError: + from arc.tckdb.adapter import TCKDBAdapter + adapter = TCKDBAdapter(tckdb_config, project_directory=arc_object.project_directory) + run_upload_sweep( + adapter=adapter, + project_directory=arc_object.project_directory, + tckdb_config=tckdb_config, + ) + finally: + from arc.job.ssh_pool import reset_default_pool + reset_default_pool() if __name__ == '__main__': diff --git a/ARC_test.py b/ARC_test.py new file mode 100644 index 0000000000..bad07b5825 --- /dev/null +++ b/ARC_test.py @@ -0,0 +1,354 @@ +"""Tests for the ARC.py end-of-run TCKDB upload sweep dispatcher. + +These tests focus on the wiring between ``tckdb.upload_mode`` and the +adapter method that gets called per species. They use a stub adapter so +no network or live ARC objects are required. +""" + +import os +import shutil +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace + +import yaml + +from arc.tckdb.adapter import UploadOutcome +from arc.tckdb.config import TCKDBConfig +from arc.tckdb.sweep import _resolve_artifact_path, run_upload_sweep + + +# -------------------------------------------------------------------------- +# Test doubles +# -------------------------------------------------------------------------- + + +class _StubAdapter: + """Records which adapter method was called per species, no network.""" + + def __init__(self, *, conformer_outcome=None, bundle_outcome=None, + conformer_raises=None, bundle_raises=None): + self.conformer_calls = [] + self.bundle_calls = [] + self.artifact_calls = [] + self._conformer_outcome = conformer_outcome + self._bundle_outcome = bundle_outcome + self._conformer_raises = conformer_raises + self._bundle_raises = bundle_raises + + def submit_from_output(self, *, output_doc, species_record): + self.conformer_calls.append(species_record.get("label")) + if self._conformer_raises is not None: + raise self._conformer_raises + return self._conformer_outcome + + def submit_computed_species_from_output(self, *, output_doc, species_record): + self.bundle_calls.append(species_record.get("label")) + if self._bundle_raises is not None: + raise self._bundle_raises + return self._bundle_outcome + + def submit_artifacts_for_calculation(self, **kwargs): + self.artifact_calls.append(kwargs) + return None + + +def _outcome(status, *, label="ethanol", error=None, + primary=None, additional=None): + """Build a stand-in UploadOutcome with the fields the sweep reads.""" + return UploadOutcome( + status=status, + payload_path=Path(f"/tmp/{label}.payload.json"), + sidecar_path=Path(f"/tmp/{label}.meta.json"), + idempotency_key=f"arc:test:{label}:k:abc1234567890def", + error=error, + primary_calculation=primary, + additional_calculations=additional or [], + ) + + +# -------------------------------------------------------------------------- +# Fixtures +# -------------------------------------------------------------------------- + + +def _write_output_yml(project_dir: str, *, species_labels=("CCO",), with_ts=False): + """Write a minimal ``output.yml`` matching what the sweep reads.""" + out_dir = os.path.join(project_dir, "output") + os.makedirs(out_dir, exist_ok=True) + doc = { + "schema_version": "1.0", + "project": "test_project", + "arc_version": "0.0.0", + "opt_level": {"method": "wb97xd", "basis": "def2-tzvp", "software": "gaussian"}, + "species": [ + { + "label": label, + "smiles": "CCO", + "charge": 0, + "multiplicity": 1, + "is_ts": False, + "converged": True, + "xyz": "C 0.0 0.0 0.0\nH 1.0 0.0 0.0", + "opt_n_steps": 12, + "opt_final_energy_hartree": -154.0, + "ess_versions": {"opt": "Gaussian 16, Revision A.03"}, + } + for label in species_labels + ], + "transition_states": [ + {"label": "TS0", "is_ts": True, "converged": True} + ] if with_ts else [], + } + with open(os.path.join(out_dir, "output.yml"), "w") as f: + yaml.safe_dump(doc, f) + return doc + + +# -------------------------------------------------------------------------- +# Dispatch behavior +# -------------------------------------------------------------------------- + + +class TestRunTckdbUploadSweepDispatch(unittest.TestCase): + """Wiring tests: which adapter method gets called per upload_mode.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="arc-sweep-test-") + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + _write_output_yml(self.tmp) + self.arc_object = SimpleNamespace(project_directory=self.tmp) + + def _cfg(self, **overrides): + defaults = dict( + enabled=True, + base_url="http://localhost:8000/api/v1", + api_key_env="X_TCKDB_API_KEY", + ) + defaults.update(overrides) + return TCKDBConfig(**defaults) + + # ---------------- 1: missing upload_mode → conformer (default) + def test_default_mode_uses_legacy_conformer_path(self): + cfg = self._cfg() # upload_mode defaults to "conformer" + adapter = _StubAdapter(conformer_outcome=_outcome("uploaded")) + run_upload_sweep(adapter=adapter, project_directory=self.arc_object.project_directory, tckdb_config=cfg) + self.assertEqual(adapter.conformer_calls, ["CCO"]) + self.assertEqual(adapter.bundle_calls, []) + + # ---------------- 2: explicit conformer + def test_explicit_conformer_mode_uses_legacy_path(self): + cfg = self._cfg(upload_mode="conformer") + adapter = _StubAdapter(conformer_outcome=_outcome("uploaded")) + run_upload_sweep(adapter=adapter, project_directory=self.arc_object.project_directory, tckdb_config=cfg) + self.assertEqual(adapter.conformer_calls, ["CCO"]) + self.assertEqual(adapter.bundle_calls, []) + + # ---------------- 3: computed_species → bundle path + def test_computed_species_mode_dispatches_bundle(self): + cfg = self._cfg(upload_mode="computed_species") + adapter = _StubAdapter(bundle_outcome=_outcome("uploaded")) + run_upload_sweep(adapter=adapter, project_directory=self.arc_object.project_directory, tckdb_config=cfg) + self.assertEqual(adapter.bundle_calls, ["CCO"]) + self.assertEqual(adapter.conformer_calls, []) + + # ---------------- 4: bundle path never calls legacy + def test_computed_species_does_not_call_legacy_submit(self): + # Multiple species so we'd notice any leak across iterations. + _write_output_yml(self.tmp, species_labels=("CCO", "CO", "CC")) + cfg = self._cfg(upload_mode="computed_species") + adapter = _StubAdapter(bundle_outcome=_outcome("uploaded")) + run_upload_sweep(adapter=adapter, project_directory=self.arc_object.project_directory, tckdb_config=cfg) + self.assertEqual(adapter.bundle_calls, ["CCO", "CO", "CC"]) + self.assertEqual(adapter.conformer_calls, []) + # And no per-artifact sweep call: bundles inline artifacts. + self.assertEqual(adapter.artifact_calls, []) + + # ---------------- 5: failure in bundle mode is recorded; sweep continues + def test_computed_species_failure_continues_to_next_species(self): + _write_output_yml(self.tmp, species_labels=("CCO", "CO")) + cfg = self._cfg(upload_mode="computed_species") + # First species: outcome with status=failed (non-strict path). + # Second species: outcome with status=uploaded. + # We achieve "different per call" by mutating the stub's outcome + # mid-sweep, since the stub returns the same outcome each call by + # default. Use a side-effect via a wrapper instead. + outcomes = iter([ + _outcome("failed", label="CCO", error="HTTP 503"), + _outcome("uploaded", label="CO"), + ]) + adapter = _StubAdapter() + adapter.submit_computed_species_from_output = ( + lambda *, output_doc, species_record: ( + adapter.bundle_calls.append(species_record.get("label")) + or next(outcomes) + ) + ) + run_upload_sweep(adapter=adapter, project_directory=self.arc_object.project_directory, tckdb_config=cfg) + # Both species processed; first failed, second uploaded. + self.assertEqual(adapter.bundle_calls, ["CCO", "CO"]) + + # ---------------- 5b: an unhandled exception in bundle mode is caught + def test_computed_species_exception_is_caught_and_logged(self): + _write_output_yml(self.tmp, species_labels=("CCO", "CO")) + cfg = self._cfg(upload_mode="computed_species") + # Simulate an unhandled exception on the FIRST species; second + # should still be attempted (matches conformer-mode behavior). + call_log = [] + def fake_submit(*, output_doc, species_record): + label = species_record.get("label") + call_log.append(label) + if label == "CCO": + raise RuntimeError("boom") + return _outcome("uploaded", label=label) + adapter = _StubAdapter() + adapter.submit_computed_species_from_output = fake_submit + run_upload_sweep(adapter=adapter, project_directory=self.arc_object.project_directory, tckdb_config=cfg) + self.assertEqual(call_log, ["CCO", "CO"]) + + # ---------------- 6: sidecar written before live upload failure (bundle) + def test_bundle_mode_sidecar_written_before_upload_failure(self): + # This is fundamentally an adapter-level guarantee, but we verify + # the wiring preserves it: a "failed" outcome carrying real + # payload_path and sidecar_path values means the sweep still + # passes those upward to the user. + cfg = self._cfg(upload_mode="computed_species") + sentinel_payload = Path("/tmp/sentinel.payload.json") + sentinel_sidecar = Path("/tmp/sentinel.meta.json") + outcome = UploadOutcome( + status="failed", + payload_path=sentinel_payload, + sidecar_path=sentinel_sidecar, + idempotency_key="arc:t:CCO:c:abc1234567890def", + error="HTTP 503", + ) + adapter = _StubAdapter(bundle_outcome=outcome) + # Capture stdout to confirm the failure summary is printed + # (don't assert on exact text — assert on key tokens). + with unittest.mock.patch("builtins.print") as mock_print: + run_upload_sweep(adapter=adapter, project_directory=self.arc_object.project_directory, tckdb_config=cfg) + printed = "\n".join(str(c.args[0]) for c in mock_print.call_args_list) + self.assertIn("computed-species bundle", printed) + self.assertIn("failed: 1", printed) + self.assertIn("HTTP 503", printed) + + +# -------------------------------------------------------------------------- +# Summary-print mode awareness +# -------------------------------------------------------------------------- + + +class TestSweepSummaryByMode(unittest.TestCase): + """The summary line names the mode; bundle mode omits the artifact line.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="arc-sweep-summary-") + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + _write_output_yml(self.tmp) + self.arc_object = SimpleNamespace(project_directory=self.tmp) + + def _run_with_mode(self, *, upload_mode, artifacts_upload=False): + from arc.tckdb.config import TCKDBArtifactConfig + cfg = TCKDBConfig( + enabled=True, base_url="http://x", api_key_env="X", + upload_mode=upload_mode, + artifacts=TCKDBArtifactConfig(upload=artifacts_upload), + ) + adapter = _StubAdapter( + conformer_outcome=_outcome("uploaded"), + bundle_outcome=_outcome("uploaded"), + ) + with unittest.mock.patch("builtins.print") as mock_print: + run_upload_sweep(adapter=adapter, project_directory=self.arc_object.project_directory, tckdb_config=cfg) + return "\n".join(str(c.args[0]) for c in mock_print.call_args_list) + + def test_conformer_mode_summary_says_conformer(self): + out = self._run_with_mode(upload_mode="conformer") + self.assertIn("conformer/calculation", out) + self.assertNotIn("computed-species bundle", out) + + def test_bundle_mode_summary_says_bundle(self): + out = self._run_with_mode(upload_mode="computed_species") + self.assertIn("computed-species bundle", out) + self.assertNotIn("conformer/calculation", out) + + def test_bundle_mode_omits_artifact_line_even_when_enabled(self): + # Inline artifacts mean the standalone artifact tally would mislead. + out = self._run_with_mode(upload_mode="computed_species", artifacts_upload=True) + self.assertNotIn("artifacts: uploaded", out) + + def test_conformer_mode_emits_artifact_line_when_enabled(self): + out = self._run_with_mode(upload_mode="conformer", artifacts_upload=True) + self.assertIn("artifacts:", out) + + +# -------------------------------------------------------------------------- +# _resolve_artifact_path: prefer recorded _input over derivation +# -------------------------------------------------------------------------- + + +class TestResolveArtifactPath(unittest.TestCase): + """The legacy artifact sweep prefers ``output.yml``'s ``_input`` + field, falling back to settings-based derivation only when absent.""" + + def test_input_kind_prefers_recorded_field(self): + """When ``opt_input`` is on the record, it wins over the derived path.""" + species_record = { + "opt_log": "calcs/CH4/opt/input.log", + "opt_input": "calcs/CH4/opt/explicit_input.gjf", # NEW field + } + output_doc = {"opt_level": {"software": "gaussian"}} + path = _resolve_artifact_path( + kind="input", calc_type="opt", + species_record=species_record, output_doc=output_doc, + ) + self.assertEqual(path, "calcs/CH4/opt/explicit_input.gjf") + + def test_input_kind_falls_back_to_settings_when_field_absent(self): + """Older output.yml without ``_input`` still resolves via settings.""" + species_record = {"opt_log": "/abs/calcs/CH4/opt/input.log"} + output_doc = {"opt_level": {"software": "gaussian"}} + path = _resolve_artifact_path( + kind="input", calc_type="opt", + species_record=species_record, output_doc=output_doc, + ) + # Derived sibling: input.gjf next to the log. + self.assertEqual(path, "/abs/calcs/CH4/opt/input.gjf") + + def test_input_kind_falls_back_when_recorded_field_is_none(self): + """Explicit ``None`` in the record (deck wasn't kept) → fallback.""" + species_record = { + "opt_log": "/abs/calcs/CH4/opt/input.log", + "opt_input": None, + } + output_doc = {"opt_level": {"software": "gaussian"}} + path = _resolve_artifact_path( + kind="input", calc_type="opt", + species_record=species_record, output_doc=output_doc, + ) + self.assertEqual(path, "/abs/calcs/CH4/opt/input.gjf") + + def test_input_kind_per_job_picks_correct_recorded_field(self): + """Different calcs hit different ``_input`` fields, not all opt's.""" + species_record = { + "opt_log": "/abs/opt.log", "opt_input": "/abs/opt_deck.gjf", + "freq_log": "/abs/freq.log", "freq_input": "/abs/freq_deck.gjf", + "sp_log": "/abs/sp.log", "sp_input": "/abs/sp_deck.in", # cross-software run + } + output_doc = {"opt_level": {"software": "gaussian"}} + for calc, expected in ( + ("opt", "/abs/opt_deck.gjf"), + ("freq", "/abs/freq_deck.gjf"), + ("sp", "/abs/sp_deck.in"), # NOT input.gjf — sp uses its own software + ): + path = _resolve_artifact_path( + kind="input", calc_type=calc, + species_record=species_record, output_doc=output_doc, + ) + self.assertEqual(path, expected, + msg=f"{calc}: expected {expected}, got {path}") + + +if __name__ == "__main__": + unittest.main() diff --git a/Makefile b/Makefile index 08ebdd70eb..343134ac71 100644 --- a/Makefile +++ b/Makefile @@ -35,6 +35,7 @@ help: @echo " install-kinbot Install KinBot" @echo " install-sella Install Sella" @echo " install-xtb Install xTB" + @echo " install-crest Install CREST" @echo " install-torchani Install TorchANI" @echo " install-uma Install UMA (fairchem MLIP, gated model; users only, not CI)" @echo " install-ob Install OpenBabel" @@ -99,6 +100,9 @@ install-sella: install-xtb: bash $(DEVTOOLS_DIR)/install_xtb.sh +install-crest: + bash $(DEVTOOLS_DIR)/install_crest.sh + install-torchani: bash $(DEVTOOLS_DIR)/install_torchani.sh diff --git a/arc/checks/nmd.py b/arc/checks/nmd.py index b59996262d..09cd4beda9 100644 --- a/arc/checks/nmd.py +++ b/arc/checks/nmd.py @@ -25,6 +25,7 @@ from arc.parser import parser from arc.checks.common import get_index_of_abs_largest_neg_freq, record_ts_check_warning from arc.common import get_element_mass, get_logger +from arc.exceptions import ReactionError from arc.species.converter import get_most_common_isotope_for_element, xyz_from_data, xyz_to_np_array from arc.species.vectors import calculate_distance, VectorsError @@ -39,6 +40,7 @@ SIGMA_THRESHOLD = 3.0 STD_FLOOR = 1e-4 DIRECTIONALITY_MIN_DELTA = 0.005 +NMD_UNDETERMINED_BONDS_WARNING = 'Could not determine the bonds that change in the reaction, NMD not checked; ' DEFAULT_AMPLITUDE = 0.9 ATOM_COUNT_MISMATCH_WARNING = 'The TS atom count does not match the reactants; skipped the TS normal mode ' \ 'displacement check; ' @@ -102,7 +104,14 @@ def analyze_ts_normal_mode_displacement(reaction: ARCReaction, f'applied to this TS geometry. Skipping the normal mode displacement analysis.') record_ts_check_warning(species=reaction.ts_species, warning=ATOM_COUNT_MISMATCH_WARNING) return None - bond_change_candidates = get_bond_change_candidates(reaction=reaction) + try: + bond_change_candidates = get_bond_change_candidates(reaction=reaction) + except ReactionError as e: + logger.warning(f'Could not determine the bonds that change in reaction {reaction.label}, ' + f'got {type(e).__name__}: {e}\n' + f'Not checking the normal mode displacement of TS {reaction.ts_species.label}.') + record_ts_check_warning(species=reaction.ts_species, warning=NMD_UNDETERMINED_BONDS_WARNING) + return None if not bond_change_candidates: logger.warning(f'Neither the reaction family recipe nor an atom map could supply the bonds that change ' f'in reaction {reaction.label}, so there is nothing to validate the normal mode against. ' diff --git a/arc/checks/nmd_test.py b/arc/checks/nmd_test.py index 08a11e3a7d..cd44a8ac93 100644 --- a/arc/checks/nmd_test.py +++ b/arc/checks/nmd_test.py @@ -5,6 +5,7 @@ This module contains unit tests for the arc.checks.nmd module """ +import copy import unittest import math import os @@ -15,7 +16,8 @@ import arc.checks.nmd as nmd from arc.checks.ts import check_ts -from arc.common import ARC_PATH, ARC_TESTING_PATH, get_element_mass +from arc.common import ARC_PATH, ARC_TESTING_PATH, get_element_mass, get_test_project_directory +from arc.exceptions import ReactionError from arc.job.factory import job_factory from arc.level import Level from arc.molecule import Molecule @@ -37,12 +39,13 @@ def setUpClass(cls): A method that is run before all unit tests in this class. """ cls.maxDiff = None + cls.project_directory = get_test_project_directory('tmp_nmd_project') cls.generic_job = job_factory(job_adapter='gaussian', species=[ARCSpecies(label='SPC', smiles='C')], job_type='composite', level=Level(method='CBS-QB3'), project='test_project', - project_directory=os.path.join(ARC_PATH, 'Projects', 'tmp_nmd_project'), + project_directory=cls.project_directory, ) cls.xyz_1 = {'symbols': ('C', 'N', 'H', 'H', 'H', 'H'), 'isotopes': (13, 14, 1, 1, 1, 1), @@ -650,11 +653,17 @@ def test_get_ts_xyz_in_normal_mode_frame_warns_when_neither_source_has_a_geometr self.assertNotIn('frame of the normal modes', warning) def test_get_ts_xyz_in_normal_mode_frame_falls_back_when_parsing_fails(self): - """Test that a log file the geometry parser cannot handle falls back to the species geometry.""" + """Test that a geometry parser which raises falls back to the species geometry. + + The parser is made to raise rather than being fed a log that happens to break it, so that + this branch stays covered as parser support for more ESS log formats is added. + """ log_path = os.path.join(ARC_TESTING_PATH, 'freq', 'CH2O_freq_molpro.out') self.generic_job.local_path_to_output_file = log_path rxn = self.make_ch4_oh_rxn(ts_xyz=self.ts_1_xyz) - with self.assertLogs('arc', level='WARNING') as captured: + with patch.object(nmd.parser, 'parse_geometry_in_normal_mode_frame', + side_effect=NotImplementedError('no parser adapter for this log')), \ + self.assertLogs('arc', level='WARNING') as captured: frame_xyz = nmd.get_ts_xyz_in_normal_mode_frame(reaction=rxn, job=self.generic_job) self.assertEqual(frame_xyz['symbols'], self.ts_1_xyz['symbols']) np.testing.assert_allclose(np.array(frame_xyz['coords']), @@ -753,6 +762,25 @@ def test_analyze_ts_normal_mode_displacement_correct_and_incorrect_data(self): valid = nmd.analyze_ts_normal_mode_displacement(reaction=rxn, job=self.generic_job, amplitude=amplitude) self.assertFalse(valid) + def test_analyze_ts_normal_mode_displacement_when_the_bonds_are_undetermined(self): + """Test analyze_ts_normal_mode_displacement() when the bonds that change cannot be determined.""" + def raise_reaction_error(): + raise ReactionError('Cannot get bonds without an atom map.') + + def raise_value_error(): + raise ValueError('11 is not in list') + + self.generic_job.local_path_to_output_file = os.path.join(ARC_TESTING_PATH, 'freq', 'TS_CH4_OH.log') + rxn = copy.deepcopy(self.rxn_1) + rxn.get_formed_and_broken_bonds = raise_reaction_error + valid = nmd.analyze_ts_normal_mode_displacement(reaction=rxn, job=self.generic_job, amplitude=0.25) + self.assertIsNone(valid) + self.assertIn(nmd.NMD_UNDETERMINED_BONDS_WARNING, rxn.ts_species.ts_checks['warnings']) + + rxn.get_formed_and_broken_bonds = raise_value_error + with self.assertRaises(ValueError): + nmd.analyze_ts_normal_mode_displacement(reaction=rxn, job=self.generic_job, amplitude=0.25) + def test_analyze_ts_normal_mode_displacement_for_hypervalence_nitrogen(self): """Test the analyze_ts_normal_mode_displacement() function for a hypervalence nitrogen.""" # C2H5NO2 <=> C2H5ONO @@ -1489,10 +1517,7 @@ def tearDownClass(cls): A function that is run ONCE after all unit tests in this class. Delete all project directories created during these unit tests """ - projects = ['tmp_nmd_project'] - for project in projects: - project_directory = os.path.join(ARC_PATH, 'Projects', project) - shutil.rmtree(project_directory, ignore_errors=True) + shutil.rmtree(cls.project_directory, ignore_errors=True) file_paths = [os.path.join(ARC_PATH, 'arc', 'checks', 'nul'), os.path.join(ARC_PATH, 'arc', 'checks', 'run.out')] for file_path in file_paths: if os.path.isfile(file_path): diff --git a/arc/checks/ts.py b/arc/checks/ts.py index a141d696dc..108ae5b270 100644 --- a/arc/checks/ts.py +++ b/arc/checks/ts.py @@ -254,7 +254,8 @@ def check_rxn_e0(reaction: ARCReaction, if r_e0 >= ts_e0 or p_e0 >= ts_e0: reaction.ts_species.ts_checks['E0'] = False if r_e0 + 1 >= ts_e0 or p_e0 + 1 >= ts_e0: - logger.warning('TS energy gas relative to one fo the wells is lower than 1 kJ/mol, skipping this TS') + logger.warning(f'The TS energy of {reaction.ts_label} is less than 1 kJ/mol above one of the wells ' + f'of {reaction.label}, skipping this TS') reaction.ts_species.ts_checks['E0'] = False else: reaction.ts_species.ts_checks['E0'] = True diff --git a/arc/checks/ts_test.py b/arc/checks/ts_test.py index 46eec30cbe..87567b177c 100644 --- a/arc/checks/ts_test.py +++ b/arc/checks/ts_test.py @@ -13,7 +13,7 @@ import numpy as np import arc.checks.ts as ts -from arc.common import ARC_PATH, ARC_TESTING_PATH, almost_equal_lists +from arc.common import ARC_PATH, ARC_TESTING_PATH, almost_equal_lists, get_test_project_directory from arc.exceptions import ReactionError from arc.job.factory import job_factory from arc.level import Level @@ -105,9 +105,7 @@ def setUpClass(cls): job_type='composite', level=Level(method='CBS-QB3'), project='test_project', - project_directory=os.path.join(ARC_PATH, - 'Projects', - 'arc_project_for_testing_delete_after_usage4'), + project_directory=get_test_project_directory('arc_project_for_testing_delete_after_usage4'), ) cls.rxn_3 = ARCReaction(r_species=[ARCSpecies(label='NH3', smiles='N'), ARCSpecies(label='H', smiles='[H]')], @@ -215,7 +213,7 @@ def setUpClass(cls): (-1.1265684046717404, -0.2344009055503307, -1.0127644068816903))} cls.species_dict_8 = {spc.label: spc for spc in cls.rxn_8.r_species + cls.rxn_8.p_species + [cls.rxn_8.ts_species]} - cls.project_directory_8 = os.path.join(ts.ARC_PATH, 'Projects', 'arc_project_for_testing_delete_after_usage5') + cls.project_directory_5 = get_test_project_directory('arc_project_for_testing_delete_after_usage5') cls.output_dict_8 = {'iC3H7': {'paths': {'freq': os.path.join(ARC_TESTING_PATH, 'freq', 'iC3H7.out'), 'sp': os.path.join(ARC_TESTING_PATH, 'opt', 'iC3H7.out'), 'opt': os.path.join(ARC_TESTING_PATH, 'opt', 'iC3H7.out'), @@ -350,9 +348,9 @@ def test_compute_rxn_e0(self): """Test the compute_rxn_e0() function.""" for spc_label in self.rxn_8.reactants + self.rxn_8.products + [self.rxn_8.ts_label]: folder = 'rxns' if self.species_dict_8[spc_label].is_ts else 'Species' - base_path = os.path.join(self.project_directory_8, 'output', folder, spc_label, 'geometry') + base_path = os.path.join(self.project_directory_5, 'output', folder, spc_label, 'geometry') os.makedirs(base_path, exist_ok=True) - freq_path = os.path.join(self.project_directory_8, 'output', folder, spc_label, 'geometry', 'freq.out') + freq_path = os.path.join(self.project_directory_5, 'output', folder, spc_label, 'geometry', 'freq.out') shutil.copy(src=self.output_dict_8[spc_label]['paths']['freq'], dst=freq_path) self.assertIsNone(self.rxn_8.r_species[0].e0) @@ -361,7 +359,7 @@ def test_compute_rxn_e0(self): rxn_copy = ts.compute_rxn_e0(reaction=self.rxn_8, species_dict=self.species_dict_8, - project_directory=self.project_directory_8, + project_directory=self.project_directory_5, kinetics_adapter='arkane', output=self.output_dict_8, sp_level=Level(repr='cbs-qb3'), @@ -375,13 +373,13 @@ def test_check_rxn_e0(self): """Test the check_rxn_e0() function.""" for spc_label in self.rxn_8.reactants + self.rxn_8.products + [self.rxn_8.ts_label]: folder = 'rxns' if self.species_dict_8[spc_label].is_ts else 'Species' - base_path = os.path.join(self.project_directory_8, 'output', folder, spc_label, 'geometry') + base_path = os.path.join(self.project_directory_5, 'output', folder, spc_label, 'geometry') os.makedirs(base_path, exist_ok=True) - freq_path = os.path.join(self.project_directory_8, 'output', folder, spc_label, 'geometry', 'freq.out') + freq_path = os.path.join(self.project_directory_5, 'output', folder, spc_label, 'geometry', 'freq.out') shutil.copy(src=self.output_dict_8[spc_label]['paths']['freq'], dst=freq_path) rxn_copy = ts.compute_rxn_e0(reaction=self.rxn_8, species_dict=self.species_dict_8, - project_directory=self.project_directory_8, + project_directory=self.project_directory_5, kinetics_adapter='arkane', output=self.output_dict_8, sp_level=Level(repr='CBS-QB3'), @@ -1030,15 +1028,38 @@ def test_check_irc_isomorphism_mismatch_alone_is_not_a_failure(self): ts.check_irc_species_and_rxn(xyz_1=xyz_2, xyz_2=xyz_2, rxn=rxn) self.assertIsNone(rxn.ts_species.ts_checks['IRC']) + def test_check_irc_identical_endpoints(self): + """Test that two identical IRC endpoints are a positive IRC failure (False, not None).""" + xyz_1 = parse_geometry(os.path.join(ARC_TESTING_PATH, 'irc', 'rxn_1_irc_1.out')) + xyz_2 = parse_geometry(os.path.join(ARC_TESTING_PATH, 'irc', 'rxn_1_irc_2.out')) + rxn = ARCReaction(r_species=[ARCSpecies(label='R', smiles='O=[C]COO', xyz=xyz_1)], + p_species=[ARCSpecies(label='P', smiles='O=CCO[O]', xyz=xyz_2)]) + rxn.ts_species = ARCSpecies(label='TS', is_ts=True) + # Both endpoints re-perceive as the product, the "TS" connects P <=> P. + ts.check_irc_species_and_rxn(xyz_1=xyz_2, xyz_2=xyz_2, rxn=rxn) + self.assertIs(rxn.ts_species.ts_checks['IRC'], False) + + def test_check_irc_unknown_if_no_comparison_was_performed(self): + """Test that the IRC check is None (unknown), not False, if no comparison could be performed.""" + xyz_1 = parse_geometry(os.path.join(ARC_TESTING_PATH, 'irc', 'rxn_1_irc_1.out')) + xyz_2 = parse_geometry(os.path.join(ARC_TESTING_PATH, 'irc', 'rxn_1_irc_2.out')) + rxn = ARCReaction(r_species=[ARCSpecies(label='R', smiles='O=[C]COO', xyz=xyz_1)], + p_species=[ARCSpecies(label='P', smiles='O=CCO[O]', xyz=xyz_2)]) + rxn.ts_species = ARCSpecies(label='TS', is_ts=True) + # Neither the isomorphism check nor the bond-list fallback can be carried out. + with patch.object(ts, '_perceive_irc_fragments', return_value=None), \ + patch.object(rxn, 'get_bonds', side_effect=ReactionError('Cannot get bonds without an atom map.')): + ts.check_irc_species_and_rxn(xyz_1=xyz_1, xyz_2=xyz_2, rxn=rxn) + self.assertIsNone(rxn.ts_species.ts_checks['IRC']) + @classmethod def tearDownClass(cls): """ A function that is run ONCE after all unit tests in this class. Delete all project directories created during these unit tests """ - projects = ['arc_project_for_testing_delete_after_usage4', 'arc_project_for_testing_delete_after_usage5'] - for project in projects: - project_directory = os.path.join(ARC_PATH, 'Projects', project) + for project_directory in [get_test_project_directory('arc_project_for_testing_delete_after_usage4'), + cls.project_directory_5]: shutil.rmtree(project_directory, ignore_errors=True) file_paths = [os.path.join(ARC_PATH, 'arc', 'checks', 'nul'), os.path.join(ARC_PATH, 'arc', 'checks', 'run.out')] for file_path in file_paths: diff --git a/arc/common.py b/arc/common.py index 1db45bf329..78c6d24a9c 100644 --- a/arc/common.py +++ b/arc/common.py @@ -33,6 +33,7 @@ if TYPE_CHECKING: from arc.molecule.molecule import Atom, Molecule + from arc.species.species import ARCSpecies logger = logging.getLogger('arc') logging.getLogger('matplotlib.font_manager').disabled = True @@ -156,7 +157,7 @@ def check_ess_settings(ess_settings: dict | None = None, f'strings. Got: {server_list} which is a {type(server_list)}') # run checks: for ess, server_list in settings_dict.items(): - if ess.lower() not in supported_ess + ['gcn', 'goflow', 'heuristics', 'autotst', 'kinbot', 'rits', 'xtb_gsm', 'orca_neb']: + if ess.lower() not in supported_ess + ['gcn', 'goflow', 'heuristics', 'autotst', 'kinbot', 'rits', 'xtb_gsm', 'orca_neb', 'qst2']: raise SettingsError(f'Recognized ESS software are {supported_ess}. Got: {ess}') for server in server_list: if not isinstance(server, bool) and server.lower() not in [s.lower() for s in servers.keys()]: @@ -357,6 +358,66 @@ def log_footer(execution_time: str, logger.log(level, f'ARC execution terminated on {time.asctime()}') +def format_table(headers: Sequence[str | Sequence[str]], + rows: Sequence[Sequence[str]], + alignments: str | None = None, + separator: str = ' ', + rule_char: str = '-', + ) -> list[str]: + """ + Format a table as a list of lines with aligned columns. + + Each column is exactly as wide as its widest entry, considering both its header and its cells. + Widths are counted in characters, so the columns line up for single-width text. + A header may be given as a sequence of strings to render it on several lines, e.g., a title + line and a units line; shorter headers are padded with blank lines at the bottom, placing every + header's first string on the first line. Trailing whitespace is stripped from every line. + + Args: + headers (Sequence[str | Sequence[str]]): The column headers, each a string, + or a sequence of strings for a multi-line header. + rows (Sequence[Sequence[str]]): The table rows, each a sequence of one cell string per column. + alignments (str, optional): One alignment character ('<', '>' or '^') per column. + Columns are left-aligned if not given. + separator (str, optional): The string separating two adjacent columns. + rule_char (str, optional): The character to draw the rule under the header with, + an empty string to omit the rule. + + Returns: list[str] + The rendered lines: the header line(s), a rule, and one line per row. + + Raises: + InputError: If a row, or the alignments, does not have exactly one entry per column, + if an alignment character is not one of '<', '>' and '^', + or if a cell is not a string. + """ + headers = [(header,) if isinstance(header, str) else tuple(header) for header in headers] + alignments = alignments if alignments is not None else '<' * len(headers) + if len(alignments) != len(headers): + raise InputError(f'Expected {len(headers)} alignment characters, got {len(alignments)}: {alignments}') + if any(alignment not in '<>^' for alignment in alignments): + raise InputError(f'Alignment characters must each be one of "<", ">" and "^", got {alignments}') + for row in rows: + if len(row) != len(headers): + raise InputError(f'Expected {len(headers)} cells per row, got {len(row)}: {row}') + if any(not isinstance(cell, str) for cell in row): + raise InputError(f'Table cells must be strings, got {row}') + header_height = max((len(header) for header in headers), default=0) + headers = [header + ('',) * (header_height - len(header)) for header in headers] + widths = [max([len(line) for line in header] + [len(row[i]) for row in rows] + [0]) + for i, header in enumerate(headers)] + + def render(cells: Sequence[str]) -> str: + """Render one line of cells, padded to the column widths and stripped of trailing space.""" + return separator.join(f'{cell:{alignment}{width}}' + for cell, alignment, width in zip(cells, alignments, widths)).rstrip() + + lines = [render([header[i] for header in headers]) for i in range(header_height)] + if rule_char: + lines.append(render([rule_char * width for width in widths])) + return lines + [render(row) for row in rows] + + def get_git_commit(path: str | None = None) -> tuple[str, str]: """ Get the recent git commit to be logged. @@ -560,6 +621,35 @@ def globalize_path(string: str, return string +def get_test_project_name(base_name: str) -> str: + """ + Get a project name for a unit test that is unique per pytest-xdist worker. + + Args: + base_name (str): The base project name. + + Returns: + str: ``base_name`` suffixed by the pytest-xdist worker ID when running under pytest-xdist, + otherwise ``base_name`` unchanged. + """ + worker_id = os.environ.get('PYTEST_XDIST_WORKER') + return f'{base_name}_{worker_id}' if worker_id else base_name + + +def get_test_project_directory(base_name: str) -> str: + """ + Get a path to a project directory for a unit test that is unique per pytest-xdist worker. + + Args: + base_name (str): The base project name. + + Returns: + str: The path under ARC's ``Projects`` folder to a directory named ``base_name`` suffixed by the + pytest-xdist worker ID when running under pytest-xdist, and named ``base_name`` otherwise. + """ + return os.path.join(ARC_PATH, 'Projects', get_test_project_name(base_name)) + + def delete_check_files(project_directory: str): """ Delete local ESS checkfiles. They usually take up lots of space and are not needed after ARC terminates. @@ -1405,6 +1495,43 @@ def clean_text(text: str) -> str: return text +def join_stream_lines(stream: list | tuple | str | bytes | None) -> str: + """ + Join the content of a standard output or standard error stream into a single line of text. + Each line is stripped of surrounding whitespace before being joined by a single space, + and the result is stripped as well. Bytes are decoded as UTF-8, undecodable characters + are replaced. + + Args: + stream (list | tuple | str | bytes | None): The stream content, either as a sequence of lines + or as a single string. + + Returns: + str: The joined stream content, an empty string if the stream is empty or ``None``. + """ + if stream is None: + return '' + if isinstance(stream, (list, tuple)): + return ' '.join(_decode_stream_line(line) for line in stream).strip() + return _decode_stream_line(stream) + + +def _decode_stream_line(line) -> str: + """ + Render a single line of a standard output or standard error stream as stripped text. + Bytes are decoded as UTF-8, undecodable characters are replaced. + + Args: + line: The line to render. + + Returns: + str: The stripped text of the line. + """ + if isinstance(line, (bytes, bytearray)): + return line.decode('utf-8', errors='replace').strip() + return str(line).strip() + + def time_lapse(t0) -> str: """ A helper function returning the elapsed time since t0. @@ -1420,12 +1547,44 @@ def time_lapse(t0) -> str: h, m = divmod(m, 60) d, h = divmod(h, 24) if d > 0: - d = str(d) + ' days, ' + d = f'{d:.0f} days, ' else: d = '' return f'{d}{h:02.0f}:{m:02.0f}:{s:02.0f}' +def format_duration(duration: 'datetime.timedelta | str | None') -> str: + """ + Format a duration briefly, in the largest unit it fills, e.g. '3.4 s', '47.2 m', '13.1 h', '2.1 d'. + + Seconds ('s') are used below a minute, minutes ('m') below an hour, hours ('h') below a day, + and days ('d') beyond that. The value is given to one decimal place and is followed by a space + and the symbol of its unit. A duration of exactly zero is reported as '0.0 s'. A negative + duration, and a value that cannot be read as a duration, are reported as an empty string. + + A string duration is read by :func:`timedelta_from_str`, so both accept the same grammars. + An empty or blank string is treated as an absent duration and is not reported as unreadable. + + Args: + duration (datetime.timedelta, str, optional): The duration, either a timedelta object + or its ``str()`` representation, + e.g. '2 days, 3:04:05.678'. + + Returns: str + The brief representation of the duration. + """ + if isinstance(duration, str): + duration = timedelta_from_str(duration) if duration.strip() else None + if not isinstance(duration, datetime.timedelta) or duration.total_seconds() < 0: + return '' + seconds = duration.total_seconds() + for limit, divisor, unit in ((60, 1, 's'), (60, 60, 'm'), (24, 3600, 'h')): + value = seconds / divisor + if round(value, 1) < limit: + return f'{value:.1f} {unit}' + return f'{seconds / 86400:.1f} d' + + def estimate_orca_mem_cpu_requirement(num_heavy_atoms: int, server: str = '', consider_server_limits: bool = False, @@ -1767,26 +1926,41 @@ def get_close_tuple(key_1: tuple[float | str, ...], raise ValueError(f'Could not locate a key close to {key_1} within the tolerance {tolerance} in the given keys list.') -def timedelta_from_str(time_str: str): +TIMEDELTA_STR_REGEX = re.compile(r'^(?:(?P[-+]?\d+)\s+days?,\s*)?' + r'(?P\d{1,2}):(?P\d{2}):(?P\d{2})' + r'(?:\.(?P\d{1,6}))?$') +TIMEDELTA_COMPACT_REGEX = re.compile(r'^(?:(?P\d+)hr)?(?:(?P\d+)m)?(?:(?P\d+)s)?$') + + +def timedelta_from_str(time_str: str) -> datetime.timedelta | None: """ - Get a datetime.timedelta object from its str() representation + Get a datetime.timedelta object from its string representation. + + Two grammars are accepted. The primary one is the ``str(datetime.timedelta)`` representation, + e.g., '0:00:03.420000', '2 days, 3:00:00', or '-1 day, 23:59:59', which is the form ARC persists + in restart.yml and output.yml. The secondary one is a compact 'hr'/'m'/'s' form, e.g., '1hr2m3s' + or '45s', in which at least one of the three components must be present. Args: time_str (str): The string representation of a datetime.timedelta object. Returns: - datetime.timedelta: The corresponding timedelta object. + datetime.timedelta | None: The corresponding timedelta object, + or ``None`` if ``time_str`` does not represent a duration. """ - regex = re.compile(r'((?P\d+?)hr)?((?P\d+?)m)?((?P\d+?)s)?') - - parts = regex.match(time_str) - if not parts: - return - parts = parts.groupdict() + if not isinstance(time_str, str) or not time_str.strip(): + logger.warning(f'Could not interpret {time_str!r} as a time delta, returning None.') + return None + match = TIMEDELTA_STR_REGEX.match(time_str.strip()) + if match is None: + match = TIMEDELTA_COMPACT_REGEX.match(time_str.strip()) + if match is None or not any(match.groupdict().values()): + logger.warning(f'Could not interpret {time_str!r} as a time delta, returning None.') + return None time_params = {} - for (name, param) in parts.items(): - if param: - time_params[name] = int(param) + for name, param in match.groupdict().items(): + if param is not None: + time_params[name] = int(param.ljust(6, '0')) if name == 'microseconds' else int(param) return datetime.timedelta(**time_params) diff --git a/arc/common_test.py b/arc/common_test.py index 5f3b6c9c6b..2e5bd37be8 100644 --- a/arc/common_test.py +++ b/arc/common_test.py @@ -147,6 +147,127 @@ def test_time_lapse(self): lap = common.time_lapse(t0) self.assertEqual(lap, '00:00:02') + def test_time_lapse_beyond_a_day(self): + """Test that time_lapse() gives a whole number of days and stays readable by timedelta_from_str()""" + now = time.time() + self.assertEqual(common.time_lapse(now - 30 * 3600), '1 days, 06:00:00') + self.assertEqual(common.time_lapse(now - 3 * 86400), '3 days, 00:00:00') + self.assertEqual(common.time_lapse(now - (400 * 86400 + 1)), '400 days, 00:00:01') + for seconds in [6 * 3600, 23 * 3600 + 59 * 60 + 59, 30 * 3600, 3 * 86400, 400 * 86400 + 1]: + lap = common.time_lapse(now - seconds) + self.assertEqual(common.timedelta_from_str(lap), datetime.timedelta(seconds=seconds), + msg=f'time_lapse() gave {lap!r}, which does not read back as {seconds} s') + + def test_format_duration(self): + """Test the format_duration() function""" + self.assertEqual(common.format_duration(datetime.timedelta(0)), '0.0 s') + self.assertEqual(common.format_duration(datetime.timedelta(seconds=3.42)), '3.4 s') + self.assertEqual(common.format_duration(datetime.timedelta(minutes=7)), '7.0 m') + self.assertEqual(common.format_duration(datetime.timedelta(hours=13, minutes=7)), '13.1 h') + self.assertEqual(common.format_duration(datetime.timedelta(days=2, hours=3)), '2.1 d') + self.assertEqual(common.format_duration(datetime.timedelta(days=140, hours=1)), '140.0 d') + + def test_format_duration_keeps_sub_minute_timings_distinct(self): + """Test that the sub-minute TS guess timings of a real run render to three distinct strings""" + rendered = [common.format_duration(datetime.timedelta(seconds=seconds)) + for seconds in [3.4, 16.8, 18.1]] + self.assertEqual(rendered, ['3.4 s', '16.8 s', '18.1 s']) + self.assertEqual(len(set(rendered)), 3) + + def test_format_duration_unit_boundaries(self): + """Test that format_duration() steps up a unit rather than reporting a full unit's worth of the smaller""" + self.assertEqual(common.format_duration(datetime.timedelta(seconds=59.9)), '59.9 s') + self.assertEqual(common.format_duration(datetime.timedelta(seconds=59.99)), '1.0 m') + self.assertEqual(common.format_duration(datetime.timedelta(seconds=60)), '1.0 m') + self.assertEqual(common.format_duration(datetime.timedelta(minutes=59.99)), '1.0 h') + self.assertEqual(common.format_duration(datetime.timedelta(hours=23.99)), '1.0 d') + + def test_format_duration_spans_seconds_to_days(self): + """Test that format_duration() gives one decimal place and a unit symbol across its whole range""" + rendered = [common.format_duration(datetime.timedelta(seconds=seconds)) + for seconds in [0, 0.05, 3.4, 59.9, 60, 3599, 3600, 86399, 86400, 12096000, 864000000]] + self.assertEqual(rendered, ['0.0 s', '0.1 s', '3.4 s', '59.9 s', '1.0 m', '1.0 h', '1.0 h', + '1.0 d', '1.0 d', '140.0 d', '10000.0 d']) + for cell in rendered: + self.assertRegex(cell, r'^\d+\.\d [smhd]$') + + def test_format_duration_from_str(self): + """Test that format_duration() accepts the str() representation of a timedelta""" + for delta in [datetime.timedelta(seconds=3.42), + datetime.timedelta(hours=13, minutes=7, seconds=6.5), + datetime.timedelta(days=1, seconds=12), + datetime.timedelta(days=2, hours=3)]: + self.assertEqual(common.format_duration(str(delta)), common.format_duration(delta)) + + def test_format_duration_uninterpretable(self): + """Test that format_duration() reports an absent or negative duration as an empty string""" + for duration in [None, '', 'not a duration', 24, datetime.timedelta(seconds=-1)]: + self.assertEqual(common.format_duration(duration), '') + + def test_format_duration_reads_a_string_exactly_as_timedelta_from_str_does(self): + """Test that format_duration() and timedelta_from_str() agree on every duration string""" + for time_str in ['0:00:00', '0:00:00.500000', '0:00:03.4', '0:00:03.420000', '0:00:16.8', '0:00:18.1', + '0:17:05', '13:07:06.500000', '1 day, 0:00:00', '2 days, 3:00:00', '400 days, 0:00:01', + '-1 day, 23:59:59', '-3 days, 5:00:00', '1hr2m3s', '45s', + '', ' ', 'not a duration', '3:04', 'None']: + self.assertEqual(common.format_duration(time_str), + common.format_duration(common.timedelta_from_str(time_str)), + msg=f'{time_str!r} is read differently by the two entry points') + self.assertEqual([common.format_duration(time_str) for time_str in ['0:00:03.4', '0:00:16.8', '0:00:18.1']], + ['3.4 s', '16.8 s', '18.1 s']) + self.assertEqual(common.format_duration('0:00:00'), '0.0 s') + self.assertEqual(common.format_duration('0:00:00.500000'), '0.5 s') + self.assertEqual(common.format_duration('2 days, 3:00:00'), '2.1 d') + self.assertEqual(common.format_duration('-1 day, 23:59:59'), '') + self.assertEqual(common.timedelta_from_str('-1 day, 23:59:59'), datetime.timedelta(seconds=-1)) + self.assertEqual(common.format_duration('not a duration'), '') + + def test_format_duration_does_not_warn_for_an_absent_duration(self): + """Test that format_duration() reports an absent duration quietly, without a parse warning""" + with self.assertNoLogs('arc', level='WARNING'): + for duration in [None, '', ' ', datetime.timedelta(seconds=-1)]: + self.assertEqual(common.format_duration(duration), '') + + def test_format_table(self): + """Test the format_table() function""" + table = common.format_table(headers=['Label', ('H298', '(kJ/mol)')], + rows=[['CH4', '-74.60'], ['a longer label', '1.00']], + alignments='<>', + ) + self.assertEqual(table, ['Label H298', + ' (kJ/mol)', + '-------------- --------', + 'CH4 -74.60', + 'a longer label 1.00']) + + def test_format_table_column_widths(self): + """Test that format_table() sizes each column to its widest entry, header or cell""" + table = common.format_table(headers=['A', 'BBBBB'], rows=[['CCC', 'D']], separator='|', rule_char='') + self.assertEqual(table, ['A |BBBBB', 'CCC|D']) + + def test_format_table_no_rows(self): + """Test that format_table() renders the header alone when there are no rows""" + self.assertEqual(common.format_table(headers=['A', 'BB'], rows=[]), ['A BB', '- --']) + + def test_format_table_raises_on_a_ragged_row(self): + """Test that format_table() rejects a row or an alignment string that does not match the headers""" + with self.assertRaises(InputError): + common.format_table(headers=['A', 'B'], rows=[['1']]) + with self.assertRaises(InputError): + common.format_table(headers=['A', 'B'], rows=[['1', '2']], alignments='<') + + def test_format_table_raises_input_error_on_a_bad_cell_or_alignment(self): + """Test that format_table() reports a non-string cell or an unknown alignment as an InputError""" + for row in [[None, '2'], [1, '2'], [['1'], '2']]: + with self.assertRaises(InputError): + common.format_table(headers=['A', 'B'], rows=[row]) + with self.assertRaises(InputError): + common.format_table(headers=['A', 'B'], rows=[['1', '2']], alignments='= 2: @@ -1466,4 +1471,4 @@ def check_family_name(family: str """ if not isinstance(family, str) and family is not None: raise TypeError("Family name must be a string or None.") - return family in get_all_families() or family is None + return family in get_all_families(rmg_family_set=settings['rmg_family_set']) or family is None diff --git a/arc/family/family_test.py b/arc/family/family_test.py index 1aed269ff6..291e473a1e 100644 --- a/arc/family/family_test.py +++ b/arc/family/family_test.py @@ -750,6 +750,22 @@ def test_get_all_families_rejects_unknown_set_name(self): with self.assertRaises(ValueError): get_all_families(rmg_family_set='not_a_real_family_set', consider_arc_families=False) + def test_apply_recipe_coerces_string_bond_order(self): + """A CHANGE_BOND recipe whose bond-order is a string must still produce valid products. + + Some RMG family recipes write the order change as a string (e.g. Intra_RH_Add_Endocyclic + has ['CHANGE_BOND', '*2', '-1', '*3']) while others use an int. Without coercion the string + corrupts the bond order to -1.0 and the family silently produces 0 products. Regression test. + """ + family_dir = get_rmg_db_subpath('kinetics', 'families', 'Intra_RH_Add_Endocyclic', must_exist=False) + if not os.path.isdir(family_dir): + self.skipTest('Intra_RH_Add_Endocyclic is not available in this RMG database') + rxn = ARCReaction(r_species=[ARCSpecies(label='R', smiles='C=CCC', multiplicity=1)], + p_species=[ARCSpecies(label='P', smiles='C1CCC1', multiplicity=1)]) + product_dicts = rxn.get_product_dicts(rmg_family_set=['Intra_RH_Add_Endocyclic']) + self.assertGreater(len(product_dicts), 0) + self.assertEqual(product_dicts[0]['family'], 'Intra_RH_Add_Endocyclic') + def test_rmg_family_set_setting_ships_as_default(self): """ARC ships with the 'default' family set, so an untouched installation considers only RMG's recommended families.""" diff --git a/arc/imports.py b/arc/imports.py index 860ff6020b..99982f713a 100644 --- a/arc/imports.py +++ b/arc/imports.py @@ -56,6 +56,22 @@ def resolve_overridden_dependents(settings: dict, local_settings_dict: dict) -> queue_deferred_warning(msg) +def _local_overlays_disabled() -> bool: + """Return True when ~/.arc/{settings,submit,inputs}.py overlays should be skipped. + + The repo's arc/settings/settings.py is always loaded as the baseline. This + guard only controls whether a user's personal ~/.arc/*.py files are layered + on top — we want them ignored under pytest (so tests see the same defaults + CI does) and overridable explicitly via env var. + + Triggered by either pytest being loaded (`'pytest' in sys.modules`, true + from collection onward) or an explicit ARC_IGNORE_LOCAL_SETTINGS=1 env var. + """ + if os.environ.get('ARC_IGNORE_LOCAL_SETTINGS') == '1': + return True + return 'pytest' in sys.modules + + _UNUSABLE_OVERLAYS_REPORTED = set() @@ -102,10 +118,11 @@ def _report_unusable_overlay(path: str, module: str, error: ImportError, what: s # Common imports where the user can optionally put a modified copy of settings.py or submit.py file under ~/.arc home = os.getenv("HOME") or os.path.expanduser("~") local_arc_path = os.path.join(home, '.arc') +_skip_local = _local_overlays_disabled() local_arc_settings_path = os.path.join(local_arc_path, 'settings.py') settings = {key: val for key, val in vars(arc_settings).items() if '__' not in key} -if os.path.isfile(local_arc_settings_path): +if not _skip_local and os.path.isfile(local_arc_settings_path): local_settings = dict() if local_arc_path not in sys.path: sys.path.insert(1, local_arc_path) @@ -122,7 +139,7 @@ def _report_unusable_overlay(path: str, module: str, error: ImportError, what: s if 'global_ess_settings' in local_settings_dict and local_settings_dict['global_ess_settings'] else None local_arc_submit_path = os.path.join(local_arc_path, 'submit.py') -if os.path.isfile(local_arc_submit_path): +if not _skip_local and os.path.isfile(local_arc_submit_path): local_incore_commands, local_pipe_submit, local_submit_scripts = dict(), dict(), dict() if local_arc_path not in sys.path: sys.path.insert(1, local_arc_path) @@ -146,7 +163,7 @@ def _report_unusable_overlay(path: str, module: str, error: ImportError, what: s submit_scripts.update(local_submit_scripts) local_arc_inputs_path = os.path.join(local_arc_path, 'inputs.py') -if os.path.isfile(local_arc_inputs_path): +if not _skip_local and os.path.isfile(local_arc_inputs_path): local_input_files = dict() if local_arc_path not in sys.path: sys.path.insert(1, local_arc_path) diff --git a/arc/job/adapter.py b/arc/job/adapter.py index 4256b914e4..6e2644e5de 100644 --- a/arc/job/adapter.py +++ b/arc/job/adapter.py @@ -113,6 +113,7 @@ class JobEnum(str, Enum): autotst = 'autotst' # AutoTST, 10.1021/acs.jpca.7b07361, 10.26434/chemrxiv.13277870.v2 gcn = 'gcn' # Graph neural network for isomerization, https://doi.org/10.1021/acs.jpclett.0c00500 heuristics = 'heuristics' # ARC's heuristics + crest = 'crest' # CREST conformer/TS search kinbot = 'kinbot' # KinBot, 10.1016/j.cpc.2019.106947 linear = 'linear' # ARC's linear TS search goflow = 'goflow' # GoFlow, flow-matching E(3)-equivariant TS generator (Galustian et al., Digital Discovery 2025, 10.1039/D5DD00283D); https://github.com/heid-lab/goflow_lean @@ -120,6 +121,7 @@ class JobEnum(str, Enum): user = 'user' # user guesses xtb_gsm = 'xtb_gsm' # Double ended growing string method (DE-GSM), [10.1021/ct400319w, 10.1063/1.4804162] via xTB orca_neb = 'orca_neb' + qst2 = 'qst2' # Gaussian synchronous-transit-guided quasi-Newton TS search (opt=qst2) class JobTypeEnum(str, Enum): @@ -158,6 +160,34 @@ class JobAdapter(ABC): An abstract class for job adapters. """ + def __repr__(self) -> str: + """ + A concise single-line representation of the job, used whenever a job instance is + interpolated into a log message. Only attributes that are set by ``_initialize_adapter`` + are considered, all attributes are accessed defensively so that this method never raises, + and attributes which were not set are omitted rather than reported as ``None``. + + Returns: + str: The string representation of the job. + """ + descriptors = list() + for attribute, key in [('job_name', 'name'), + ('job_num', 'num'), + ('job_id', 'id'), + ('job_adapter', 'adapter'), + ('job_type', 'type'), + ('execution_type', 'execution'), + ('species_label', 'label'), + ('server', 'server'), + ]: + value = getattr(self, attribute, None) + if value is not None: + descriptors.append(f'{key}={value}') + status = getattr(self, 'job_status', None) + if isinstance(status, (list, tuple)) and len(status): + descriptors.append(f'status={status[0]}') + return f'{self.__class__.__name__}({", ".join(descriptors)})' + @abstractmethod def write_input_file(self) -> None: """ @@ -211,6 +241,11 @@ def execute_queue(self): """ pass + @property + def ess_software(self) -> str: + """The electronic-structure software used to interpret this adapter's output.""" + return self.job_adapter + def execute(self) -> None: """ Execute a job. @@ -429,7 +464,7 @@ def set_file_paths(self) -> None: self.local_path_to_xyz = None if not os.path.isdir(self.local_path): - os.makedirs(self.local_path) + os.makedirs(self.local_path, exist_ok=True) if self.server is not None: # Parentheses don't play well in folder names: @@ -527,11 +562,7 @@ def download_files(self) -> None: def remove_remote_files(self) -> None: """ - Remove the job's remote work directory, to keep cluster quota in check. - - This is the remote-cleanup entry point for a job. ARC's own job flow does not call it: - no job removes its remote work directory today, and the caller that will is added - separately, which is why a run still leaves its remote directories behind. + Remove the job's remote work directory after a successful run, to keep cluster quota in check. Does nothing for a local server or when no remote path has been set. """ @@ -719,9 +750,10 @@ def set_cpu_and_mem(self): max_mem = servers[self.server].get('memory', None) if self.server is not None else 32.0 # Max memory per node in GB. job_max_server_node_memory_allocation = default_job_settings.get('job_max_server_node_memory_allocation', 0.95) if max_mem is not None and self.job_memory_gb > max_mem * job_max_server_node_memory_allocation: + node_str = f' on {self.server}' if self.server is not None else '' logger.warning(f'The memory for job {self.job_name} using {self.job_adapter} ({self.job_memory_gb} GB) ' - f'exceeds {100 * job_max_server_node_memory_allocation}% of the the maximum node memory on ' - f'{self.server}. Setting it to {job_max_server_node_memory_allocation * max_mem:.2f} GB.') + f'exceeds {100 * job_max_server_node_memory_allocation}% of the maximum node memory' + f'{node_str}. Setting it to {job_max_server_node_memory_allocation * max_mem:.2f} GB.') self.job_memory_gb = job_max_server_node_memory_allocation * max_mem total_submit_script_memory_mib = math.ceil(self.job_memory_gb * MEMORY_GB_TO_MIB * CAPPED_JOB_MEMORY_OVERHEAD) self.job_status[1]['keywords'].append('max_total_job_memory') # Useful info when troubleshooting. @@ -929,8 +961,7 @@ def _get_additional_job_info(self): content += '\n' else: raise ValueError(f'Unrecognized cluster software: {cluster_soft}') - if content: - self.additional_job_info = content.lower() + self.additional_job_info = content.lower() if content else None def _check_job_server_status(self) -> str: """ @@ -950,6 +981,10 @@ def _check_job_ess_status(self): Raises: IOError: If the output file and any additional server information cannot be found. """ + existing_keywords = list(self.job_status[1].get('keywords', list())) + # Refresh scheduler-side logs before ESS parsing so server-reported OOMs + # can be detected even when the output file is absent or incomplete. + self._get_additional_job_info() if self.server != 'local' and self.execution_type != 'incore': if os.path.exists(self.local_path_to_output_file): os.remove(self.local_path_to_output_file) @@ -974,7 +1009,7 @@ def _check_job_ess_status(self): species_label=self.species_label, job_type=self.job_type, job_log=self.additional_job_info, - software=self.job_adapter, + software=self.ess_software, ) if status != 'done' and self.final_time is not None \ and datetime.datetime.now() - self.final_time < datetime.timedelta(seconds=30): @@ -984,11 +1019,26 @@ def _check_job_ess_status(self): species_label=self.species_label, job_type=self.job_type, job_log=self.additional_job_info, - software=self.job_adapter, + software=self.ess_software, ) else: status, keywords, error, line = '', '', '', '' + if self.additional_job_info: + try: + status, keywords, error, line = determine_ess_status( + output_path=self.local_path_to_output_file, + species_label=self.species_label, + job_type=self.job_type, + job_log=self.additional_job_info, + software=self.ess_software, + ) + except FileNotFoundError: + status, keywords, error, line = '', '', '', '' self.job_status[1]['status'] = status + if 'max_total_job_memory' in existing_keywords and status == 'errored' \ + and isinstance(keywords, list) and 'Memory' in keywords \ + and 'max_total_job_memory' not in keywords: + keywords.append('max_total_job_memory') self.job_status[1]['keywords'] = keywords self.job_status[1]['error'] = error self.job_status[1]['line'] = line.rstrip() diff --git a/arc/job/adapter_test.py b/arc/job/adapter_test.py index 3499cc5f19..2694515683 100644 --- a/arc/job/adapter_test.py +++ b/arc/job/adapter_test.py @@ -15,7 +15,7 @@ import unittest from unittest.mock import patch -from arc.common import ARC_TESTING_PATH +from arc.common import ARC_TESTING_PATH, get_test_project_name from arc.exceptions import ServerError from arc.imports import settings from arc.job.adapter import JobAdapter, JobEnum, JobTypeEnum, JobExecutionTypeEnum @@ -31,6 +31,10 @@ servers, submit_filenames = settings['servers'], settings['submit_filenames'] +JOB_ADAPTER_DIRS = tuple(os.path.join(ARC_TESTING_PATH, get_test_project_name(f'test_JobAdapter{suffix}')) + for suffix in ('', '_scan', '_ServerTimeLimit')) +JOB_ADAPTER_DIR, JOB_ADAPTER_SCAN_DIR, JOB_ADAPTER_STL_DIR = JOB_ADAPTER_DIRS + class TestEnumerationClasses(unittest.TestCase): """ @@ -96,16 +100,13 @@ def setUpClass(cls): A method that is run before all unit tests in this class. """ cls.maxDiff = None - # Register project-dir cleanups before any fixture creation so they - # still fire if a constructor below raises mid-setUpClass — that's - # how leftover scratch files end up committed to the repo. - for subdir in ('test_JobAdapter', 'test_JobAdapter_scan', 'test_JobAdapter_ServerTimeLimit'): - cls.addClassCleanup(shutil.rmtree, os.path.join(ARC_TESTING_PATH, subdir), ignore_errors=True) + for dir_path in JOB_ADAPTER_DIRS: + cls.addClassCleanup(shutil.rmtree, dir_path, ignore_errors=True) cls.job_1 = GaussianAdapter(execution_type='queue', job_type='conf_opt', level=Level(method='cbs-qb3'), project='test', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_JobAdapter'), + project_directory=JOB_ADAPTER_DIR, species=[ARCSpecies(label='spc1', xyz=['O 0 0 1', 'O 0 0 2', @@ -134,7 +135,7 @@ def setUpClass(cls): job_type='opt', level=Level(method='cbs-qb3'), project='test', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_JobAdapter'), + project_directory=JOB_ADAPTER_DIR, species=[ARCSpecies(label='spc1', xyz=['O 0 0 1'])], testing=True, ) @@ -161,7 +162,7 @@ def setUpClass(cls): torsions=[[1, 2, 3, 4]], level=Level(method='wb97xd', basis='def2-tzvp'), project='test_scans', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_JobAdapter_scan'), + project_directory=JOB_ADAPTER_SCAN_DIR, species=[cls.spc_3a, cls.spc_3b, cls.spc_3c, cls.spc_3d, cls.spc_3e, cls.spc_3f], testing=True, ) @@ -169,12 +170,12 @@ def setUpClass(cls): job_type='opt', level=Level(method='cbs-qb3'), project='test', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_JobAdapter'), + project_directory=JOB_ADAPTER_DIR, species=[ARCSpecies(label='spc1', xyz=['O 0 0 1'])], testing=True, ) # Copy the PBS time limit fixture into the directory structure the adapter expects. - stl_dir = os.path.join(ARC_TESTING_PATH, 'test_JobAdapter_ServerTimeLimit') + stl_dir = JOB_ADAPTER_STL_DIR err_dest = os.path.join(stl_dir, 'calcs', 'Species', 'spc1', 'opt_101') os.makedirs(err_dest, exist_ok=True) shutil.copy(os.path.join(ARC_TESTING_PATH, 'server', 'pbs', 'timelimit', 'err.txt'), @@ -192,6 +193,12 @@ def setUpClass(cls): server='server3', testing=True, ) + os.makedirs(cls.job_5.local_path, exist_ok=True) + fixture_path = os.path.join(ARC_TESTING_PATH, 'trsh', 'wall_exceeded.txt') + with open(fixture_path, 'r') as f: + log_content = f.read() + with open(os.path.join(cls.job_5.local_path, 'out.txt'), 'w') as f: + f.write(log_content) cls.job_6 = GaussianAdapter(execution_type='queue', job_name='opt_101', job_type='opt', @@ -260,6 +267,24 @@ def test_set_cpu_and_mem(self): self.assertEqual(self.job_4.submit_script_memory, expected_memory) self.job_4.server = 'local' + def test_set_cpu_and_mem_marks_max_total_job_memory(self): + """Test tagging jobs whose requested memory is clipped to the node cap.""" + job = GaussianAdapter(execution_type='queue', + job_type='opt', + level=Level(method='cbs-qb3'), + project='test', + project_directory=JOB_ADAPTER_DIR, + species=[ARCSpecies(label='spc1', xyz=['O 0 0 1'])], + server='server2', + job_memory_gb=300, + testing=True, + ) + + job.set_cpu_and_mem() + + self.assertAlmostEqual(job.job_memory_gb, 256 * 0.95) + self.assertIn('max_total_job_memory', job.job_status[1]['keywords']) + def test_set_file_paths(self): """Test setting up the job's paths""" self.assertEqual(self.job_1.local_path, os.path.join(self.job_1.project_directory, 'calcs', 'Species', @@ -298,7 +323,7 @@ def test_add_to_args(self): job_type='opt', level=Level(method='cbs-qb3'), project='test', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_JobAdapter'), + project_directory=JOB_ADAPTER_DIR, species=[ARCSpecies(label='spc1', xyz=['O 0 0 1'])], testing=True, args={'keyword': {'general': 'val_tst_1 val_tst_2 val_tst_3'}, @@ -329,13 +354,51 @@ def test_get_file_property_dictionary(self): 'source': 'input_files', 'make_x': True}) - def test_determine_job_status(self): + @patch('arc.job.adapter.check_job_status', return_value='done') + def test_determine_job_status(self, mock_check_job_status): """Test determining the job status""" self.job_5.determine_job_status() + mock_check_job_status.assert_called_once_with('123456') self.assertEqual(self.job_5.job_status[0], 'done') self.assertEqual(self.job_5.job_status[1]['status'], 'errored') self.assertEqual(self.job_5.job_status[1]['keywords'], ['ServerTimeLimit']) + @patch('arc.job.adapter.determine_ess_status') + def test_preserve_max_total_job_memory_keyword(self, mock_determine_ess_status): + """Test preserving the max_total_job_memory marker across ESS status parsing.""" + self.job_4.job_status[1]['keywords'] = ['max_total_job_memory'] + self.job_4.initial_time = datetime.datetime.now() - datetime.timedelta(minutes=2) + self.job_4.final_time = datetime.datetime.now() - datetime.timedelta(minutes=1) + os.makedirs(self.job_4.local_path, exist_ok=True) + with open(self.job_4.local_path_to_output_file, 'w') as f: + f.write('dummy output') + mock_determine_ess_status.return_value = ( + 'errored', + ['MDCI', 'Memory'], + 'Insufficient job memory.', + 'Please increase MaxCore', + ) + + self.job_4._check_job_ess_status() + + self.assertEqual(self.job_4.job_status[1]['status'], 'errored') + self.assertEqual(self.job_4.job_status[1]['keywords'], ['MDCI', 'Memory', 'max_total_job_memory']) + + def test_check_job_ess_status_without_output_uses_job_log_memory_error(self): + """Test detecting server-reported memory errors even when the output file is absent.""" + if os.path.isfile(self.job_4.local_path_to_output_file): + os.remove(self.job_4.local_path_to_output_file) + self.job_4.initial_time = datetime.datetime.now() - datetime.timedelta(minutes=2) + self.job_4.final_time = datetime.datetime.now() - datetime.timedelta(minutes=1) + self.job_4.additional_job_info = '\tMEMORY EXCEEDED\n' + + with patch.object(self.job_4, '_get_additional_job_info'): + self.job_4._check_job_ess_status() + + self.assertEqual(self.job_4.job_status[1]['status'], 'errored') + self.assertEqual(self.job_4.job_status[1]['keywords'], ['Memory']) + self.assertEqual(self.job_4.job_status[1]['error'], 'Insufficient job memory.') + @patch( "arc.job.trsh.servers", { @@ -355,6 +418,55 @@ def test_troubleshoot_queue(self): self.assertIn('short_queue', self.job_6.attempted_queues) self.assertIn('middle_queue', self.job_6.attempted_queues) + def test_repr(self): + """Test the string representation of a job""" + for job in [self.job_1, self.job_2, self.job_5]: + representation = repr(job) + self.assertNotIn(' object at 0x', representation) + self.assertEqual(len(representation.splitlines()), 1) + self.assertIn('GaussianAdapter(', representation) + self.assertIn('adapter=gaussian', representation) + self.assertIn(f'name={job.job_name}', representation) + self.assertIn(f'type={job.job_type}', representation) + self.assertIn(f'label={job.species_label}', representation) + self.assertIn('execution=incore', repr(self.job_2)) + self.assertIn('id=123456', repr(self.job_5)) + self.assertIn('server=server3', repr(self.job_5)) + self.assertNotIn('=None', repr(self.job_2)) + + def test_repr_of_a_minimally_populated_adapter(self): + """Test that the string representation does not raise when attributes were not set""" + job = GaussianAdapter.__new__(GaussianAdapter) + representation = repr(job) + self.assertEqual(representation, 'GaussianAdapter()') + self.assertNotIn(' object at 0x', representation) + job.job_name = 'opt_a1234' + job.job_status = ['running', {'status': 'running'}] + self.assertEqual(repr(job), 'GaussianAdapter(name=opt_a1234, status=running)') + + def test_set_cpu_and_mem_capping_warning(self): + """Test that the memory capping warning is well-phrased when no server is defined""" + job = GaussianAdapter(execution_type='incore', + job_type='opt', + job_memory_gb=42, + level=Level(method='cbs-qb3'), + project='test', + project_directory=os.path.join(ARC_TESTING_PATH, 'test_JobAdapter'), + species=[ARCSpecies(label='spc1', xyz=['O 0 0 1'])], + testing=True, + ) + self.assertIsNone(job.server) + job.job_memory_gb = 42 + with self.assertLogs('arc', level='WARNING') as cm: + job.set_cpu_and_mem() + message = '\n'.join(cm.output) + self.assertIn('exceeds', message) + self.assertIn('Setting it to 30.40 GB.', message) + self.assertNotIn('the the', message) + self.assertNotIn('on None', message) + self.assertAlmostEqual(job.job_memory_gb, 30.4) + + class TestRotateCSV(unittest.TestCase): """ diff --git a/arc/job/adapters/common.py b/arc/job/adapters/common.py index 43d567cddc..4221f26001 100644 --- a/arc/job/adapters/common.py +++ b/arc/job/adapters/common.py @@ -27,13 +27,13 @@ default_job_settings, global_ess_settings, rotor_scan_resolution = \ settings['default_job_settings'], settings['global_ess_settings'], settings['rotor_scan_resolution'] -ts_adapters_by_rmg_family = {'1+2_Cycloaddition': ['kinbot', 'goflow', 'rits', 'linear'], +ts_adapters_by_rmg_family = {'1+2_Cycloaddition': ['kinbot', 'xtb_gsm', 'orca_neb', 'goflow', 'rits', 'linear'], '1,2_Insertion_CO': ['kinbot', 'goflow', 'rits', 'linear'], '1,2_Insertion_carbene': ['kinbot', 'goflow', 'rits', 'linear'], '1,2_NH3_elimination': ['goflow', 'rits', 'linear'], '1,2_XY_interchange': ['orca_neb', 'goflow', 'rits', 'linear'], - '1,2_shiftC': ['gcn', 'xtb_gsm', 'orca_neb', 'goflow', 'rits', 'linear'], - '1,2_shiftS': ['gcn', 'kinbot', 'xtb_gsm', 'orca_neb', 'goflow', 'rits', 'linear'], + '1,2_shiftC': ['gcn', 'xtb_gsm', 'orca_neb', 'qst2', 'goflow', 'rits', 'linear'], + '1,2_shiftS': ['gcn', 'kinbot', 'xtb_gsm', 'orca_neb', 'qst2', 'goflow', 'rits', 'linear'], '1,3_Insertion_CO2': ['kinbot', 'goflow', 'rits', 'linear'], '1,3_Insertion_ROR': ['kinbot', 'goflow', 'rits', 'linear'], '1,3_Insertion_RSR': ['kinbot', 'goflow', 'rits', 'linear'], @@ -49,8 +49,9 @@ 'Cyclic_Ether_Formation': ['kinbot', 'goflow', 'rits', 'linear'], 'Cyclic_Thioether_Formation': ['goflow', 'rits', 'linear'], 'Cyclopentadiene_scission': ['gcn', 'xtb_gsm', 'orca_neb', 'goflow', 'rits', 'linear'], - 'Diels_alder_addition': ['kinbot', 'goflow', 'rits', 'linear'], + 'Diels_alder_addition': ['kinbot', 'xtb_gsm', 'orca_neb', 'goflow', 'rits', 'linear'], 'Diels_alder_addition_Aromatic': ['goflow', 'rits', 'linear'], + 'Disproportionation': ['autotst'], 'HO2_Elimination_from_PeroxyRadical': ['kinbot', 'goflow', 'rits', 'linear'], 'H_Abstraction': ['heuristics', 'autotst', 'crest'], 'Intra_2+2_cycloaddition_Cd': ['gcn', 'xtb_gsm', 'orca_neb', 'goflow', 'rits', 'linear'], @@ -65,29 +66,30 @@ 'Intra_R_Add_Exocyclic': ['gcn', 'kinbot', 'xtb_gsm', 'orca_neb', 'goflow', 'rits', 'linear'], 'Intra_Retro_Diels_alder_bicyclic': ['kinbot', 'goflow', 'rits', 'linear'], 'Intra_ene_reaction': ['gcn', 'kinbot', 'xtb_gsm', 'orca_neb', 'goflow', 'rits', 'linear'], - 'Ketoenol': ['gcn', 'kinbot', 'xtb_gsm', 'orca_neb', 'goflow', 'rits', 'linear'], + 'Ketoenol': ['gcn', 'kinbot', 'xtb_gsm', 'orca_neb', 'qst2', 'goflow', 'rits', 'linear'], 'Korcek_step1': ['gcn', 'xtb_gsm', 'orca_neb', 'goflow', 'rits', 'linear'], 'Korcek_step2': ['kinbot', 'goflow', 'rits', 'linear'], 'R_Addition_COm': ['kinbot', 'goflow', 'rits', 'linear'], 'R_Addition_CSm': ['kinbot', 'goflow', 'rits', 'linear'], 'R_Addition_MultipleBond': ['autotst', 'kinbot', 'goflow', 'rits', 'linear'], - 'Retroene': ['kinbot', 'goflow', 'rits', 'linear'], + 'Retroene': ['kinbot', 'xtb_gsm', 'orca_neb', 'goflow', 'rits', 'linear'], 'Singlet_Carbene_Intra_Disproportionation': ['gcn', 'xtb_gsm', 'orca_neb', 'goflow', 'rits', 'linear'], - 'XY_Addition_MultipleBond': ['goflow', 'rits', 'linear'], - 'XY_elimination_hydroxyl': ['goflow', 'rits', 'linear'], + 'XY_Addition_MultipleBond': ['heuristics', 'crest', 'xtb_gsm', 'orca_neb', 'goflow', 'rits', 'linear'], + 'XY_elimination_hydroxyl': ['xtb_gsm', 'orca_neb', 'goflow', 'rits', 'linear'], 'carbonyl_based_hydrolysis': ['heuristics'], 'ether_hydrolysis': ['heuristics'], 'halocarbene_recombination': ['goflow', 'rits', 'linear'], 'halocarbene_recombination_double': ['goflow', 'rits', 'linear'], - 'intra_H_migration': ['autotst', 'gcn', 'kinbot', 'xtb_gsm', 'orca_neb', 'goflow', 'rits', 'linear'], - 'intra_NO2_ONO_conversion': ['gcn', 'xtb_gsm', 'orca_neb', 'goflow', 'rits', 'linear'], - 'intra_OH_migration': ['gcn', 'kinbot', 'xtb_gsm', 'orca_neb', 'goflow', 'rits', 'linear'], - 'intra_halogen_migration': ['goflow', 'rits', 'linear'], + 'intra_H_migration': ['autotst', 'gcn', 'kinbot', 'xtb_gsm', 'orca_neb', 'qst2', 'goflow', 'rits', 'linear'], + 'intra_NO2_ONO_conversion': ['gcn', 'xtb_gsm', 'orca_neb', 'qst2', 'goflow', 'rits', 'linear', 'crest'], + 'intra_OH_migration': ['gcn', 'kinbot', 'xtb_gsm', 'orca_neb', 'qst2', 'goflow', 'rits', 'linear'], + 'intra_halogen_migration': ['xtb_gsm', 'orca_neb', 'qst2', 'goflow', 'rits', 'linear'], 'intra_substitutionCS_cyclization': ['goflow', 'rits', 'linear'], 'intra_substitutionCS_isomerization': ['gcn', 'xtb_gsm', 'orca_neb', 'goflow', 'rits', 'linear'], 'intra_substitutionS_cyclization': ['goflow', 'rits', 'linear'], 'intra_substitutionS_isomerization': ['gcn', 'xtb_gsm', 'orca_neb', 'goflow', 'rits', 'linear'], 'lone_electron_pair_bond': ['goflow', 'rits', 'linear'], + 'nitrile_hydrolysis': ['heuristics'] } @@ -158,7 +160,7 @@ def _initialize_adapter(obj: JobAdapter, obj.project = project obj.project_directory = project_directory if obj.project_directory and not os.path.isdir(obj.project_directory): - os.makedirs(obj.project_directory) + os.makedirs(obj.project_directory, exist_ok=True) obj.additional_job_info = None obj.args = args or dict() @@ -554,9 +556,10 @@ def set_job_args(args: dict | None, """ Set the job args considering args from ``level`` and from ``trsh``. - The args of ``level`` are adopted when ``args`` carries no options. When ``args`` does carry - options it is used as given, and a warning reports the options of ``level`` which it does not - carry, i.e., only the options which are actually being dropped. + The args of ``level`` are adopted when ``args`` carries no options, deep-copied so that keywords + which the adapters later inject into the returned dictionary do not reach the ``Level`` object + itself. When ``args`` does carry options it is used as given, and a warning reports the options + of ``level`` which it does not carry, i.e., only the options which are actually being dropped. Args: args (dict): The job specific arguments. diff --git a/arc/job/adapters/common_test.py b/arc/job/adapters/common_test.py index 56176b16bd..2573ed9c3b 100644 --- a/arc/job/adapters/common_test.py +++ b/arc/job/adapters/common_test.py @@ -205,7 +205,8 @@ def test_set_job_args_warns_only_for_dropped_options(self): dropping_args = {'keyword': dict(), 'block': dict(), 'trsh': {'trsh': 'scf=(qc)'}} with self.assertLogs(logger='arc', level='WARNING') as captured: - common.set_job_args(args=dropping_args, level=level, job_name='j1') + args = common.set_job_args(args=dropping_args, level=level, job_name='j1') + self.assertEqual(args['trsh'], {'trsh': 'scf=(qc)'}) message = ''.join(captured.output) self.assertIn('opt=(verytight)', message) self.assertIn('j1', message) diff --git a/arc/job/adapters/gaussian.py b/arc/job/adapters/gaussian.py index 33b1e695bf..136f32dfbf 100644 --- a/arc/job/adapters/gaussian.py +++ b/arc/job/adapters/gaussian.py @@ -221,6 +221,32 @@ def __init__(self, elif self.species[0].checkfile is not None and os.path.isfile(self.species[0].checkfile): self.checkfile = self.species[0].checkfile + def _user_requested_verytight(self) -> bool: + """ + Return True if the user passed ``verytight`` through ``self.args``. + + When True, the fine-mode opt-keyword builder will skip auto-adding + ``tight`` so the user's ``verytight`` wins unambiguously in the final + ``opt=(...)`` clause assembled by ``combine_parameters``. + + Scope: only the ``keyword`` channel is meaningful here — ``block`` is for + Gaussian input blocks and ``trsh`` is for ARC's troubleshooting layer, + neither of which is the right place for a user opt-cutoff override. + + Notes for the implementer: + - ``self.args['keyword']`` is a ``dict[str, str]`` (see + ``Level._check_args`` at arc/level.py:281). Values may be in any + case and may contain other keywords too (e.g. ``"opt=(verytight) + freq=hpmodes"``). + - Match must be word-bounded: a stray ``verytightscf`` should not + count, and ``tight`` alone must NOT match. + - Be defensive: ``self.args`` or ``self.args['keyword']`` may be + missing or empty depending on how the Level was constructed. + """ + keyword_args = (self.args or {}).get('keyword') or {} + joined = ' '.join(str(v) for v in keyword_args.values()) + return re.search(r'\bverytight\b', joined, re.IGNORECASE) is not None + def write_input_file(self) -> None: """ Write the input file to execute the job on the server. @@ -250,8 +276,17 @@ def write_input_file(self) -> None: input_dict['method'] = self.level.method input_dict['multiplicity'] = self.multiplicity input_dict['scan_trsh'] = self.args['keyword']['scan_trsh'] if 'scan_trsh' in self.args['keyword'] else '' - integral_algorithm = 'Acc2E=14' if 'Acc2E=14' in input_dict['trsh'] else 'Acc2E=12' - input_dict['trsh'] = input_dict['trsh'].replace('int=(Acc2E=14)', '') if 'Acc2E=14' in input_dict['trsh'] else input_dict['trsh'] + acc2e_requested = 'Acc2E=14' in input_dict['trsh'] # troubleshooting asked to tighten integrals + integral_algorithm = 'Acc2E=14' if acc2e_requested else 'Acc2E=12' + input_dict['trsh'] = input_dict['trsh'].replace('int=(Acc2E=14)', '') if acc2e_requested else input_dict['trsh'] + # The InaccurateQuadrature remedy escalates to a finer DFT integration grid (recorded as + # 'int=grid=NNNMMM'). Fold that grid into the single integral=() keyword (replacing the + # default ultrafine) rather than emitting a second, conflicting Int keyword - ultrafine is + # (99,590), the remedy grid e.g. 300590 = (300,590) is finer. Sourced from ess_trsh_methods + # so the finer grid persists across retries, and the standalone int=grid= token is dropped. + grid_remedy = next((m for m in (self.ess_trsh_methods or []) if m.startswith('int=grid=')), None) + integration_grid = grid_remedy.split('int=grid=', 1)[1] if grid_remedy else 'ultrafine' + input_dict['trsh'] = re.sub(r'\s*int=grid=\S+', '', input_dict['trsh']) if grid_remedy else input_dict['trsh'] input_dict['xyz'] = [xyz_to_str(xyz) for xyz in self.xyz] if self.run_multi_species else xyz_to_str(self.xyz) if self.level.basis is not None: @@ -267,9 +302,12 @@ def write_input_file(self) -> None: if self.level.method[:2] == 'ro': self.add_to_args(val='use=L506') - elif not('no_xqc' in list(self.args['trsh'].values())) and 'qc' in input_dict['trsh']: + elif 'no_xqc' not in self.ess_trsh_methods \ + and not('no_xqc' in list(self.args['trsh'].values())) and 'qc' in input_dict['trsh']: # xqc will do qc (quadratic convergence) if the job fails w/o it, so use it by default. - # replace qc with xqc if it's not already there + # replace qc with xqc if it's not already there. + # 'no_xqc' in ess_trsh_methods records that the xqc algorithm itself already failed + # (Gaussian l508), so don't upgrade qc to xqc in that case (see arc.job.trsh.trsh_keyword_no_qc). input_dict['trsh'] = input_dict['trsh'].replace('qc', 'xqc') if self.level.method == 'cbs-qb3-paraskevas': @@ -293,7 +331,7 @@ def write_input_file(self) -> None: if self.fine: if self.level.method_type in ['dft', 'composite']: # Note that the Acc2E argument is not available in Gaussian03 - input_dict['fine'] = f'integral=(grid=ultrafine, {integral_algorithm})' + input_dict['fine'] = f'integral=(grid={integration_grid}, {integral_algorithm})' # input_dict['trsh'] may have scf=(...) in it, so we need to add the tight and direct keywords to it scf_start = input_dict['trsh'].find('scf=(') scf_end = input_dict['trsh'].find(')', scf_start) @@ -309,21 +347,26 @@ def write_input_file(self) -> None: if input_dict['trsh']: input_dict['trsh'] += ' ' input_dict['trsh'] += 'scf=(tight,direct)' + # 'no_tight' is set by trsh_keyword_loose_disp when a previous attempt hit + # MaxOptCycles with forces converged but displacement criteria unreachable. + # 'tight' is also dropped when the user requested verytight, which replaces it. + drop_tight = 'no_tight' in self.ess_trsh_methods or self._user_requested_verytight() + tight_kw = [] if drop_tight else ['tight'] if self.is_ts: - keywords.extend(['tight', 'maxstep=5']) + keywords.extend([*tight_kw, 'maxstep=5']) else: - keywords.extend(['tight', 'maxstep=5', f'maxcycle={max_c}']) + keywords.extend([*tight_kw, 'maxstep=5', f'maxcycle={max_c}']) input_dict['job_type_1'] = "opt" if self.level.method_type not in ['dft', 'composite', 'wavefunction']\ else f"opt=({', '.join(key for key in keywords)})" elif self.job_type == 'freq': - input_dict['job_type_2'] = f'freq IOp(7/33=1) scf=(tight, direct) integral=(grid=ultrafine, {integral_algorithm})' + input_dict['job_type_2'] = f'freq IOp(7/33=1) scf=(tight, direct) integral=(grid={integration_grid}, {integral_algorithm})' elif self.job_type == 'optfreq': input_dict['job_type_2'] = 'freq IOp(7/33=1)' elif self.job_type in ['sp', 'conf_sp']: - input_dict['job_type_1'] = f'integral=(grid=ultrafine, {integral_algorithm})' + input_dict['job_type_1'] = f'integral=(grid={integration_grid}, {integral_algorithm})' if input_dict['trsh']: input_dict['trsh'] += ' ' input_dict['trsh'] += 'scf=(tight, direct)' @@ -344,7 +387,7 @@ def write_input_file(self) -> None: ts = 'ts, ' if self.is_ts else '' input_dict['job_type_1'] = f'opt=({ts}modredundant, calcfc, noeigentest, maxStep=5)' \ - f'integral=(grid=ultrafine, {integral_algorithm})' + f'integral=(grid={integration_grid}, {integral_algorithm})' if input_dict['trsh']: input_dict['trsh'] += ' ' input_dict['trsh'] += 'scf=(tight, direct)' @@ -355,7 +398,7 @@ def write_input_file(self) -> None: elif self.job_type == 'irc': if self.fine: # Note that the Acc2E argument is not available in Gaussian03 - input_dict['fine'] = f'integral=(grid=ultrafine, {integral_algorithm})' + input_dict['fine'] = f'integral=(grid={integration_grid}, {integral_algorithm})' # We need to add scf=(direct) to the trsh argument # But we to check if it's already there, and if 'direct' not in input_dict['trsh']: @@ -376,6 +419,16 @@ def write_input_file(self) -> None: input_dict['job_type_1'] = f'irc=(CalcAll, {self.irc_direction}, maxpoints=50, stepsize=7)' + if (acc2e_requested or grid_remedy) and not self.fine and not input_dict['fine'] \ + and self.job_type in ['opt', 'conf_opt', 'optfreq', 'composite', 'irc']: + # Fine jobs fold Acc2E=14 (and any InaccurateQuadrature grid remedy) into integral=(...). + # A non-fine opt/IRC job would otherwise silently drop those troubleshooting requests + # (no integral= in the route), producing a byte-identical resubmit. Emit the integral=() + # keyword so the tightened accuracy and/or finer grid actually take effect (the default + # ultrafine grid stays a fine-only concern - only an explicit grid remedy is emitted here). + grid_part = f'grid={integration_grid}, ' if grid_remedy else '' + input_dict['fine'] = f'integral=({grid_part}{integral_algorithm})' + for constraint_tuple in self.constraints: constraint_type = constraint_type_dict[len(constraint_tuple[0])] constraint_atom_indices = ' '.join([str(atom_index) for atom_index in constraint_tuple[0]]) @@ -387,21 +440,41 @@ def write_input_file(self) -> None: input_dict['job_type_1'] += f' SCRF=({self.level.solvation_method}, Solvent={self.level.solvent})' if self.species[0].number_of_atoms > 1: - if input_dict['job_type_1']: - input_dict['job_type_1'] += ' ' + guess_keyword = '' if 'guess=INDO' in input_dict['trsh']: - input_dict['job_type_1'] += 'guess=INDO' + guess_keyword = 'guess=INDO' input_dict['trsh'] = input_dict['trsh'].replace('guess=INDO', '') - else: - input_dict['job_type_1'] += ' guess=read' if self.checkfile is not None and os.path.isfile(self.checkfile) \ - else ' guess=mix' + elif self.checkfile is not None and os.path.isfile(self.checkfile): + guess_keyword = ' guess=read' + elif any(spc.is_ts or (spc.multiplicity == 1 and spc.number_of_radicals is not None + and spc.number_of_radicals > 1) + for spc in self.species): + # guess=mix mixes the HOMO and LUMO to break alpha-beta (and spatial) symmetry in the initial + # guess, i.e., it seeds a broken-symmetry unrestricted wavefunction. This is desired for + # open-shell (biradical) singlets and for TSs (partial diradical character along the breaking + # bonds), but serves no purpose for restricted closed-shell species (the wavefunction cannot + # break spin symmetry, so the SCF merely starts from a deliberately perturbed guess) or for + # simple high-spin radicals (doublets/triplets converge to the correct state from the default + # guess). Benchmark data (arcbench, 2026-07): 426 doublet jobs show identical, clean + # (~0.755) whether seeded with guess=mix or guess=read. + guess_keyword = ' guess=mix' + if guess_keyword: + if input_dict['job_type_1']: + input_dict['job_type_1'] += ' ' + input_dict['job_type_1'] += guess_keyword # Fix OPT terms_opt = [r'opt=\((.*?)\)', r'opt=(\w+)'] input_dict, parameters_opt = combine_parameters(input_dict, terms_opt) # If 'opt' parameters are found, concatenate and reinsert them if parameters_opt: - # Remove duplicate parameters + # Keep a single force-constant recompute directive (calcall > recalcfc=* > calcfc) so a + # troubleshot opt=() clause never carries conflicting Hessian options - the base route + # always contributes 'calcfc', while the MaxOptCycles ladder may add recalcfc/calcall. + if 'calcall' in parameters_opt: + parameters_opt = [p for p in parameters_opt if p != 'calcfc' and not p.startswith('recalcfc')] + elif any(p.startswith('recalcfc') for p in parameters_opt): + parameters_opt = [p for p in parameters_opt if p != 'calcfc'] combined_opt_params = ','.join(parameters_opt) input_dict['job_type_1'] = f"opt=({combined_opt_params}) {input_dict['job_type_1']}" diff --git a/arc/job/adapters/gaussian_test.py b/arc/job/adapters/gaussian_test.py index a0b8af26eb..3ae37eed7f 100644 --- a/arc/job/adapters/gaussian_test.py +++ b/arc/job/adapters/gaussian_test.py @@ -11,6 +11,7 @@ import tempfile import unittest +from arc.common import ARC_TESTING_PATH from arc.job.adapters.gaussian import GaussianAdapter, get_memory_headroom_fraction from arc.level import Level from arc.settings.settings import input_filenames, output_filenames, servers, submit_filenames @@ -466,7 +467,8 @@ def setUpClass(cls): ) # Gaussian MaxOptCycles error - Part 2 - # Intend to troubleshoot a MaxOptCycles error by adding opt=(RFO) to the input file + # Intend to troubleshoot a MaxOptCycles error by recomputing the Hessian (opt=(recalcfc=5), + # which supersedes the base calcfc) before any step-algorithm flip. job_status = {'keywords': ['MaxOptCycles']} ess_trsh_methods = ['opt=(maxcycle=200)'] output_errors, ess_trsh_methods, remove_checkfile, level_of_theory, software, job_type, fine, trsh_keyword, \ @@ -489,7 +491,8 @@ def setUpClass(cls): ) # Gaussian MaxOptCycles error - Part 3 - # Intend to troubleshoot a MaxOptCycles error by adding opt=(GDIIS) and removing opt=(RFO) to the input file + # With maxcycle+RFO already tried, the next remedy is the Hessian recompute opt=(recalcfc=5) + # (it precedes the DIIS accelerators); RFO is retained as the single step algorithm. job_status = {'keywords': ['MaxOptCycles']} ess_trsh_methods = ['opt=(maxcycle=200)', 'opt=(RFO)'] output_errors, ess_trsh_methods, remove_checkfile, level_of_theory, software, job_type, fine, trsh_keyword, \ @@ -668,7 +671,7 @@ def test_write_input_file(self): %mem=14193mb %NProcShared=8 -#P opt=(calcfc,maxStep=5,modredundant,noeigentest) integral=(grid=ultrafine, Acc2E=12) guess=mix wb97xd/def2tzvp IOp(2/9=2000) scf=(direct,tight) +#P opt=(calcfc,maxStep=5,modredundant,noeigentest) integral=(grid=ultrafine, Acc2E=12) wb97xd/def2tzvp IOp(2/9=2000) scf=(direct,tight) ethanol @@ -798,7 +801,7 @@ def test_gaussian_def2tzvp(self): def test_trsh_write_input_file(self): """Test writing a trsh input file 10. Create an input file for a job with int=(Acc2E=14) included - 11. Create an input file for a job with guess=mix included (removal of Checkfile via ess_trsh_methods) + 11. Create an input file for a job after checkfile removal via ess_trsh_methods (no guess keyword: closed-shell) 12. Create an input file for a job with nosymm included, and also the first pass of SCF error troubleshooting 13. Create an input file for a job with NDamp=30 included, and also the previous pass of SCF error troubleshooting 14. Create an input file for a job with NoDIIS included, and also previous passes of SCF error troubleshooting @@ -810,8 +813,8 @@ def test_trsh_write_input_file(self): 20. Create an input file for a job with L502 error but had already been troubleshooted with L502 error and InaccurateQuadrature 21. Create an input file for a job with L502 error but had already been troubleshooted with L502 error and InaccurateQuadrature 22. Create an input file for a job with MaxOptCycles error - changes maxcycle to 200 from 100 - 23. Create an input file for a job with MaxOptCycles error - Add RFO to the input file - 24. Create an input file for a job with MaxOptCycles error - Add GDIIS and remove RFO from the input file + 23. Create an input file for a job with MaxOptCycles error - recompute the Hessian (recalcfc=5), superseding calcfc + 24. Create an input file for a job with MaxOptCycles error - recalcfc=5 with RFO retained as the step algorithm """ self.job_10.write_input_file() with open(os.path.join(self.job_10.local_path, input_filenames[self.job_10.job_adapter]), 'r') as f: @@ -838,7 +841,7 @@ def test_trsh_write_input_file(self): %mem=14193mb %NProcShared=8 -#P opt=(calcfc,maxcycle=100,maxstep=5,tight) guess=mix wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) scf=(direct,tight) +#P opt=(calcfc,maxcycle=100,maxstep=5,tight) wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) scf=(direct,tight) ethanol @@ -864,7 +867,7 @@ def test_trsh_write_input_file(self): %mem=14193mb %NProcShared=8 -#P opt=(calcfc,maxcycle=100,maxstep=5,tight) guess=mix wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) nosymm scf=(direct,tight,xqc) +#P opt=(calcfc,maxcycle=100,maxstep=5,tight) wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) nosymm scf=(direct,tight,xqc) ethanol @@ -890,7 +893,7 @@ def test_trsh_write_input_file(self): %mem=14193mb %NProcShared=8 -#P opt=(calcfc,maxcycle=100,maxstep=5,tight) guess=mix wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) nosymm scf=(NDamp=30,direct,tight,xqc) +#P opt=(calcfc,maxcycle=100,maxstep=5,tight) wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) nosymm scf=(NDamp=30,direct,tight,xqc) ethanol @@ -916,7 +919,7 @@ def test_trsh_write_input_file(self): %mem=14193mb %NProcShared=8 -#P opt=(calcfc,maxcycle=100,maxstep=5,tight) guess=mix wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) nosymm scf=(NDamp=30,NoDIIS,direct,tight,xqc) +#P opt=(calcfc,maxcycle=100,maxstep=5,tight) wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) nosymm scf=(NDamp=30,NoDIIS,direct,tight,xqc) ethanol @@ -942,7 +945,7 @@ def test_trsh_write_input_file(self): %mem=14193mb %NProcShared=8 -#P opt=(calcfc,cartesian,maxcycle=100,maxstep=5,tight) guess=mix wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) nosymm scf=(NDamp=30,NoDIIS,direct,tight,xqc) +#P opt=(calcfc,cartesian,maxcycle=100,maxstep=5,tight) wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) nosymm scf=(NDamp=30,NoDIIS,direct,tight,xqc) ethanol @@ -996,7 +999,7 @@ def test_trsh_write_input_file(self): %mem=14193mb %NProcShared=8 -#P opt=(calcfc,maxcycle=200,maxstep=5,tight) guess=mix wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) scf=(direct,tight,xqc) +#P opt=(calcfc,maxcycle=200,maxstep=5,tight) wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) scf=(direct,tight,xqc) ethanol @@ -1022,7 +1025,7 @@ def test_trsh_write_input_file(self): %mem=14193mb %NProcShared=8 -#P opt=(calcfc,maxcycle=100,maxstep=5,tight) guess=mix wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) int=grid=300590 scf=(direct,tight) +#P opt=(calcfc,maxcycle=100,maxstep=5,tight) wb97xd integral=(grid=300590, Acc2E=14) IOp(2/9=2000) scf=(direct,tight) ethanol @@ -1048,7 +1051,7 @@ def test_trsh_write_input_file(self): %mem=14193mb %NProcShared=8 -#P opt=(calcfc,maxcycle=100,maxstep=5,tight) guess=mix wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) nosymm scf=(Fermi,NDamp=30,NoDIIS,NoVarAcc,Noincfock,direct,tight,xqc) +#P opt=(calcfc,maxcycle=100,maxstep=5,tight) wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) nosymm scf=(Fermi,NDamp=30,NoDIIS,NoVarAcc,Noincfock,direct,tight,xqc) ethanol @@ -1074,7 +1077,7 @@ def test_trsh_write_input_file(self): %mem=14193mb %NProcShared=8 -#P opt=(calcfc,maxcycle=100,maxstep=5,tight) guess=mix wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) scf=(NDamp=30,NoDIIS,NoVarAcc,direct,tight,xqc) +#P opt=(calcfc,maxcycle=100,maxstep=5,tight) wb97xd integral=(grid=300590, Acc2E=14) IOp(2/9=2000) scf=(NDamp=30,NoDIIS,NoVarAcc,direct,tight,xqc) ethanol @@ -1101,7 +1104,7 @@ def test_trsh_write_input_file(self): %mem=14193mb %NProcShared=8 -#P opt=(calcfc,maxcycle=100,maxstep=5,tight) guess=INDO wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) int=grid=300590 scf=(NDamp=30,NoDIIS,NoVarAcc,direct,tight,xqc) +#P opt=(calcfc,maxcycle=100,maxstep=5,tight) wb97xd integral=(grid=300590, Acc2E=14) IOp(2/9=2000) scf=(NDamp=30,NoDIIS,NoVarAcc,direct,tight,xqc) ethanol @@ -1128,7 +1131,7 @@ def test_trsh_write_input_file(self): %mem=14193mb %NProcShared=8 -#P opt=(calcfc,maxcycle=200,maxstep=5,tight) guess=mix wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) scf=(direct,tight) +#P opt=(calcfc,maxcycle=200,maxstep=5,tight) wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) scf=(direct,tight) ethanol @@ -1155,7 +1158,7 @@ def test_trsh_write_input_file(self): %mem=14193mb %NProcShared=8 -#P opt=(RFO,calcfc,maxcycle=200,maxstep=5,tight) guess=mix wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) scf=(direct,tight) +#P opt=(maxcycle=200,maxstep=5,recalcfc=5,tight) wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) scf=(direct,tight) ethanol @@ -1182,7 +1185,7 @@ def test_trsh_write_input_file(self): %mem=14193mb %NProcShared=8 -#P opt=(GDIIS,calcfc,maxcycle=200,maxstep=5,tight) guess=mix wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) scf=(direct,tight) +#P opt=(RFO,maxcycle=200,maxstep=5,recalcfc=5,tight) wb97xd integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) scf=(direct,tight) ethanol @@ -1202,6 +1205,28 @@ def test_trsh_write_input_file(self): self.assertEqual(content_24, job_24_expected_input_file) + def test_user_requested_verytight(self): + """Detection only fires for word-bounded ``verytight`` in the keyword channel.""" + cases = [ + ({'keyword': {'opt': 'opt=(verytight)'}, 'block': {}, 'trsh': {}}, True), + ({'keyword': {'opt': 'opt=(VeryTight)'}, 'block': {}, 'trsh': {}}, True), + ({'keyword': {'general': 'opt=(calcfc)'}, 'block': {}, 'trsh': {}}, False), + ({'keyword': {'opt': 'opt=(tight)'}, 'block': {}, 'trsh': {}}, False), + ({'keyword': {'general': 'verytightscf'}, 'block': {}, 'trsh': {}}, False), + ({'keyword': {}, 'block': {}, 'trsh': {}}, False), + # verytight smuggled in via block/trsh must not count + ({'keyword': {}, 'block': {'1': 'opt=(verytight)'}, 'trsh': {}}, False), + ({'keyword': {}, 'block': {}, 'trsh': {'opt': 'opt=(verytight)'}}, False), + ] + original_args = self.job_3.args + try: + for args, expected in cases: + with self.subTest(args=args): + self.job_3.args = args + self.assertEqual(self.job_3._user_requested_verytight(), expected) + finally: + self.job_3.args = original_args + def test_user_keyword_args_survive_a_level_round_trip(self): """ Test that user-specified keyword args reach the route section after a Level round-trip. @@ -1228,6 +1253,212 @@ def test_user_keyword_args_survive_a_level_round_trip(self): self.assertIn('verytight', route_section[0]) +class TestGaussianAdapterNoXqc(unittest.TestCase): + """ + Contains unit tests for the GaussianAdapter qc -> xqc upgrade and its 'no_xqc' opt-out. + + Self-contained (does not depend on the server settings used by TestGaussianAdapter's fixtures). + """ + + def write_input(self, ess_trsh_methods: list) -> str: + """Render a Gaussian input with scf=(qc) requested via trsh args and return its content.""" + project_directory = os.path.join(ARC_TESTING_PATH, 'test_GaussianAdapterNoXqc') + self.addCleanup(shutil.rmtree, project_directory, ignore_errors=True) + job = GaussianAdapter(execution_type='incore', + job_type='opt', + level=Level(method='wb97xd', basis='def2tzvp'), + project='test', + project_directory=project_directory, + species=[ARCSpecies(label='spc1', xyz=['O 0 0 1'], multiplicity=3)], + testing=True, + ess_trsh_methods=ess_trsh_methods, + # the scheduler passes the trsh keywords as a list under args['trsh']['trsh'] + args={'trsh': {'trsh': ['scf=(qc)']}}, + ) + job.write_input_file() + with open(os.path.join(job.local_path, input_filenames[job.job_adapter]), 'r') as f: + return f.read() + + def test_write_input_file_upgrades_qc_to_xqc_by_default(self): + """By default, a requested scf=(qc) is upgraded to scf=(xqc).""" + content = self.write_input(ess_trsh_methods=['scf=(qc)']) + self.assertIn('scf=(xqc)', content) + self.assertNotIn('scf=(qc)', content) + + def test_write_input_file_no_xqc_blocks_qc_upgrade(self): + """Once 'no_xqc' is recorded (Gaussian l508 failed), qc must not be upgraded to xqc.""" + content = self.write_input(ess_trsh_methods=['no_xqc']) + self.assertIn('scf=(qc)', content) + self.assertNotIn('xqc', content) + + +class TestGaussianAdapterAcc2E(unittest.TestCase): + """ + P2: int=(Acc2E=14) must take effect on non-fine opt/IRC jobs (self-contained). + """ + + def render(self, fine, trsh_list, job_type='opt'): + project_directory = os.path.join(ARC_TESTING_PATH, 'test_GaussianAdapterAcc2E') + self.addCleanup(shutil.rmtree, project_directory, ignore_errors=True) + kwargs = dict(execution_type='incore', job_type=job_type, + level=Level(method='wb97xd', basis='def2tzvp'), project='test', + project_directory=project_directory, + species=[ARCSpecies(label='spc1', xyz=['O 0 0 1'], multiplicity=3)], + testing=True, fine=fine, args={'trsh': {'trsh': trsh_list}}) + if job_type == 'irc': + kwargs['irc_direction'] = 'forward' + job = GaussianAdapter(**kwargs) + job.write_input_file() + with open(os.path.join(job.local_path, input_filenames[job.job_adapter]), 'r') as f: + return next(line for line in f if line.startswith('#')) + + def test_non_fine_opt_emits_acc2e(self): + """A non-fine opt with int=(Acc2E=14) in trsh must actually emit the integral setting.""" + route = self.render(fine=False, trsh_list=['int=(Acc2E=14)']) + self.assertIn('integral=(Acc2E=14)', route) + self.assertIn('Acc2E=14', route) + + def test_non_fine_opt_without_acc2e_unchanged(self): + """A normal non-fine opt (no Acc2E trsh) must not gain any integral= setting.""" + route = self.render(fine=False, trsh_list=[]) + self.assertNotIn('integral=', route) + self.assertNotIn('Acc2E', route) + + def test_fine_opt_still_folds_acc2e_into_ultrafine(self): + """A fine opt keeps folding Acc2E=14 into the ultrafine integral grid (unchanged).""" + route = self.render(fine=True, trsh_list=['int=(Acc2E=14)']) + self.assertIn('integral=(grid=ultrafine, Acc2E=14)', route) + + def test_non_fine_irc_emits_acc2e(self): + """A non-fine IRC with int=(Acc2E=14) in trsh must emit the integral setting too.""" + route = self.render(fine=False, trsh_list=['int=(Acc2E=14)'], job_type='irc') + self.assertIn('integral=(Acc2E=14)', route) + + +class TestGaussianAdapterOptLadder(unittest.TestCase): + """ + P3: the opt=() clause the adapter renders from the MaxOptCycles remedy ladder must carry a + single, non-conflicting force-constant directive, and a TS route must never receive GDIIS. + """ + + def render(self, trsh_list, is_ts): + project_directory = os.path.join(ARC_TESTING_PATH, 'test_GaussianAdapterOptLadder') + self.addCleanup(shutil.rmtree, project_directory, ignore_errors=True) + # GaussianAdapter derives self.is_ts from species[0].is_ts. + if is_ts: + spc = ARCSpecies(label='TS0', is_ts=True, + xyz=['O 0.0 0.0 0.0', 'H 0.0 0.0 0.97', 'H 0.94 0.0 -0.24']) + else: + spc = ARCSpecies(label='spc1', xyz=['O 0 0 1'], multiplicity=3) + job = GaussianAdapter(execution_type='incore', job_type='opt', + level=Level(method='wb97xd', basis='def2tzvp'), project='test', + project_directory=project_directory, species=[spc], testing=True, + fine=False, args={'trsh': {'trsh': trsh_list}}) + job.write_input_file() + with open(os.path.join(job.local_path, input_filenames[job.job_adapter]), 'r') as f: + return next(line for line in f if line.startswith('#')) + + def test_recalcfc_supersedes_base_calcfc(self): + """When the ladder adds recalcfc, the base calcfc must be dropped (no conflicting FC opts).""" + route = self.render(['opt=(maxcycle=200)', 'opt=(recalcfc=5)'], is_ts=False) + self.assertIn('recalcfc=5', route) + self.assertNotIn('calcfc,', route.replace('recalcfc', '')) # no standalone calcfc token + + def test_calcall_supersedes_recalcfc_and_calcfc(self): + """calcall is most aggressive: it must drop both recalcfc and calcfc.""" + route = self.render(['opt=(recalcfc=5)', 'opt=(calcall)'], is_ts=False) + self.assertIn('calcall', route) + self.assertNotIn('recalcfc', route) + self.assertNotIn('calcfc', route) + + def test_ts_route_renders_rfo_ladder(self): + """ + A TS opt route built from the (TS-aware) ladder renders with ts + RFO + Hessian recompute, + with the base calcfc superseded by recalcfc. (GDIIS is never produced for a TS - that guard + lives in arc.job.trsh.prioritize_opt_methods, covered by the trsh tests.) + """ + route = self.render(['opt=(maxcycle=200)', 'opt=(recalcfc=5)', 'opt=(RFO)'], is_ts=True) + self.assertIn('ts', route) + self.assertIn('RFO', route) + self.assertIn('recalcfc=5', route) + self.assertNotIn('GDIIS', route) + + +class TestGaussianAdapterGuessMixGating(unittest.TestCase): + """ + guess=mix seeds a broken-symmetry initial guess and must only be rendered where such a guess + is wanted: open-shell (biradical) singlets and TSs. Restricted closed-shell singlets and simple + high-spin radicals (doublets/triplets) must not carry it. guess=read (checkfile present) and + guess=INDO (troubleshooting) take precedence. + """ + + OH_XYZ = ['O 0.0 0.0 0.0\nH 0.0 0.0 0.97'] + C2H4_XYZ = ["""C -0.6 0.0 0.0 + C 0.6 0.0 0.0 + H -1.2 0.9 0.0 + H -1.2 -0.9 0.0 + H 1.2 0.9 0.0 + H 1.2 -0.9 0.0"""] + O2_XYZ = ['O 0.0 0.0 0.0\nO 0.0 0.0 1.2'] + TS_XYZ = ['O 0.0 0.0 0.0\nH 0.0 0.0 0.97\nH 0.94 0.0 -0.24'] + + def render(self, species, checkfile=None, args=None): + project_directory = os.path.join(ARC_TESTING_PATH, 'test_GaussianAdapterGuessMixGating') + self.addCleanup(shutil.rmtree, project_directory, ignore_errors=True) + job = GaussianAdapter(execution_type='incore', job_type='opt', + level=Level(method='wb97xd', basis='def2tzvp'), project='test', + project_directory=project_directory, species=[species], testing=True, + checkfile=checkfile, args=args) + job.write_input_file() + with open(os.path.join(job.local_path, input_filenames[job.job_adapter]), 'r') as f: + return next(line for line in f if line.startswith('#')) + + def test_closed_shell_singlet_has_no_guess_mix(self): + """A restricted closed-shell singlet must not carry guess=mix (it cannot break spin symmetry).""" + route = self.render(ARCSpecies(label='C2H4', xyz=self.C2H4_XYZ, multiplicity=1)) + self.assertNotIn('guess=', route) + self.assertIn(' wb97xd', route) # restricted + + def test_doublet_radical_has_no_guess_mix(self): + """A simple doublet radical converges to a clean from the default guess; no mix.""" + route = self.render(ARCSpecies(label='OH', xyz=self.OH_XYZ, multiplicity=2)) + self.assertNotIn('guess=', route) + self.assertIn('uwb97xd', route) # unrestricted + + def test_singlet_biradical_keeps_guess_mix(self): + """An open-shell singlet needs the broken-symmetry guess.""" + route = self.render(ARCSpecies(label='O2_singlet', xyz=self.O2_XYZ, + multiplicity=1, number_of_radicals=2)) + self.assertIn('guess=mix', route) + self.assertIn('uwb97xd', route) + + def test_ts_keeps_guess_mix(self): + """A TS opt keeps guess=mix (partial diradical character along the breaking bonds).""" + route = self.render(ARCSpecies(label='TS0', is_ts=True, multiplicity=2, xyz=self.TS_XYZ)) + self.assertIn('guess=mix', route) + self.assertIn('ts', route) + + def test_checkfile_guess_read_takes_precedence(self): + """With a checkfile present, guess=read is rendered for any species, including a TS.""" + project_directory = os.path.join(ARC_TESTING_PATH, 'test_GaussianAdapterGuessMixGating') + os.makedirs(project_directory, exist_ok=True) + checkfile = os.path.join(project_directory, 'check.chk') + with open(checkfile, 'w') as f: + f.write('dummy') + for spc in [ARCSpecies(label='TS0', is_ts=True, multiplicity=2, xyz=self.TS_XYZ), + ARCSpecies(label='OH', xyz=self.OH_XYZ, multiplicity=2)]: + route = self.render(spc, checkfile=checkfile) + self.assertIn('guess=read', route) + self.assertNotIn('guess=mix', route) + + def test_trsh_guess_indo_takes_precedence(self): + """guess=INDO requested via troubleshooting overrides everything, even for a TS.""" + route = self.render(ARCSpecies(label='TS0', is_ts=True, multiplicity=2, xyz=self.TS_XYZ), + args={'trsh': {'trsh': ['guess=INDO']}}) + self.assertIn('guess=INDO', route) + self.assertNotIn('guess=mix', route) + + class TestGetMemoryHeadroomFraction(unittest.TestCase): """ Contains unit tests for the get_memory_headroom_fraction() function. diff --git a/arc/job/adapters/molpro.py b/arc/job/adapters/molpro.py index 0ed556bf55..3d256c163b 100644 --- a/arc/job/adapters/molpro.py +++ b/arc/job/adapters/molpro.py @@ -347,11 +347,17 @@ def set_input_file_memory(self) -> None: """ Set the input_file_memory attribute. """ - # Molpro's memory is per cpu core, but here we ask for Total memory. - # Molpro measures memory in MW (mega word; 1000 MW = 7.45 GB on a 64-bit machine) - # The conversion from mW to GB was done using https://www.molpro.net/manual/doku.php?id=general_program_structure#memory_option_in_command_line - # 3.2 GB = 100 mw (case sensitive) total (as in this implimentation) -> 31.25 mw/GB is the conversion rate - self.input_file_memory = math.ceil(self.job_memory_gb * 31.25) + # Molpro's `memory,Total=N,m` card is the NODE-TOTAL memory pool, NOT per process. + # Empirically (molpro26 on zeus): a written card `Total=438` printed + # "Total memory per node: 438 MW" while giving only "Memory per process: 30 MW" and + # "Total GA space: 110 MW" -- Molpro itself reserves the Global-Array space (~25%) and + # splits the remainder across the `molpro -n {cpu_cores}` MPI ranks. So the card must + # carry the WHOLE job memory, and Molpro handles the per-rank division; dividing by + # cpu_cores here double-divides and starves the node ~cpu_cores-fold. + # On a 64-bit machine 1 MW = 8 bytes * 1e6 = 8e6 bytes = 0.008 GB, i.e. 125 MW = 1 GB. + # Therefore N = job_memory_gb * 125 makes the node-total card equal the PBS memory + # reservation (card-as-GB = N * 0.008 = job_memory_gb). Do NOT divide by cpu_cores. + self.input_file_memory = max(1, math.ceil(self.job_memory_gb * 125.0)) def execute_incore(self): """ diff --git a/arc/job/adapters/molpro_test.py b/arc/job/adapters/molpro_test.py index 9649875558..7a19b1edb3 100644 --- a/arc/job/adapters/molpro_test.py +++ b/arc/job/adapters/molpro_test.py @@ -5,11 +5,12 @@ This module contains unit tests of the arc.job.adapters.molpro module """ +import math import os import shutil import unittest -from arc.common import ARC_TESTING_PATH +from arc.common import ARC_TESTING_PATH, get_test_project_name from arc.job.adapters.molpro import MolproAdapter from arc.level import Level from arc.settings.settings import input_filenames, output_filenames @@ -30,7 +31,8 @@ def setUpClass(cls): job_type='sp', level=Level(method='CCSD(T)-F12', basis='cc-pVTZ-f12'), project='test', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_MolproAdapter_1'), + project_directory=os.path.join( + ARC_TESTING_PATH, get_test_project_name('test_MolproAdapter_1')), species=[ARCSpecies(label='spc1', xyz=['O 0 0 1'], multiplicity=3)], testing=True, ) @@ -38,7 +40,8 @@ def setUpClass(cls): job_type='opt', level=Level(method='CCSD(T)', basis='cc-pVQZ'), project='test', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_MolproAdapter_2'), + project_directory=os.path.join( + ARC_TESTING_PATH, get_test_project_name('test_MolproAdapter_2')), species=[ARCSpecies(label='spc1', xyz=['O 0 0 1'], multiplicity=3)], testing=True, ) @@ -46,7 +49,8 @@ def setUpClass(cls): job_type='sp', level=Level(method='MRCI', basis='aug-cc-pvtz-f12'), project='test', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_MolproAdapter_3'), + project_directory=os.path.join( + ARC_TESTING_PATH, get_test_project_name('test_MolproAdapter_3')), species=[ARCSpecies(label='HNO_t', xyz=["""N -0.08142 0.37454 0.00000 O 1.01258 -0.17285 0.00000 H -0.93116 -0.20169 0.00000"""], @@ -57,7 +61,8 @@ def setUpClass(cls): job_type='sp', level=Level(method='MRCI-F12', basis='aug-cc-pvtz-f12'), project='test', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_MolproAdapter_4'), + project_directory=os.path.join( + ARC_TESTING_PATH, get_test_project_name('test_MolproAdapter_4')), species=[ARCSpecies(label='HNO_t', xyz=["""N -0.08142 0.37454 0.00000 O 1.01258 -0.17285 0.00000 H -0.93116 -0.20169 0.00000"""], @@ -68,7 +73,8 @@ def setUpClass(cls): job_type='sp', level=Level(method='MP2_CASSCF_MRCI-F12', basis='aug-cc-pVTZ-F12'), project='test', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_MolproAdapter_5'), + project_directory=os.path.join( + ARC_TESTING_PATH, get_test_project_name('test_MolproAdapter_5')), species=[ARCSpecies(label='HNO_t', xyz=["""N -0.08142 0.37454 0.00000 O 1.01258 -0.17285 0.00000 H -0.93116 -0.20169 0.00000"""], @@ -79,7 +85,8 @@ def setUpClass(cls): job_type='sp', level=Level(method='MP2_CASSCF_RS2C', basis='aug-cc-pVTZ'), # CASPT2 project='test', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_MolproAdapter_6'), + project_directory=os.path.join( + ARC_TESTING_PATH, get_test_project_name('test_MolproAdapter_6')), species=[ARCSpecies(label='HNO_t', xyz=["""N -0.08142 0.37454 0.00000 O 1.01258 -0.17285 0.00000 H -0.93116 -0.20169 0.00000"""], @@ -90,7 +97,8 @@ def setUpClass(cls): job_type='sp', level=Level(method='MP2_CASSCF_RS2C', basis='aug-cc-pVTZ'), # CASPT2 project='test', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_MolproAdapter_7'), + project_directory=os.path.join( + ARC_TESTING_PATH, get_test_project_name('test_MolproAdapter_7')), species=[ARCSpecies(label='N', xyz=["""N 0.0 0.0 0.0"""], multiplicity=4, active={'occ': [3, 1, 1, 0, 1, 0, 0, 0], @@ -108,19 +116,33 @@ def test_set_cpu_and_mem(self): def test_set_input_file_memory(self): """Test setting the input_file_memory argument""" + # The `memory,Total=N,m` card is the NODE-TOTAL memory pool (Molpro reserves the + # Global-Array space and splits the remainder across the `molpro -n cpu_cores` ranks + # itself), NOT per process. 125 MW == 1 GB (1 MW == 8e6 bytes), so the node-total card + # equals the reserved memory: N = ceil(job_memory_gb * 125), INDEPENDENT of cpu_cores. + self.assertEqual(self.job_1.job_memory_gb, 14.0) + expected_card = math.ceil(14.0 * 125) # 1750 MW node-total == 14 GB reservation + self.job_1.input_file_memory = None self.job_1.cpu_cores = 48 self.job_1.set_input_file_memory() - self.assertEqual(self.job_1.input_file_memory, 438) + self.assertEqual(self.job_1.input_file_memory, expected_card) # 1750 + # node-total card (in GB) == the job's reserved memory: + self.assertAlmostEqual(self.job_1.input_file_memory * 8e6 / 1e9, 14.0, delta=0.5) + # The card is INDEPENDENT of the number of MPI ranks (node-total, not per-process): + self.job_1.input_file_memory = None self.job_1.cpu_cores = 8 self.job_1.set_input_file_memory() - self.assertEqual(self.job_1.input_file_memory, 438) + self.assertEqual(self.job_1.input_file_memory, expected_card) # 1750, unchanged self.job_1.input_file_memory = None self.job_1.cpu_cores = 1 self.job_1.set_input_file_memory() - self.assertEqual(self.job_1.input_file_memory, 438) + self.assertEqual(self.job_1.input_file_memory, expected_card) # 1750, unchanged + + # Invariant: node-total card == ceil(job_memory_gb * 125) and does NOT depend on cpu_cores. + self.assertEqual(self.job_1.input_file_memory, math.ceil(self.job_1.job_memory_gb * 125)) def test_write_input_file(self): """Test writing Molpro input files""" @@ -130,7 +152,7 @@ def test_write_input_file(self): with open(os.path.join(self.job_1.local_path, input_filenames[self.job_1.job_adapter]), 'r') as f: content_1 = f.read() job_1_expected_input_file = """***,spc1 -memory,Total=438,m; +memory,Total=1750,m; geometry={angstrom; O 0.00000000 0.00000000 1.00000000} @@ -163,7 +185,7 @@ def test_write_input_file(self): with open(os.path.join(self.job_2.local_path, input_filenames[self.job_2.job_adapter]), 'r') as f: content_2 = f.read() job_2_expected_input_file = """***,spc1 -memory,Total=438,m; +memory,Total=1750,m; geometry={angstrom; O 0.00000000 0.00000000 1.00000000} @@ -198,7 +220,7 @@ def test_write_mrci_input_file(self): with open(os.path.join(self.job_3.local_path, input_filenames[self.job_3.job_adapter]), 'r') as f: content_3 = f.read() job_3_expected_input_file = """***,HNO_t -memory,Total=438,m; +memory,Total=1750,m; geometry={angstrom; N -0.08142000 0.37454000 0.00000000 @@ -246,7 +268,7 @@ def test_write_mrci_input_file(self): with open(os.path.join(self.job_4.local_path, input_filenames[self.job_4.job_adapter]), 'r') as f: content_4 = f.read() job_4_expected_input_file = """***,HNO_t -memory,Total=438,m; +memory,Total=1750,m; geometry={angstrom; N -0.08142000 0.37454000 0.00000000 @@ -294,7 +316,7 @@ def test_write_mrci_input_file(self): with open(os.path.join(self.job_5.local_path, input_filenames[self.job_5.job_adapter]), 'r') as f: content_5 = f.read() job_5_expected_input_file = """***,HNO_t -memory,Total=438,m; +memory,Total=1750,m; geometry={angstrom; N -0.08142000 0.37454000 0.00000000 @@ -348,7 +370,7 @@ def test_write_mrci_input_file(self): with open(os.path.join(self.job_6.local_path, input_filenames[self.job_6.job_adapter]), 'r') as f: content_6 = f.read() job_6_expected_input_file = """***,HNO_t -memory,Total=438,m; +memory,Total=1750,m; geometry={angstrom; N -0.08142000 0.37454000 0.00000000 @@ -397,7 +419,7 @@ def test_write_mrci_input_file(self): with open(os.path.join(self.job_7.local_path, input_filenames[self.job_7.job_adapter]), 'r') as f: content_7 = f.read() job_7_expected_input_file = """***,N -memory,Total=438,m; +memory,Total=1750,m; geometry={angstrom; N 0.00000000 0.00000000 0.00000000} diff --git a/arc/job/adapters/orca_test.py b/arc/job/adapters/orca_test.py index c0ce422c89..3fecc69ad5 100644 --- a/arc/job/adapters/orca_test.py +++ b/arc/job/adapters/orca_test.py @@ -11,7 +11,7 @@ import shutil import unittest -from arc.common import ARC_TESTING_PATH +from arc.common import ARC_TESTING_PATH, get_test_project_name from arc.job.adapters.orca import (OrcaAdapter, _format_orca_basis, _format_orca_basis_token, @@ -21,6 +21,8 @@ from arc.settings.settings import input_filenames, output_filenames from arc.species import ARCSpecies +PROJECT_DIR = os.path.join(ARC_TESTING_PATH, get_test_project_name('test_OrcaAdapter')) + class TestOrcaAdapter(unittest.TestCase): """ @@ -36,7 +38,7 @@ def setUpClass(cls): job_type='sp', level=Level(method='DLPNO-CCSD(T)', basis='def2-tzvp', auxiliary_basis='def2-tzvp/c'), project='test', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_OrcaAdapter'), + project_directory=PROJECT_DIR, species=[ARCSpecies(label='CH3O', xyz="""C 0.03807240 0.00035621 -0.00484242 O 1.35198769 0.01264937 -0.17195885 @@ -50,7 +52,7 @@ def setUpClass(cls): level=Level(method='DLPNO-CCSD(T)', basis='def2-tzvp', auxiliary_basis='def2-tzvp/c', solvation_method='SMD', solvent='DMSO'), project='test', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_OrcaAdapter'), + project_directory=PROJECT_DIR, species=[ARCSpecies(label='CH3O', xyz="""C 0.03807240 0.00035621 -0.00484242 O 1.35198769 0.01264937 -0.17195885 @@ -64,7 +66,7 @@ def setUpClass(cls): level=Level(method='DLPNO-CCSD(T)', basis='def2-tzvp', auxiliary_basis='def2-tzvp/c', solvation_method='cpcm', solvent='water'), project='test', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_OrcaAdapter'), + project_directory=PROJECT_DIR, species=[ARCSpecies(label='CH3O', xyz="""C 0.03807240 0.00035621 -0.00484242 O 1.35198769 0.01264937 -0.17195885 @@ -77,7 +79,7 @@ def setUpClass(cls): job_type='sp', level=Level(method='MP2_CASSCF_MRCI', basis='aug-cc-pVTZ'), project='test4', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_OrcaAdapter'), + project_directory=PROJECT_DIR, species=[ARCSpecies(label='CH3O', active=(14, 7), xyz="""C 0.03807240 0.00035621 -0.00484242 @@ -99,6 +101,34 @@ def test_set_input_file_memory(self): expected_memory = math.ceil(14 * 1024 / 8) self.assertEqual(self.job_1.input_file_memory, expected_memory) + def test_set_input_file_memory_with_configured_core_count(self): + """Test ORCA %%maxcore calculation for a configured total memory and cpu count.""" + original_memory = self.job_1.job_memory_gb + original_cpu_cores = self.job_1.cpu_cores + self.job_1.job_memory_gb = 250 + self.job_1.cpu_cores = 22 + self.job_1.set_input_file_memory() + self.assertEqual(self.job_1.input_file_memory, math.ceil(250 * 1024 / 22)) + self.job_1.job_memory_gb = original_memory + self.job_1.cpu_cores = original_cpu_cores + self.job_1.set_input_file_memory() + + def test_write_input_file_with_configured_core_count(self): + """Test rendering ORCA input for a configured total memory and cpu count.""" + original_memory = self.job_1.job_memory_gb + original_cpu_cores = self.job_1.cpu_cores + self.job_1.job_memory_gb = 250 + self.job_1.cpu_cores = 22 + self.job_1.set_input_file_memory() + self.job_1.write_input_file() + with open(os.path.join(self.job_1.local_path, input_filenames[self.job_1.job_adapter]), 'r') as f: + content = f.read() + self.assertIn('%maxcore 11637', content) + self.assertIn('%pal nprocs 22 end', content) + self.job_1.job_memory_gb = original_memory + self.job_1.cpu_cores = original_cpu_cores + self.job_1.set_input_file_memory() + def test_write_input_file(self): """Test writing Orca input files""" self.job_1.write_input_file() @@ -197,7 +227,7 @@ def test_write_input_file_f12_with_cabs(self): auxiliary_basis='aug-cc-pVTZ/C', cabs='cc-pVTZ-F12-CABS'), project='test_f12', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_OrcaAdapter'), + project_directory=PROJECT_DIR, species=[ARCSpecies(label='O_atom', smiles='[O]', xyz='O 0.0 0.0 0.0')], testing=True, @@ -223,7 +253,7 @@ def test_write_input_file_f12_without_cabs_raises(self): basis='cc-pVTZ-F12', auxiliary_basis='aug-cc-pVTZ/C'), project='test_f12_bad', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_OrcaAdapter'), + project_directory=PROJECT_DIR, species=[ARCSpecies(label='O_atom', smiles='[O]', xyz='O 0.0 0.0 0.0')], testing=True, @@ -321,7 +351,7 @@ def test_dft_grid_regular_opt(self): job_type='opt', level=Level(method='wb97x-d3', basis='def2-tzvp'), project='test_dft_grid', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_OrcaAdapter'), + project_directory=PROJECT_DIR, species=[ARCSpecies(label='CH3O', xyz="""C 0.03807240 0.00035621 -0.00484242 O 1.35198769 0.01264937 -0.17195885 @@ -343,7 +373,7 @@ def test_dft_grid_fine_opt(self): job_type='opt', level=Level(method='wb97x-d3', basis='def2-tzvp'), project='test_dft_grid_fine', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_OrcaAdapter'), + project_directory=PROJECT_DIR, species=[ARCSpecies(label='CH3O', xyz="""C 0.03807240 0.00035621 -0.00484242 O 1.35198769 0.01264937 -0.17195885 @@ -364,7 +394,7 @@ def test_dft_grid_freq(self): job_type='freq', level=Level(method='wb97x-d3', basis='def2-tzvp'), project='test_dft_grid_freq', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_OrcaAdapter'), + project_directory=PROJECT_DIR, species=[ARCSpecies(label='CH3O', xyz="""C 0.03807240 0.00035621 -0.00484242 O 1.35198769 0.01264937 -0.17195885 @@ -385,7 +415,7 @@ def test_dft_grid_optfreq(self): job_type='optfreq', level=Level(method='wb97x-d3', basis='def2-tzvp'), project='test_dft_grid_optfreq', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_OrcaAdapter'), + project_directory=PROJECT_DIR, species=[ARCSpecies(label='CH3O', xyz="""C 0.03807240 0.00035621 -0.00484242 O 1.35198769 0.01264937 -0.17195885 @@ -406,7 +436,7 @@ def test_fine_opt_convergence_tightopt(self): job_type='opt', level=Level(method='wb97x-d3', basis='def2-tzvp'), project='test_fine_opt_conv', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_OrcaAdapter'), + project_directory=PROJECT_DIR, species=[ARCSpecies(label='CH3O', xyz="""C 0.03807240 0.00035621 -0.00484242 O 1.35198769 0.01264937 -0.17195885 @@ -428,7 +458,7 @@ def test_recalc_hess_in_optts(self): job_type='opt', level=Level(method='wb97x-d3', basis='def2-tzvp'), project='test_optts_hess', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_OrcaAdapter'), + project_directory=PROJECT_DIR, species=[ARCSpecies(label='TS_example', xyz="""C 0.03807240 0.00035621 -0.00484242 O 1.35198769 0.01264937 -0.17195885 @@ -454,7 +484,7 @@ def test_recalc_hess_not_in_regular_opt(self): job_type='opt', level=Level(method='wb97x-d3', basis='def2-tzvp'), project='test_opt_no_hess', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_OrcaAdapter'), + project_directory=PROJECT_DIR, species=[ARCSpecies(label='CH3O', xyz="""C 0.03807240 0.00035621 -0.00484242 O 1.35198769 0.01264937 -0.17195885 @@ -483,7 +513,7 @@ def test_writing_input_does_not_pollute_level_args(self): job_type='opt', level=level, project='test', - project_directory=os.path.join(ARC_TESTING_PATH, 'test_OrcaAdapter'), + project_directory=PROJECT_DIR, species=[ARCSpecies(label='CH3O', xyz="""C 0.03807240 0.00035621 -0.00484242 O 1.35198769 0.01264937 -0.17195885 @@ -503,7 +533,7 @@ def tearDownClass(cls): A function that is run ONCE after all unit tests in this class. Delete all project directories created during these unit tests """ - shutil.rmtree(os.path.join(ARC_TESTING_PATH, 'test_OrcaAdapter'), ignore_errors=True) + shutil.rmtree(PROJECT_DIR, ignore_errors=True) if __name__ == '__main__': diff --git a/arc/job/adapters/scripts/gcn_script.py b/arc/job/adapters/scripts/gcn_script.py index bb71802375..172c15bb63 100644 --- a/arc/job/adapters/scripts/gcn_script.py +++ b/arc/job/adapters/scripts/gcn_script.py @@ -20,17 +20,69 @@ python gcn_script.py --yml_in_path input.yml where the YAML file contains the keys ``reactant_path``, ``product_path``, - ``local_path``, ``yml_out_path``, and ``repetitions``. + ``local_path``, ``yml_out_path``, ``repetitions``, and (optionally) ``seed``. + +GCN inference is stochastic, so both modes seed python-random, NumPy and PyTorch +before every inference call. The base seed crosses the subprocess boundary +explicitly -- via ``--seed`` in the direct mode and via the ``seed`` key of the +YAML input file in the batch mode -- because ARC's process-level seeding cannot +reach a child interpreter. Each repetition uses ``base seed + repetition index``, +so repetitions still sample different starting points while the whole set of TS +guesses is reproducible between runs. """ import argparse import datetime import os +import random import sys import traceback import yaml +DEFAULT_RANDOM_SEED = 1 + + +def set_random_seeds(seed: int) -> None: + """ + Seed every random number generator GCN inference draws from. + + Seeds python-random, NumPy and PyTorch (both the CPU and, when compiled in, + the CUDA generators), and asks cuDNN for deterministic kernel selection. + ``torch.use_deterministic_algorithms`` is deliberately NOT enabled: several + graph-network primitives (scatter-add in particular) have no deterministic + CUDA implementation and would raise instead of running. + + NumPy and PyTorch are optional here only so that this script keeps working + in an environment where they are absent; in a working ``ts_gcn`` environment + both are installed and both get seeded. + + ``PYTHONHASHSEED`` is not set here: it only takes effect before the + interpreter starts, so ARC exports it into the child's environment instead + (see ``arc.job.adapters.ts.gcn_ts.run_subprocess_locally``). + + Args: + seed (int): The random seed to use. + """ + random.seed(seed) + try: + import numpy as np + except ImportError as e: + print(f'Could not seed NumPy, GCN results may not be reproducible: {e}', file=sys.stderr) + else: + np.random.seed(seed) + try: + import torch + except ImportError as e: + print(f'Could not seed PyTorch, GCN results may not be reproducible: {e}', file=sys.stderr) + return + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + if hasattr(torch.backends, 'cudnn'): + torch.backends.cudnn.deterministic = True + torch.backends.cudnn.benchmark = False + def import_inference(): """ @@ -78,21 +130,25 @@ def initialize_gcn_run(input_dict: dict): Args: input_dict (dict): The input dictionary with keys ``reactant_path``, ``product_path``, - ``local_path``, ``yml_out_path``, and ``repetitions``. + ``local_path``, ``yml_out_path``, ``repetitions``, and optionally ``seed``. """ ts_fwd_path = os.path.join(input_dict['local_path'], "TS_fwd.xyz") ts_rev_path = os.path.join(input_dict['local_path'], "TS_rev.xyz") + base_seed = input_dict.get('seed') + base_seed = DEFAULT_RANDOM_SEED if base_seed is None else int(base_seed) tsgs = list() for i in range(input_dict['repetitions']): tsg_f = run_gcn_locally(direction='F', reactant_path=input_dict['reactant_path'], product_path=input_dict['product_path'], ts_path=ts_fwd_path, + seed=base_seed + i, ) tsg_r = run_gcn_locally(direction='R', reactant_path=input_dict['product_path'], product_path=input_dict['reactant_path'], ts_path=ts_rev_path, + seed=base_seed + i, ) tsgs.extend([tsg_f, tsg_r]) save_yaml_file(path=input_dict['yml_out_path'], content=tsgs) @@ -102,6 +158,7 @@ def run_gcn_locally(direction: str, reactant_path: str, product_path: str, ts_path: str, + seed: int = DEFAULT_RANDOM_SEED, ) -> dict: """ Run GCN in a single direction and package the result as a TS guess dictionary. @@ -111,6 +168,7 @@ def run_gcn_locally(direction: str, reactant_path (str): The path to the reactant SDF file. product_path (str): The path to the product SDF file. ts_path (str): The path to the resulting TS guess file. + seed (int, optional): The random seed to use for this inference. Returns: dict: The TS guess dictionary. @@ -125,6 +183,7 @@ def run_gcn_locally(direction: str, success = run_gcn(r_sdf_path=reactant_path, p_sdf_path=product_path, ts_xyz_path=ts_path, + seed=seed, ) if success and os.path.isfile(ts_path): with open(ts_path, 'r') as f: @@ -139,19 +198,26 @@ def run_gcn_locally(direction: str, def run_gcn(r_sdf_path: str, p_sdf_path: str, ts_xyz_path: str, + seed: int = DEFAULT_RANDOM_SEED, ) -> bool: """ Run a single GCN inference: read the reactant and product SDF files and write the TS guess to ``ts_xyz_path``. + The random number generators are (re)seeded immediately before the inference + call so that this specific inference is reproducible regardless of how many + inferences preceded it in the same interpreter. + Args: r_sdf_path (str): The path to the reactant SDF file. p_sdf_path (str): The path to the product SDF file. ts_xyz_path (str): The path to write the TS guess to. + seed (int, optional): The random seed to use for this inference. Returns: bool: Whether the inference completed without raising. """ + set_random_seeds(seed) from rdkit import Chem inference = import_inference() r_mols = Chem.SDMolSupplier(r_sdf_path, removeHs=False, sanitize=True) @@ -219,6 +285,10 @@ def parse_command_line_arguments(command_line_args=None): help='A path to the product SDF file (direct mode)') parser.add_argument('--ts_xyz_path', metavar='ts', type=str, default=None, help='A path to write the TS guess XYZ file to (direct mode)') + parser.add_argument('--seed', metavar='seed', type=int, default=DEFAULT_RANDOM_SEED, + help='The random seed to use, making the TS guess reproducible ' + '(ARC passes arc.settings.settings.TS_SEARCH_RANDOM_SEED ' + 'plus the repetition index)') args = parser.parse_args(command_line_args) return args @@ -229,11 +299,15 @@ def main(): """ args = parse_command_line_arguments() if args.yml_in_path is not None: - initialize_gcn_run(input_dict=read_yaml_file(str(args.yml_in_path))) + input_dict = read_yaml_file(str(args.yml_in_path)) + if isinstance(input_dict, dict) and input_dict.get('seed') is None: + input_dict['seed'] = args.seed + initialize_gcn_run(input_dict=input_dict) elif args.r_sdf_path is not None and args.p_sdf_path is not None and args.ts_xyz_path is not None: success = run_gcn(r_sdf_path=str(args.r_sdf_path), p_sdf_path=str(args.p_sdf_path), ts_xyz_path=str(args.ts_xyz_path), + seed=int(args.seed), ) if not success or not os.path.isfile(str(args.ts_xyz_path)): sys.exit(1) diff --git a/arc/job/adapters/scripts/xtb_gsm/ograd b/arc/job/adapters/scripts/xtb_gsm/ograd old mode 100644 new mode 100755 index d208a7502f..f5c01f4f3a --- a/arc/job/adapters/scripts/xtb_gsm/ograd +++ b/arc/job/adapters/scripts/xtb_gsm/ograd @@ -24,3 +24,21 @@ tm2orca.py $basename rm xtbrestart cd .. +# ── Per-node provenance preservation (TCKDB path_search_result.points) ── +# tm2orca.py renames the xTB-generated Turbomole-format ``energy`` +# and ``gradient`` files (xTB writes its --grad output in Turbomole's +# on-disk text format; the calculation provenance is xTB, not Turbomole) +# to ``.energy`` and ``.gradient`` +# inside scratch/. The GSM binary then consumes the ORCA-shaped +# ``.engrad`` and may overwrite or remove the per-node files on +# subsequent calls. Copy them (plus the captured xtb stdout) into a +# stable side-effect directory at the run root so the TCKDB adapter's +# parser can recover per-node electronic energies and gradient metrics +# later. The copies are not consumed by GSM — the original scratch/ +# files stay in place unchanged for the algorithm. +node_label="$1" +preserve_dir="gsm_node_outputs" +mkdir -p "$preserve_dir" +[ -f "scratch/${basename}.energy" ] && cp -p "scratch/${basename}.energy" "$preserve_dir/${node_label}.energy" +[ -f "scratch/${basename}.gradient" ] && cp -p "scratch/${basename}.gradient" "$preserve_dir/${node_label}.gradient" +[ -f "scratch/${ofile}.xtbout" ] && cp -p "scratch/${ofile}.xtbout" "$preserve_dir/${node_label}.xtbout" diff --git a/arc/job/adapters/torch_ani_test.py b/arc/job/adapters/torch_ani_test.py index fd29a677e8..9ba5225422 100644 --- a/arc/job/adapters/torch_ani_test.py +++ b/arc/job/adapters/torch_ani_test.py @@ -15,13 +15,15 @@ from arc.common import almost_equal_coords, almost_equal_lists, read_yaml_file from arc.job.adapters.torch_ani import TorchANIAdapter -from arc.settings.settings import tani_default_options_dict +from arc.settings.settings import TANI_PYTHON, tani_default_options_dict from arc.species import ARCSpecies from arc.species.vectors import calculate_distance, calculate_angle, calculate_dihedral_angle TANI_SCHEMA_VERSION = 2 +@unittest.skipUnless(TANI_PYTHON is not None, + "tani_env conda environment not found; TorchANI adapter tests require it.") class TestTorchANIAdapter(unittest.TestCase): """ Contains unit tests for the TorchANIAdapter class. diff --git a/arc/job/adapters/ts/__init__.py b/arc/job/adapters/ts/__init__.py index e1e5306438..46bf34e1f6 100644 --- a/arc/job/adapters/ts/__init__.py +++ b/arc/job/adapters/ts/__init__.py @@ -1,9 +1,12 @@ import arc.job.adapters.ts.autotst_ts +import arc.job.adapters.ts.crest import arc.job.adapters.ts.gcn_ts import arc.job.adapters.ts.goflow_ts import arc.job.adapters.ts.heuristics import arc.job.adapters.ts.kinbot_ts import arc.job.adapters.ts.linear import arc.job.adapters.ts.orca_neb +import arc.job.adapters.ts.qst2 import arc.job.adapters.ts.rits_ts +import arc.job.adapters.ts.seed_hub import arc.job.adapters.ts.xtb_gsm diff --git a/arc/job/adapters/ts/autotst_ts.py b/arc/job/adapters/ts/autotst_ts.py index 6698adc48e..d4e8a2712b 100644 --- a/arc/job/adapters/ts/autotst_ts.py +++ b/arc/job/adapters/ts/autotst_ts.py @@ -124,9 +124,13 @@ def __init__(self, self.execution_type = execution_type or 'incore' self.command = None # AutoTST does not have an executable file, just an API. self.url = 'https://github.com/ReactionMechanismGenerator/AutoTST' + # Note: 'Disproportionation' requires the AutoTST env to be on a branch whose + # SUPPORTED_FAMILIES includes 'Disproportionation' (e.g. fix/disproportionation-support), + # not on AutoTST main. self.supported_families = ['intra_H_migration', 'H_Abstraction', - 'R_Addition_MultipleBond'] + 'R_Addition_MultipleBond', + 'Disproportionation'] if reactions is None: raise ValueError('Cannot execute AutoTST without ARCReaction object(s).') @@ -208,6 +212,36 @@ def set_input_file_memory(self) -> None: """ pass + def save_subprocess_error_log(self, + output, + rxn: ARCReaction, + direction_str: str, + ) -> str | None: + """ + Save the stdout and stderr of a failed AutoTST subprocess to a dedicated file + next to the job's output, keeping the (often lengthy) traceback out of arc.log. + + Args: + output: The ``CompletedProcess`` instance returned by the AutoTST subprocess. + rxn (ARCReaction): The reaction for which AutoTST was executed. + direction_str (str): The direction in which AutoTST was executed, 'forward' or 'reverse'. + + Returns: + Optional[str]: The path to which the details were written, ``None`` if writing failed. + """ + err_path = os.path.join(os.path.dirname(self.output_path), 'autotst_err.log') + try: + os.makedirs(os.path.dirname(err_path), exist_ok=True) + with open(err_path, 'a') as f: + f.write(f'AutoTST subprocess for {rxn} in the {direction_str} direction ' + f'returned code {output.returncode} at {datetime.datetime.now()}\n') + f.write(f'stdout:\n{output.stdout}\n') + f.write(f'stderr:\n{output.stderr}\n\n') + except OSError as e: + logger.debug(f'Could not write the AutoTST subprocess error details to {err_path}, got:\n{e}') + return None + return err_path + def execute_incore(self): """ Execute a job incore. @@ -258,11 +292,15 @@ def execute_incore(self): if output.returncode: direction_str = 'forward' if direction == 'F' else 'reverse' - logger.warning(f'AutoTST subprocess did not give a successful return code for {rxn} ' - f'in the {direction_str} direction.\n' - f'Got return code: {output.returncode}\n' - f'stdout: {output.stdout}\n' - f'stderr: {output.stderr}') + err_path = self.save_subprocess_error_log(output=output, + rxn=rxn, + direction_str=direction_str, + ) + logger.warning(f'AutoTST subprocess for {rxn} in the {direction_str} direction returned ' + f'code {output.returncode}, see {err_path} for details.' + if err_path is not None else + f'AutoTST subprocess for {rxn} in the {direction_str} direction returned ' + f'code {output.returncode}.') if os.path.isfile(self.output_path): results = read_yaml_file(path=self.output_path) if results: diff --git a/arc/job/adapters/ts/autotst_ts_test.py b/arc/job/adapters/ts/autotst_ts_test.py new file mode 100644 index 0000000000..3c2a6444e3 --- /dev/null +++ b/arc/job/adapters/ts/autotst_ts_test.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +""" +This module contains unit tests of the arc.job.adapters.ts.autotst_ts module +""" + +import os +import shutil +import subprocess +import unittest +from unittest import mock + +from arc.common import ARC_TESTING_PATH, get_logger +import arc.job.adapters.common as common +import arc.job.adapters.ts.autotst_ts as autotst_ts +from arc.job.adapters.ts.autotst_ts import AutoTSTAdapter +from arc.reaction import ARCReaction +from arc.species import ARCSpecies + + +logger = get_logger() + +TRACEBACK = """Traceback (most recent call last): + File "autotst_script.py", line 1, in + raise ValueError('a very long AutoTST traceback') +ValueError: a very long AutoTST traceback""" + + +class TestAutoTSTAdapter(unittest.TestCase): + """ + Contains unit tests for the AutoTSTAdapter class. + """ + + @classmethod + def setUpClass(cls): + """ + A method that is run before all unit tests in this class. + """ + cls.maxDiff = None + cls.project_dir = os.path.join(ARC_TESTING_PATH, 'test_AutoTST') + + def setUp(self): + """ + A method that is run before each unit test in this class. + """ + self.rxn_1 = ARCReaction(reactants=['CC[O]'], products=['[CH2]CO'], + r_species=[ARCSpecies(label='CC[O]', smiles='CC[O]')], + p_species=[ARCSpecies(label='[CH2]CO', smiles='[CH2]CO')]) + + def _remove_test_dir(self, path: str): + """A helper function to remove a single test's project directory (and the shared + parent directory if it is empty). Tests may run in parallel (pytest-xdist), so + each test must only ever remove its own subdirectory.""" + shutil.rmtree(path, ignore_errors=True) + try: + os.rmdir(self.project_dir) + except OSError: + pass + + def get_adapter(self, dir_name: str) -> AutoTSTAdapter: + """A helper function to instantiate an AutoTSTAdapter instance.""" + project_directory = os.path.join(self.project_dir, dir_name) + self.addCleanup(self._remove_test_dir, project_directory) + return AutoTSTAdapter(job_type='tsg', + reactions=[self.rxn_1], + testing=True, + project='test', + project_directory=project_directory, + ) + + def test_supported_families(self): + """Test that the AutoTST adapter advertises the expected RMG families (gate 1).""" + adapter = self.get_adapter(dir_name='tst_supported_families') + for family in ['intra_H_migration', 'H_Abstraction', 'R_Addition_MultipleBond', 'Disproportionation']: + self.assertIn(family, adapter.supported_families) + + def test_disproportionation_passes_both_gates(self): + """Test that a Disproportionation reaction is admitted through BOTH TS-adapter gates for autotst. + + Gate 1: the adapter's own ``supported_families`` includes 'Disproportionation'. + Gate 2: ``ts_adapters_by_rmg_family`` maps 'Disproportionation' to a list that includes 'autotst'. + """ + # Gate 2: the RMG-family -> adapters registry (checked directly, no RMG classification needed). + self.assertIn('Disproportionation', common.ts_adapters_by_rmg_family) + self.assertIn('autotst', common.ts_adapters_by_rmg_family['Disproportionation']) + + # Gate 1: the adapter advertises Disproportionation as supported. + adapter = self.get_adapter(dir_name='tst_disprop_gates') + self.assertIn('Disproportionation', adapter.supported_families) + + def test_intra_h_migration_still_supported(self): + """Test that enabling Disproportionation did not disturb the pre-existing intra_H_migration gate.""" + adapter = self.get_adapter(dir_name='tst_intra_h') + self.assertIn('intra_H_migration', adapter.supported_families) + + + + def test_save_subprocess_error_log(self): + """Test saving the subprocess stdout and stderr to a dedicated file""" + adapter = self.get_adapter(dir_name='tst_save_error_log') + output = subprocess.CompletedProcess(args=[], returncode=1, stdout='some stdout', stderr=TRACEBACK) + err_path = adapter.save_subprocess_error_log(output=output, rxn=self.rxn_1, direction_str='forward') + self.assertEqual(err_path, os.path.join(os.path.dirname(adapter.output_path), 'autotst_err.log')) + self.assertTrue(os.path.isfile(err_path)) + with open(err_path, 'r') as f: + content = f.read() + self.assertIn('some stdout', content) + self.assertIn('ValueError: a very long AutoTST traceback', content) + self.assertIn('returned code 1', content) + adapter.save_subprocess_error_log(output=output, rxn=self.rxn_1, direction_str='reverse') + with open(err_path, 'r') as f: + content = f.read() + self.assertIn('forward', content) + self.assertIn('reverse', content) + + def test_save_subprocess_error_log_does_not_raise(self): + """Test that a failure to write the error log does not break the run""" + adapter = self.get_adapter(dir_name='tst_save_error_log_fail') + output = subprocess.CompletedProcess(args=[], returncode=1, stdout='', stderr='') + with mock.patch('builtins.open', side_effect=OSError('read-only file system')): + err_path = adapter.save_subprocess_error_log(output=output, rxn=self.rxn_1, direction_str='forward') + self.assertIsNone(err_path) + + def test_failed_subprocess_logs_a_single_line(self): + """Test that a failed AutoTST subprocess does not dump its traceback into the log""" + self.assertEqual(self.rxn_1.family, 'intra_H_migration') + adapter = self.get_adapter(dir_name='tst_single_line_warning') + + def fake_run_in_conda_env(python_executable, script_path, *script_args, check=False, + strip_pythonpath=False): + """Mimic a crashing AutoTST worker script.""" + return subprocess.CompletedProcess(args=[], returncode=1, stdout='', stderr=TRACEBACK) + + with mock.patch.object(autotst_ts, 'AUTOTST_PYTHON', autotst_ts.__file__), \ + mock.patch.object(autotst_ts, 'run_in_conda_env', side_effect=fake_run_in_conda_env): + with self.assertLogs('arc', level='WARNING') as cm: + adapter.execute_incore() + + warnings = [record.getMessage() for record in cm.records if record.levelname == 'WARNING' + and 'AutoTST subprocess' in record.getMessage()] + self.assertEqual(len(warnings), 2) + err_path = os.path.join(os.path.dirname(adapter.output_path), 'autotst_err.log') + for message, direction_str in zip(warnings, ['forward', 'reverse']): + self.assertEqual(len(message.splitlines()), 1) + self.assertNotIn('Traceback', message) + self.assertIn(direction_str, message) + self.assertIn('returned code 1', message) + self.assertIn(err_path, message) + self.assertTrue(os.path.isfile(err_path)) + with open(err_path, 'r') as f: + content = f.read() + self.assertIn('ValueError: a very long AutoTST traceback', content) + +if __name__ == '__main__': + unittest.main(testRunner=unittest.TextTestRunner(verbosity=2)) diff --git a/arc/job/adapters/ts/crest.py b/arc/job/adapters/ts/crest.py new file mode 100644 index 0000000000..11067f24d8 --- /dev/null +++ b/arc/job/adapters/ts/crest.py @@ -0,0 +1,669 @@ +""" +Utilities for running CREST within ARC. + +Separated from heuristics so CREST can be conditionally imported and reused. +""" + +import datetime +import math +import os +import time +from typing import TYPE_CHECKING, List, Optional, Union + +from arc.common import almost_equal_coords, get_logger +from arc.imports import settings, submit_scripts +from arc.job.adapter import JobAdapter +from arc.job.adapters.common import _initialize_adapter, ts_adapters_by_rmg_family +from arc.job.adapters.ts.heuristics import DIHEDRAL_INCREMENT +from arc.job.adapters.ts.seed_hub import get_backup_ts_seeds, get_ts_seeds, get_wrapper_constraints +from arc.job.factory import register_job_adapter +from arc.job.local import check_job_status, submit_job +from arc.plotter import save_geo +from arc.species.converter import reorder_xyz_string, xyz_file_format_to_xyz, xyz_to_str +from arc.species.species import ARCSpecies, TSGuess + +if TYPE_CHECKING: + from arc.level import Level + from arc.reaction import ARCReaction + +logger = get_logger() + +MAX_CHECK_INTERVAL_SECONDS = 100 + +CREST_PATH = settings.get("CREST_PATH", None) +CREST_ENV_PATH = settings.get("CREST_ENV_PATH", None) +SERVERS = settings.get("servers", {}) + + +def crest_available() -> bool: + """ + Return whether CREST is configured for use. + """ + return bool(SERVERS.get("local")) and bool(CREST_PATH or CREST_ENV_PATH) + + +class CrestAdapter(JobAdapter): + """ + A class for executing CREST TS conformer searches based on heuristics-generated guesses. + """ + + def __init__(self, + project: str, + project_directory: str, + job_type: Union[List[str], str], + args: Optional[dict] = None, + bath_gas: Optional[str] = None, + checkfile: Optional[str] = None, + conformer: Optional[int] = None, + constraints: Optional[List] = None, + cpu_cores: Optional[str] = None, + dihedral_increment: Optional[float] = None, + dihedrals: Optional[List[float]] = None, + directed_scan_type: Optional[str] = None, + ess_settings: Optional[dict] = None, + ess_trsh_methods: Optional[List[str]] = None, + execution_type: Optional[str] = None, + fine: bool = False, + initial_time: Optional[Union['datetime.datetime', str]] = None, + irc_direction: Optional[str] = None, + job_id: Optional[int] = None, + job_memory_gb: float = 14.0, + job_name: Optional[str] = None, + job_num: Optional[int] = None, + job_server_name: Optional[str] = None, + job_status: Optional[List[Union[dict, str]]] = None, + level: Optional['Level'] = None, + max_job_time: Optional[float] = None, + run_multi_species: bool = False, + reactions: Optional[List['ARCReaction']] = None, + rotor_index: Optional[int] = None, + server: Optional[str] = None, + server_nodes: Optional[list] = None, + queue: Optional[str] = None, + attempted_queues: Optional[List[str]] = None, + species: Optional[List[ARCSpecies]] = None, + testing: bool = False, + times_rerun: int = 0, + torsions: Optional[List[List[int]]] = None, + tsg: Optional[int] = None, + xyz: Optional[dict] = None, + ): + + self.incore_capacity = 50 + self.job_adapter = 'crest' + self.command = None + self.execution_type = execution_type or 'incore' + + if reactions is None: + raise ValueError('Cannot execute TS CREST without ARCReaction object(s).') + + dihedral_increment = dihedral_increment or DIHEDRAL_INCREMENT + + _initialize_adapter(obj=self, + is_ts=True, + project=project, + project_directory=project_directory, + job_type=job_type, + args=args, + bath_gas=bath_gas, + checkfile=checkfile, + conformer=conformer, + constraints=constraints, + cpu_cores=cpu_cores, + dihedral_increment=dihedral_increment, + dihedrals=dihedrals, + directed_scan_type=directed_scan_type, + ess_settings=ess_settings, + ess_trsh_methods=ess_trsh_methods, + fine=fine, + initial_time=initial_time, + irc_direction=irc_direction, + job_id=job_id, + job_memory_gb=job_memory_gb, + job_name=job_name, + job_num=job_num, + job_server_name=job_server_name, + job_status=job_status, + level=level, + max_job_time=max_job_time, + run_multi_species=run_multi_species, + reactions=reactions, + rotor_index=rotor_index, + server=server, + server_nodes=server_nodes, + queue=queue, + attempted_queues=attempted_queues, + species=species, + testing=testing, + times_rerun=times_rerun, + torsions=torsions, + tsg=tsg, + xyz=xyz, + ) + + def write_input_file(self) -> None: + pass + + def set_files(self) -> None: + pass + + def set_additional_file_paths(self) -> None: + pass + + def set_input_file_memory(self) -> None: + pass + + def execute_incore(self): + self._log_job_execution() + self.initial_time = self.initial_time if self.initial_time else datetime.datetime.now() + + supported_families = [key for key, val in ts_adapters_by_rmg_family.items() if 'crest' in val] + + self.reactions = [self.reactions] if not isinstance(self.reactions, list) else self.reactions + for rxn in self.reactions: + if rxn.family not in supported_families: + logger.warning(f'The CREST TS search adapter does not support the {rxn.family} reaction family.') + continue + if any(spc.get_xyz() is None for spc in rxn.r_species + rxn.p_species): + logger.warning(f'The CREST TS search adapter cannot process a reaction if 3D coordinates of ' + f'some/all of its reactants/products are missing.\nNot processing {rxn}.') + continue + if not crest_available(): + logger.warning('CREST is not available. Skipping CREST TS search.') + break + + if _crest_reactive_core_covers_molecule(rxn): + logger.info( + f'Skipping CREST TS search for {rxn.label}: the reactive core spans essentially ' + f'the entire molecule (<=1 spectator atom), so CREST has no conformational degrees ' + f'of freedom to sample. Deferring to the other TS-search methods.' + ) + continue + + if rxn.ts_species is None: + rxn.ts_species = ARCSpecies(label='TS', + is_ts=True, + charge=rxn.charge, + multiplicity=rxn.multiplicity, + ) + + tsg = TSGuess(method='CREST') + tsg.tic() + + crest_job_dirs = [] + crest_references = {} + xyz_guesses = get_ts_seeds( + reaction=rxn, + base_adapter='heuristics', + dihedral_increment=self.dihedral_increment, + ) + if not xyz_guesses: + # Backup path: CREST's own heuristic seed construction produced nothing + # (e.g. a linear/cumulene reactive center such as HCCO that the heuristic + # Z-matrix builder cannot assemble). Seed CREST instead from a successful + # TS guess that another adapter (e.g. AutoTST) already generated for this + # TS. Those guesses are on rxn.ts_species.ts_guesses because the incore TS + # adapters run sequentially with CREST last. CREST only needs a seed + # geometry plus the family reactive-atom constraints, and the constraints + # are re-derived from the seed geometry below, so an external guess is a + # valid seed. The feedback-loop guard (exclude_method='crest') ensures + # CREST is never seeded from a prior CREST result. + xyz_guesses = get_backup_ts_seeds(rxn, exclude_method='crest') + if xyz_guesses: + logger.info( + f'CREST heuristic seed construction failed for {rxn.label}; falling back to ' + f'{len(xyz_guesses)} external TS guess(es) as CREST seed(s).' + ) + if not xyz_guesses: + logger.warning(f'CREST TS search failed to generate any seed guesses for {rxn.label}.') + tsg.tok() + continue + + for iteration, xyz_entry in enumerate(xyz_guesses): + xyz_guess = xyz_entry.get("xyz") + family = xyz_entry.get("family", rxn.family) + if xyz_guess is None: + continue + + crest_constraints = get_wrapper_constraints( + wrapper='crest', + reaction=rxn, + seed=xyz_entry, + ) + if not crest_constraints: + logger.warning( + f"Could not determine CREST constraint atoms for {rxn.label} crest seed {iteration} " + f"(family: {family}). Skipping this CREST seed." + ) + continue + + crest_job_dir = crest_ts_conformer_search( + xyz_guess, + constraints=crest_constraints, + path=self.local_path, + xyz_crest_int=iteration, + ) + crest_job_dirs.append(crest_job_dir) + crest_references[crest_job_dir] = { + 'xyz': xyz_guess, + 'constraints': crest_constraints, + } + + if not crest_job_dirs: + logger.warning(f'CREST TS search failed to prepare any jobs for {rxn.label}.') + tsg.tok() + continue + + crest_jobs = submit_crest_jobs(crest_job_dirs) + monitor_crest_jobs(crest_jobs) + xyz_guesses_crest = process_completed_jobs(crest_jobs, crest_references=crest_references) + tsg.tok() + + for method_index, xyz in enumerate(xyz_guesses_crest): + if xyz is None: + continue + unique = True + for other_tsg in rxn.ts_species.ts_guesses: + if almost_equal_coords(xyz, other_tsg.initial_xyz): + if hasattr(other_tsg, "method_sources"): + other_tsg.method_sources = other_tsg._normalize_method_sources( + (other_tsg.method_sources or []) + ["crest"] + ) + unique = False + break + if unique: + # CREST is run without an explicit method flag, i.e., at its GFN2-xTB default. + ts_guess = TSGuess(method='CREST', + index=len(rxn.ts_species.ts_guesses), + method_index=method_index, + t0=tsg.t0, + execution_time=tsg.execution_time, + success=True, + family=rxn.family, + xyz=xyz, + level={'method': 'gfn2-xtb', 'software': 'crest'}, + ) + rxn.ts_species.ts_guesses.append(ts_guess) + save_geo(xyz=xyz, + path=self.local_path, + filename=f'CREST_{method_index}', + format_='xyz', + comment=f'CREST {method_index}, family: {rxn.family}', + ) + + if len(self.reactions) < 5: + successes = [tsg for tsg in rxn.ts_species.ts_guesses if tsg.success and 'crest' in tsg.method.lower()] + if successes: + logger.info(f'CREST successfully found {len(successes)} TS guesses for {rxn.label}.') + else: + logger.info(f'CREST did not find any successful TS guesses for {rxn.label}.') + + self.final_time = datetime.datetime.now() + + def execute_queue(self): + self.execute_incore() + + +def _crest_reactive_core_covers_molecule(rxn: 'ARCReaction') -> bool: + """ + Return whether the CREST reactive core spans essentially the whole molecule. + + For H_Abstraction the reactive core is the three-center A--H--B triad. When the TS has + at most one atom outside that triad there are no meaningful spectator conformational + degrees of freedom for CREST metadynamics to sample, so CREST cannot improve on the + heuristic seed and should be skipped (the other TS-search methods still cover the case). + + This is intentionally conservative: it only returns ``True`` for H_Abstraction systems + with <=1 spectator atom (i.e. total atom count <= 4), never for systems that retain + real spectator degrees of freedom. + """ + if getattr(rxn, 'family', None) != 'H_Abstraction': + return False + reactant_species = getattr(rxn, 'r_species', None) or [] + atom_counts = [getattr(spc, 'number_of_atoms', None) for spc in reactant_species] + if not atom_counts or any(count is None for count in atom_counts): + return False + reactive_core_size = 3 # the A--H--B triad + return (sum(atom_counts) - reactive_core_size) <= 1 + + +def crest_ts_conformer_search( + xyz_guess: dict, + a_atom: Optional[int] = None, + h_atom: Optional[int] = None, + b_atom: Optional[int] = None, + path: str = "", + xyz_crest_int: int = 0, + constraints: Optional[dict] = None, +) -> str: + """ + Prepare a CREST TS conformer search job: + - Write coords.ref and constraints.inp + - Write a PBS/HTCondor submit script using submit_scripts["local"]["crest"] + - Return the CREST job directory path + """ + if constraints is None: + if not all(isinstance(atom, int) for atom in (a_atom, h_atom, b_atom)): + raise ValueError('CREST requires either a constraint specification or legacy A, H, and B atom indices.') + constraints = { + 'atoms': (a_atom, h_atom, b_atom), + 'distance_pairs': ((a_atom, h_atom), (h_atom, b_atom)), + 'angle_atoms': (a_atom, h_atom, b_atom), + } + + path = os.path.join(path, f"crest_{xyz_crest_int}") + os.makedirs(path, exist_ok=True) + + # --- coords.ref --- + symbols = xyz_guess["symbols"] + converted_coords = reorder_xyz_string( + xyz_str=xyz_to_str(xyz_guess), + reverse_atoms=True, + convert_to="bohr", + ) + coords_ref_content = f"$coord\n{converted_coords}\n$end\n" + coords_ref_path = os.path.join(path, "coords.ref") + with open(coords_ref_path, "w") as f: + f.write(coords_ref_content) + + # --- constraints.inp --- + num_atoms = len(symbols) + # CREST uses 1-based indices. + participating_atoms = tuple(atom + 1 for atom in constraints['atoms']) + distance_pairs = tuple((atom_1 + 1, atom_2 + 1) + for atom_1, atom_2 in constraints['distance_pairs']) + + # A three-center path additionally pins the terminal--terminal separation and the + # bridge angle. For H-abstraction, without them GFN2-xTB metadynamics can collapse a + # linear A...H...B seed into a bent A--B minimum on small double-radical PESs, and the + # post-CREST angle guard then rejects the result. ``angle_atoms`` is present only for + # the three-center cases (H-abstraction, intra_NO2_ONO_conversion); the XY four-center + # path does not set it and is intentionally left unchanged. The terminal--terminal + # distance is skipped when it is already one of the explicit ``distance_pairs`` + # (as it is for intra_NO2_ONO_conversion, where C--O is the forming bond). + angle_atoms = constraints.get('angle_atoms') + heavy_heavy_pair = None + angle_triad = None + if angle_atoms is not None: + angle_triad = tuple(atom + 1 for atom in angle_atoms) + terminal_pair = (angle_triad[0], angle_triad[2]) + if not any(set(pair) == set(terminal_pair) for pair in distance_pairs): + heavy_heavy_pair = terminal_pair + + # All atoms outside the reactive zone go into the metadynamics atom list. + list_of_atoms_numbers_not_participating_in_reaction = [ + i for i in range(1, num_atoms + 1) if i not in participating_atoms + ] + + constraints_path = os.path.join(path, "constraints.inp") + with open(constraints_path, "w") as f: + f.write("$constrain\n") + f.write(f" atoms: {', '.join(map(str, participating_atoms))}\n") + f.write(" force constant: 0.5\n") + f.write(" reference=coords.ref\n") + for atom_1, atom_2 in distance_pairs: + f.write(f" distance: {atom_1}, {atom_2}, auto\n") + if heavy_heavy_pair is not None: + f.write(f" distance: {heavy_heavy_pair[0]}, {heavy_heavy_pair[1]}, auto\n") + if angle_triad is not None: + f.write(f" angle: {angle_triad[0]}, {angle_triad[1]}, {angle_triad[2]}, auto\n") + f.write("$metadyn\n") + if list_of_atoms_numbers_not_participating_in_reaction: + f.write( + f' atoms: {", ".join(map(str, list_of_atoms_numbers_not_participating_in_reaction))}\n' + ) + f.write("$end\n") + + # --- build CREST command string --- + # Example: crest coords.ref --cinp constraints.inp --noreftopo -T 8 + local_server = SERVERS.get("local", {}) + cpus = int(local_server.get("cpus", 8)) + if CREST_ENV_PATH: + crest_exe = "crest" + else: + crest_exe = CREST_PATH if CREST_PATH is not None else "crest" + + commands = [ + crest_exe, + "coords.ref", + "--cinp constraints.inp", + "--noreftopo", + f"-T {cpus}", + ] + command = " ".join(commands) + + # --- activation line (optional) --- + activation_line = CREST_ENV_PATH or "" + + if SERVERS.get("local") is not None: + cluster_soft = SERVERS["local"]["cluster_soft"].lower() + local_templates = submit_scripts.get("local", {}) + crest_template = local_templates.get("crest") + crest_job_template = local_templates.get("crest_job") + + if cluster_soft in ["condor", "htcondor"]: + # HTCondor branch with a built-in fallback template. + if crest_template is None: + crest_template = ( + "universe = vanilla\n" + "executable = job.sh\n" + "output = out.txt\n" + "error = err.txt\n" + "log = log.txt\n" + "request_cpus = {cpus}\n" + "request_memory = {memory}\n" + "JobBatchName = {name}\n" + "queue\n" + ) + if crest_job_template is None: + crest_job_template = ( + "#!/bin/bash -l\n" + "{activation_line}\n" + "cd {path}\n" + "{commands}\n" + ) + sub_job = crest_template + format_params = { + "name": f"crest_{xyz_crest_int}", + "cpus": cpus, + "memory": int(SERVERS["local"].get("memory", 32.0) * 1024), + } + sub_job = sub_job.format(**format_params) + + with open( + os.path.join(path, settings["submit_filenames"]["HTCondor"]), "w" + ) as f: + f.write(sub_job) + + crest_job = crest_job_template.format( + path=path, + activation_line=activation_line, + commands=command, + ) + + with open(os.path.join(path, "job.sh"), "w") as f: + f.write(crest_job) + os.chmod(os.path.join(path, "job.sh"), 0o700) + + # Pre-create out/err for any status checkers that expect them + for fname in ("out.txt", "err.txt"): + fpath = os.path.join(path, fname) + if not os.path.exists(fpath): + with open(fpath, "w") as f: + f.write("") + os.chmod(fpath, 0o600) + + elif cluster_soft == "pbs": + # PBS branch with a built-in fallback template. + if crest_template is None: + crest_template = ( + "#!/bin/bash -l\n" + "#PBS -q {queue}\n" + "#PBS -N {name}\n" + "#PBS -l select=1:ncpus={cpus}:mem={memory}gb\n" + "#PBS -o out.txt\n" + "#PBS -e err.txt\n\n" + "{activation_line}\n" + "cd {path}\n" + "{commands}\n" + ) + sub_job = crest_template + format_params = { + "queue": SERVERS["local"].get("queue", "alon_q"), + "name": f"crest_{xyz_crest_int}", + "cpus": cpus, + # 'memory' is in GB for the template: mem={memory}gb + "memory": int( + SERVERS["local"].get("memory", 32) + if SERVERS["local"].get("memory", 32) < 60 + else 40 + ), + "activation_line": activation_line, + "path": path, + "commands": command, + } + sub_job = sub_job.format(**format_params) + + submit_filename = settings["submit_filenames"]["PBS"] # usually 'submit.sh' + submit_path = os.path.join(path, submit_filename) + with open(submit_path, "w") as f: + f.write(sub_job) + os.chmod(submit_path, 0o700) + + else: + raise ValueError(f"Unsupported cluster_soft for CREST: {cluster_soft!r}") + + return path + + +def submit_crest_jobs(crest_paths: List[str]) -> dict: + """ + Submit CREST jobs to the server. + + Args: + crest_paths (List[str]): List of paths to the CREST directories. + + Returns: + dict: A dictionary containing job IDs as keys and their statuses as values. + """ + crest_jobs = {} + for crest_path in crest_paths: + job_status, job_id = submit_job(path=crest_path) + logger.debug(f"CREST job {job_id} submitted for {crest_path}") + crest_jobs[job_id] = {"path": crest_path, "status": job_status} + if crest_jobs: + job_ids = list(crest_jobs.keys()) + parent = os.path.dirname(crest_paths[0]) + logger.info(f"Submitted {len(job_ids)} CREST jobs ({job_ids[0]}-{job_ids[-1]}) for {parent}") + return crest_jobs + + +def monitor_crest_jobs(crest_jobs: dict, check_interval: int = 300) -> None: + """ + Monitor CREST jobs until they are complete. + + Args: + crest_jobs (dict): Dictionary containing job information (job ID, path, and status). + check_interval (int): Time interval (in seconds) to wait between status checks. + """ + while True: + all_done = True + for job_id, job_info in crest_jobs.items(): + if job_info["status"] not in ["done", "failed"]: + try: + job_info["status"] = check_job_status(job_id) # Update job status + except Exception as e: + logger.error(f"Error checking job status for job {job_id}: {e}") + job_info["status"] = "failed" + if job_info["status"] not in ["done", "failed"]: + all_done = False + if all_done: + break + time.sleep(min(check_interval, MAX_CHECK_INTERVAL_SECONDS)) + + +def process_completed_jobs(crest_jobs: dict, crest_references: dict) -> list: + """ + Process the completed CREST jobs and update XYZ guesses. + + Args: + crest_jobs (dict): Dictionary containing job information. + crest_references (dict): Reference seed geometry and constraint specification, keyed by CREST path. + """ + xyz_guesses = [] + for job_id, job_info in crest_jobs.items(): + crest_path = job_info["path"] + if job_info["status"] == "done": + crest_best_path = os.path.join(crest_path, "crest_best.xyz") + if os.path.exists(crest_best_path): + with open(crest_best_path, "r") as f: + content = f.read() + xyz_guess = xyz_file_format_to_xyz(content) + reference = crest_references.get(crest_path) + if reference is None: + logger.warning(f"Rejecting unvalidated CREST geometry from {crest_path}: reference data is missing.") + continue + if not _preserves_reactive_constraints( + xyz=xyz_guess, + reference_xyz=reference['xyz'], + constraints=reference['constraints'], + ): + logger.warning( + f"Rejecting CREST geometry from {crest_path}: it does not preserve the reactive constraints." + ) + continue + xyz_guesses.append(xyz_guess) + else: + logger.error(f"crest_best.xyz not found in {crest_path}") + elif job_info["status"] == "failed": + logger.error(f"CREST job failed for {crest_path}") + + return xyz_guesses + + +def _preserves_reactive_constraints(xyz: dict, reference_xyz: dict, constraints: dict) -> bool: + """Return whether a CREST geometry preserves the seed's constrained reactive zone.""" + symbols = xyz.get('symbols') if isinstance(xyz, dict) else None + reference_symbols = reference_xyz.get('symbols') if isinstance(reference_xyz, dict) else None + if not symbols or symbols != reference_symbols or not isinstance(constraints, dict): + return False + try: + atoms = tuple(constraints['atoms']) + distance_pairs = tuple(tuple(pair) for pair in constraints['distance_pairs']) + if (not atoms or len(set(atoms)) != len(atoms) + or any(not isinstance(atom, int) or not 0 <= atom < len(symbols) for atom in atoms) + or any(len(pair) != 2 or pair[0] not in atoms or pair[1] not in atoms + for pair in distance_pairs)): + return False + for atom_1, atom_2 in distance_pairs: + distance = math.dist(xyz['coords'][atom_1], xyz['coords'][atom_2]) + reference_distance = math.dist(reference_xyz['coords'][atom_1], reference_xyz['coords'][atom_2]) + if abs(distance - reference_distance) > max(0.5, reference_distance * 0.5): + return False + angle_atoms = constraints.get('angle_atoms') + if angle_atoms is not None: + atom_1, vertex, atom_2 = angle_atoms + angle = _get_angle(xyz['coords'][atom_1], xyz['coords'][vertex], xyz['coords'][atom_2]) + reference_angle = _get_angle( + reference_xyz['coords'][atom_1], + reference_xyz['coords'][vertex], + reference_xyz['coords'][atom_2], + ) + if abs(angle - reference_angle) > 30.0: + return False + except (IndexError, KeyError, TypeError, ValueError): + return False + return True + + +def _get_angle(point_1: tuple, vertex: tuple, point_2: tuple) -> float: + """Return the angle in degrees for ``point_1``--``vertex``--``point_2``.""" + vector_1 = tuple(coord - center for coord, center in zip(point_1, vertex)) + vector_2 = tuple(coord - center for coord, center in zip(point_2, vertex)) + norm_product = math.sqrt(sum(coord ** 2 for coord in vector_1) * sum(coord ** 2 for coord in vector_2)) + if not norm_product: + raise ValueError('Cannot determine an angle from a zero-length vector.') + cosine = sum(coord_1 * coord_2 for coord_1, coord_2 in zip(vector_1, vector_2)) / norm_product + return math.degrees(math.acos(max(-1.0, min(1.0, cosine)))) + +register_job_adapter('crest', CrestAdapter) diff --git a/arc/job/adapters/ts/crest_test.py b/arc/job/adapters/ts/crest_test.py new file mode 100644 index 0000000000..3ce61bb3f5 --- /dev/null +++ b/arc/job/adapters/ts/crest_test.py @@ -0,0 +1,455 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +""" +Unit tests for arc.job.adapters.ts.crest +""" + +import os +import tempfile +import types +import unittest + +from arc.species.converter import str_to_xyz, xyz_to_str + + +class TestCrestAdapter(unittest.TestCase): + """ + Tests for CREST input generation. + """ + + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + + def tearDown(self): + self.tmpdir.cleanup() + + def test_creates_valid_input_files(self): + """ + Ensure CREST inputs are written with expected content/format. + """ + from arc.job.adapters.ts import crest as crest_mod + + xyz = str_to_xyz( + """O 0.0 0.0 0.0 + H 0.0 0.0 0.96 + H 0.9 0.0 0.0""" + ) + + backups = { + "settings": crest_mod.settings, + "submit_scripts": crest_mod.submit_scripts, + "CREST_PATH": crest_mod.CREST_PATH, + "CREST_ENV_PATH": crest_mod.CREST_ENV_PATH, + "SERVERS": crest_mod.SERVERS, + } + + try: + crest_mod.settings = {"submit_filenames": {"PBS": "submit.sh"}} + crest_mod.submit_scripts = { + "local": { + "crest": ( + "#PBS -q {queue}\n" + "#PBS -N {name}\n" + "#PBS -l select=1:ncpus={cpus}:mem={memory}gb\n" + ), + "crest_job": "{activation_line}\ncd {path}\n{commands}\n", + } + } + crest_mod.CREST_PATH = "/usr/bin/crest" + crest_mod.CREST_ENV_PATH = "" + crest_mod.SERVERS = { + "local": {"cluster_soft": "pbs", "cpus": 4, "memory": 8, "queue": "testq"} + } + + crest_dir = crest_mod.crest_ts_conformer_search( + xyz, 0, 1, 2, self.tmpdir.name, 0, + ) + + coords_path = os.path.join(crest_dir, "coords.ref") + constraints_path = os.path.join(crest_dir, "constraints.inp") + submit_path = os.path.join(crest_dir, "submit.sh") + + self.assertTrue(os.path.exists(coords_path)) + self.assertTrue(os.path.exists(constraints_path)) + self.assertTrue(os.path.exists(submit_path)) + + with open(coords_path) as f: + coords = f.read().strip().splitlines() + self.assertEqual(coords[0].strip(), "$coord") + self.assertEqual(coords[-1].strip(), "$end") + self.assertEqual(len(coords) - 2, len(xyz["symbols"])) + + with open(constraints_path) as f: + constraints = f.read() + self.assertIn("atoms: 1, 2, 3", constraints) + self.assertIn("force constant: 0.5", constraints) + self.assertIn("reference=coords.ref", constraints) + self.assertIn("distance: 1, 2, auto", constraints) + self.assertIn("distance: 2, 3, auto", constraints) + self.assertIn("$metadyn", constraints) + self.assertTrue(constraints.strip().endswith("$end")) + finally: + crest_mod.settings = backups["settings"] + crest_mod.submit_scripts = backups["submit_scripts"] + crest_mod.CREST_PATH = backups["CREST_PATH"] + crest_mod.CREST_ENV_PATH = backups["CREST_ENV_PATH"] + crest_mod.SERVERS = backups["SERVERS"] + + def test_h_abstraction_constrains_heavy_heavy_distance_and_bridge_angle(self): + """The H-abstraction $constrain block pins A--B distance and the A--H--B angle.""" + from arc.job.adapters.ts import crest as crest_mod + + # O(0)--H(1)--O(2)--H(3), an OH + OH -> H2O + O style seed. A=0, H=1, B=2. + xyz = str_to_xyz("""O 0.00000000 -0.02752832 -1.20590500 + H 0.00000000 -0.02752832 -0.03383145 + O 0.00000000 -0.02752832 1.12142787 + H 0.00000000 0.90131726 1.37454478""") + constraints = { + 'atoms': (0, 1, 2), + 'distance_pairs': ((0, 1), (1, 2)), + 'angle_atoms': (0, 1, 2), + } + + backups = { + 'settings': crest_mod.settings, + 'submit_scripts': crest_mod.submit_scripts, + 'CREST_PATH': crest_mod.CREST_PATH, + 'CREST_ENV_PATH': crest_mod.CREST_ENV_PATH, + 'SERVERS': crest_mod.SERVERS, + } + try: + crest_mod.settings = {'submit_filenames': {'PBS': 'submit.sh'}} + crest_mod.submit_scripts = {'local': {}} + crest_mod.CREST_PATH = '/usr/bin/crest' + crest_mod.CREST_ENV_PATH = '' + crest_mod.SERVERS = { + 'local': {'cluster_soft': 'pbs', 'cpus': 4, 'memory': 8, 'queue': 'testq'}, + } + crest_path = crest_mod.crest_ts_conformer_search( + xyz_guess=xyz, + constraints=constraints, + path=self.tmpdir.name, + xyz_crest_int=3, + ) + with open(os.path.join(crest_path, 'constraints.inp')) as f: + constraints_text = f.read() + # The two original A--H and H--B distances remain. + self.assertIn('distance: 1, 2, auto', constraints_text) + self.assertIn('distance: 2, 3, auto', constraints_text) + # The heavy--heavy A--B distance (1-based 1, 3) is now also pinned. + self.assertIn('distance: 1, 3, auto', constraints_text) + # The A--H--B bridge angle (1-based 1, 2, 3) is now constrained. + self.assertIn('angle: 1, 2, 3, auto', constraints_text) + finally: + crest_mod.settings = backups['settings'] + crest_mod.submit_scripts = backups['submit_scripts'] + crest_mod.CREST_PATH = backups['CREST_PATH'] + crest_mod.CREST_ENV_PATH = backups['CREST_ENV_PATH'] + crest_mod.SERVERS = backups['SERVERS'] + + def test_tiny_system_gate_skips_crest_only_for_whole_molecule_core(self): + """CREST is gated off for a 4-atom H-abs case but not for one with real spectators.""" + from arc.job.adapters.ts import crest as crest_mod + + def make_rxn(family, reactant_atom_counts): + return types.SimpleNamespace( + family=family, + r_species=[types.SimpleNamespace(number_of_atoms=n) for n in reactant_atom_counts], + ) + + # OH + OH -> H2O + O: 4 atoms, only 1 spectator -> skip CREST. + self.assertTrue( + crest_mod._crest_reactive_core_covers_molecule(make_rxn('H_Abstraction', [2, 2])) + ) + # H + H2: 3 atoms, no spectators -> skip CREST. + self.assertTrue( + crest_mod._crest_reactive_core_covers_molecule(make_rxn('H_Abstraction', [1, 2])) + ) + # CH4 + OH -> CH3 + H2O: 7 atoms, 4 spectators -> CREST runs. + self.assertFalse( + crest_mod._crest_reactive_core_covers_molecule(make_rxn('H_Abstraction', [5, 2])) + ) + # A different family is never gated by this predicate. + self.assertFalse( + crest_mod._crest_reactive_core_covers_molecule(make_rxn('XY_Addition_MultipleBond', [2, 2])) + ) + # Missing atom counts -> do not skip (fail safe toward running CREST). + self.assertFalse( + crest_mod._crest_reactive_core_covers_molecule(make_rxn('H_Abstraction', [None, 2])) + ) + + def test_creates_submit_file_without_crest_templates(self): + """ + Ensure fallback submit template generation works when submit.py has no CREST templates. + """ + from arc.job.adapters.ts import crest as crest_mod + + xyz = str_to_xyz( + """O 0.0 0.0 0.0 + H 0.0 0.0 0.96 + H 0.9 0.0 0.0""" + ) + + backups = { + "settings": crest_mod.settings, + "submit_scripts": crest_mod.submit_scripts, + "CREST_PATH": crest_mod.CREST_PATH, + "CREST_ENV_PATH": crest_mod.CREST_ENV_PATH, + "SERVERS": crest_mod.SERVERS, + } + + try: + crest_mod.settings = {"submit_filenames": {"PBS": "submit.sh"}} + crest_mod.submit_scripts = {"local": {}} + crest_mod.CREST_PATH = "/usr/bin/crest" + crest_mod.CREST_ENV_PATH = "" + crest_mod.SERVERS = { + "local": {"cluster_soft": "pbs", "cpus": 4, "memory": 8, "queue": "testq"} + } + + crest_dir = crest_mod.crest_ts_conformer_search( + xyz_guess=xyz, + constraints={ + 'atoms': (0, 1, 2), + 'distance_pairs': ((0, 1), (1, 2)), + 'angle_atoms': (0, 1, 2), + }, + path=self.tmpdir.name, + xyz_crest_int=1, + ) + + submit_path = os.path.join(crest_dir, "submit.sh") + self.assertTrue(os.path.exists(submit_path)) + with open(submit_path) as f: + submit_text = f.read() + self.assertIn("#PBS -q testq", submit_text) + self.assertIn("coords.ref --cinp constraints.inp --noreftopo -T 4", submit_text) + finally: + crest_mod.settings = backups["settings"] + crest_mod.submit_scripts = backups["submit_scripts"] + crest_mod.CREST_PATH = backups["CREST_PATH"] + crest_mod.CREST_ENV_PATH = backups["CREST_ENV_PATH"] + crest_mod.SERVERS = backups["SERVERS"] + + def test_process_completed_jobs_rejects_dissociated_reactive_triad(self): + """Do not accept a crest_best.xyz whose acceptor has separated from the transferring H.""" + from arc.job.adapters.ts import crest as crest_mod + + reference_xyz = str_to_xyz("""O 0.00000000 -0.02752832 -1.20590500 + H 0.00000000 -0.02752832 -0.03383145 + O 0.00000000 -0.02752832 1.12142787 + H 0.00000000 0.90131726 1.37454478""") + dissociated_xyz = str_to_xyz("""O -1.1644 0.0000 0.0000 + H 0.0000 0.0000 0.0000 + O 4.9000 0.0000 0.0000 + H 5.8703 0.0000 0.0000""") + zeus_bad_xyz = str_to_xyz("""O -0.71236464 0.03765902 -0.02937463 + H -0.60136223 -0.77534746 0.43444583 + O 0.69301187 0.05895500 0.02917997 + H 0.90856791 -0.75830305 -0.43135588""") + crest_path = os.path.join(self.tmpdir.name, 'crest_0') + os.makedirs(crest_path) + crest_best_path = os.path.join(crest_path, 'crest_best.xyz') + with open(crest_best_path, 'w') as f: + f.write(f"4\nCREST geometry\n{xyz_to_str(dissociated_xyz)}\n") + + jobs = {'123': {'path': crest_path, 'status': 'done'}} + references = { + crest_path: { + 'xyz': reference_xyz, + 'constraints': { + 'atoms': (0, 1, 2), + 'distance_pairs': ((0, 1), (1, 2)), + 'angle_atoms': (0, 1, 2), + }, + }, + } + self.assertEqual(crest_mod.process_completed_jobs(jobs, crest_references={}), []) + self.assertEqual(crest_mod.process_completed_jobs(jobs, crest_references=references), []) + + with open(crest_best_path, 'w') as f: + f.write(f"4\nCREST geometry\n{xyz_to_str(zeus_bad_xyz)}\n") + self.assertEqual(crest_mod.process_completed_jobs(jobs, crest_references=references), []) + + with open(crest_best_path, 'w') as f: + f.write(f"4\nCREST geometry\n{xyz_to_str(reference_xyz)}\n") + self.assertEqual( + crest_mod.process_completed_jobs(jobs, crest_references=references), + [reference_xyz], + ) + + def test_creates_xy_distance_constraints_and_validates_completed_geometry(self): + """Write all three XY recipe distances and reject a geometry that loses one.""" + from arc.job.adapters.ts import crest as crest_mod + + reference_xyz = str_to_xyz("""C 0.0000 0.0000 0.6670 + C 0.0000 0.0000 -0.6670 + H 0.0000 0.9210 1.2320 + H 0.0000 -0.9210 1.2320 + H 0.0000 0.9210 -1.2320 + H 0.0000 -0.9210 -1.2320 + Cl 0.0000 2.1000 -0.6670 + H 0.0000 1.6000 0.6670""") + constraints = { + 'atoms': (1, 0, 7, 6), + 'distance_pairs': ((1, 7), (0, 6), (7, 6)), + } + + backups = { + 'settings': crest_mod.settings, + 'submit_scripts': crest_mod.submit_scripts, + 'CREST_PATH': crest_mod.CREST_PATH, + 'CREST_ENV_PATH': crest_mod.CREST_ENV_PATH, + 'SERVERS': crest_mod.SERVERS, + } + try: + crest_mod.settings = {'submit_filenames': {'PBS': 'submit.sh'}} + crest_mod.submit_scripts = {'local': {}} + crest_mod.CREST_PATH = '/usr/bin/crest' + crest_mod.CREST_ENV_PATH = '' + crest_mod.SERVERS = { + 'local': {'cluster_soft': 'pbs', 'cpus': 4, 'memory': 8, 'queue': 'testq'}, + } + crest_path = crest_mod.crest_ts_conformer_search( + xyz_guess=reference_xyz, + constraints=constraints, + path=self.tmpdir.name, + xyz_crest_int=2, + ) + with open(os.path.join(crest_path, 'constraints.inp')) as f: + constraints_text = f.read() + self.assertIn('atoms: 2, 1, 8, 7', constraints_text) + self.assertIn('distance: 2, 8, auto', constraints_text) + self.assertIn('distance: 1, 7, auto', constraints_text) + self.assertIn('distance: 8, 7, auto', constraints_text) + self.assertIn('atoms: 3, 4, 5, 6', constraints_text) + + crest_best_path = os.path.join(crest_path, 'crest_best.xyz') + jobs = {'123': {'path': crest_path, 'status': 'done'}} + references = {crest_path: {'xyz': reference_xyz, 'constraints': constraints}} + with open(crest_best_path, 'w') as f: + f.write(f"8\nCREST geometry\n{xyz_to_str(reference_xyz)}\n") + self.assertEqual(crest_mod.process_completed_jobs(jobs, references), [reference_xyz]) + + dissociated_xyz = dict(reference_xyz) + dissociated_coords = list(reference_xyz['coords']) + dissociated_coords[6] = (0.0, 8.0, -0.6670) + dissociated_xyz['coords'] = tuple(dissociated_coords) + with open(crest_best_path, 'w') as f: + f.write(f"8\nCREST geometry\n{xyz_to_str(dissociated_xyz)}\n") + self.assertEqual(crest_mod.process_completed_jobs(jobs, references), []) + finally: + crest_mod.settings = backups['settings'] + crest_mod.submit_scripts = backups['submit_scripts'] + crest_mod.CREST_PATH = backups['CREST_PATH'] + crest_mod.CREST_ENV_PATH = backups['CREST_ENV_PATH'] + crest_mod.SERVERS = backups['SERVERS'] + + + def test_get_backup_ts_seeds_selects_successful_non_crest_guesses(self): + """The backup seed picker skips CREST/failed guesses, prefers opt_xyz, and dedups.""" + from arc.job.adapters.ts.seed_hub import get_backup_ts_seeds + + seed_geom = str_to_xyz("""O 0.00000000 -0.02752832 -1.20590500 + H 0.00000000 -0.02752832 -0.03383145 + O 0.00000000 -0.02752832 1.12142787 + H 0.00000000 0.90131726 1.37454478""") + other_geom = str_to_xyz("""O 0.10000000 -0.02752832 -1.20590500 + H 0.00000000 -0.02752832 -0.03383145 + O 0.00000000 -0.02752832 1.12142787 + H 0.00000000 0.90131726 1.37454478""") + + def make_tsg(method, success, initial_xyz, opt_xyz=None): + return types.SimpleNamespace(method=method, success=success, + initial_xyz=initial_xyz, opt_xyz=opt_xyz) + + ts_guesses = [ + make_tsg('crest', True, other_geom), # feedback-loop guard -> excluded + make_tsg('autotst', False, other_geom), # unsuccessful -> excluded + make_tsg('autotst', True, other_geom, opt_xyz=seed_geom), # selected; opt_xyz preferred + make_tsg('heuristics', True, seed_geom), # duplicate geometry -> deduped + ] + reaction = types.SimpleNamespace( + family='H_Abstraction', + ts_species=types.SimpleNamespace(ts_guesses=ts_guesses), + ) + + seeds = get_backup_ts_seeds(reaction, exclude_method='crest') + self.assertEqual(len(seeds), 1) + seed = seeds[0] + self.assertEqual(seed['xyz'], seed_geom) # opt_xyz was preferred over initial_xyz + self.assertEqual(seed['source_adapter'], 'autotst') + self.assertEqual(seed['family'], 'H_Abstraction') + self.assertEqual(seed['metadata'], {}) + + # No ts_species / no guesses -> empty (graceful, unchanged behavior). + self.assertEqual(get_backup_ts_seeds(types.SimpleNamespace(family='H_Abstraction')), []) + empty_rxn = types.SimpleNamespace(family='H_Abstraction', + ts_species=types.SimpleNamespace(ts_guesses=[])) + self.assertEqual(get_backup_ts_seeds(empty_rxn), []) + + def test_backup_seed_drives_constraint_derivation_and_input_generation(self): + """An external TS guess seeds CREST: constraints are re-derived from its geometry.""" + from arc.job.adapters.ts import crest as crest_mod + from arc.job.adapters.ts.seed_hub import get_backup_ts_seeds, get_wrapper_constraints + + # An OH + OH H-abstraction-style geometry; the reactive A--H--B triad is inferred + # from the geometry alone (no metadata), exactly as it would be for HCCO. + external_guess_xyz = str_to_xyz("""O 0.00000000 -0.02752832 -1.20590500 + H 0.00000000 -0.02752832 -0.03383145 + O 0.00000000 -0.02752832 1.12142787 + H 0.00000000 0.90131726 1.37454478""") + reaction = types.SimpleNamespace( + family='H_Abstraction', + ts_species=types.SimpleNamespace(ts_guesses=[ + types.SimpleNamespace(method='autotst', success=True, + initial_xyz=external_guess_xyz, opt_xyz=None), + ]), + ) + + seeds = get_backup_ts_seeds(reaction, exclude_method='crest') + self.assertEqual(len(seeds), 1) + constraints = get_wrapper_constraints(wrapper='crest', reaction=reaction, seed=seeds[0]) + self.assertIsNotNone(constraints) + self.assertIn('atoms', constraints) + self.assertIn('distance_pairs', constraints) + self.assertIn('angle_atoms', constraints) + + backups = { + 'settings': crest_mod.settings, + 'submit_scripts': crest_mod.submit_scripts, + 'CREST_PATH': crest_mod.CREST_PATH, + 'CREST_ENV_PATH': crest_mod.CREST_ENV_PATH, + 'SERVERS': crest_mod.SERVERS, + } + try: + crest_mod.settings = {'submit_filenames': {'PBS': 'submit.sh'}} + crest_mod.submit_scripts = {'local': {}} + crest_mod.CREST_PATH = '/usr/bin/crest' + crest_mod.CREST_ENV_PATH = '' + crest_mod.SERVERS = { + 'local': {'cluster_soft': 'pbs', 'cpus': 4, 'memory': 8, 'queue': 'testq'}, + } + crest_path = crest_mod.crest_ts_conformer_search( + xyz_guess=seeds[0]['xyz'], + constraints=constraints, + path=self.tmpdir.name, + xyz_crest_int=0, + ) + self.assertTrue(os.path.exists(os.path.join(crest_path, 'coords.ref'))) + with open(os.path.join(crest_path, 'constraints.inp')) as f: + constraints_text = f.read() + self.assertIn('atoms:', constraints_text) + self.assertIn('reference=coords.ref', constraints_text) + self.assertIn('$metadyn', constraints_text) + finally: + crest_mod.settings = backups['settings'] + crest_mod.submit_scripts = backups['submit_scripts'] + crest_mod.CREST_PATH = backups['CREST_PATH'] + crest_mod.CREST_ENV_PATH = backups['CREST_ENV_PATH'] + crest_mod.SERVERS = backups['SERVERS'] + + +if __name__ == "__main__": + unittest.main() diff --git a/arc/job/adapters/ts/gcn_test.py b/arc/job/adapters/ts/gcn_test.py index 27498871fd..65cff4eaac 100644 --- a/arc/job/adapters/ts/gcn_test.py +++ b/arc/job/adapters/ts/gcn_test.py @@ -7,6 +7,7 @@ import importlib.util import os +import random import shutil import subprocess import unittest @@ -16,6 +17,7 @@ import arc.job.adapters.ts.gcn_ts as ts_gcn from arc.job.adapters.ts.gcn_ts import GCNAdapter from arc.reaction import ARCReaction +from arc.settings.settings import TS_GCN_PYTHON from arc.species.converter import str_to_xyz from arc.species.species import ARCSpecies, TSGuess @@ -50,7 +52,7 @@ def fake_run_in_conda_env_factory(returncode: int = 0, write_ts_file: bool = Tru it writes the TS xyz file passed via --ts_xyz_path (direction-dependent content) and returns a CompletedProcess with the requested return code. """ - def fake_run_in_conda_env(python_executable, script_path, *script_args): + def fake_run_in_conda_env(python_executable, script_path, *script_args, **kwargs): args = list(script_args) ts_xyz_path = args[args.index('--ts_xyz_path') + 1] if write_ts_file: @@ -147,6 +149,11 @@ def test_run_subprocess_locally_success(self): self.assertEqual(flags['--r_sdf_path'], self.reactant_path) self.assertEqual(flags['--p_sdf_path'], self.product_path) self.assertEqual(flags['--ts_xyz_path'], self.ts_fwd_path) + self.assertEqual(flags['--seed'], str(ts_gcn.TS_SEARCH_RANDOM_SEED)) + # PYTHONHASHSEED only takes effect before the child interpreter starts, + # so it must cross the boundary as an environment variable, not as a flag. + self.assertEqual(mock_run.call_args.kwargs['extra_env'], + {'PYTHONHASHSEED': str(ts_gcn.TS_SEARCH_RANDOM_SEED)}) self.assertEqual(len(ts_species.ts_guesses), 1) tsg = ts_species.ts_guesses[0] self.assertTrue(tsg.success) @@ -198,6 +205,41 @@ def test_execute_incore(self): self.assertEqual(rxn.ts_species.ts_guesses[1].method_direction, 'R') self.assertTrue(all(tsg.success for tsg in rxn.ts_species.ts_guesses)) + def test_execute_incore_seeds_every_repetition(self): + """Each incore repetition must get a distinct, reproducible seed derived from the setting.""" + rxn = self.get_reaction() + project_dir = os.path.join(self.output_dir, 'project') + adapter = GCNAdapter(job_type='tsg', + reactions=[rxn], + testing=True, + project='test_GCNAdapter', + project_directory=project_dir, + dihedral_increment=3, + ) + with patch.object(ts_gcn, 'TS_GCN_PYTHON', '/fake/envs/ts_gcn/bin/python'), \ + patch.object(ts_gcn, 'gcn_available', return_value=True), \ + patch.object(ts_gcn, 'run_in_conda_env', + side_effect=fake_run_in_conda_env_factory()) as mock_run: + adapter.execute_incore() + self.assertEqual(mock_run.call_count, 6) # 3 repetitions x 2 directions + seeds = [dict(zip(call.args[2::2], call.args[3::2]))['--seed'] for call in mock_run.call_args_list] + base = ts_gcn.TS_SEARCH_RANDOM_SEED + self.assertEqual(seeds, [str(base), str(base), str(base + 1), str(base + 1), str(base + 2), str(base + 2)]) + hash_seeds = [call.kwargs['extra_env']['PYTHONHASHSEED'] for call in mock_run.call_args_list] + self.assertEqual(hash_seeds, seeds) + + def test_queue_input_file_carries_the_seed(self): + """The queue (batch) mode crosses the subprocess boundary via input.yml, which must carry the seed.""" + rxn = self.get_reaction() + adapter = self.get_adapter(rxn) + with patch.object(ts_gcn, 'TS_GCN_PYTHON', '/fake/envs/ts_gcn/bin/python'), \ + patch.object(ts_gcn, 'gcn_available', return_value=True), \ + patch.object(GCNAdapter, 'legacy_queue_execution') as mock_queue: + adapter.execute_gcn(exe_type='queue') + mock_queue.assert_called_once() + input_dict = read_yaml_file(adapter.yml_in_path) + self.assertEqual(input_dict['seed'], ts_gcn.TS_SEARCH_RANDOM_SEED) + def test_execute_incore_gcn_unavailable(self): """Test that execution degrades gracefully (no crash, no subprocess) when the ts_gcn env is missing.""" rxn = self.get_reaction() @@ -261,6 +303,29 @@ def test_parse_command_line_arguments(self): self.assertEqual(args.r_sdf_path, 'r.sdf') self.assertEqual(args.p_sdf_path, 'p.sdf') self.assertEqual(args.ts_xyz_path, 'ts.xyz') + self.assertEqual(args.seed, self.gcn_script.DEFAULT_RANDOM_SEED) + args = self.gcn_script.parse_command_line_arguments(['--yml_in_path', 'input.yml', '--seed', '7']) + self.assertEqual(args.seed, 7) + + def test_set_random_seeds(self): + """set_random_seeds() must make the RNGs GCN draws from reproducible.""" + self.gcn_script.set_random_seeds(11) + first = [random.random() for _ in range(5)] + self.gcn_script.set_random_seeds(11) + self.assertEqual([random.random() for _ in range(5)], first) + self.gcn_script.set_random_seeds(12) + self.assertNotEqual([random.random() for _ in range(5)], first) + + def test_run_gcn_seeds_before_inference(self): + """run_gcn() must seed the RNGs before every inference call, not once per interpreter.""" + seeded = list() + original_set_random_seeds = self.gcn_script.set_random_seeds + self.gcn_script.set_random_seeds = lambda seed: seeded.append(seed) + self.addCleanup(setattr, self.gcn_script, 'set_random_seeds', original_set_random_seeds) + # import_inference() raises without the ts_gcn env, i.e. after the seeding call. + with self.assertRaises(ImportError): + self.gcn_script.run_gcn(r_sdf_path='r.sdf', p_sdf_path='p.sdf', ts_xyz_path='ts.xyz', seed=5) + self.assertEqual(seeded, [5]) def test_initialize_gcn_run(self): """Test the batch (queue) mode: input dict in, TS guess list YAML out.""" @@ -272,7 +337,10 @@ def test_initialize_gcn_run(self): 'repetitions': 2, } - def fake_run_gcn(r_sdf_path, p_sdf_path, ts_xyz_path): + seeds = list() + + def fake_run_gcn(r_sdf_path, p_sdf_path, ts_xyz_path, seed=None): + seeds.append(seed) with open(ts_xyz_path, 'w') as f: f.write(TS_XYZ_F) return True @@ -290,6 +358,12 @@ def fake_run_gcn(r_sdf_path, p_sdf_path, ts_xyz_path): self.assertTrue(tsg['success']) # The two xyz header lines must be stripped. self.assertEqual(tsg['initial_xyz'].split()[0], 'O') + # Batch mode seeds every inference, deriving base seed + repetition index, + # so the two repetitions differ from each other but repeat between runs. + self.assertEqual(seeds, [self.gcn_script.DEFAULT_RANDOM_SEED, + self.gcn_script.DEFAULT_RANDOM_SEED, + self.gcn_script.DEFAULT_RANDOM_SEED + 1, + self.gcn_script.DEFAULT_RANDOM_SEED + 1]) def test_import_inference_raises_informatively(self): """Without the ts_gcn env, import_inference() must raise an informative ImportError.""" diff --git a/arc/job/adapters/ts/gcn_ts.py b/arc/job/adapters/ts/gcn_ts.py index 0553e1efb8..22210ae024 100644 --- a/arc/job/adapters/ts/gcn_ts.py +++ b/arc/job/adapters/ts/gcn_ts.py @@ -30,6 +30,7 @@ servers, submit_filenames, TS_GCN_PYTHON = settings['servers'], settings['submit_filenames'], settings['TS_GCN_PYTHON'] +TS_SEARCH_RANDOM_SEED = settings['TS_SEARCH_RANDOM_SEED'] DIHEDRAL_INCREMENT = 10 GCN_SCRIPT_PATH = os.path.join(ARC_PATH, 'arc', 'job', 'adapters', 'scripts', 'gcn_script.py') @@ -307,17 +308,20 @@ def execute_gcn(self, exe_type: str = 'incore'): 'local_path': self.local_path, 'yml_out_path': self.yml_out_path, 'repetitions': self.repetitions, + 'seed': TS_SEARCH_RANDOM_SEED, } save_yaml_file(path=self.yml_in_path, content=input_dict) self.legacy_queue_execution() elif exe_type == 'incore': - for _ in range(self.repetitions): + for repetition in range(self.repetitions): + seed = TS_SEARCH_RANDOM_SEED + repetition run_subprocess_locally(direction='F', reactant_path=self.reactant_path, product_path=self.product_path, ts_path=self.ts_fwd_path, local_path=self.local_path, ts_species=rxn.ts_species, + seed=seed, ) run_subprocess_locally(direction='R', reactant_path=self.product_path, @@ -325,6 +329,7 @@ def execute_gcn(self, exe_type: str = 'incore'): ts_path=self.ts_rev_path, local_path=self.local_path, ts_species=rxn.ts_species, + seed=seed, ) if len(self.reactions) < 5: successes = len([tsg for tsg in rxn.ts_species.ts_guesses if tsg.success and 'gcn' in tsg.method]) @@ -363,10 +368,17 @@ def run_subprocess_locally(direction: str, ts_path: str, local_path: str, ts_species: ARCSpecies, + seed: int = TS_SEARCH_RANDOM_SEED, ): """ Run GCN incore using a subprocess. + GCN inference is stochastic, and ARC's own process-level seeding cannot reach a + child interpreter, so the seed is handed over explicitly: ``--seed`` seeds + python-random / NumPy / PyTorch inside the script, and ``PYTHONHASHSEED`` is + exported into the child's environment because it must be set before the child + interpreter starts. + Args: direction (str): Either 'F' or 'R' for forward ort reverse directions, respectively. reactant_path (str): The path to the reactant SDF file. @@ -374,6 +386,7 @@ def run_subprocess_locally(direction: str, ts_path (str): The path to the resulting TS guess file. local_path (str): The local path to the job folder. ts_species (ARCSpecies): The TS ``ARCSpecies`` object instance. + seed (int, optional): The random seed to pass to the GCN subprocess. """ ts_xyz = None tsg = TSGuess(method='GCN', @@ -387,6 +400,8 @@ def run_subprocess_locally(direction: str, '--r_sdf_path', reactant_path, '--p_sdf_path', product_path, '--ts_xyz_path', ts_path, + '--seed', str(seed), + extra_env={'PYTHONHASHSEED': str(seed)}, ) if output.returncode: direction_str = 'forward' if direction == 'F' else 'reverse' diff --git a/arc/job/adapters/ts/heuristics.py b/arc/job/adapters/ts/heuristics.py index e0d227e56e..060786e7f9 100644 --- a/arc/job/adapters/ts/heuristics.py +++ b/arc/job/adapters/ts/heuristics.py @@ -21,28 +21,44 @@ import os from typing import TYPE_CHECKING, Any -from arc.common import (ARC_PATH, almost_equal_coords, get_angle_in_180_range, get_logger, is_angle_linear, - is_xyz_linear, key_by_val, read_yaml_file) +from arc.common import ( + ARC_PATH, + almost_equal_coords, + get_angle_in_180_range, + get_logger, + is_angle_linear, + is_xyz_linear, + key_by_val, + read_yaml_file, +) from arc.family import get_reaction_family_products from arc.job.adapter import JobAdapter from arc.job.adapters.common import _initialize_adapter, ts_adapters_by_rmg_family from arc.job.factory import register_job_adapter from arc.plotter import save_geo -from arc.species.converter import (compare_zmats, relocate_zmat_dummy_atoms_to_the_end, zmat_from_xyz, zmat_to_xyz, - add_atom_to_xyz_using_internal_coords, sorted_distances_of_atom) +from arc.species.converter import ( + add_atom_to_xyz_using_internal_coords, + compare_zmats, + relocate_zmat_dummy_atoms_to_the_end, + sorted_distances_of_atom, + zmat_from_xyz, + zmat_to_xyz, +) from arc.mapping.engine import map_two_species from arc.molecule.molecule import Molecule from arc.species.species import ARCSpecies, TSGuess, SpeciesError, colliding_atoms from arc.species.zmat import get_parameter_from_atom_indices, remove_zmat_atom_0, up_param, xyz_to_zmat from arc.species.vectors import calculate_angle +from arc.job.adapters.ts.seed_hub import get_ts_seeds if TYPE_CHECKING: from arc.level import Level from arc.reaction import ARCReaction - -FAMILY_SETS = {'hydrolysis_set_1': ['carbonyl_based_hydrolysis', 'ether_hydrolysis'], - 'hydrolysis_set_2': ['nitrile_hydrolysis']} +FAMILY_SETS = { + 'hydrolysis_set_1': ['carbonyl_based_hydrolysis', 'ether_hydrolysis'], + 'hydrolysis_set_2': ['nitrile_hydrolysis'], +} DIHEDRAL_INCREMENT = 30 @@ -258,55 +274,59 @@ def execute_incore(self): multiplicity=rxn.multiplicity, ) - xyzs = list() - tsg, families = None, None - if rxn.family == 'H_Abstraction': - tsg = TSGuess(method='Heuristics') - tsg.tic() - xyzs = h_abstraction(reaction=rxn, dihedral_increment=self.dihedral_increment) - tsg.tok() - + tsg = TSGuess(method='Heuristics') + tsg.tic() + xyzs = get_ts_seeds( + reaction=rxn, + base_adapter='heuristics', + dihedral_increment=self.dihedral_increment, + ) + tsg.tok() if rxn.family in FAMILY_SETS['hydrolysis_set_1'] or rxn.family in FAMILY_SETS['hydrolysis_set_2']: - try: - tsg = TSGuess(method='Heuristics') - tsg.tic() - xyzs, families, indices = hydrolysis(reaction=rxn) - tsg.tok() - if not xyzs: - logger.warning(f'Heuristics TS search failed to generate any valid TS guesses for {rxn.label}.') - continue - except ValueError: + if not xyzs: + logger.warning( + f'Heuristics TS search failed to generate any valid TS guesses for {rxn.label}.' + ) continue - for method_index, xyz in enumerate(xyzs): + for method_index, xyz_entry in enumerate(xyzs): + xyz = xyz_entry.get("xyz") + method_label = xyz_entry.get("method", "Heuristics") + family = xyz_entry.get("family", rxn.family) + if xyz is None: + continue unique = True for other_tsg in rxn.ts_species.ts_guesses: if almost_equal_coords(xyz, other_tsg.initial_xyz): - if 'heuristics' not in other_tsg.method.lower(): - other_tsg.method += ' and Heuristics' + existing_sources = getattr(other_tsg, "method_sources", None) + if existing_sources is not None: + combined_sources = list(existing_sources) + [method_label] + else: + combined_sources = [other_tsg.method, method_label] + other_tsg.method_sources = TSGuess._normalize_method_sources(combined_sources) unique = False break if unique: - ts_guess = TSGuess(method='Heuristics', + ts_guess = TSGuess(method=method_label, method_index=method_index, t0=tsg.t0, execution_time=tsg.execution_time, success=True, - family=rxn.family if families is None else families[method_index], + family=family, xyz=xyz, ) rxn.ts_species.append_ts_guess(ts_guess) save_geo(xyz=xyz, path=self.local_path, - filename=f'Heuristics_{method_index}', + filename=f'{method_label}_{method_index}', format_='xyz', - comment=f'Heuristics {method_index}, family: {rxn.family}', + comment=f'{method_label} {method_index}, family: {rxn.family}', ) if len(self.reactions) < 5: - successes = len([tsg for tsg in rxn.ts_species.ts_guesses if tsg.success and 'heuristics' in tsg.method]) + successes = [tsg for tsg in rxn.ts_species.ts_guesses if tsg.success] if successes: - logger.info(f'Heuristics successfully found {successes} TS guesses for {rxn.label}.') + logger.info(f'Heuristics successfully found {len(successes)} TS guesses for {rxn.label}.') else: logger.info(f'Heuristics did not find any successful TS guesses for {rxn.label}.') @@ -686,6 +706,35 @@ def get_modified_params_from_zmat_2(zmat_1: dict, return new_symbols, new_coords, new_vars, new_map +def _perturb_collinear_zmat_angles(zmat: dict, + delta: float = 5.0, + ) -> dict: + """ + Return a copy of ``zmat`` with any near-collinear (~0 deg or ~180 deg) bond-angle parameter nudged by ``delta`` + degrees, keeping it within the valid [0, 180] range. Only bond-angle parameters ('A_...' / 'AX_...') are touched; + bond lengths and dihedrals are preserved, so connectivity is retained. + + This is a fallback used solely to build a throwaway species for connectivity-based atom mapping when a + linear/cumulene fragment reduces to a zmat that zmat_to_xyz cannot place (three collinear reference atoms make a + dihedral undefined, collapsing every atom to the origin). It never affects returned TS coordinates. + + Args: + zmat (dict): The zmat to copy and perturb. + delta (float, optional): The magnitude (in degrees) by which to nudge a collinear angle. + + Returns: + dict: A copy of ``zmat`` with collinear bond angles perturbed. + """ + new_vars = dict(zmat['vars']) + for param, value in zmat['vars'].items(): + if param.split('_')[0] in ('A', 'AX'): + if value <= delta: + new_vars[param] = delta + elif value >= 180.0 - delta: + new_vars[param] = 180.0 - delta + return {**zmat, 'vars': new_vars} + + def get_new_zmat_2_map(zmat_1: dict, zmat_2: dict, reactant_2: ARCSpecies | None, @@ -714,8 +763,19 @@ def get_new_zmat_2_map(zmat_1: dict, new_map = get_new_map_based_on_zmat_1(zmat_1=zmat_1, zmat_2=zmat_2, reactants_reversed=reactants_reversed) zmat_2_mod = remove_zmat_atom_0(zmat_2) zmat_2_mod['map'] = relocate_zmat_dummy_atoms_to_the_end(zmat_2_mod['map']) - spc_from_zmat_2 = ARCSpecies(label='spc_from_zmat_2', xyz=zmat_2_mod, multiplicity=reactant_2.multiplicity, - number_of_radicals=reactant_2.number_of_radicals, charge=reactant_2.charge) + try: + spc_from_zmat_2 = ARCSpecies(label='spc_from_zmat_2', xyz=zmat_2_mod, multiplicity=reactant_2.multiplicity, + number_of_radicals=reactant_2.number_of_radicals, charge=reactant_2.charge) + except SpeciesError: + # A linear/cumulene R(*3) fragment (e.g. the O=C=C acceptor center of ketene) can reduce, after removing + # the redundant H, to a zmat with a collinear (~0 deg / ~180 deg) bond angle. zmat_to_xyz cannot place a + # dihedral referencing three collinear atoms and collapses every atom to the origin, so the ARCSpecies build + # raises a "colliding atoms" SpeciesError. This species is used ONLY for connectivity-based atom mapping, so + # perturb the collinear angle(s) to recover a valid geometry (identical connectivity); the returned TS + # coordinates are unaffected as they are built from the original, un-perturbed zmat_2. + spc_from_zmat_2 = ARCSpecies(label='spc_from_zmat_2', xyz=_perturb_collinear_zmat_angles(zmat_2_mod), + multiplicity=reactant_2.multiplicity, + number_of_radicals=reactant_2.number_of_radicals, charge=reactant_2.charge) atom_map = map_two_species(spc_1=spc_from_zmat_2, spc_2=reactant_2, consider_chirality=False) new_map = update_new_map_based_on_zmat_2(new_map=new_map, zmat_2=zmat_2_mod, @@ -830,26 +890,31 @@ def find_distant_neighbor(mol: 'Molecule', def are_h_abs_wells_reversed(rxn: ARCReaction, product_dict: dict, - ) -> tuple[bool, bool]: + ) -> tuple[bool, bool, bool]: """ Determine whether the reactants or the products in an H_Abstraction reaction are reversed relative to the RMG template: R(*1)-H(*2) + R(*3)j <=> R(*1)j + R(*3)-H(*2) + ``reactants_reversed`` is True when the atom labeled *2 belongs to the reaction's second + reactant. ``products_reversed`` is True when the product bearing the atom labeled *2 is the + reaction's first product. ``dict_products_reversed`` is True when the atom labeled *2 belongs + to the first molecule of ``product_dict['products']``. The atom indices in ``product_dict`` + are zero-based global indices into the concatenated wells. Args: rxn (ARCReaction): The ARCReaction object. product_dict (dict): The product dictionary. Returns: - tuple[bool, bool]: reactants_reversed, products_reversed. + tuple[bool, bool, bool]: reactants_reversed, products_reversed, dict_products_reversed. """ r_star_2 = product_dict['r_label_map']['*2'] p_star_2 = product_dict['p_label_map']['*2'] - r_species, p_species = rxn.get_reactants_and_products(return_copies=True) - reactants_reversed = len(r_species[0].mol.atoms) < r_star_2 - products_reversed = len(product_dict['products'][0].atoms) >= p_star_2 + r_species, p_species = rxn.get_reactants_and_products(return_copies=False) + reactants_reversed = r_star_2 >= len(r_species[0].mol.atoms) + dict_products_reversed = p_star_2 < len(product_dict['products'][0].atoms) same_order_between_rxn_prods_and_dict_prods = p_species[0].is_isomorphic(product_dict['products'][0]) - products_reversed = products_reversed == same_order_between_rxn_prods_and_dict_prods - return reactants_reversed, products_reversed + products_reversed = dict_products_reversed == same_order_between_rxn_prods_and_dict_prods + return reactants_reversed, products_reversed, dict_products_reversed def h_abstraction(reaction: ARCReaction, @@ -871,20 +936,27 @@ def h_abstraction(reaction: ARCReaction, dihedral_increment (int, optional): The dihedral increment to use for B-H-A-C and D-B-H-C dihedral scans. Returns: list[dict] - Entries are Cartesian coordinates of TS guesses for all reactions. Returns an empty list if - ``reaction.product_dicts`` is empty (i.e., no matching H_Abstraction family products were identified). + Entries hold Cartesian coordinates of TS guesses and the generating method label. + Returns an empty list if ``reaction.product_dicts`` is empty (i.e., no matching + H_Abstraction family products were identified). """ xyz_guesses = list() dihedral_increment = dihedral_increment or DIHEDRAL_INCREMENT if not reaction.product_dicts: logger.warning(f'Could not generate H_Abstraction TS guesses for reaction {reaction}: ' - f'no product_dicts were identified for this reaction. Other TS search methods will be attempted.') + f'no product_dicts were identified for this reaction. Other TS search methods will be attempted.') return xyz_guesses - reactants_reversed, products_reversed = are_h_abs_wells_reversed(rxn=reaction, product_dict=reaction.product_dicts[0]) for product_dict in reaction.product_dicts: + reactive_atoms = { + 'A': product_dict['r_label_map']['*1'], + 'H': product_dict['r_label_map']['*2'], + 'B': product_dict['r_label_map']['*3'], + } # Identify R1H and R2H in the "R1H + R2 <=> R1 + R2H" or "R2 + R1H <=> R2H + R1" reaction # The expected RMG atom labels are: R(*1)-H(*2) + R(*3)j <=> R(*1)j + R(*3)-H(*2). # They appear in each product_dict under the 'r_label_map' key. + reactants_reversed, products_reversed, dict_prods_reversed = are_h_abs_wells_reversed( + rxn=reaction, product_dict=product_dict) reactants, products = reaction.get_reactants_and_products(return_copies=False) reactant = reactants[int(reactants_reversed)] # Get R(*1)-H(*2). reactant_2 = reactants[int(not reactants_reversed)] # Get R(*3)j. @@ -894,10 +966,9 @@ def h_abstraction(reaction: ARCReaction, # Don't modify dihedrals for an attacking H (or other linear radical) at a linear angle, C ~ A -- H1 - H2 -- H. dihedral_increment = 360 h1 = product_dict['r_label_map']['*2'] - if reactants_reversed and h1 >= len(reactants[0].mol.atoms): + if reactants_reversed: h1 -= len(reactants[0].mol.atoms) h2 = product_dict['p_label_map']['*2'] - dict_prods_reversed = h2 < len(product_dict['products'][0].atoms) dict_product = product_dict['products'][int(not dict_prods_reversed)] # Get R(*3)-H(*2) from the product_dict. product_atom_map = map_two_species(spc_1=dict_product, spc_2=product) if h2 >= len(product_atom_map): @@ -955,7 +1026,12 @@ def h_abstraction(reaction: ARCReaction, else: # This TS is unique, and has no atom collisions. zmats.append(zmat_guess) - xyz_guesses.append(xyz_guess) + xyz_guesses.append({ + "xyz": xyz_guess, + "method": "Heuristics", + "metadata": {"reactive_atoms": reactive_atoms}, + }) + return xyz_guesses @@ -990,9 +1066,11 @@ def hydrolysis(reaction: ARCReaction) -> tuple[list[dict], list[dict], list[int] is_set_1 = reaction_family in hydrolysis_parameters["family_sets"]["set_1"] is_set_2 = reaction_family in hydrolysis_parameters["family_sets"]["set_2"] - main_reactant, water, initial_xyz, xyz_indices = extract_reactant_and_indices(reaction, - product_dict, - is_set_1) + main_reactant, water, initial_xyz, xyz_indices = extract_reactant_and_indices( + reaction, + product_dict, + is_set_1, + ) base_xyz_indices = { "a": xyz_indices["a"], "b": xyz_indices["b"], @@ -1002,9 +1080,19 @@ def hydrolysis(reaction: ARCReaction) -> tuple[list[dict], list[dict], list[int] } adjustments_to_try = [False, True] if dihedrals_to_change_num == 1 else [True] for adjust_dihedral in adjustments_to_try: - chosen_xyz_indices, xyz_guesses, zmats_total, n_dihedrals_found = process_chosen_d_indices(initial_xyz, base_xyz_indices, xyz_indices, - hydrolysis_parameters,reaction_family, water, zmats_total, is_set_1, is_set_2, - dihedrals_to_change_num, should_adjust_dihedral=adjust_dihedral) + chosen_xyz_indices, xyz_guesses, zmats_total, n_dihedrals_found = process_chosen_d_indices( + initial_xyz, + base_xyz_indices, + xyz_indices, + hydrolysis_parameters, + reaction_family, + water, + zmats_total, + is_set_1, + is_set_2, + dihedrals_to_change_num, + should_adjust_dihedral=adjust_dihedral, + ) max_dihedrals_found = max(max_dihedrals_found, n_dihedrals_found) if xyz_guesses: xyz_guesses_total.extend(xyz_guesses) @@ -1018,8 +1106,8 @@ def hydrolysis(reaction: ARCReaction) -> tuple[list[dict], list[dict], list[int] condition_met = len(xyz_guesses_total) > 0 nitrile_in_inputs = any( - (pd.get("family") == "nitrile_hydrolysis") or - (isinstance(pd.get("family"), list) and "nitrile_hydrolysis" in pd.get("family")) + (pd.get("family") == "nitrile_hydrolysis") + or (isinstance(pd.get("family"), list) and "nitrile_hydrolysis" in pd.get("family")) for pd in product_dicts ) nitrile_already_found = any(fam == "nitrile_hydrolysis" for fam in reaction_families) @@ -1035,9 +1123,11 @@ def hydrolysis(reaction: ARCReaction) -> tuple[list[dict], list[dict], list[int] is_set_1 = reaction_family in hydrolysis_parameters["family_sets"]["set_1"] is_set_2 = reaction_family in hydrolysis_parameters["family_sets"]["set_2"] - main_reactant, water, initial_xyz, xyz_indices = extract_reactant_and_indices(reaction, - product_dict, - is_set_1) + main_reactant, water, initial_xyz, xyz_indices = extract_reactant_and_indices( + reaction, + product_dict, + is_set_1, + ) base_xyz_indices = { "a": xyz_indices["a"], "b": xyz_indices["b"], @@ -1051,10 +1141,18 @@ def hydrolysis(reaction: ARCReaction) -> tuple[list[dict], list[dict], list[int] break dihedrals_to_change_num += 1 chosen_xyz_indices, xyz_guesses, zmats_total, n_dihedrals_found = process_chosen_d_indices( - initial_xyz, base_xyz_indices, xyz_indices, - hydrolysis_parameters, reaction_family, water, zmats_total, is_set_1, is_set_2, - dihedrals_to_change_num, should_adjust_dihedral=True, - allow_nitrile_dihedrals=True + initial_xyz, + base_xyz_indices, + xyz_indices, + hydrolysis_parameters, + reaction_family, + water, + zmats_total, + is_set_1, + is_set_2, + dihedrals_to_change_num, + should_adjust_dihedral=True, + allow_nitrile_dihedrals=True, ) max_dihedrals_found = max(max_dihedrals_found, n_dihedrals_found) @@ -1086,11 +1184,13 @@ def get_products_and_check_families(reaction: ARCReaction) -> tuple[list[dict], consider_arc_families=True, ) carbonyl_based_present = any( - "carbonyl_based_hydrolysis" in (d.get("family", []) if isinstance(d.get("family"), list) else [d.get("family")]) + "carbonyl_based_hydrolysis" + in (d.get("family", []) if isinstance(d.get("family"), list) else [d.get("family")]) for d in product_dicts ) ether_present = any( - "ether_hydrolysis" in (d.get("family", []) if isinstance(d.get("family"), list) else [d.get("family")]) + "ether_hydrolysis" + in (d.get("family", []) if isinstance(d.get("family"), list) else [d.get("family")]) for d in product_dicts ) @@ -1166,11 +1266,13 @@ def extract_reactant_and_indices(reaction: ARCReaction, main_reactant, a_xyz_index, b_xyz_index, - two_neighbors + two_neighbors, ) except ValueError as e: - raise ValueError(f"Failed to determine neighbors by electronegativity for atom {a_xyz_index} " - f"in species {main_reactant.label}: {e}") + raise ValueError( + f"Failed to determine neighbors by electronegativity for atom {a_xyz_index} " + f"in species {main_reactant.label}: {e}" + ) o_index = len(main_reactant.mol.atoms) h1_index = o_index + 1 @@ -1181,7 +1283,7 @@ def extract_reactant_and_indices(reaction: ARCReaction, "e": e_xyz_index, "d": d_xyz_indices, "o": o_index, - "h1": h1_index + "h1": h1_index, } return main_reactant, water, initial_xyz, xyz_indices @@ -1226,11 +1328,18 @@ def process_chosen_d_indices(initial_xyz: dict, """ max_dihedrals_found = 0 for d_index in xyz_indices.get("d", []) or [None]: - chosen_xyz_indices = {**base_xyz_indices, "d": d_index} if d_index is not None else {**base_xyz_indices, - "d": None} + chosen_xyz_indices = {**base_xyz_indices, "d": d_index} if d_index is not None else { + **base_xyz_indices, + "d": None, + } current_zmat, zmat_indices = setup_zmat_indices(initial_xyz, chosen_xyz_indices) - matches = get_matching_dihedrals(current_zmat, zmat_indices['a'], zmat_indices['b'], - zmat_indices['e'], zmat_indices['d']) + matches = get_matching_dihedrals( + current_zmat, + zmat_indices['a'], + zmat_indices['b'], + zmat_indices['e'], + zmat_indices['d'], + ) max_dihedrals_found = max(max_dihedrals_found, len(matches)) if should_adjust_dihedral and dihedrals_to_change_num > len(matches): continue @@ -1248,22 +1357,28 @@ def process_chosen_d_indices(initial_xyz: dict, zmat_variants = generate_dihedral_variants(current_zmat, indices, adjustment_factors) if zmat_variants: adjusted_zmats.extend(zmat_variants) - if not adjusted_zmats: - pass - else: + if adjusted_zmats: zmats_to_process = adjusted_zmats ts_guesses_list = [] for zmat_to_process in zmats_to_process: ts_guesses, updated_zmats = process_family_specific_adjustments( - is_set_1, is_set_2, reaction_family, hydrolysis_parameters, - zmat_to_process, water, chosen_xyz_indices, zmats_total) + is_set_1, + is_set_2, + reaction_family, + hydrolysis_parameters, + zmat_to_process, + water, + chosen_xyz_indices, + zmats_total, + ) zmats_total = updated_zmats ts_guesses_list.extend(ts_guesses) if attempted_dihedral_adjustments and not ts_guesses_list and ( - reaction_family != 'nitrile_hydrolysis' or allow_nitrile_dihedrals): - flipped_zmats= [] + reaction_family != 'nitrile_hydrolysis' or allow_nitrile_dihedrals + ): + flipped_zmats = [] adjustment_factors = [15, 25, 35, 45, 55] for indices in indices_list: flipped_variants = generate_dihedral_variants(current_zmat, indices, adjustment_factors, flip=True) @@ -1271,8 +1386,14 @@ def process_chosen_d_indices(initial_xyz: dict, for zmat_to_process in flipped_zmats: ts_guesses, updated_zmats = process_family_specific_adjustments( - is_set_1, is_set_2, reaction_family, hydrolysis_parameters, - zmat_to_process, water, chosen_xyz_indices, zmats_total + is_set_1, + is_set_2, + reaction_family, + hydrolysis_parameters, + zmat_to_process, + water, + chosen_xyz_indices, + zmats_total, ) zmats_total = updated_zmats ts_guesses_list.extend(ts_guesses) @@ -1342,8 +1463,11 @@ def get_neighbors_by_electronegativity(spc: ARCSpecies, Raises: ValueError: If the atom has no valid neighbors. """ - neighbors = [neighbor for neighbor in spc.mol.atoms[atom_index].edges.keys() - if spc.mol.atoms.index(neighbor) != exclude_index] + neighbors = [ + neighbor + for neighbor in spc.mol.atoms[atom_index].edges.keys() + if spc.mol.atoms.index(neighbor) != exclude_index + ] if not neighbors: raise ValueError(f"Atom at index {atom_index} has no valid neighbors.") @@ -1357,12 +1481,17 @@ def get_neighbor_total_electronegativity(neighbor: 'Atom') -> float: float: The total electronegativity of the neighbor """ return sum( - ELECTRONEGATIVITIES[n.symbol] * neighbor.edges[n].order - for n in neighbor.edges.keys() + ELECTRONEGATIVITIES[n.symbol] * neighbor.edges[n].order for n in neighbor.edges.keys() ) - effective_electronegativities = [(ELECTRONEGATIVITIES[n.symbol] * spc.mol.atoms[atom_index].edges[n].order, - get_neighbor_total_electronegativity(n), n ) for n in neighbors] + effective_electronegativities = [ + ( + ELECTRONEGATIVITIES[n.symbol] * spc.mol.atoms[atom_index].edges[n].order, + get_neighbor_total_electronegativity(n), + n, + ) + for n in neighbors + ] effective_electronegativities.sort(reverse=True, key=lambda x: (x[0], x[1])) sorted_neighbors = [spc.mol.atoms.index(n[2]) for n in effective_electronegativities] most_electronegative = sorted_neighbors[0] @@ -1389,7 +1518,7 @@ def setup_zmat_indices(initial_xyz: dict, 'a': key_by_val(initial_zmat.get('map', {}), xyz_indices['a']), 'b': key_by_val(initial_zmat.get('map', {}), xyz_indices['b']), 'e': key_by_val(initial_zmat.get('map', {}), xyz_indices['e']), - 'd': key_by_val(initial_zmat.get('map', {}), xyz_indices['d']) if xyz_indices['d'] is not None else None + 'd': key_by_val(initial_zmat.get('map', {}), xyz_indices['d']) if xyz_indices['d'] is not None else None, } return initial_zmat, zmat_indices @@ -1400,15 +1529,15 @@ def generate_dihedral_variants(zmat: dict, flip: bool = False, tolerance_degrees: float = 10.0) -> list[dict]: """ - Create variants of a Z-matrix by adjusting dihedral angles using multiple adjustment factors. + Create variants of a Z-matrix by adjusting dihedral angles using multiple adjustment factors. This function creates variants of the Z-matrix using different adjustment factors: - 1. Retrieve the current dihedral value and normalize it to the (-180°, 180°] range. - 2. For each adjustment factor, slightly push the angle away from 0° or ±180° to avoid - unstable, boundary configurations. - 3. If `flip=True`, the same procedure is applied starting from a flipped - (180°-shifted) baseline angle. - 4. Each adjusted or flipped variant is deep-copied to ensure independence. + 1. Retrieve the current dihedral value and normalize it to the (-180°, 180°] range. + 2. For each adjustment factor, slightly push the angle away from 0° or ±180° to avoid + unstable, boundary configurations. + 3. If `flip=True`, the same procedure is applied starting from a flipped + (180°-shifted) baseline angle. + 4. Each adjusted or flipped variant is deep-copied to ensure independence. Args: zmat (dict): The initial Z-matrix. @@ -1416,7 +1545,8 @@ def generate_dihedral_variants(zmat: dict, adjustment_factors (list[float], optional): List of factors to try. flip (bool, optional): Whether to start from a flipped (180°) baseline dihedral angle. Defaults to False. - tolerance_degrees (float, optional): Tolerance (in degrees) for detecting angles near 0° or ±180°. Defaults to 10.0. + tolerance_degrees (float, optional): Tolerance (in degrees) for detecting angles near 0° or ±180°. + Defaults to 10.0. Returns: list[dict]: List of Z-matrix variants with adjusted dihedral angles. @@ -1442,8 +1572,9 @@ def push_up_dihedral(val: float, adj_factor: float) -> float: seed_value = normalized_value if flip: seed_value = get_angle_in_180_range(normalized_value + 180.0) - boundary_like = ((abs(seed_value) < tolerance_degrees) - or (180 - tolerance_degrees <= abs(seed_value) <= 180+tolerance_degrees)) + boundary_like = (abs(seed_value) < tolerance_degrees) or ( + 180 - tolerance_degrees <= abs(seed_value) <= 180 + tolerance_degrees + ) if boundary_like: for factor in adjustment_factors: variant = copy.deepcopy(zmat) @@ -1486,11 +1617,13 @@ def get_matching_dihedrals(zmat: dict, return matches -def stretch_ab_bond(initial_zmat: 'dict', - xyz_indices: 'dict', - zmat_indices: 'dict', - hydrolysis_parameters: 'dict', - reaction_family: str) -> None: +def stretch_ab_bond( + initial_zmat: dict, + xyz_indices: dict, + zmat_indices: dict, + hydrolysis_parameters: dict, + reaction_family: str, +) -> None: """ Stretch the bond between atoms a and b in the Z-matrix based on the reaction family parameters. @@ -1530,7 +1663,7 @@ def process_family_specific_adjustments(is_set_1: bool, xyz_indices: dict, zmats_total: list[dict]) -> tuple[list[dict], list[dict]]: """ - Process specific adjustments for different hydrolysis reaction families if needed, then generate TS guesses . + Process specific adjustments for different hydrolysis reaction families if needed, then generate TS guesses. Args: is_set_1 (bool): Whether the reaction belongs to parameter set 1. @@ -1548,21 +1681,34 @@ def process_family_specific_adjustments(is_set_1: bool, Raises: ValueError: If the reaction family is not supported. """ - a_xyz, b_xyz, e_xyz, o_xyz, h1_xyz, d_xyz= xyz_indices.values() + a_xyz, b_xyz, e_xyz, o_xyz, h1_xyz, d_xyz = xyz_indices.values() r_atoms = [a_xyz, o_xyz, o_xyz] a_atoms = [[b_xyz, a_xyz], [a_xyz, o_xyz], [h1_xyz, o_xyz]] - d_atoms = ([[e_xyz, d_xyz, a_xyz], [b_xyz, a_xyz, o_xyz], [a_xyz, h1_xyz, o_xyz]] - if d_xyz is not None else - [[e_xyz, b_xyz, a_xyz], [b_xyz, a_xyz, o_xyz], [a_xyz, h1_xyz, o_xyz]]) + d_atoms = ( + [[e_xyz, d_xyz, a_xyz], [b_xyz, a_xyz, o_xyz], [a_xyz, h1_xyz, o_xyz]] + if d_xyz is not None + else [[e_xyz, b_xyz, a_xyz], [b_xyz, a_xyz, o_xyz], [a_xyz, h1_xyz, o_xyz]] + ) r_value = hydrolysis_parameters['family_parameters'][str(reaction_family)]['r_value'] a_value = hydrolysis_parameters['family_parameters'][str(reaction_family)]['a_value'] d_values = hydrolysis_parameters['family_parameters'][str(reaction_family)]['d_values'] if is_set_1 or is_set_2: initial_xyz = zmat_to_xyz(initial_zmat) - return generate_hydrolysis_ts_guess(initial_xyz, xyz_indices.values(), water, r_atoms, a_atoms, d_atoms, - r_value, a_value, d_values, zmats_total, is_set_1, - threshold=0.6 if reaction_family == 'nitrile_hydrolysis' else 0.8) + return generate_hydrolysis_ts_guess( + initial_xyz, + xyz_indices.values(), + water, + r_atoms, + a_atoms, + d_atoms, + r_value, + a_value, + d_values, + zmats_total, + is_set_1, + threshold=0.6 if reaction_family == 'nitrile_hydrolysis' else 0.8, + ) else: raise ValueError(f"Family {reaction_family} not supported for hydrolysis TS guess generation.") @@ -1602,7 +1748,7 @@ def generate_hydrolysis_ts_guess(initial_xyz: dict, """ xyz_guesses = [] - for index, d_value in enumerate(d_values): + for d_value in d_values: xyz_guess = copy.deepcopy(initial_xyz) for i in range(3): xyz_guess = add_atom_to_xyz_using_internal_coords( @@ -1613,18 +1759,18 @@ def generate_hydrolysis_ts_guess(initial_xyz: dict, d_indices=d_atoms[i], r_value=r_value[i], a_value=a_value[i], - d_value=d_value[i] + d_value=d_value[i], ) - a_xyz, b_xyz, e_xyz, o_xyz, h1_xyz, d_xyz= xyz_indices - are_valid_bonds=check_ts_bonds(xyz_guess, [o_xyz, h1_xyz, h1_xyz+1, a_xyz, b_xyz]) - colliding=colliding_atoms(xyz_guess, threshold=threshold) + a_xyz, b_xyz, e_xyz, o_xyz, h1_xyz, d_xyz = xyz_indices + are_valid_bonds = check_ts_bonds(xyz_guess, [o_xyz, h1_xyz, h1_xyz + 1, a_xyz, b_xyz]) + colliding = colliding_atoms(xyz_guess, threshold=threshold) duplicate = any(compare_zmats(existing, xyz_to_zmat(xyz_guess)) for existing in zmats_total) if is_set_1: - dihedral_edao=[e_xyz, d_xyz, a_xyz, o_xyz] - dao_is_linear=check_dao_angle(dihedral_edao, xyz_guess) + dihedral_edao = [e_xyz, d_xyz, a_xyz, o_xyz] + dao_is_linear = check_dao_angle(dihedral_edao, xyz_guess) else: - dao_is_linear=False + dao_is_linear = False if xyz_guess is not None and not colliding and not duplicate and are_valid_bonds and not dao_is_linear: xyz_guesses.append(xyz_guess) zmats_total.append(xyz_to_zmat(xyz_guess)) @@ -1645,7 +1791,7 @@ def check_dao_angle(d_indices: list[int], xyz_guess: dict) -> bool: """ angle_indices = [d_indices[1], d_indices[2], d_indices[3]] angle_value = calculate_angle(xyz_guess, angle_indices) - norm_value=(angle_value + 180) % 180 + norm_value = (angle_value + 180) % 180 return (norm_value < 10) or (norm_value > 170) @@ -1660,7 +1806,7 @@ def check_ts_bonds(transition_state_xyz: dict, tested_atom_indices: list) -> boo Returns: bool: Whether the transition state guess has the expected water-related bonds. """ - oxygen_index, h1_index, h2_index, a_index, b_index= tested_atom_indices + oxygen_index, h1_index, h2_index, a_index, b_index = tested_atom_indices oxygen_bonds = sorted_distances_of_atom(transition_state_xyz, oxygen_index) h1_bonds = sorted_distances_of_atom(transition_state_xyz, h1_index) h2_bonds = sorted_distances_of_atom(transition_state_xyz, h2_index) @@ -1679,10 +1825,12 @@ def check_oxygen_bonds(bonds): return rel_error <= 0.1 return False - oxygen_has_valid_bonds = (oxygen_bonds[0][0] == h2_index and check_oxygen_bonds(oxygen_bonds)) - h1_has_valid_bonds = (h1_bonds[0][0] in {oxygen_index, b_index}and h1_bonds[1][0] in {oxygen_index, b_index}) + oxygen_has_valid_bonds = oxygen_bonds[0][0] == h2_index and check_oxygen_bonds(oxygen_bonds) + h1_has_valid_bonds = (h1_bonds[0][0] in {oxygen_index, b_index}) and ( + h1_bonds[1][0] in {oxygen_index, b_index} + ) h2_has_valid_bonds = h2_bonds[0][0] == oxygen_index return oxygen_has_valid_bonds and h1_has_valid_bonds and h2_has_valid_bonds -register_job_adapter('heuristics', HeuristicsAdapter) +register_job_adapter("heuristics", HeuristicsAdapter) diff --git a/arc/job/adapters/ts/heuristics_test.py b/arc/job/adapters/ts/heuristics_test.py index ea7de2d8b5..3798a9789a 100644 --- a/arc/job/adapters/ts/heuristics_test.py +++ b/arc/job/adapters/ts/heuristics_test.py @@ -10,6 +10,10 @@ import os import shutil import unittest +from types import SimpleNamespace +from unittest.mock import patch + +import numpy as np from arc.common import ARC_TESTING_PATH, almost_equal_coords from arc.family import get_reaction_family_products @@ -22,19 +26,25 @@ get_modified_params_from_zmat_2, get_new_map_based_on_zmat_1, get_new_zmat_2_map, + h_abstraction, stretch_zmat_bond, get_main_reactant_and_water_from_hydrolysis_reaction, setup_zmat_indices, get_neighbors_by_electronegativity, get_matching_dihedrals, generate_dihedral_variants, + h_abstraction, check_dao_angle, check_ts_bonds, h_abstraction, ) +from arc.job.adapters.common import ts_adapters_by_rmg_family +from arc.job.adapters.ts.seed_hub import get_ts_seeds, get_wrapper_constraints +from arc.molecule.molecule import Molecule from arc.reaction import ARCReaction from arc.species.converter import str_to_xyz, zmat_to_xyz, zmat_from_xyz -from arc.species.species import ARCSpecies +from arc.species.species import ARCSpecies, colliding_atoms +from arc.species.vectors import calculate_distance from arc.species.zmat import _compare_zmats, get_parameter_from_atom_indices from arc.species.species import check_isomorphism @@ -549,6 +559,26 @@ def test_heuristics_for_h_abstraction_1(self): (2.063927797423041, -2.798718649055232e-07, -1.7157539600187732e-07))} self.assertTrue(almost_equal_coords(rxn4.ts_species.ts_guesses[0].initial_xyz, expected_xyz)) + def test_heuristics_for_h_abstraction_symmetric_reactants(self): + """Symmetric H-abstraction with identical reactants (OH + OH <=> H2O + O). + + Regression: with identical reactants, different product_dicts place the transferring H (*2) + in different reactants, so a reaction-level reactants_reversed value mis-indexed h1 into the + wrong reactant and raised IndexError. reactants_reversed must be computed per product_dict. + """ + o_triplet = ARCSpecies(label='O', smiles='[O]', xyz='O 0.0 0.0 0.0') + rxn = ARCReaction(r_species=[self.oh, self.oh], p_species=[self.h2o, o_triplet]) + self.assertEqual(rxn.family, 'H_Abstraction') + heuristics = HeuristicsAdapter(job_type='tsg', + reactions=[rxn], + testing=True, + project='test', + project_directory=os.path.join(ARC_TESTING_PATH, 'heuristics'), + dihedral_increment=30, + ) + heuristics.execute_incore() # Must not raise IndexError. + self.assertGreater(len(rxn.ts_species.ts_guesses), 0) + def test_h_abstraction_with_empty_product_dicts(self): """ Test that h_abstraction() does not raise and returns an empty list of TS guesses @@ -949,6 +979,19 @@ def test_heuristics_for_h_abstraction_8(self): h2o = ARCSpecies(label='H2O', smiles='O', xyz=self.h2o_xyz) rxn12 = ARCReaction(r_species=[nh3, oh], p_species=[nh2, h2o]) self.assertEqual(rxn12.family, 'H_Abstraction') + raw_seeds = h_abstraction(reaction=rxn12, dihedral_increment=60) + expected_reactive_atoms = [ + {'A': product_dict['r_label_map']['*1'], + 'H': product_dict['r_label_map']['*2'], + 'B': product_dict['r_label_map']['*3']} + for product_dict in rxn12.product_dicts + ] + for seed in raw_seeds: + reactive_atoms = seed['metadata']['reactive_atoms'] + self.assertIn(reactive_atoms, expected_reactive_atoms) + self.assertTrue(seed['xyz']['symbols'][reactive_atoms['H']].startswith('H')) + self.assertFalse(seed['xyz']['symbols'][reactive_atoms['A']].startswith('H')) + self.assertFalse(seed['xyz']['symbols'][reactive_atoms['B']].startswith('H')) heuristics_12 = HeuristicsAdapter(job_type='tsg', reactions=[rxn12], testing=True, @@ -1141,6 +1184,68 @@ def test_heuristics_for_h_abstraction_13(self): heuristics_1.execute_incore() self.assertEqual(len(rxn1.ts_species.ts_guesses), 12) + def test_heuristics_for_h_abstraction_linear_cumulene_acceptor(self): + # A cumulene (double-linear O=C=C) H-abstraction acceptor: HCCO + THF <=> CH2CO + THF_rad. + # The acceptor product (ketene) has a collinear O=C=C backbone; after removing the redundant H, + # its reduced zmat carries a collinear (~0 deg) bond angle that zmat_to_xyz cannot place (all atoms + # collapse to the origin), which previously raised a SpeciesError inside the atom-mapping step and + # yielded no TS guesses. The builder must now recover a valid combined structure. + hcco_xyz = {'symbols': ('O', 'C', 'C', 'H'), 'isotopes': (16, 12, 12, 1), + 'coords': ((-0.996315, 0.647862, 0.0), (0.985725, -0.782196, 0.0), + (0.0, 0.042764, 0.0), (2.056167, -0.746307, 0.0))} + ketene_xyz = {'symbols': ('O', 'C', 'C', 'H', 'H'), 'isotopes': (16, 12, 12, 1, 1), + 'coords': ((0.0, 0.0, 1.258284), (0.0, 0.0, -1.203594), (0.0, 0.0, 0.103424), + (0.0, 0.939888, -1.732624), (0.0, -0.939888, -1.732624))} + thf_xyz = {'symbols': ('O', 'C', 'C', 'C', 'C', 'H', 'H', 'H', 'H', 'H', 'H', 'H', 'H'), + 'isotopes': (16, 12, 12, 12, 12, 1, 1, 1, 1, 1, 1, 1, 1), + 'coords': ((0.0, 0.0, 1.240693), (-0.307372, -0.698827, -0.989684), + (0.307372, 0.698827, -0.989684), (0.0, 1.165081, 0.428647), + (0.0, -1.165081, 0.428647), (-1.386637, -0.638055, -1.145748), + (0.111128, -1.358444, -1.748946), (1.386637, 0.638055, -1.145748), + (-0.111128, 1.358444, -1.748946), (-0.985667, 1.64344, 0.479232), + (0.7404, 1.866175, 0.818913), (0.985667, -1.64344, 0.479232), + (-0.7404, -1.866175, 0.818913))} + thf_rad_xyz = {'symbols': ('O', 'C', 'C', 'C', 'C', 'H', 'H', 'H', 'H', 'H', 'H', 'H'), + 'isotopes': (16, 12, 12, 12, 12, 1, 1, 1, 1, 1, 1, 1), + 'coords': ((-0.765665, -0.986183, -0.060071), (0.093511, 1.206488, -0.190713), + (1.232815, 0.240321, 0.155856), (-1.140085, 0.376675, 0.149294), + (0.592627, -1.070428, -0.148362), (0.131081, 2.140234, 0.368336), + (0.110708, 1.439925, -1.256145), (2.1369, 0.419334, -0.42724), + (1.506174, 0.331401, 1.217331), (-2.004576, 0.594346, -0.477252), + (-1.427388, 0.495868, 1.199024), (0.999212, -2.04998, 0.060061))} + hcco = ARCSpecies(label='HCCO', smiles='[CH]=C=O', xyz=hcco_xyz) + thf = ARCSpecies(label='THF', smiles='C1CCOC1', xyz=thf_xyz) + ketene = ARCSpecies(label='CH2CO', smiles='C=C=O', xyz=ketene_xyz) + thf_rad = ARCSpecies(label='THF_rad', smiles='[CH]1CCOC1', xyz=thf_rad_xyz) + rxn = ARCReaction(r_species=[hcco, thf], p_species=[ketene, thf_rad]) + self.assertEqual(rxn.family, 'H_Abstraction') + heuristics = HeuristicsAdapter(job_type='tsg', + reactions=[rxn], + testing=True, + project='test', + project_directory=os.path.join(ARC_TESTING_PATH, 'heuristics_cumulene'), + dihedral_increment=120, + ) + heuristics.execute_incore() + # The combine must yield valid (non-degenerate, non-colliding) TS guesses for the cumulene acceptor. + self.assertTrue(rxn.ts_species.is_ts) + self.assertGreater(len(rxn.ts_species.ts_guesses), 0) + tsg_xyz = rxn.ts_species.ts_guesses[0].initial_xyz + self.assertEqual(len(tsg_xyz['symbols']), 17) # HCCO (4) + THF (13). + coords = np.array(tsg_xyz['coords'], dtype=float) + self.assertFalse(np.isnan(coords).any()) + self.assertFalse(colliding_atoms(tsg_xyz)) + # The transferred H sits between the two carbons at reasonable forming/breaking distances (~1.3 Angstrom). + c_indices = [i for i, s in enumerate(tsg_xyz['symbols']) if s == 'C'] + for h in [i for i, s in enumerate(tsg_xyz['symbols']) if s == 'H']: + near = sorted(calculate_distance(coords=tsg_xyz['coords'], atoms=[h, c]) for c in c_indices) + if near[0] < 1.6 and near[1] < 1.8: + self.assertGreater(near[0], 1.0) + self.assertGreater(near[1], 1.0) + break + else: + self.fail('Did not find a transferred H bridging two carbons in the cumulene-acceptor TS guess.') + def test_heuristics_for_carbonyl_based_hydrolysis(self): """ Test heuristics for carbonyl-based hydrolysis: C2H4O2 + H2O <=> CH2O2 + CH4O. @@ -1998,7 +2103,7 @@ def _check_h_abs_wells_reversed(self, rxn: 'ARCReaction', expected_r_reversed: b consider_arc_families=False, discover_own_reverse_rxns_in_reverse=False, ) - r_reversed, p_reversed = are_h_abs_wells_reversed(rxn, product_dict=product_dicts[0]) + r_reversed, p_reversed, _ = are_h_abs_wells_reversed(rxn, product_dict=product_dicts[0]) self.assertEqual(r_reversed, expected_r_reversed) self.assertEqual(p_reversed, expected_p_reversed) @@ -2050,6 +2155,163 @@ def test_are_h_abs_wells_reversed_butyrate(self): ARCSpecies(label='H2O', smiles='O')]) self._check_h_abs_wells_reversed(rxn, expected_r_reversed=False, expected_p_reversed=False) + def _get_h_abs_product_dicts(self, rxn: 'ARCReaction') -> list: + """Helper: get the H_Abstraction family product dicts of ``rxn``.""" + return get_reaction_family_products(rxn=rxn, + rmg_family_set=[rxn.family], + consider_rmg_families=True, + consider_arc_families=False, + discover_own_reverse_rxns_in_reverse=False, + ) + + def test_are_h_abs_wells_reversed_star_2_is_the_first_atom_of_the_second_reactant(self): + """CH3 + H2 <=> CH4 + H — *2 is the first atom of the second reactant. + + One of the two product dicts labels the first H of H2 as *2, i.e. r_label_map['*2'] equals + the number of atoms in the first reactant. The transferring H is then in the second + reactant, so reactants_reversed must be True, and h_abstraction must index h1 into H2 + rather than into CH3. + """ + rxn = ARCReaction(r_species=[ARCSpecies(label='CH3', smiles='[CH3]'), + ARCSpecies(label='H2', smiles='[H][H]')], + p_species=[ARCSpecies(label='CH4', smiles='C'), + ARCSpecies(label='H', smiles='[H]')]) + self.assertEqual(rxn.family, 'H_Abstraction') + product_dicts = self._get_h_abs_product_dicts(rxn) + n_atoms_r_0 = len(rxn.r_species[0].mol.atoms) + boundary_dicts = [p_dict for p_dict in product_dicts if p_dict['r_label_map']['*2'] == n_atoms_r_0] + self.assertEqual(len(boundary_dicts), 1) + reactants = rxn.get_reactants_and_products(return_copies=False)[0] + for product_dict in product_dicts: + reactants_reversed, _, _ = are_h_abs_wells_reversed(rxn, product_dict=product_dict) + reactant = reactants[int(reactants_reversed)] + h1 = product_dict['r_label_map']['*2'] + if reactants_reversed: + h1 -= len(reactants[0].mol.atoms) + self.assertLess(h1, len(reactant.mol.atoms)) + self.assertTrue(reactant.mol.atoms[h1].is_hydrogen()) + self.assertTrue(are_h_abs_wells_reversed(rxn, product_dict=boundary_dicts[0])[0]) + rxn.product_dicts = product_dicts + xyz_guesses = h_abstraction(reaction=rxn, dihedral_increment=120) + self.assertTrue(len(xyz_guesses)) + + def test_are_h_abs_wells_reversed_star_2_is_the_first_atom_of_the_second_dict_product(self): + """C2H6 + OH <=> C2H5 + H2O with the transferred H first in the second dict product. + + p_label_map['*2'] is a global index into the concatenated dict products, so a value equal + to the number of atoms of the first dict product already belongs to the second one and the + H bearing dict product is the second one. No RMG template was found that orders its products + this way, so the dict products are written out here with the transferred H of the water + first. The reaction product paired with the H bearing dict product must be the water in both + product orders. + """ + h2o_h_first = Molecule().from_adjacency_list("""1 H u0 p0 c0 {2,S} +2 O u0 p2 c0 {1,S} {3,S} +3 H u0 p0 c0 {2,S} +""") + c2h5, h2o = ARCSpecies(label='C2H5', smiles='[CH2]C'), ARCSpecies(label='H2O', smiles='O') + dict_products = [c2h5.mol.copy(deep=True), h2o_h_first] + p_star_2 = len(dict_products[0].atoms) + self.assertTrue(dict_products[1].atoms[0].is_hydrogen()) + rxn = ARCReaction(r_species=[ARCSpecies(label='C2H6', smiles='CC'), + ARCSpecies(label='OH', smiles='[OH]')], + p_species=[c2h5, h2o]) + self.assertEqual(rxn.family, 'H_Abstraction') + product_dict = self._get_h_abs_product_dicts(rxn)[0] + product_dict['products'] = dict_products + product_dict['p_label_map'] = {'*2': p_star_2} + _, products_reversed, dict_products_reversed = are_h_abs_wells_reversed(rxn, product_dict=product_dict) + self.assertFalse(dict_products_reversed) + self.assertFalse(products_reversed) + self.assertEqual(rxn.p_species[int(not products_reversed)].mol.get_formula(), 'H2O') + rxn_reversed_products = ARCReaction(r_species=[ARCSpecies(label='C2H6', smiles='CC'), + ARCSpecies(label='OH', smiles='[OH]')], + p_species=[h2o, c2h5]) + product_dict = self._get_h_abs_product_dicts(rxn_reversed_products)[0] + product_dict['products'] = dict_products + product_dict['p_label_map'] = {'*2': p_star_2} + _, products_reversed, dict_products_reversed = are_h_abs_wells_reversed(rxn_reversed_products, + product_dict=product_dict) + self.assertFalse(dict_products_reversed) + self.assertTrue(products_reversed) + self.assertEqual(rxn_reversed_products.p_species[int(not products_reversed)].mol.get_formula(), 'H2O') + + def test_are_h_abs_wells_reversed_with_a_charge_separated_perception(self): + """isoxazol-5-yl + propyne <=> isoxazole + propargyl, with a charge separated re-perception. + + Perceiving isoxazole from its Cartesian coordinates may yield the charge separated aromatic + [n-]1ccc[o+]1 rather than the neutral c1ccno1, and copying an ARCSpecies that carries + coordinates re-perceives its 2D graph. Here that re-perception is forced deterministically: + the orientation must still be resolved against the neutral graph the reaction holds, so that + the product paired with each dict product keeps its molecular formula. + """ + isoxazole_charge_separated_adjlist = """1 C u0 p0 c0 {2,S} {5,D} {6,S} +2 C u0 p0 c0 {1,S} {3,D} {7,S} +3 C u0 p0 c0 {2,D} {4,S} {8,S} +4 N u0 p2 c-1 {3,S} {5,S} +5 O u0 p1 c+1 {1,D} {4,S} +6 H u0 p0 c0 {1,S} +7 H u0 p0 c0 {2,S} +8 H u0 p0 c0 {3,S} +""" + isoxazolyl = ARCSpecies(label='isoxazolyl', smiles='O1[C]=CC=N1', multiplicity=2) + isoxazolyl.final_xyz = str_to_xyz("""O 1.62581768 -0.29842095 -0.47814520 +C 0.65307399 -1.11584355 -0.97117760 +C -0.57247729 -0.54346433 -0.73207366 +C -0.27021319 0.65775554 -0.07029399 +N 1.04374329 0.82411794 0.09216569 +H -1.53760759 -0.94291122 -1.00026412 +H -0.94233690 1.41876655 0.30089417""") + propyne = ARCSpecies(label='propyne', smiles='C#CC') + propyne.final_xyz = str_to_xyz("""C 1.69667781 -0.18348962 0.34176455 +C 0.50414063 -0.05452099 0.28779005 +C -0.94919940 0.10265252 0.22201144 +H 2.75513851 -0.29795839 0.38967067 +H -1.44299398 -0.87115669 0.15094730 +H -1.23736098 0.69413790 -0.65222854 +H -1.32640259 0.61033527 1.11485159""") + isoxazole = ARCSpecies(label='isoxazole', smiles='c1ccno1') + isoxazole.final_xyz = str_to_xyz("""C -1.09143635 -0.08868244 -0.00645500 +C -0.03176118 0.77687168 -0.08273051 +C 1.09337013 -0.05945157 0.02217020 +N 0.75345117 -1.34403600 0.15327038 +O -0.63329583 -1.35807858 0.13462360 +H -2.16536354 0.01990666 -0.03354656 +H -0.06648136 1.84914961 -0.19678701 +H 2.14151697 0.20432063 0.00945491""") + propargyl = ARCSpecies(label='propargyl', smiles='C#C[CH2]', multiplicity=2) + propargyl.final_xyz = str_to_xyz("""C 1.49185539 0.01644235 0.23786746 +C 0.30713117 0.00338503 0.04897028 +C -1.08917296 -0.01200422 -0.17366215 +H 2.54370249 0.02803524 0.40557840 +H -1.61674460 0.90953448 -0.38628957 +H -1.63677149 -0.94539289 -0.13246443""") + rxn = ARCReaction(r_species=[isoxazolyl, propyne], p_species=[isoxazole, propargyl]) + self.assertEqual(rxn.family, 'H_Abstraction') + self.assertEqual(rxn.p_species[0].mol.get_net_charge(), 0) + self.assertTrue(all(not atom.charge for atom in rxn.p_species[0].mol.atoms)) + product_dicts = self._get_h_abs_product_dicts(rxn) + original_mol_from_xyz = ARCSpecies.mol_from_xyz + + def charge_separated_mol_from_xyz(spc, xyz=None, get_cheap=False): + """Perceive isoxazole as its charge separated aromatic form, anything else as usual.""" + if spc.mol is not None and spc.mol.get_formula() == 'C3H3NO': + spc.mol = Molecule().from_adjacency_list(isoxazole_charge_separated_adjlist, + raise_atomtype_exception=False, + raise_charge_exception=False, + ) + return None + return original_mol_from_xyz(spc, xyz=xyz, get_cheap=get_cheap) + + with patch.object(ARCSpecies, 'mol_from_xyz', charge_separated_mol_from_xyz): + self.assertTrue(any(atom.charge for atom in isoxazole.copy().mol.atoms)) + for product_dict in product_dicts: + _, products_reversed, dict_products_reversed = are_h_abs_wells_reversed(rxn, + product_dict=product_dict) + product = rxn.get_reactants_and_products(return_copies=False)[1][int(not products_reversed)] + dict_product = product_dict['products'][int(not dict_products_reversed)] + self.assertEqual(product.mol.get_formula(), dict_product.get_formula()) + def test_process_hydrolysis_reaction(self): """Test the process_hydrolysis_reaction() function.""" acetamide = self.acetamide @@ -2261,9 +2523,262 @@ def tearDownClass(cls): A function that is run ONCE after all unit tests in this class. Delete all project directories created during these unit tests. """ - for sub in ('heuristics', 'heuristics_1', 'heuristics_carbonyl', 'heuristics_ether', 'heuristics_nitrile'): + for sub in ('heuristics', 'heuristics_1', 'heuristics_carbonyl', 'heuristics_ether', 'heuristics_nitrile', + 'heuristics_cumulene'): shutil.rmtree(os.path.join(ARC_TESTING_PATH, sub), ignore_errors=True) +class TestHeuristicsHub(unittest.TestCase): + """Unit tests for shared heuristic seed and CREST-constraint helpers.""" + + def test_get_ts_seeds_h_abstraction(self): + rxn = SimpleNamespace(family='H_Abstraction') + with patch('arc.job.adapters.ts.heuristics.h_abstraction', + return_value=[{'xyz': {'symbols': ('H',), 'coords': ((0.0, 0.0, 0.0),), 'isotopes': (1,)}, + 'method': 'Heuristics', + 'metadata': {'source': 'reaction_mapping'}}]): + seeds = get_ts_seeds(reaction=rxn, base_adapter='heuristics', dihedral_increment=60) + self.assertEqual(len(seeds), 1) + self.assertEqual(seeds[0]['family'], 'H_Abstraction') + self.assertEqual(seeds[0]['method'], 'Heuristics') + self.assertEqual(seeds[0]['source_adapter'], 'heuristics') + self.assertEqual(seeds[0]['metadata'], {'source': 'reaction_mapping'}) + + def test_get_ts_seeds_hydrolysis(self): + rxn = SimpleNamespace(family='carbonyl_based_hydrolysis') + xyz = {'symbols': ('O',), 'coords': ((0.0, 0.0, 0.0),), 'isotopes': (16,)} + with patch('arc.job.adapters.ts.heuristics.hydrolysis', + return_value=([xyz], ['carbonyl_based_hydrolysis'], [[0, 1, 2]])): + seeds = get_ts_seeds(reaction=rxn, base_adapter='heuristics') + self.assertEqual(len(seeds), 1) + self.assertEqual(seeds[0]['family'], 'carbonyl_based_hydrolysis') + self.assertEqual(seeds[0]['xyz'], xyz) + self.assertEqual(seeds[0]['metadata'], {'indices': [0, 1, 2]}) + + def test_get_wrapper_constraints_crest(self): + rxn = SimpleNamespace(family='H_Abstraction') + xyz = str_to_xyz("""O 0.0000 0.0000 0.0000 + H 0.0000 0.0000 0.9600 + O 0.9000 0.0000 0.0000""") + seed = {'xyz': xyz, 'family': rxn.family} + constraints = get_wrapper_constraints(wrapper='crest', reaction=rxn, seed=seed) + self.assertIsInstance(constraints, dict) + self.assertTrue({'A', 'H', 'B', 'atoms', 'distance_pairs', 'angle_atoms'} <= set(constraints)) + self.assertEqual( + (constraints['A'], constraints['H'], constraints['B']), + constraints['angle_atoms'], + ) + self.assertEqual(len(constraints['atoms']), 3) + self.assertEqual(len(constraints['distance_pairs']), 2) + + def test_get_wrapper_constraints_crest_symmetric_oh_oh(self): + """The transferring H must be bracketed by the two O atoms in either atom ordering.""" + rxn = SimpleNamespace(family='H_Abstraction') + seeds_and_expected_atoms = [ + (str_to_xyz("""O 0.00000000 -0.02752832 -1.20590500 + H 0.00000000 -0.02752832 -0.03383145 + O 0.00000000 -0.02752832 1.12142787 + H 0.00000000 0.90131726 1.37454478"""), 1), + (str_to_xyz("""O 0.00000000 -0.02752832 1.12142787 + H 0.00000000 0.90131726 1.37454478 + O 0.00000000 -0.02752832 -1.20590500 + H 0.00000000 -0.02752832 -0.03383145"""), 3), + ] + for xyz, expected_h_atom in seeds_and_expected_atoms: + with self.subTest(symbols=xyz['symbols']): + seed = { + 'xyz': xyz, + 'family': rxn.family, + 'metadata': {'reactive_atoms': {'A': 0, 'H': expected_h_atom, 'B': 2}}, + } + constraints = get_wrapper_constraints(wrapper='crest', reaction=rxn, seed=seed) + self.assertEqual(constraints['angle_atoms'][1], expected_h_atom) + self.assertSetEqual({constraints['angle_atoms'][0], constraints['angle_atoms'][2]}, {0, 2}) + self.assertFalse(xyz['symbols'][constraints['angle_atoms'][0]].startswith('H')) + self.assertFalse(xyz['symbols'][constraints['angle_atoms'][2]].startswith('H')) + + def test_get_wrapper_constraints_crest_rejects_hydrogen_as_heavy_atom(self): + rxn = SimpleNamespace(family='H_Abstraction') + xyz = str_to_xyz("""O -1.0000 0.0000 0.0000 + H 0.0000 0.0000 0.0000 + O 1.0000 0.0000 0.0000 + H 2.0000 0.0000 0.0000""") + seed = { + 'xyz': xyz, + 'family': rxn.family, + 'metadata': {'reactive_atoms': {'A': 0, 'H': 1, 'B': 3}}, + } + self.assertIsNone(get_wrapper_constraints(wrapper='crest', reaction=rxn, seed=seed)) + + def test_get_ts_seeds_preserves_invalid_explicit_reactive_atoms(self): + """Do not hide an invalid generator mapping with the geometric compatibility fallback.""" + rxn = SimpleNamespace(family='H_Abstraction') + xyz = str_to_xyz("""O -1.0000 0.0000 0.0000 + H 0.0000 0.0000 0.0000 + O 1.0000 0.0000 0.0000 + H 2.0000 0.0000 0.0000""") + invalid_atoms = {'A': 0, 'H': 1, 'B': 3} + with patch('arc.job.adapters.ts.heuristics.h_abstraction', return_value=[{ + 'xyz': xyz, + 'method': 'Heuristics', + 'metadata': {'reactive_atoms': invalid_atoms}, + }]): + seed = get_ts_seeds(reaction=rxn, base_adapter='heuristics')[0] + self.assertEqual(seed['metadata']['reactive_atoms'], invalid_atoms) + self.assertIsNone(get_wrapper_constraints(wrapper='crest', reaction=rxn, seed=seed)) + + def test_get_wrapper_constraints_crest_prefers_explicit_reactive_atoms(self): + """Explicit generator metadata wins when distances would select a spectator hydrogen.""" + rxn = SimpleNamespace(family='H_Abstraction') + xyz = str_to_xyz("""O -1.0000 0.0000 0.0000 + H 0.0000 0.0000 0.0000 + O 1.0000 0.0000 0.0000 + H 0.0000 1.5000 0.0000""") + seed = { + 'xyz': xyz, + 'family': rxn.family, + 'metadata': {'reactive_atoms': {'A': 0, 'H': 3, 'B': 2}}, + } + self.assertEqual( + get_wrapper_constraints(wrapper='crest', reaction=rxn, seed=seed), + { + 'A': 0, + 'H': 3, + 'B': 2, + 'atoms': (0, 3, 2), + 'distance_pairs': ((0, 3), (3, 2)), + 'angle_atoms': (0, 3, 2), + }, + ) + + def test_get_wrapper_constraints_crest_xy_addition(self): + """XY constraints follow the exact family-label mapping for both seed orderings.""" + rxn = SimpleNamespace(family='XY_Addition_MultipleBond') + xyz = str_to_xyz("""C 0.0 0.0 0.667 + C 0.0 0.0 -0.667 + H 0.0 1.6 0.667 + Cl 0.0 2.1 -0.667""") + for reactive_atoms in ( + {'*1': 1, '*2': 0, '*3': 2, '*4': 3}, + {'*1': 0, '*2': 1, '*3': 2, '*4': 3}, + ): + with self.subTest(reactive_atoms=reactive_atoms): + seed = { + 'xyz': xyz, + 'family': rxn.family, + 'metadata': {'reactive_atoms': reactive_atoms}, + } + self.assertEqual( + get_wrapper_constraints(wrapper='crest', reaction=rxn, seed=seed), + { + 'atoms': tuple(reactive_atoms[label] for label in ('*1', '*2', '*3', '*4')), + 'distance_pairs': ( + (reactive_atoms['*1'], reactive_atoms['*3']), + (reactive_atoms['*2'], reactive_atoms['*4']), + (reactive_atoms['*3'], reactive_atoms['*4']), + ), + }, + ) + + def test_get_wrapper_constraints_crest_rejects_invalid_explicit_xy_atoms(self): + """Do not infer an XY mapping when explicit generator metadata is invalid.""" + rxn = SimpleNamespace(family='XY_Addition_MultipleBond') + xyz = str_to_xyz("""C 0.0 0.0 0.667 + C 0.0 0.0 -0.667 + H 0.0 1.6 0.667 + Cl 0.0 2.1 -0.667""") + invalid_atoms = {'*1': 0, '*2': 1, '*3': 2, '*4': 2} + with patch('arc.job.adapters.ts.xy_addition.xy_addition', return_value=[{ + 'xyz': xyz, + 'method': 'Heuristics-XY', + 'metadata': {'reactive_atoms': invalid_atoms}, + }]): + seed = get_ts_seeds(reaction=rxn)[0] + self.assertEqual(seed['metadata']['reactive_atoms'], invalid_atoms) + self.assertIsNone(get_wrapper_constraints(wrapper='crest', reaction=rxn, seed=seed)) + + def test_intra_no2_ono_conversion_is_registered_for_crest(self): + """CREST is a registered TS adapter for the intra_NO2_ONO_conversion family.""" + self.assertIn('crest', ts_adapters_by_rmg_family['intra_NO2_ONO_conversion']) + + def test_get_wrapper_constraints_crest_no2_ono(self): + """The NO2 -> ONO constraints pin the breaking C-N, forming C-O, and retained N-O bonds.""" + rxn = ARCReaction(label='R1 <=> P1', + r_species=[ARCSpecies(label='R1', smiles='[O-][N+](=O)CC', multiplicity=1)], + p_species=[ARCSpecies(label='P1', smiles='CCON=O', multiplicity=1)]) + self.assertEqual(rxn.family, 'intra_NO2_ONO_conversion') + xyz = rxn.r_species[0].get_xyz() + seed = {'xyz': xyz, 'family': rxn.family, 'metadata': {}} + constraints = get_wrapper_constraints(wrapper='crest', reaction=rxn, seed=seed) + self.assertIsInstance(constraints, dict) + self.assertLessEqual({'C', 'N', 'O', 'atoms', 'distance_pairs', 'angle_atoms'}, set(constraints)) + c_atom, n_atom, o_atom = constraints['C'], constraints['N'], constraints['O'] + self.assertEqual(len({c_atom, n_atom, o_atom}), 3) + self.assertEqual(xyz['symbols'][c_atom], 'C') + self.assertEqual(xyz['symbols'][n_atom], 'N') + self.assertEqual(xyz['symbols'][o_atom], 'O') + self.assertEqual(constraints['atoms'], (c_atom, n_atom, o_atom)) + self.assertEqual(constraints['distance_pairs'], + ((c_atom, n_atom), (c_atom, o_atom), (n_atom, o_atom))) + self.assertEqual(constraints['angle_atoms'], (c_atom, n_atom, o_atom)) + + def test_get_wrapper_constraints_crest_no2_ono_explicit_labels(self): + """Explicit RMG recipe labels on the seed metadata are honored for NO2 -> ONO.""" + rxn = SimpleNamespace(family='intra_NO2_ONO_conversion') + xyz = str_to_xyz("""C 0.0000 0.0000 0.0000 + N 1.5000 0.0000 0.0000 + O 2.1000 1.0000 0.0000 + O 2.1000 -1.0000 0.0000""") + for reactive_atoms in ({'*1': 0, '*2': 1, '*3': 2}, {'C': 0, 'N': 1, 'O': 2}): + with self.subTest(reactive_atoms=reactive_atoms): + seed = {'xyz': xyz, 'family': rxn.family, 'metadata': {'reactive_atoms': reactive_atoms}} + self.assertEqual( + get_wrapper_constraints(wrapper='crest', reaction=rxn, seed=seed), + { + 'C': 0, + 'N': 1, + 'O': 2, + 'atoms': (0, 1, 2), + 'distance_pairs': ((0, 1), (0, 2), (1, 2)), + 'angle_atoms': (0, 1, 2), + }, + ) + + def test_get_wrapper_constraints_crest_rejects_invalid_no2_ono_atoms(self): + """An invalid or incomplete NO2 -> ONO atom assignment returns None without raising.""" + rxn = SimpleNamespace(family='intra_NO2_ONO_conversion') + xyz = str_to_xyz("""C 0.0000 0.0000 0.0000 + N 1.5000 0.0000 0.0000 + O 2.1000 1.0000 0.0000 + O 2.1000 -1.0000 0.0000""") + for reactive_atoms in ({'*1': 1, '*2': 0, '*3': 2}, + {'*1': 0, '*2': 1, '*3': 1}, + {'*1': 0, '*2': 1, '*3': 9}, + {'*1': 0, '*2': 1}, + None): + with self.subTest(reactive_atoms=reactive_atoms): + seed = {'xyz': xyz, 'family': rxn.family, 'metadata': {'reactive_atoms': reactive_atoms}} + self.assertIsNone(get_wrapper_constraints(wrapper='crest', reaction=rxn, seed=seed)) + + def test_get_wrapper_constraints_crest_unsupported_family(self): + rxn = SimpleNamespace(family='carbonyl_based_hydrolysis') + xyz = str_to_xyz("""O 0.0000 0.0000 0.0000 + H 0.0000 0.0000 0.9600 + H 0.9000 0.0000 0.0000""") + seed = {'xyz': xyz, 'family': rxn.family} + atoms = get_wrapper_constraints(wrapper='crest', reaction=rxn, seed=seed) + self.assertIsNone(atoms) + + def test_get_ts_seeds_unsupported_adapter(self): + rxn = SimpleNamespace(family='H_Abstraction') + with self.assertRaises(ValueError): + get_ts_seeds(reaction=rxn, base_adapter='gcn') + + def test_get_wrapper_constraints_unsupported_wrapper(self): + rxn = SimpleNamespace(family='H_Abstraction') + with self.assertRaises(ValueError): + get_wrapper_constraints(wrapper='foo_wrapper', reaction=rxn, seed={}) + + if __name__ == '__main__': unittest.main(testRunner=unittest.TextTestRunner(verbosity=2)) diff --git a/arc/job/adapters/ts/orca_neb.py b/arc/job/adapters/ts/orca_neb.py index cabc84b037..831c2c1955 100644 --- a/arc/job/adapters/ts/orca_neb.py +++ b/arc/job/adapters/ts/orca_neb.py @@ -18,7 +18,7 @@ from arc.job.adapters.orca import OrcaAdapter, _format_orca_method, _format_orca_basis from arc.job.factory import register_job_adapter from arc.job.local import execute_command -from arc.level import Level +from arc.level import Level, plain_level_dict from arc.parser.parser import parse_geometry from arc.species import TSGuess from arc.species.converter import xyz_to_xyz_file_format @@ -40,7 +40,7 @@ %%maxcore ${memory} %%pal nprocs ${cpus} end -%%neb +%%neb Interpolation ${interpolation} NImages ${nnodes} PrintLevel 3 @@ -210,6 +210,11 @@ def __init__(self, self.local_path_to_output_file = os.path.join(self.local_path, output_filenames[self.job_adapter]) self.execution_type = execution_type or 'queue' + @property + def ess_software(self) -> str: + """Orca NEB is a TS-search adapter, but its output is an Orca log.""" + return 'orca' + def write_input_file(self) -> None: """ Write the input file to execute the job on the server. @@ -246,7 +251,10 @@ def write_input_file(self) -> None: raise ValueError('Cannot write Orca NEB input file without an atom map in the reaction.') reactant_xyz = self.reactions[0].get_reactants_xyz(return_format=dict) - product_xyz = self.reactions[0].get_products_xyz(return_format=dict) # This implicitly uses the atom map. + # Aligning the products to the reactants (Kabsch, per fragment, using the atom map) keeps the + # NEB interpolation short and physical, especially for fragmenting (multi-product) reactions. + product_xyz = self.reactions[0].get_products_xyz(return_format=dict, # This implicitly uses the atom map. + align_to_reactants=True) with open(os.path.join(self.local_path, 'reactant.xyz'), 'w') as f: f.write(xyz_to_xyz_file_format(reactant_xyz)) @@ -356,6 +364,7 @@ def process_run(self): tsg = TSGuess(method='orca_neb', success=False, t0=self.initial_time, + level=plain_level_dict(self.level), ) if os.path.isfile(self.local_path_to_output_file): tsg.initial_xyz = parse_geometry(self.local_path_to_output_file) diff --git a/arc/job/adapters/ts/orca_neb_test.py b/arc/job/adapters/ts/orca_neb_test.py index 315fe862e3..b89100840f 100644 --- a/arc/job/adapters/ts/orca_neb_test.py +++ b/arc/job/adapters/ts/orca_neb_test.py @@ -13,6 +13,7 @@ import unittest.mock import pytest +from arc.common import ARC_TESTING_PATH from arc.exceptions import SettingsError from arc.job.adapters.ts.orca_neb import OrcaNEBAdapter from arc.level import Level @@ -243,6 +244,43 @@ def test_task_2_post_processing(self): # C -1.406738 -0.055989 0.104836 self.assertAlmostEqual(tsg.initial_xyz['coords'][0][0], -1.406738, places=5) + def test_task_3_ess_status_of_a_normally_terminated_neb_log(self): + """ + Task 3: Orca NEB keeps its adapter identity while using Orca's ESS status classification, + so that a normally terminated run is marked 'done' and its guesses may be ingested. + """ + # This test owns its fixture: a private project directory and a private adapter instance, so it + # neither races the shared class-level project directory under xdist nor depends on the state + # (initial_time / final_time / job_status) that the other tests in this class mutate in place. + project_directory = os.path.join(ARC_TESTING_PATH, 'test_OrcaNEBAdapter_ess_status') + if os.path.exists(project_directory): + shutil.rmtree(project_directory, ignore_errors=True) + self.addCleanup(shutil.rmtree, project_directory, ignore_errors=True) + job = OrcaNEBAdapter(project='test_orca_neb_ess_status', + job_type='tsg', + project_directory=project_directory, + reactions=[ARCReaction(r_species=[ARCSpecies(label='i-C3H7', smiles='C[CH]C')], + p_species=[ARCSpecies(label='n-C3H7', smiles='CC[CH2]')])], + level=self.level, + server='local') + + log_path = os.path.join(ARC_TESTING_PATH, 'neb', 'neb_res.out') + output_path = job.local_path_to_output_file + os.makedirs(os.path.dirname(output_path), exist_ok=True) + shutil.copy(log_path, output_path) + + job.initial_time = datetime.datetime(2026, 2, 16, 10, 0, 0) + job.final_time = datetime.datetime(2026, 2, 16, 10, 15, 42) + with unittest.mock.patch.object(OrcaNEBAdapter, '_get_additional_job_info', return_value=None): + job._check_job_ess_status() + + self.assertEqual(job.job_adapter, 'orca_neb') + self.assertEqual(job.ess_software, 'orca') + self.assertEqual(job.job_status[1]['status'], 'done') + self.assertEqual(job.job_status[1]['keywords'], list()) + self.assertEqual(job.job_status[1]['error'], '') + self.assertEqual(job.job_status[1]['line'], '') + @classmethod def tearDownClass(cls): """ diff --git a/arc/job/adapters/ts/qst2.py b/arc/job/adapters/ts/qst2.py new file mode 100644 index 0000000000..b3ac3eafbd --- /dev/null +++ b/arc/job/adapters/ts/qst2.py @@ -0,0 +1,318 @@ +""" +An adapter for executing Gaussian QST2 transition-state search jobs. + +QST2 is Gaussian's synchronous-transit-guided quasi-Newton method (``opt=qst2``) +that locates a TS from a reactant and a product geometry (two molecule +specifications in a single input file). It has been found to converge to TSs +that other ARC TS-search adapters miss (e.g., 1,2-halogen migrations). + +https://gaussian.com/opt/ +""" + +import datetime +import os +from typing import TYPE_CHECKING + +from mako.template import Template + +from arc.common import get_logger +from arc.imports import incore_commands, settings +from arc.job.adapters.common import which +from arc.job.adapters.gaussian import GaussianAdapter +from arc.job.factory import register_job_adapter +from arc.job.local import execute_command +from arc.level import Level, plain_level_dict +from arc.parser.parser import parse_geometry +from arc.species import ARCSpecies, TSGuess +from arc.species.converter import xyz_to_str + +if TYPE_CHECKING: + from arc.reaction import ARCReaction + + +logger = get_logger() + +input_filenames, output_filenames, servers, submit_filenames, qst2_settings = \ + settings['input_filenames'], settings['output_filenames'], settings['servers'], settings['submit_filenames'], \ + settings.get('qst2_settings', {}) + +input_template = """%%chk=check.chk +%%mem=${memory}mb +%%NProcShared=${cpus} + +#P opt=(qst2,calcfc,noeigentest,maxcycle=${maxcycle}) freq ${method}/${basis} int=ultrafine nosymm + +QST2 ${label} reactant + +${charge} ${multiplicity} +${reactant_xyz} + +QST2 ${label} product + +${charge} ${multiplicity} +${product_xyz} + +""" + + +class QST2Adapter(GaussianAdapter): + """ + A class for executing Gaussian QST2 TS-search jobs. + + Args: + project (str): The project's name. Used for setting the remote path. + project_directory (str): The path to the local project directory. + job_type (list, str): The job's type, validated against ``JobTypeEnum``. If it's a list, pipe.py will be called. + args (dict, optional): Methods (including troubleshooting) to be used in input files. + Keys are either 'keyword', 'block', or 'trsh', values are dictionaries with values + to be used either as keywords or as blocks in the respective software input file. + If 'trsh' is specified, an action might be taken instead of appending a keyword or a + block to the input file (e.g., change server or change scan resolution). + bath_gas (str, optional): A bath gas. Currently only used in OneDMin to calculate L-J parameters. + checkfile (str, optional): The path to a previous Gaussian checkfile to be used in the current job. + conformer (int, optional): Conformer number if optimizing conformers. + constraints (list, optional): A list of constraints to use during an optimization or scan. + cpu_cores (int, optional): The total number of cpu cores requested for a job. + dihedral_increment (float, optional): The degrees increment to use when scanning dihedrals of TS guesses. + dihedrals (list[float], optional): The dihedral angels corresponding to self.torsions. + directed_scan_type (str, optional): The type of the directed scan. + ess_settings (dict, optional): A dictionary of available ESS and a corresponding server list. + ess_trsh_methods (list[str], optional): A list of troubleshooting methods already tried out. + execution_type (str, optional): The execution type, 'incore', 'queue', or 'pipe'. + fine (bool, optional): Whether to use fine geometry optimization parameters. Default: ``False``. + initial_time (datetime.datetime or str, optional): The time at which this job was initiated. + irc_direction (str, optional): The direction of the IRC job (`forward` or `reverse`). + job_id (int, optional): The job's ID determined by the server. + job_memory_gb (int, optional): The total job allocated memory in GB (14 by default). + job_name (str, optional): The job's name (e.g., 'opt_a103'). + job_num (int, optional): Used as the entry number in the database, as well as in ``job_name``. + job_server_name (str, optional): Job's name on the server (e.g., 'a103'). + job_status (list, optional): The job's server and ESS statuses. + level (Level, optional): The level of theory to use. + max_job_time (float, optional): The maximal allowed job time on the server in hours (can be fractional). + run_multi_species (bool, optional): Whether to run a job for multiple species in the same input file. + reactions (list[ARCReaction], optional): Entries are ARCReaction instances, used for TS search methods. + rotor_index (int, optional): The 0-indexed rotor number (key) in the species.rotors_dict dictionary. + server (str): The server to run on. + server_nodes (list, optional): The nodes this job was previously submitted to. + species (list[ARCSpecies], optional): Entries are ARCSpecies instances. + Either ``reactions`` or ``species`` must be given. + testing (bool, optional): Whether the object is generated for testing purposes, ``True`` if it is. + times_rerun (int, optional): Number of times this job was re-run with the same arguments (no trsh methods). + torsions (list[list[int]], optional): The 0-indexed atom indices of the torsion(s). + tsg (int, optional): TSGuess number if optimizing TS guesses. + xyz (dict, optional): The 3D coordinates to use. If not give, species.get_xyz() will be used. + """ + + def __init__(self, + project: str, + project_directory: str, + job_type: list[str] | str, + args: dict | None = None, + bath_gas: str | None = None, + checkfile: str | None = None, + conformer: int | None = None, + constraints: list[tuple[list[int], float]] | None = None, + cpu_cores: str | None = None, + dihedral_increment: float | None = None, + dihedrals: list[float] | None = None, + directed_scan_type: str | None = None, + ess_settings: dict | None = None, + ess_trsh_methods: list[str] | None = None, + execution_type: str | None = None, + fine: bool = False, + initial_time: datetime.datetime | str | None = None, + irc_direction: str | None = None, + job_id: int | None = None, + job_memory_gb: float = 14.0, + job_name: str | None = None, + job_num: int | None = None, + job_server_name: str | None = None, + job_status: list[dict | str] | None = None, + level: Level | None = None, + max_job_time: float | None = None, + run_multi_species: bool = False, + reactions: list[ARCReaction] | None = None, + rotor_index: int | None = None, + server: str | None = None, + server_nodes: list | None = None, + queue: str | None = None, + attempted_queues: list[str] | None = None, + species: list[ARCSpecies] | None = None, + testing: bool = False, + times_rerun: int = 0, + torsions: list[list[int]] | None = None, + tsg: int | None = None, + xyz: dict | None = None, + ): + + if reactions is None: + raise ValueError('Cannot execute a QST2 TS search without an ARCReaction object.') + + if reactions and reactions[0].ts_species is None: + # Create a dummy TS species from the reactants (or products). + # This is a temporary placeholder to satisfy GaussianAdapter's species requirement. + # The actual TS geometry will be obtained from the QST2 calculation. + if reactions[0].r_species: + reactions[0].ts_species = ARCSpecies(label=f'{reactions[0].label}_TS', + is_ts=True, + charge=reactions[0].charge, + multiplicity=reactions[0].multiplicity, + xyz=reactions[0].r_species[0].get_xyz()) + else: + raise ValueError('ARCReaction object must contain reactant species to initialize a QST2 job.') + + level = level or qst2_settings.get('level', '') + if not level: + raise ValueError('A level of theory must be specified for QST2 jobs, either in the job arguments ' + 'or in the settings file.') + species_for_super = [reactions[0].ts_species] + super().__init__(project=project, + project_directory=project_directory, + job_type=job_type, + args=args, + bath_gas=bath_gas, + checkfile=checkfile, + conformer=conformer, + constraints=constraints, + cpu_cores=cpu_cores, + dihedral_increment=dihedral_increment, + dihedrals=dihedrals, + directed_scan_type=directed_scan_type, + ess_settings=ess_settings, + ess_trsh_methods=ess_trsh_methods, + execution_type=execution_type, + fine=fine, + initial_time=initial_time, + irc_direction=irc_direction, + job_id=job_id, + job_memory_gb=job_memory_gb, + job_name=job_name, + job_num=job_num, + job_server_name=job_server_name, + job_status=job_status, + level=level, + max_job_time=max_job_time, + run_multi_species=run_multi_species, + reactions=reactions, + rotor_index=rotor_index, + server=server, + server_nodes=server_nodes, + queue=queue, + attempted_queues=attempted_queues, + species=species_for_super, + testing=testing, + times_rerun=times_rerun, + torsions=torsions, + tsg=tsg, + xyz=xyz, + ) + + self.job_adapter = 'qst2' + self.url = 'https://gaussian.com/opt/' + # ``self.command`` is inherited from GaussianAdapter (['g16', 'g09', 'g03']), + # since QST2 is executed by the Gaussian binary. + self.local_path_to_output_file = os.path.join(self.local_path, output_filenames[self.job_adapter]) + self.execution_type = execution_type or 'queue' + + def write_input_file(self) -> None: + """ + Write the input file to execute the job on the server. + A single Gaussian input file holding two molecule specifications + (reactant block, then product block) is generated. + """ + atom_map = self.reactions[0].atom_map + if atom_map is None: + raise ValueError('Cannot write a QST2 input file without an atom map in the reaction.') + + reactant_xyz = self.reactions[0].get_reactants_xyz(return_format=dict) + # Aligning the products to the reactants (Kabsch, per fragment, using the atom map) keeps the + # QST2 interpolation short and physical, especially for fragmenting (multi-product) reactions. + product_xyz = self.reactions[0].get_products_xyz(return_format=dict, # This implicitly uses the atom map. + align_to_reactants=True) + + input_dict = {'memory': self.input_file_memory, + 'cpus': self.cpu_cores, + 'maxcycle': qst2_settings.get('maxcycle', 150), + 'method': self.level.method, + 'basis': self.level.basis, + 'label': self.species_label, + 'charge': self.charge, + 'multiplicity': self.multiplicity, + 'reactant_xyz': xyz_to_str(reactant_xyz), + 'product_xyz': xyz_to_str(product_xyz), + } + + with open(os.path.join(self.local_path, input_filenames[self.job_adapter]), 'w') as f: + f.write(Template(input_template).render(**input_dict)) + + @property + def ess_software(self) -> str: + """QST2 is a TS-search adapter, but its output is a Gaussian log.""" + return 'gaussian' + + def set_input_file_memory(self) -> None: + """ + Set the input_file_memory attribute. + """ + super().set_input_file_memory() + + def write_submit_script(self) -> None: + """ + Write a submit script to execute the job. + """ + original_job_adapter = self.job_adapter + self.job_adapter = 'gaussian' # Temporarily change to 'gaussian' for submit script lookup. + try: + super().write_submit_script() + finally: + self.job_adapter = original_job_adapter # Revert job_adapter. + + def process_run(self): + """ + Process a completed QST2 run, parsing the optimized TS geometry into a TSGuess. + """ + tsg = TSGuess(method='qst2', + index=len(self.reactions[0].ts_species.ts_guesses), + success=False, + t0=self.initial_time, + level=plain_level_dict(self.level), + ) + if os.path.isfile(self.local_path_to_output_file): + tsg.initial_xyz = parse_geometry(self.local_path_to_output_file) + tsg.execution_time = self.final_time - self.initial_time + tsg.log_path = self.local_path_to_output_file + tsg.success = True + self.reactions[0].ts_species.ts_guesses.append(tsg) + + def cleanup_files(self): + """Remove unneeded files after run.""" + file_path = os.path.join(self.local_path, input_filenames[self.job_adapter]) + if os.path.exists(file_path): + os.remove(file_path) + + def execute_incore(self): + """ + Execute a job incore. + """ + self.initial_time = self.initial_time if self.initial_time else datetime.datetime.now() + binary = which(self.command, + return_bool=False, + raise_error=True, + raise_msg=f'Please install Gaussian, see {self.url} for more information.', + ) + binary_name = os.path.basename(binary) + self._log_job_execution() + commands = [cmd.replace('g16', binary_name) for cmd in incore_commands['gaussian']] + execute_command([f'cd {self.local_path}'] + commands, executable='/bin/bash') + self.final_time = datetime.datetime.now() + self.process_run() + + def execute_queue(self): + """ + Execute a job to the server's queue. + """ + self.legacy_queue_execution() + + +register_job_adapter('qst2', QST2Adapter) diff --git a/arc/job/adapters/ts/qst2_test.py b/arc/job/adapters/ts/qst2_test.py new file mode 100644 index 0000000000..e43542638a --- /dev/null +++ b/arc/job/adapters/ts/qst2_test.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +""" +This module contains unit tests of the arc.job.adapters.ts.qst2 module. +""" + +import datetime +import os +import shutil +import unittest + +from arc.common import ARC_TESTING_PATH +from arc.job.adapters.ts.qst2 import QST2Adapter +from arc.level import Level +from arc.reaction import ARCReaction +from arc.species.species import ARCSpecies + + +class TestQST2Adapter(unittest.TestCase): + """ + Contains unit tests for the QST2Adapter class. + """ + + @classmethod + def setUpClass(cls): + """ + A method that is run before all unit tests in this class. + """ + cls.maxDiff = None + for i in range(10): + cls.addClassCleanup(shutil.rmtree, os.path.join(ARC_TESTING_PATH, f'test_QST2Adapter_{i}'), + ignore_errors=True) + + # A 1,2-halogen migration: unimolecular <-> unimolecular (a QST2-friendly reaction). + # R = O[CH]CCl, P = [CH2]C(O)Cl. The atoms keep the same identity and order across + # the reaction, so the atom map is the identity permutation. + cls.r_xyz = {'symbols': ('O', 'C', 'C', 'Cl', 'H', 'H', 'H', 'H'), + 'isotopes': (16, 12, 12, 35, 1, 1, 1, 1), + 'coords': ((-1.95574208, -0.61841681, 0.08130563), + (-1.27101508, 0.47222919, -0.31974537), + (-0.07165208, 0.90591219, 0.32866963), + (1.43978292, -0.26297181, -0.06795737), + (-1.52857108, -1.02344581, 0.84821663), + (-1.57959308, 0.83127419, -1.29027737), + (0.26922792, 1.87651319, -0.01033137), + (-0.09175408, 0.84551219, 1.41369363))} + cls.p_xyz = {'symbols': ('O', 'C', 'C', 'Cl', 'H', 'H', 'H', 'H'), + 'isotopes': (16, 12, 12, 35, 1, 1, 1, 1), + 'coords': ((-1.19607055, -1.10305652, -0.17298926), + (-0.62305755, -0.00083652, 0.40430474), + (-1.19066255, 1.23097748, -0.09069726), + (1.28331745, 0.04100148, -0.05770126), + (-0.77288255, -1.90184752, 0.16462374), + (-0.52506955, -0.06107952, 1.48622674), + (-1.65785655, 1.24294248, -1.06456526), + (-0.99373755, 2.15657148, 0.42718974))} + cls.level = Level(method='b3lyp', basis='def2tzvp') + + def _make_reaction(self) -> ARCReaction: + """Build a mapped unimolecular <-> unimolecular reaction for QST2.""" + r_species = ARCSpecies(label='R', smiles='O[CH]CCl', xyz=self.r_xyz, multiplicity=2) + p_species = ARCSpecies(label='P', smiles='[CH2]C(O)Cl', xyz=self.p_xyz, multiplicity=2) + reaction = ARCReaction(r_species=[r_species], p_species=[p_species]) + # The atoms keep the same order across the reaction (intramolecular migration). + reaction.atom_map = list(range(len(self.r_xyz['symbols']))) + return reaction + + def test_adapter_constructs(self): + """Test that the QST2 adapter constructs and creates a dummy TS species.""" + job = QST2Adapter(project='test_0', + job_type='tsg', + project_directory=os.path.join(ARC_TESTING_PATH, 'test_QST2Adapter_0'), + reactions=[self._make_reaction()], + level=self.level, + server='local', + ) + self.assertEqual(job.job_adapter, 'qst2') + self.assertEqual(job.execution_type, 'queue') + self.assertIsNotNone(job.reactions[0].ts_species) + self.assertTrue(job.reactions[0].ts_species.is_ts) + + def test_no_reactions_raises(self): + """Test that instantiating without reactions raises a ValueError.""" + with self.assertRaises(ValueError): + QST2Adapter(project='test_err', + job_type='tsg', + project_directory=os.path.join(ARC_TESTING_PATH, 'test_QST2Adapter_err'), + reactions=None, + level=self.level, + server='local', + ) + + def test_write_input_file(self): + """Test writing a valid Gaussian QST2 input file with two matching geometry blocks.""" + job = QST2Adapter(project='test_1', + job_type='tsg', + project_directory=os.path.join(ARC_TESTING_PATH, 'test_QST2Adapter_1'), + reactions=[self._make_reaction()], + level=self.level, + server='local', + ) + input_path = os.path.join(job.local_path, 'input.gjf') + self.assertTrue(os.path.isfile(input_path)) + with open(input_path, 'r') as f: + content = f.read() + + # 1. The QST2 opt keyword and a frequency job are present. + self.assertIn('opt=(qst2', content) + self.assertIn('freq', content) + self.assertIn('b3lyp/def2tzvp', content) + + # 2. There are exactly two molecule specifications (reactant and product). + self.assertIn('QST2 R__=__P_TS reactant', content) + self.assertIn('QST2 R__=__P_TS product', content) + self.assertEqual(content.count('opt=(qst2'), 1) + + # 3. Split the file into the two geometry blocks and compare atom counts and order. + reactant_block = content.split('reactant')[1].split('product')[0] + product_block = content.split('product')[1] + reactant_symbols = self._parse_block_symbols(reactant_block) + product_symbols = self._parse_block_symbols(product_block) + + expected_symbols = list(self.r_xyz['symbols']) + self.assertEqual(len(reactant_symbols), len(expected_symbols)) + self.assertEqual(len(product_symbols), len(expected_symbols)) + self.assertEqual(len(reactant_symbols), len(product_symbols)) + # The two blocks must be in the same atom order (critical for QST2). + self.assertEqual(reactant_symbols, product_symbols) + self.assertEqual(reactant_symbols, expected_symbols) + + @staticmethod + def _parse_block_symbols(block: str) -> list: + """Extract the element symbols (first token of each coordinate line) from a geometry block.""" + symbols = list() + for line in block.splitlines(): + tokens = line.split() + if len(tokens) == 4: + element, x, y, z = tokens + try: + float(x), float(y), float(z) + except ValueError: + continue + symbols.append(element) + return symbols + + def test_process_run_no_output_no_success(self): + """Test that process_run appends an unsuccessful TSGuess when no output file exists.""" + job = QST2Adapter(project='test_2', + job_type='tsg', + project_directory=os.path.join(ARC_TESTING_PATH, 'test_QST2Adapter_2'), + reactions=[self._make_reaction()], + level=self.level, + server='local', + ) + job.reactions[0].ts_species.ts_guesses = list() + # Point the output file at a path that does not exist. + job.local_path_to_output_file = os.path.join(job.local_path, 'does_not_exist.log') + job.initial_time = datetime.datetime.now() + job.final_time = datetime.datetime.now() + job.process_run() + guesses = job.reactions[0].ts_species.ts_guesses + self.assertEqual(len(guesses), 1) + self.assertEqual(guesses[0].method, 'qst2') + self.assertFalse(guesses[0].success) + + def test_gaussian_gl101_endpoint_interpolation_failure_is_classified(self): + """QST2 preserves its adapter identity while using Gaussian ESS error classification.""" + job = QST2Adapter(project='test_3', + job_type='tsg', + project_directory=os.path.join(ARC_TESTING_PATH, 'test_QST2Adapter_3'), + reactions=[self._make_reaction()], + level=self.level, + server='local', + ) + with open(job.local_path_to_output_file, 'w') as f: + f.write('\n'.join([ + ' Entering Gaussian System', + ' QST2 interpolation', + ' New curvilinear step not converged', + ' RedCar/ORedCr failed for GTrans.', + ' Leave Link 101', + ' Error termination via Lnk1e in /usr/local/g16/l101.exe', + ])) + job.initial_time = datetime.datetime.now() - datetime.timedelta(minutes=2) + job.final_time = datetime.datetime.now() - datetime.timedelta(minutes=1) + + job._check_job_ess_status() + + self.assertEqual(job.job_adapter, 'qst2') + self.assertEqual(job.ess_software, 'gaussian') + self.assertEqual(job.job_status[1]['status'], 'errored') + self.assertEqual(job.job_status[1]['keywords'], ['InternalCoordinateError', 'GL101', 'NoSymm']) + self.assertEqual(job.job_status[1]['error'], + 'Endpoint interpolation failed in curvilinear coordinates.') + + def test_gaussian_ordinary_gl101_retains_input_error_classification(self): + """A GL101 failure without interpolation markers retains the legacy diagnosis.""" + job = QST2Adapter(project='test_4', + job_type='tsg', + project_directory=os.path.join(ARC_TESTING_PATH, 'test_QST2Adapter_4'), + reactions=[self._make_reaction()], + level=self.level, + server='local', + ) + with open(job.local_path_to_output_file, 'w') as f: + f.write('\n'.join([ + ' Entering Gaussian System', + ' QST2 input processing', + ' Charge = 0 Multiplicity = 1', + ' Coordinates follow', + ' Leave Link 101', + ' Error termination via Lnk1e in /usr/local/g16/l101.exe', + ])) + job.initial_time = datetime.datetime.now() - datetime.timedelta(minutes=2) + job.final_time = datetime.datetime.now() - datetime.timedelta(minutes=1) + + job._check_job_ess_status() + + self.assertEqual(job.job_adapter, 'qst2') + self.assertEqual(job.job_status[1]['status'], 'errored') + self.assertEqual(job.job_status[1]['keywords'], ['InputError', 'GL101']) + self.assertEqual(job.job_status[1]['error'], + 'The blank line after the coordinate section is missing, ' + 'or charge/multiplicity was not specified correctly.') + + @classmethod + def tearDownClass(cls): + """ + A function that is run ONCE after all unit tests in this class. + Delete all project directories created during these unit tests. + """ + for i in range(10): + shutil.rmtree(os.path.join(ARC_TESTING_PATH, f'test_QST2Adapter_{i}'), ignore_errors=True) + shutil.rmtree(os.path.join(ARC_TESTING_PATH, 'test_QST2Adapter_err'), ignore_errors=True) + + +if __name__ == '__main__': + unittest.main(testRunner=unittest.TextTestRunner(verbosity=2)) diff --git a/arc/job/adapters/ts/seed_hub.py b/arc/job/adapters/ts/seed_hub.py new file mode 100644 index 0000000000..e15aa0f43b --- /dev/null +++ b/arc/job/adapters/ts/seed_hub.py @@ -0,0 +1,351 @@ +""" +Shared TS-seed and wrapper-constraint hub. + +This module centralizes: +1. How TS seeds are requested from a base TS-search adapter. +2. How wrapper adapters (e.g., CREST) request family-specific constraints for a seed. +""" + +from typing import Dict, List, Optional + +from arc.common import almost_equal_coords, get_logger +from arc.species.converter import xyz_to_dmat + +logger = get_logger() + + +def get_ts_seeds(reaction: 'ARCReaction', + base_adapter: str = 'heuristics', + dihedral_increment: Optional[int] = None, + ) -> List[dict]: + """ + Return TS seed entries from a base TS-search adapter. + + Seed schema: + - ``xyz`` (dict): Cartesian coordinates. + - ``family`` (str): The family associated with this seed. + - ``method`` (str): Human-readable generator label. + - ``source_adapter`` (str): Adapter id that generated the seed. + - ``metadata`` (dict, optional): Adapter-specific auxiliary fields. + + Args: + reaction: The ARC reaction object. + base_adapter: The underlying TS-search adapter providing seeds. + dihedral_increment: Optional scan increment used by adapters that support it. + """ + adapter = (base_adapter or '').lower() + if adapter != 'heuristics': + raise ValueError(f'Unsupported TS seed base adapter: {base_adapter}') + + # Lazily import to avoid circular imports with heuristics.py. + from arc.job.adapters.ts.heuristics import FAMILY_SETS, h_abstraction, hydrolysis + + xyz_entries = list() + if reaction.family == 'H_Abstraction': + xyzs = h_abstraction(reaction=reaction, dihedral_increment=dihedral_increment) + for entry in xyzs: + xyz = entry.get('xyz') if isinstance(entry, dict) else entry + method = entry.get('method', 'Heuristics') if isinstance(entry, dict) else 'Heuristics' + if xyz is not None: + entry_metadata = entry.get('metadata') if isinstance(entry, dict) else None + metadata = entry_metadata.copy() if isinstance(entry_metadata, dict) else {} + if 'reactive_atoms' not in metadata: + reactive_atoms = _get_h_abs_atoms_from_xyz(xyz) + if reactive_atoms is not None: + metadata['reactive_atoms'] = reactive_atoms + xyz_entries.append({ + 'xyz': xyz, + 'method': method, + 'family': reaction.family, + 'source_adapter': 'heuristics', + 'metadata': metadata, + }) + elif reaction.family in FAMILY_SETS['hydrolysis_set_1'] or reaction.family in FAMILY_SETS['hydrolysis_set_2']: + try: + xyzs_raw, families, indices = hydrolysis(reaction=reaction) + xyz_entries = [{ + 'xyz': xyz, + 'method': 'Heuristics', + 'family': family, + 'source_adapter': 'heuristics', + 'metadata': {'indices': idx}, + } for xyz, family, idx in zip(xyzs_raw, families, indices)] + except ValueError: + xyz_entries = list() + elif reaction.family == 'XY_Addition_MultipleBond': + # Lazily import to keep the family-specific builder decoupled from this hub. + from arc.job.adapters.ts.xy_addition import xy_addition + for entry in xy_addition(reaction=reaction): + xyz_entries.append({ + 'xyz': entry['xyz'], + 'method': entry.get('method', 'Heuristics-XY'), + 'family': reaction.family, + 'source_adapter': 'heuristics', + 'metadata': entry.get('metadata', {}).copy(), + }) + return xyz_entries + + +def get_backup_ts_seeds(reaction: 'ARCReaction', + exclude_method: str = 'crest', + ) -> List[dict]: + """ + Build CREST seed entries from TS guesses that OTHER adapters already produced. + + This is a fallback for when CREST's own heuristic seed construction + (:func:`get_ts_seeds`) yields nothing -- e.g. a linear/cumulene reactive center + (such as HCCO in H_Abstraction) that the heuristic Z-matrix builder cannot + assemble. Any successful non-CREST TS guess already present on + ``reaction.ts_species.ts_guesses`` is a valid CREST seed: CREST only needs a seed + geometry plus the family reactive-atom constraints, and the constraints are + re-derived from the seed geometry by :func:`get_wrapper_constraints` -- they do not + depend on how the seed geometry was originally built. + + Seeds are returned with empty ``metadata`` so the wrapper-constraint derivation + re-infers the reactive atoms from the geometry itself (robust to the source + adapter's atom ordering). Guesses whose method contains ``exclude_method`` are + skipped so CREST is never seeded from a prior CREST result (feedback-loop guard). + + Args: + reaction: The ARC reaction object. + exclude_method: A method substring to exclude (default ``'crest'``). + + Returns: + List[dict]: Seed entries in the same schema as :func:`get_ts_seeds`. + """ + ts_species = getattr(reaction, 'ts_species', None) + ts_guesses = getattr(ts_species, 'ts_guesses', None) or list() + exclude = (exclude_method or '').lower() + seeds = list() + seen_xyzs = list() + for tsg in ts_guesses: + method = (getattr(tsg, 'method', '') or '').lower() + if not getattr(tsg, 'success', False): + continue + if exclude and exclude in method: + continue + xyz = getattr(tsg, 'opt_xyz', None) or getattr(tsg, 'initial_xyz', None) + if not isinstance(xyz, dict) or not xyz.get('symbols'): + continue + if any(almost_equal_coords(xyz, seen) for seen in seen_xyzs): + continue + seen_xyzs.append(xyz) + seeds.append({ + 'xyz': xyz, + 'method': getattr(tsg, 'method', None) or 'external', + 'family': reaction.family, + 'source_adapter': method or 'external', + 'metadata': {}, + }) + return seeds + + +def get_wrapper_constraints(wrapper: str, + reaction: 'ARCReaction', + seed: dict, + ) -> Optional[dict]: + """ + Return wrapper-specific constraints for a TS seed. + + Args: + wrapper: Wrapper adapter id (e.g., ``crest``). + reaction: The ARC reaction object. + seed: A seed entry returned by :func:`get_ts_seeds`. + """ + wrapper_name = (wrapper or '').lower() + if wrapper_name != 'crest': + raise ValueError(f'Unsupported wrapper adapter: {wrapper}') + return _get_crest_constraints(reaction=reaction, seed=seed) + + +def _get_crest_constraints(reaction: 'ARCReaction', seed: dict) -> Optional[dict]: + """ + Return a generic CREST constraint specification for a seed. + + The specification contains zero-based participating ``atoms`` and ``distance_pairs``. + H-abstraction and the intramolecular NO2 -> ONO conversion additionally supply + ``angle_atoms`` so completed geometries retain the seed's three-center orientation + (heavy-atom--H--heavy-atom, or C--N--O for the migrating nitro oxygen). + """ + family = seed.get('family') or reaction.family + xyz = seed.get('xyz') + if xyz is None: + return None + metadata = seed.get('metadata') + explicit_atoms = metadata.get('reactive_atoms') if isinstance(metadata, dict) else None + if family == 'H_Abstraction': + reactive_atoms = explicit_atoms if explicit_atoms is not None else _get_h_abs_atoms_from_xyz(xyz) + if _is_valid_h_abs_atom_assignment(xyz=xyz, atoms=reactive_atoms): + return { + 'A': reactive_atoms['A'], + 'H': reactive_atoms['H'], + 'B': reactive_atoms['B'], + 'atoms': tuple(reactive_atoms[key] for key in ('A', 'H', 'B')), + 'distance_pairs': ( + (reactive_atoms['A'], reactive_atoms['H']), + (reactive_atoms['H'], reactive_atoms['B']), + ), + 'angle_atoms': tuple(reactive_atoms[key] for key in ('A', 'H', 'B')), + } + if explicit_atoms is not None: + logger.warning(f'Invalid explicit CREST H-abstraction atom assignment: {explicit_atoms}') + return None + if family == 'XY_Addition_MultipleBond': + if _is_valid_xy_atom_assignment(xyz=xyz, atoms=explicit_atoms): + return { + 'atoms': tuple(explicit_atoms[label] for label in ('*1', '*2', '*3', '*4')), + 'distance_pairs': ( + (explicit_atoms['*1'], explicit_atoms['*3']), + (explicit_atoms['*2'], explicit_atoms['*4']), + (explicit_atoms['*3'], explicit_atoms['*4']), + ), + } + logger.warning(f'Invalid explicit CREST XY-addition atom assignment: {explicit_atoms}') + if family == 'intra_NO2_ONO_conversion': + reactive_atoms = _normalize_no2_ono_atoms(explicit_atoms) + if reactive_atoms is None: + reactive_atoms = _get_no2_ono_atoms_from_reaction(reaction) + if _is_valid_no2_ono_atom_assignment(xyz=xyz, atoms=reactive_atoms): + return { + 'C': reactive_atoms['C'], + 'N': reactive_atoms['N'], + 'O': reactive_atoms['O'], + 'atoms': tuple(reactive_atoms[role] for role in ('C', 'N', 'O')), + 'distance_pairs': ( + (reactive_atoms['C'], reactive_atoms['N']), + (reactive_atoms['C'], reactive_atoms['O']), + (reactive_atoms['N'], reactive_atoms['O']), + ), + 'angle_atoms': tuple(reactive_atoms[role] for role in ('C', 'N', 'O')), + } + logger.warning(f'Invalid CREST intra_NO2_ONO_conversion atom assignment: {reactive_atoms}') + return None + + +def _get_no2_ono_atoms_from_reaction(reaction: 'ARCReaction') -> Optional[Dict[str, int]]: + """ + Determine the ``intra_NO2_ONO_conversion`` recipe atoms from the RMG family label map. + + The backup-seed route (:func:`get_backup_ts_seeds`) reuses TS guesses generated by other + adapters and therefore carries no generator metadata, so the recipe atoms are taken from + the reaction's family label map instead. The family recipe is + ``BREAK_BOND *1-*2`` / ``FORM_BOND *1-*3``, i.e. ``*1`` is the carbon losing the nitro + group, ``*2`` is the nitrogen, and ``*3`` is the migrating oxygen. The label-map indices + refer to the reaction's reactant atom order, which is also the TS-guess atom order. + + Args: + reaction: The ARC reaction object. + + Returns: + Optional[Dict[str, int]]: ``{'C': int, 'N': int, 'O': int}``, or ``None``. + """ + try: + product_dicts = getattr(reaction, 'product_dicts', None) or list() + except (AttributeError, IndexError, KeyError, TypeError, ValueError) as e: + logger.debug(f'Could not obtain family product dicts for CREST intra_NO2_ONO_conversion constraints: {e}') + return None + for product_dict in product_dicts: + if not isinstance(product_dict, dict): + continue + reactive_atoms = _normalize_no2_ono_atoms(product_dict.get('r_label_map')) + if reactive_atoms is not None: + return reactive_atoms + logger.debug('No intra_NO2_ONO_conversion recipe label map was found for the CREST seed.') + return None + + +def _normalize_no2_ono_atoms(atoms: Optional[Dict[str, int]]) -> Optional[Dict[str, int]]: + """ + Translate an ``intra_NO2_ONO_conversion`` atom mapping into ``C``/``N``/``O`` roles. + + Both the role keys themselves and the RMG recipe labels ``*1`` (carbon), ``*2`` + (nitrogen) and ``*3`` (migrating oxygen) are accepted. + + Args: + atoms: A candidate atom mapping. + + Returns: + Optional[Dict[str, int]]: ``{'C': int, 'N': int, 'O': int}``, or ``None``. + """ + if not isinstance(atoms, dict): + return None + if {'C', 'N', 'O'}.issubset(atoms): + return {role: atoms[role] for role in ('C', 'N', 'O')} + if {'*1', '*2', '*3'}.issubset(atoms): + return {'C': atoms['*1'], 'N': atoms['*2'], 'O': atoms['*3']} + return None + + +def _is_valid_no2_ono_atom_assignment(xyz: dict, atoms: Optional[Dict[str, int]]) -> bool: + """Return whether ``atoms`` identifies a distinct, in-range C--N--O recipe triad in ``xyz``.""" + symbols = xyz.get('symbols') if isinstance(xyz, dict) else None + if not symbols or not isinstance(atoms, dict) or set(atoms) != {'C', 'N', 'O'}: + return False + indices = tuple(atoms[role] for role in ('C', 'N', 'O')) + if any(not isinstance(index, int) or not 0 <= index < len(symbols) for index in indices): + return False + if len(set(indices)) != 3: + return False + return all(symbols[atoms[role]] == role for role in ('C', 'N', 'O')) + + +def _is_valid_xy_atom_assignment(xyz: dict, atoms: Optional[Dict[str, int]]) -> bool: + """Return whether ``atoms`` identifies four distinct, in-range XY recipe atoms.""" + symbols = xyz.get('symbols') if isinstance(xyz, dict) else None + if not symbols or not isinstance(atoms, dict) or set(atoms) != {'*1', '*2', '*3', '*4'}: + return False + indices = tuple(atoms[label] for label in ('*1', '*2', '*3', '*4')) + return (all(isinstance(index, int) and 0 <= index < len(symbols) for index in indices) + and len(set(indices)) == 4) + + +def _is_valid_h_abs_atom_assignment(xyz: dict, atoms: Optional[Dict[str, int]]) -> bool: + """Return whether ``atoms`` identifies a heavy-atom--H--heavy-atom triad in ``xyz``.""" + symbols = xyz.get('symbols') if isinstance(xyz, dict) else None + if not symbols or not isinstance(atoms, dict) or set(atoms) != {'A', 'H', 'B'}: + return False + if any(not isinstance(atoms[key], int) or not 0 <= atoms[key] < len(symbols) for key in atoms): + return False + return (symbols[atoms['H']].startswith('H') + and not symbols[atoms['A']].startswith('H') + and not symbols[atoms['B']].startswith('H') + and atoms['A'] != atoms['B']) + + +def _get_h_abs_atoms_from_xyz(xyz: dict) -> Optional[Dict[str, int]]: + """ + Determine H-abstraction atoms from a TS guess. + + Returns: + Optional[Dict[str, int]]: ``{'H': int, 'A': int, 'B': int}``, or ``None``. + """ + symbols = xyz.get('symbols') if isinstance(xyz, dict) else None + if not symbols: + return None + dmat = xyz_to_dmat(xyz) + if dmat is None: + return None + + hydrogen_indices = [i for i, symbol in enumerate(symbols) if symbol.startswith('H')] + min_distance = float('inf') + selected_hydrogen = None + selected_heavy_atoms = None + for hydrogen_index in hydrogen_indices: + heavy_atoms = sorted( + (atom for atom, symbol in enumerate(symbols) + if atom != hydrogen_index and not symbol.startswith('H')), + key=lambda atom: dmat[hydrogen_index][atom], + )[:2] + if len(heavy_atoms) < 2: + continue + distances = dmat[hydrogen_index][heavy_atoms[0]] + dmat[hydrogen_index][heavy_atoms[1]] + if distances < min_distance: + min_distance = distances + selected_hydrogen = hydrogen_index + selected_heavy_atoms = heavy_atoms + + if selected_hydrogen is not None and selected_heavy_atoms is not None: + return {'H': selected_hydrogen, 'A': selected_heavy_atoms[0], 'B': selected_heavy_atoms[1]} + + logger.warning('No valid hydrogen atom found for CREST H-abstraction atoms.') + return None diff --git a/arc/job/adapters/ts/xtb_gsm.py b/arc/job/adapters/ts/xtb_gsm.py index a023f9e39c..87068cb18b 100644 --- a/arc/job/adapters/ts/xtb_gsm.py +++ b/arc/job/adapters/ts/xtb_gsm.py @@ -25,7 +25,7 @@ from arc.job.adapters.common import _initialize_adapter from arc.job.factory import register_job_adapter from arc.job.local import change_mode, execute_command -from arc.level import Level +from arc.level import Level, plain_level_dict from arc.parser.parser import parse_trajectory from arc.species import TSGuess from arc.species.converter import xyz_to_xyz_file_format @@ -235,6 +235,7 @@ def write_input_file(self) -> None: safe_copy_file(source=os.path.join(self.xtb_gsm_scripts_path, 'ograd'), destination=self.ograd_path) safe_copy_file(source=os.path.join(self.xtb_gsm_scripts_path, 'tm2orca.py'), destination=self.tm2orca_path) change_mode(mode='+x', file_name=self.gsm_orca_path) + change_mode(mode='+x', file_name=self.ograd_path) change_mode(mode='+x', file_name=self.tm2orca_path) def set_files(self) -> None: @@ -274,6 +275,7 @@ def set_files(self) -> None: local=os.path.join(self.xtb_gsm_scripts_path, 'inpfileq'))) # 1.4 ograd self.files_to_upload.append(self.get_file_property_dictionary(file_name='ograd', + make_x=True, local=os.path.join(self.xtb_gsm_scripts_path, 'ograd'))) # 1.5 tm2orca.py self.files_to_upload.append(self.get_file_property_dictionary(file_name='tm2orca.py', @@ -306,6 +308,13 @@ def set_additional_file_paths(self) -> None: self.tm2orca_path = os.path.join(self.local_path, 'tm2orca.py') self.scratch_initial0000_path = os.path.join(self.local_path, 'scratch', 'initial0000.xyz') self.stringfile_path = os.path.join(self.local_path, 'stringfile.xyz0000') + # Side-effect directory written by the patched ``ograd`` wrapper. + # Holds per-node ``