From 724d37496badb81753af339904d207702667e924 Mon Sep 17 00:00:00 2001 From: Calvin Pieters Date: Sat, 22 Aug 2026 10:03:27 +0300 Subject: [PATCH 1/4] Skip the ~/.arc settings/submit/inputs overlays under pytest arc/imports.py always loads arc/settings/settings.py as the baseline and then layers a developer's personal ~/.arc/{settings,submit,inputs}.py on top with a top-level dict update, which shallow-replaces whole dicts rather than merging them. A personal ~/.arc/settings.py that defines its own `servers` therefore deletes the repo's `server1` fixture, and every test that relies on it fails locally with KeyError: 'server1' while passing in CI - a difference between a developer's machine and CI that has no diagnostic value and repeatedly costs time to rediscover. Add _local_overlays_disabled() and gate all three overlay loads on it. The overlays are skipped when pytest is loaded ('pytest' in sys.modules, true from collection onward) or when ARC_IGNORE_LOCAL_SETTINGS=1 is set, so a test run sees exactly the settings CI sees, and the env var gives an explicit escape hatch outside pytest. Production runs are unaffected: neither trigger fires, and the overlay loading itself is unchanged. --- arc/imports.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/arc/imports.py b/arc/imports.py index 70022765ad..83e71adeb9 100644 --- a/arc/imports.py +++ b/arc/imports.py @@ -56,13 +56,30 @@ 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 + + # 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) @@ -79,7 +96,7 @@ def resolve_overridden_dependents(settings: dict, local_settings_dict: dict) -> 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) @@ -103,7 +120,7 @@ def resolve_overridden_dependents(settings: dict, local_settings_dict: dict) -> 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) From 550401a9668cf13c39b689a111e5b538313959ed Mon Sep 17 00:00:00 2001 From: Calvin Pieters Date: Sat, 22 Aug 2026 10:03:27 +0300 Subject: [PATCH 2/4] Repair and extend the Gaussian ESS troubleshooting ladder A benchmark run of ~500 reactions exposed several ways in which the Gaussian branch of trsh_ess_job() burned its retry budget without ever changing the job it resubmitted. This commit reworks that ladder in arc/job/trsh.py, with tests in arc/job/trsh_test.py that fail on the previous behaviour. SCF qc/no_xqc oscillation The quadratic-convergence remedy was guarded on the token 'no_qc', which is never appended to ess_trsh_methods; the token actually recorded by trsh_keyword_no_qc() (and consumed by the Gaussian adapter) is 'no_xqc'. An l508 failure therefore dropped 'scf=(qc)' and recorded 'no_xqc', the next SCF failure re-added 'scf=(qc)', and the cycle repeated - never reaching the rest of the SCF ladder (NDamp, NoDIIS, guess=INDO, Fermi, Noincfock, NoVarAcc). Align the three guards to 'no_xqc', count a dropped 'scf=(qc)' as attempted for the last-resort gate that previously required 'scf=(qc)' to still be present, and make the 'no_xqc' append idempotent. Refuse genuinely non-retryable errors trsh_keyword_intaccuracy fired on every Gaussian error, so input/method errors that no resubmit can fix picked up a spurious int=(Acc2E=14) step and wasted a resubmit before hitting all_attempted. Add GAUSSIAN_NON_RETRYABLE_KEYWORDS and a refusal branch in the shared if/elif chain so those classes set couldnt_trsh immediately with a clear error. Deliberately conservative: SCF, opt-cycles, internal-coordinate, negative-eigenvalue, memory, checkfile and the generic 'Unknown' class (which the scheduler retries on another node) are untouched. Three errors reclassified out of that refusal, each with a real remedy ZMat (l716, "angle in z-matrix outside the allowed range"): ARC always submits Cartesian geometries, so this is never a bad input - it is the collinear-angle degeneracy that Cartesian optimization sidesteps. Make trsh_keyword_cartesian fire on 'ZMat' as well as 'InternalCoordinateError'. OptOrientation (l202, standard orientation / point group changed mid-optimization): a symmetry glitch that nosymm cures, the same remedy l101/l103 already use. Add 'NoSymm' to the l202 keyword list so trsh_keyword_nosymm fires exactly once. GL401 basis-set projection failure: raised when guess=read reads a checkpoint built with a different basis. Reclassify it as ['CheckFile'], mirroring the sibling checkpoint-data-missing cases, so dropping the checkfile removes guess=read and the job restarts from a fresh SCF guess. 'BasisSet' then leaves the non-retryable set entirely: the remaining BasisSet paths (GL301 "atomic number out of range", "unrecognized basis set") are already caught by the dedicated BasisSet refuse branch, so keeping it there would be dead and misleading. In each case a recurrence still terminates via 'all_attempted'. TS-aware opt-cycle ladder with Hessian recomputation Rework the MaxOptCycles ladder to recompute the Hessian before flipping the step algorithm, and to know whether it is optimizing a saddle point: maxcycle=200 -> [cartesian, fine TS only] -> recalcfc=5 -> calcall -> RFO -> [GDIIS -> GEDIIS, minimization only] Recomputing force constants is the first-line remedy for a stuck optimization and is essential for a TS opt, where following the single negative eigenvalue depends on Hessian quality. GDIIS/GEDIIS are minimization-oriented step accelerators that can walk a TS opt downhill into a nearby minimum and lose the saddle, so they are skipped when is_ts is True while RFO eigenvector-following is kept. is_ts is threaded from the scheduler through trsh_ess_job into trsh_keyword_opt_maxcycles and prioritize_opt_methods, which now keeps a single step algorithm and collapses the force-constant family to the most aggressive directive (calcall > recalcfc=* > calcfc). A fine TS opt that dead-ends on the l9999 "optimization stopped" oscillation additionally gets opt=(cartesian) as an early rung, right after the cheap maxcycle bump and before the expensive Hessian escalation: that failure is a redundant-internal-coordinate oscillation Cartesian coordinates sidestep, and it was previously losing chemically sensible TS guesses. Ground-state opts and coarse TS opts are unchanged. This also fixes a latent cartesian-loss in mixed GL103 + MaxOptCycles histories, where the opt-route rewrite could clobber a standalone opt=(cartesian). Cleanup, no behaviour change trsh_keyword_inaccurate_quadrature: remove the final elif. It guarded on 'int=ultrafine', a token never added to ess_trsh_methods, so the guard was always true; the branch never advanced ess_trsh_methods and only re-appended keywords that combine_parameters already dedupes, firing solely on the terminal retry that trsh_ess_job discards via 'all_attempted'. trsh_keyword_unconverged: correct a copy-paste docstring. arc/testing/trsh/gaussian/l401_projection.out is a new fixture for the GL401 reclassification. The new tests pin the escalation order and the termination of the InaccurateQuadrature and MaxOptCycles ladders, simulate the SCF retry cycle end to end, and assert that each reclassified error now yields its remedy while the remaining non-retryable classes still refuse. --- arc/job/trsh.py | 225 +++++++--- arc/job/trsh_test.py | 410 ++++++++++++++++-- arc/testing/trsh/gaussian/l401_projection.out | 237 ++++++++++ 3 files changed, 783 insertions(+), 89 deletions(-) create mode 100644 arc/testing/trsh/gaussian/l401_projection.out diff --git a/arc/job/trsh.py b/arc/job/trsh.py index 8a8d248625..04a6245e2a 100644 --- a/arc/job/trsh.py +++ b/arc/job/trsh.py @@ -127,7 +127,7 @@ def determine_ess_status(output_path: str, error = 'There are two blank lines between z-matrix and ' \ 'the variables, expected only one.' elif 'l202.exe' in line: - keywords = ['OptOrientation', 'GL202'] + keywords = ['OptOrientation', 'GL202', 'NoSymm'] error = 'During the optimization process, either the standard ' \ 'orientation or the point group of the molecule has changed.' elif 'l301.exe' in line: @@ -195,8 +195,14 @@ def determine_ess_status(output_path: str, 'specified correctly. Alternatively, a specified atom does not match any ' \ 'standard atomic symbol.' elif 'GL401' in keywords: - keywords.append('BasisSet') - error = 'The projection from the old to the new basis set has failed.' + # A guess=read from a checkpoint built with a different basis fails to + # project onto the new basis. Removing the checkfile drops guess=read so + # the job restarts from a fresh SCF guess with no projection step - this + # is retryable, so classify it as CheckFile (mirroring the checkpoint- + # data-missing cases above), not as a dead-end BasisSet error. + keywords = ['CheckFile'] + error = 'The projection from the old to the new basis set has failed; ' \ + 'removing the checkfile to restart from a fresh SCF guess.' elif 'Erroneous write' in line or 'Write error in NtrExt1' in line: keywords = ['DiskSpace'] error = 'Ran out of disk space.' @@ -867,6 +873,23 @@ def trsh_special_rotor(special_rotor: list, return to_freeze +# Gaussian error classes that resubmission cannot fix: input/template/method-basis problems. +# When one of these is detected we refuse to troubleshoot instead of burning a resubmit on an +# unrelated remedy (historically every Gaussian error picked up a spurious int=(Acc2E=14) step). +# NOTE: this is deliberately conservative - it excludes classes that a resubmit *can* help +# (SCF, opt cycles, internal-coordinate, negative eigenvalues, memory, checkfile, and the +# generic 'Unknown' class, which the scheduler retries on a different node). +# 'ZMat' (L716: z-matrix angle outside 0 < x < 180) is intentionally NOT here: ARC always +# submits Cartesian geometries, so a ZMat error always means the optimizer drove atoms +# collinear - the textbook opt=(cartesian) case, handled by trsh_keyword_cartesian. +# 'BasisSet' is intentionally NOT here either: every genuine dead-end basis error (GL301 +# "atomic number out of range" / "Unrecognized basis set") is already refused by the dedicated +# BasisSet branch in trsh_ess_job, and the one retryable BasisSet case - the GL401 guess=read +# projection failure - is reclassified as 'CheckFile' (see determine_ess_status) and fixed by +# dropping the checkfile. So no BasisSet error reaches this gate. +GAUSSIAN_NON_RETRYABLE_KEYWORDS = ('Syntax', 'InputError', 'MP2', 'Scratch') + + def trsh_ess_job(label: str, level_of_theory: Level | dict | str, server: str, @@ -880,6 +903,7 @@ def trsh_ess_job(label: str, ess_trsh_methods: list, is_h: bool = False, is_monoatomic: bool = False, + is_ts: bool = False, ) -> tuple: """ Troubleshoot issues related to the electronic structure software, such as convergence. @@ -899,6 +923,8 @@ def trsh_ess_job(label: str, ess_trsh_methods (list): The troubleshooting methods tried for this job. is_h (bool): Whether the species is a hydrogen atom (or its isotope). e.g., H, D, T. is_monoatomic (bool): Whether the species is monoatomic (single atom). + is_ts (bool): Whether the species is a transition state. Makes the opt-cycle remedy + ladder TS-aware (rely on RFO + Hessian recompute, avoid GDIIS/GEDIIS). Todo: - Change server to one that has the same ESS if running out of disk space. @@ -943,6 +969,17 @@ def trsh_ess_job(label: str, logger.info(f'Troubleshooting {job_type} job in {software} for {label} that failed with ' '"Basis set data is not on the checkpoint file" by removing the checkfile.') + elif software == 'gaussian' \ + and any(kw in job_status['keywords'] for kw in GAUSSIAN_NON_RETRYABLE_KEYWORDS): + # Non-retryable input/method error: refuse rather than waste a resubmit (see note above). + non_retryable = next(kw for kw in GAUSSIAN_NON_RETRYABLE_KEYWORDS if kw in job_status['keywords']) + output_errors.append(f'Error: Could not troubleshoot {job_type} for {label}! Gaussian reported a ' + f'non-retryable "{non_retryable}" error that resubmission cannot fix ' + f'({job_status.get("error", "").strip() or "no further detail"}); ') + logger.error(f'Could not troubleshoot {job_type} job in {software} for {label}: non-retryable ' + f'"{non_retryable}" error. {job_status.get("error", "").strip()}') + couldnt_trsh = True + elif software == 'gaussian': trsh_keyword = [] # initialize as a list logger_phrase = f'Troubleshooting {job_type} job in {software} for {label}' @@ -992,7 +1029,7 @@ def trsh_ess_job(label: str, # Troubleshoot by increasing opt max cycles #P opt=(calcfc,maxstep=5,tight,maxcycle=200) guess=mix wb97xd/def2tzvp integral=(grid=ultrafine, Acc2E=14) IOp(2/9=2000) scf=(direct,tight,maxcycle=512) iop(3/33=1) - ess_trsh_methods, trsh_keyword, couldnt_trsh = trsh_keyword_opt_maxcycles(job_status, ess_trsh_methods, trsh_keyword, couldnt_trsh) + ess_trsh_methods, trsh_keyword, couldnt_trsh = trsh_keyword_opt_maxcycles(job_status, ess_trsh_methods, trsh_keyword, couldnt_trsh, is_ts, fine) # print out any words that beging with 'opt=' opt_list = [i for i in ess_trsh_methods if i.startswith('opt=')] if opt_list: @@ -1010,9 +1047,9 @@ def trsh_ess_job(label: str, formatted_string += f', {i}' logger_info.append(formatted_string) - # Remove qc from ess_trsh_methods if 'no_qc' is in the keywords + # Drop the quadratic-convergence SCF remedy (record 'no_xqc') if Gaussian failed in l508 ess_trsh_methods, trsh_keyword, couldnt_trsh = trsh_keyword_no_qc(job_status, ess_trsh_methods, trsh_keyword, couldnt_trsh) - if 'no_qc' in ess_trsh_methods: + if 'no_xqc' in ess_trsh_methods: logger_info.append('removed QC') @@ -1886,9 +1923,16 @@ def trsh_keyword_intaccuracy(ess_trsh_methods, trsh_keyword, couldnt_trsh) -> tu def trsh_keyword_cartesian(job_status, ess_trsh_methods, job_type, trsh_keyword: list, couldnt_trsh: bool) -> tuple[list, list, bool]: """ - Check if the job requires change of cartesian coordinate + Switch the optimization to Cartesian coordinates (opt=(cartesian)). + + Fires for both L103 ('InternalCoordinateError') and L716 ('ZMat', z-matrix angle driven + outside 0 < x < 180 during the optimization). Since ARC always submits Cartesian geometries, + a ZMat error is never a bad input - it is the collinear-angle degeneracy that Cartesian + optimization sidesteps. Tried once (guarded by 'cartesian' not in ess_trsh_methods); if the + error recurs after Cartesian was already attempted, ess_trsh_methods stops changing and + trsh_ess_job() terminates the retry loop via the 'all_attempted' marker. """ - if 'InternalCoordinateError' in job_status['keywords'] \ + if ('InternalCoordinateError' in job_status['keywords'] or 'ZMat' in job_status['keywords']) \ and 'cartesian' not in ess_trsh_methods: ess_trsh_methods.append('cartesian') trsh_keyword.append('opt=(cartesian)') @@ -1906,8 +1950,10 @@ def trsh_keyword_scf(job_status, ess_trsh_methods, trsh_keyword, couldnt_trsh) - Check if the job requires change of scf """ scf_pattern = r"scf=\((.*?)\)" # e.g., scf=(xqc,MaxCycle=1000), will match xqc,MaxCycle=1000 - if 'SCF' in job_status['keywords'] and 'scf=(qc)' not in ess_trsh_methods and 'no_qc' not in ess_trsh_methods: + if 'SCF' in job_status['keywords'] and 'scf=(qc)' not in ess_trsh_methods and 'no_xqc' not in ess_trsh_methods: # try both qc and nosymm + # ('no_xqc' records that the quadratic-convergence remedy already failed in l508 + # and was dropped, so don't re-add it - see trsh_keyword_no_qc()) ess_trsh_methods.append('scf=(qc)') couldnt_trsh = False elif 'SCF' in job_status['keywords'] and 'scf=(NDamp=30)' not in ess_trsh_methods: @@ -1922,8 +1968,12 @@ def trsh_keyword_scf(job_status, ess_trsh_methods, trsh_keyword, couldnt_trsh) - ess_trsh_methods.append('guess=INDO') couldnt_trsh = False trsh_keyword.append('guess=INDO') - # If we have attempted all scf methods above, then we will try last resort methods - if 'SCF' in job_status['keywords'] and 'scf=(qc)' in ess_trsh_methods and 'scf=(NDamp=30)' in ess_trsh_methods and 'scf=(NoDIIS)' in ess_trsh_methods and 'guess=INDO' in ess_trsh_methods \ + # If we have attempted all scf methods above, then we will try last resort methods. + # 'scf=(qc)' counts as attempted either if it is still active or if it was tried and + # dropped after failing in l508 (recorded as 'no_xqc'). + if 'SCF' in job_status['keywords'] \ + and ('scf=(qc)' in ess_trsh_methods or 'no_xqc' in ess_trsh_methods) \ + and 'scf=(NDamp=30)' in ess_trsh_methods and 'scf=(NoDIIS)' in ess_trsh_methods and 'guess=INDO' in ess_trsh_methods \ and 'scf=(Fermi)' not in ess_trsh_methods and 'scf=(Noincfock)' not in ess_trsh_methods and 'scf=(NoVarAcc)' not in ess_trsh_methods: # Uses Fermi broadening to help SCF convergence ess_trsh_methods.append('scf=(Fermi)') @@ -1931,7 +1981,8 @@ def trsh_keyword_scf(job_status, ess_trsh_methods, trsh_keyword, couldnt_trsh) - ess_trsh_methods.append('scf=(Noincfock)') ess_trsh_methods.append('scf=(NoVarAcc)') couldnt_trsh = False - if 'no_qc' in ess_trsh_methods and 'scf=(qc)' in ess_trsh_methods: + if 'no_xqc' in ess_trsh_methods and 'scf=(qc)' in ess_trsh_methods: + # safety net: never keep the qc remedy active once it has been dropped via 'no_xqc' ess_trsh_methods.remove('scf=(qc)') couldnt_trsh = False @@ -1944,7 +1995,8 @@ def trsh_keyword_scf(job_status, ess_trsh_methods, trsh_keyword, couldnt_trsh) - def trsh_keyword_unconverged(job_status, ess_trsh_methods, trsh_keyword, couldnt_trsh, fine) -> tuple[list, list, bool, bool]: """ - Check if the job requires change of scf + Remediate a generic 'Unconverged' (Gaussian l9999) failure by switching to a fine + integration grid for the SCF and the integrals (recorded as 'fine'). This is tried once. """ if 'Unconverged' in job_status['keywords'] and 'fine' not in ess_trsh_methods and not fine: # try a fine grid for SCF and integral @@ -1970,39 +2022,85 @@ def trsh_keyword_nosymm(job_status, ess_trsh_methods, trsh_keyword, couldnt_trsh return ess_trsh_methods, trsh_keyword, couldnt_trsh -def trsh_keyword_opt_maxcycles(job_status, ess_trsh_methods, trsh_keyword, couldnt_trsh) -> tuple[list, list, bool]: +def trsh_keyword_opt_maxcycles(job_status, ess_trsh_methods, trsh_keyword, couldnt_trsh, is_ts=False, fine=False) -> tuple[list, list, bool]: """ - Check if the job requires change of opt(maxcycle=200) + Escalate the remedy for an optimization that hit its cycle limit (MaxOptCycles), one step + per retry. The ladder recomputes the Hessian before flipping the step algorithm: + + 1. opt=(maxcycle=200) - simply allow more cycles (cheapest). + 1b. opt=(cartesian) - fine TS opt ONLY (is_ts and fine): a fine TS opt that dead-ends on + the l9999 "Optimization stopped" redundant-internal-coordinate + oscillation is far more likely to be a coordinate-system pathology + than a step/Hessian-quality problem, so switch to Cartesian + coordinates (which sidestep the collinear/redundant-coordinate + degeneracy) BEFORE spending expensive recalcfc/calcall/RFO cycles. + Tried once (guarded by 'cartesian' not in ess_trsh_methods) and + carried through the rest of the ladder via the opt-route merge. + Gated tightly to the fine TS opt so ground-state (is_ts=False) and + coarse TS opt (fine=False) ladders are unchanged. + 2. opt=(recalcfc=5) - recompute the exact Hessian every 5 steps. Recomputing force + constants is the first-line remedy for a stuck optimization, and + is essential for a TS opt where following the single negative + eigenvalue depends entirely on Hessian quality. + 3. opt=(calcall) - recompute the Hessian at every step (expensive last resort). + 4. opt=(RFO) - rational-function / eigenvector-following step. Correct for both + minima and TS. + 5. opt=(GDIIS) - minimization only. GDIIS is a DIIS step accelerator that can walk + 6. opt=(GEDIIS) a TS opt downhill into a nearby minimum and lose the saddle, so + these are skipped for TS optimizations (is_ts=True). """ opt_pattern = r"opt=\((.*?)\)" - if 'MaxOptCycles' in job_status['keywords'] and 'opt=(maxcycle=200)' not in ess_trsh_methods: - ess_trsh_methods.append('opt=(maxcycle=200)') - trsh_keyword.append('opt=(maxcycle=200)') - couldnt_trsh = False - elif 'MaxOptCycles' in job_status['keywords'] and 'opt=(RFO)' not in ess_trsh_methods: - ess_trsh_methods.append('opt=(RFO)') - trsh_keyword.append('opt=(RFO)') - couldnt_trsh = False - elif 'MaxOptCycles' in job_status['keywords'] and 'opt=(RFO)' in ess_trsh_methods and 'opt=(GDIIS)' not in ess_trsh_methods: - ess_trsh_methods.append('opt=(GDIIS)') - trsh_keyword.append('opt=(GDIIS)') - couldnt_trsh = False - elif 'MaxOptCycles' in job_status['keywords'] and 'opt=(RFO)' in ess_trsh_methods and 'opt=(GDIIS)' in ess_trsh_methods and 'opt=(GEDIIS)' not in ess_trsh_methods: - ess_trsh_methods.append('opt=(GEDIIS)') - trsh_keyword.append('opt=(GEDIIS)') - couldnt_trsh = False - + if 'MaxOptCycles' in job_status['keywords']: + if 'opt=(maxcycle=200)' not in ess_trsh_methods: + ess_trsh_methods.append('opt=(maxcycle=200)') + trsh_keyword.append('opt=(maxcycle=200)') + couldnt_trsh = False + elif is_ts and fine and 'cartesian' not in ess_trsh_methods: + # Fine TS opt oscillation (l9999 "Optimization stopped"): try Cartesian coordinates + # early, before escalating the Hessian ladder. Stored as the bare 'cartesian' marker + # (consistent with trsh_keyword_cartesian) and folded into the opt route by the merge + # block below. + ess_trsh_methods.append('cartesian') + trsh_keyword.append('opt=(cartesian)') + couldnt_trsh = False + elif 'opt=(recalcfc=5)' not in ess_trsh_methods: + ess_trsh_methods.append('opt=(recalcfc=5)') + trsh_keyword.append('opt=(recalcfc=5)') + couldnt_trsh = False + elif 'opt=(calcall)' not in ess_trsh_methods: + ess_trsh_methods.append('opt=(calcall)') + trsh_keyword.append('opt=(calcall)') + couldnt_trsh = False + elif 'opt=(RFO)' not in ess_trsh_methods: + ess_trsh_methods.append('opt=(RFO)') + trsh_keyword.append('opt=(RFO)') + couldnt_trsh = False + elif not is_ts and 'opt=(GDIIS)' not in ess_trsh_methods: + ess_trsh_methods.append('opt=(GDIIS)') + trsh_keyword.append('opt=(GDIIS)') + couldnt_trsh = False + elif not is_ts and 'opt=(GDIIS)' in ess_trsh_methods and 'opt=(GEDIIS)' not in ess_trsh_methods: + ess_trsh_methods.append('opt=(GEDIIS)') + trsh_keyword.append('opt=(GEDIIS)') + couldnt_trsh = False + if any('opt' in keyword for keyword in ess_trsh_methods): opt_list = [match for element in ess_trsh_methods for match in re.findall(opt_pattern, element)] if any(re.search(opt_pattern, element) for element in ess_trsh_methods) else [] + # Fold the bare 'cartesian' marker (see the is_ts+fine branch above) into the merged opt + # route as a leading parameter so it survives alongside the maxcycle/Hessian/algorithm + # keywords rather than being clobbered by the route rewrite below. + if 'cartesian' in ess_trsh_methods and 'cartesian' not in opt_list: + opt_list.insert(0, 'cartesian') + if opt_list: - filtered_methods = prioritize_opt_methods(opt_list) + filtered_methods = prioritize_opt_methods(opt_list, is_ts=is_ts) new_opt_keyword = 'opt=(' + ','.join(filtered_methods) + ')' trsh_keyword = [kw if not kw.startswith('opt') else new_opt_keyword for kw in trsh_keyword] - + return ess_trsh_methods, trsh_keyword, couldnt_trsh @@ -2036,12 +2134,11 @@ def trsh_keyword_inaccurate_quadrature(job_status, ess_trsh_methods, trsh_keywor ess_trsh_methods.append('guess=INDO') trsh_keyword.append('guess=INDO') couldnt_trsh = False - elif 'InaccurateQuadrature' in job_status['keywords'] and 'int=grid=300590' in ess_trsh_methods and 'scf=(NoVarAcc)' in ess_trsh_methods and 'guess=INDO' in ess_trsh_methods and 'int=ultrafine' not in ess_trsh_methods: - # Try all methods above - trsh_keyword.append('int=grid=300590') - trsh_keyword.append('guess=INDO') - # NoVarAcc is not included in trsh_keyword, because it will be in ess_trsh_methods - + # Once int=grid=300590, scf=(NoVarAcc) and guess=INDO have all been tried, the ladder is + # exhausted: ess_trsh_methods stops changing and trsh_ess_job() terminates the retry loop + # via the 'all_attempted' marker. (There is no separate int=ultrafine step - the fine-grid + # remedy is handled by trsh_keyword_unconverged, and ultrafine is the Gaussian 16 default.) + scf_pattern = r"scf=\((.*?)\)" # e.g., scf=(xqc,MaxCycle=1000), will match xqc,MaxCycle=1000 if any('scf' in keyword for keyword in ess_trsh_methods): scf_list = [match for element in ess_trsh_methods for match in re.findall(scf_pattern, element)] if any(re.search(scf_pattern, element) for element in ess_trsh_methods) else [] @@ -2090,30 +2187,48 @@ def trsh_keyword_neg_eigen(job_status, ess_trsh_methods, trsh_keyword, couldnt_t return ess_trsh_methods, trsh_keyword, couldnt_trsh -def prioritize_opt_methods(opt_methods): +def prioritize_opt_methods(opt_methods, is_ts=False): + """ + Reduce an accumulated list of opt=(...) parameters to a self-consistent set: - preferred_order = ['GEDIIS', 'GDIIS', 'RFO'] - selected_method = None - - for method in preferred_order: - if method in opt_methods: - selected_method = method - break - - filtered_methods = [method for method in opt_methods if method not in preferred_order or method == selected_method] + - Keep a single step algorithm. For minimizations prefer GEDIIS > GDIIS > RFO. For a TS + optimization keep only RFO (eigenvector following); GDIIS/GEDIIS are minimization-oriented + DIIS accelerators that can drift off the saddle, so they are dropped. + - Keep a single Hessian-recompute directive, most aggressive wins: calcall > recalcfc=* > calcfc. + """ + all_algorithms = ['GEDIIS', 'GDIIS', 'RFO'] + preferred_order = ['RFO'] if is_ts else all_algorithms + selected_method = next((method for method in preferred_order if method in opt_methods), None) + methods = [method for method in opt_methods if method not in all_algorithms or method == selected_method] + + # Collapse conflicting force-constant recompute directives to the single most aggressive one. + has_calcall = 'calcall' in methods + has_recalcfc = any(method.startswith('recalcfc') for method in methods) - return filtered_methods + def _keep_fc(method: str) -> bool: + if method == 'calcfc': + return not (has_calcall or has_recalcfc) + if method.startswith('recalcfc'): + return not has_calcall + return True + + return [method for method in methods if _keep_fc(method)] def trsh_keyword_no_qc(job_status, ess_trsh_methods, trsh_keyword, couldnt_trsh) -> tuple[list, list, bool]: """ - When a job fails with no qc, there are two possible solutions based upon the error message: - 1. If SCF fails, then try to change the algorithm to LQA. - 2. If SCF fails, then try to change the algorithm to LQA. + Drop the quadratic-convergence SCF remedy after Gaussian failed in link 508. + + A previous retry added 'scf=(qc)' (which the Gaussian adapter upgrades to scf=(xqc)). + If the job then died in l508, the QC/XQC algorithm itself failed to converge, so remove + 'scf=(qc)' from the attempted methods and record 'no_xqc' instead. 'no_xqc' guards + trsh_keyword_scf() from re-adding 'scf=(qc)' (while still counting it as attempted for + the last-resort SCF methods) and tells the Gaussian adapter not to upgrade qc to xqc. """ if 'no_xqc' in job_status['keywords'] and 'scf=(qc)' in ess_trsh_methods: ess_trsh_methods.remove('scf=(qc)') - ess_trsh_methods.append('no_xqc') + if 'no_xqc' not in ess_trsh_methods: + ess_trsh_methods.append('no_xqc') couldnt_trsh = False return ess_trsh_methods, trsh_keyword, couldnt_trsh diff --git a/arc/job/trsh_test.py b/arc/job/trsh_test.py index 251a9742cf..9e14811d41 100644 --- a/arc/job/trsh_test.py +++ b/arc/job/trsh_test.py @@ -99,6 +99,20 @@ def test_determine_ess_status(self): self.assertIn("Error termination via Lnk1e", line) self.assertIn("g09/l401.exe", line) + # A GL401 "projection from the old to the new basis set has failed" error (guess=read from a + # checkpoint built with a different basis) is retryable by dropping the checkfile, so it is + # classified as CheckFile - NOT as a dead-end BasisSet error. + path = os.path.join(self.base_path["gaussian"], "l401_projection.out") + status, keywords, error, line = trsh.determine_ess_status( + output_path=path, species_label="Zr2O4H", job_type="opt" + ) + self.assertEqual(status, "errored") + self.assertEqual(keywords, ["CheckFile"]) + self.assertNotIn("BasisSet", keywords) + self.assertIn("projection from the old to the new basis set", error) + self.assertIn("removing the checkfile", error) + self.assertIn("g09/l401.exe", line) + path = os.path.join(self.base_path["gaussian"], "l9999.out") status, keywords, error, line = trsh.determine_ess_status( output_path=path, species_label="Zr2O4H", job_type="opt" @@ -463,10 +477,13 @@ def test_trsh_ess_job(self): self.assertTrue(all('trsh_attempt' not in e for e in output_errors)) # Gaussian: test 7 - part 2 - # verify troubleshoot attempts counting (consolidated) + # verify troubleshoot attempts counting (consolidated). Full minimization MaxOptCycles + # ladder (maxcycle -> recalcfc -> calcall -> RFO -> GDIIS -> GEDIIS) must be exhausted. job_status = {'keywords': ['MaxOptCycles', 'GL9999']} ess_trsh_methods = ['trsh_attempt', 'int=(Acc2E=14)', 'opt=(maxcycle=200)', + 'trsh_attempt', 'opt=(recalcfc=5)', + 'trsh_attempt', 'opt=(calcall)', 'trsh_attempt', 'opt=(RFO)', 'trsh_attempt', 'opt=(GDIIS)', 'trsh_attempt', 'opt=(GEDIIS)', @@ -477,9 +494,10 @@ def test_trsh_ess_job(self): num_heavy_atoms, cpu_cores, ess_trsh_methods) self.assertTrue(couldnt_trsh) e = output_errors[-1] - self.assertIn('Tried troubleshooting 5 time(s)', e) + self.assertIn('Tried troubleshooting 7 time(s)', e) self.assertNotIn('trsh_attempt', e) - for opt in ("opt=(maxcycle=200)", "opt=(RFO)", "opt=(GDIIS)", "opt=(GEDIIS)"): + for opt in ("opt=(maxcycle=200)", "opt=(recalcfc=5)", "opt=(calcall)", + "opt=(RFO)", "opt=(GDIIS)", "opt=(GEDIIS)"): self.assertIn(opt, e) self.assertIn('all_attempted', e) @@ -544,9 +562,8 @@ def test_trsh_ess_job(self): self.assertFalse(couldnt_trsh) self.assertIn('opt=(maxcycle=200)', ess_trsh_methods) - # Gaussian: test 10 - part 2 - # 'MaxOptCycles', 'GL9999' - # Adding RFO to opt + # Gaussian: test 10 - part 2 (minimization ladder) + # 'MaxOptCycles', 'GL9999' - Hessian recompute (recalcfc) comes before any algorithm flip job_status = {'keywords': ['MaxOptCycles', 'GL9999']} ess_trsh_methods = ['int=(Acc2E=14)', 'opt=(maxcycle=200)'] output_errors, ess_trsh_methods, remove_checkfile, level_of_theory, software, job_type, fine, trsh_keyword, \ @@ -554,49 +571,47 @@ def test_trsh_ess_job(self): job_type, software, fine, memory_gb, num_heavy_atoms, cpu_cores, ess_trsh_methods) self.assertFalse(couldnt_trsh) - self.assertIn('opt=(maxcycle=200)', ess_trsh_methods) - self.assertIn('opt=(RFO)', ess_trsh_methods) - self.assertIn('opt=(maxcycle=200,RFO)', trsh_keyword) - - # Gaussian: test 10 - part 3 - # 'MaxOptCycles', 'GL9999' - # Adding GDIIS to opt - # Removing RFO from opt + self.assertIn('opt=(recalcfc=5)', ess_trsh_methods) + self.assertNotIn('opt=(RFO)', ess_trsh_methods) # algorithm flip must NOT precede Hessian recompute + self.assertIn('opt=(maxcycle=200,recalcfc=5)', trsh_keyword) + + # Gaussian: test 10 - part 3 - calcall (most aggressive Hessian) supersedes recalcfc in the route job_status = {'keywords': ['MaxOptCycles', 'GL9999']} - ess_trsh_methods = ['int=(Acc2E=14)', 'opt=(maxcycle=200)', 'opt=(RFO)'] + ess_trsh_methods = ['int=(Acc2E=14)', 'opt=(maxcycle=200)', 'opt=(recalcfc=5)'] output_errors, ess_trsh_methods, remove_checkfile, level_of_theory, software, job_type, fine, trsh_keyword, \ memory, shift, cpu_cores, couldnt_trsh = trsh.trsh_ess_job(label, level_of_theory, server, job_status, job_type, software, fine, memory_gb, num_heavy_atoms, cpu_cores, ess_trsh_methods) self.assertFalse(couldnt_trsh) - self.assertIn('opt=(maxcycle=200)', ess_trsh_methods) - self.assertIn('opt=(RFO)', ess_trsh_methods) - self.assertIn('opt=(GDIIS)', ess_trsh_methods) - self.assertIn('opt=(maxcycle=200,GDIIS)', trsh_keyword) - - # Gaussian: test 10 - part 4 - # 'MaxOptCycles', 'GL9999' - # Adding GEDIIS to opt - # Removing RFO from opt - # Removing GDIIS from opt + self.assertIn('opt=(calcall)', ess_trsh_methods) + self.assertIn('opt=(maxcycle=200,calcall)', trsh_keyword) # recalcfc collapsed into calcall + + # Gaussian: test 10 - part 4 - only now flip to RFO (eigenvector following) job_status = {'keywords': ['MaxOptCycles', 'GL9999']} - ess_trsh_methods = ['int=(Acc2E=14)', 'opt=(maxcycle=200)', 'opt=(RFO)', 'opt=(GDIIS)'] + ess_trsh_methods = ['int=(Acc2E=14)', 'opt=(maxcycle=200)', 'opt=(recalcfc=5)', 'opt=(calcall)'] output_errors, ess_trsh_methods, remove_checkfile, level_of_theory, software, job_type, fine, trsh_keyword, \ memory, shift, cpu_cores, couldnt_trsh = trsh.trsh_ess_job(label, level_of_theory, server, job_status, job_type, software, fine, memory_gb, num_heavy_atoms, cpu_cores, ess_trsh_methods) self.assertFalse(couldnt_trsh) - self.assertIn('opt=(maxcycle=200)', ess_trsh_methods) self.assertIn('opt=(RFO)', ess_trsh_methods) + self.assertIn('opt=(maxcycle=200,calcall,RFO)', trsh_keyword) + + # Gaussian: test 10 - part 5 - GDIIS then GEDIIS (minimization only) + job_status = {'keywords': ['MaxOptCycles', 'GL9999']} + ess_trsh_methods = ['int=(Acc2E=14)', 'opt=(maxcycle=200)', 'opt=(recalcfc=5)', 'opt=(calcall)', 'opt=(RFO)'] + output_errors, ess_trsh_methods, remove_checkfile, level_of_theory, software, job_type, fine, trsh_keyword, \ + memory, shift, cpu_cores, couldnt_trsh = trsh.trsh_ess_job(label, level_of_theory, server, job_status, + job_type, software, fine, memory_gb, + num_heavy_atoms, cpu_cores, ess_trsh_methods) + self.assertFalse(couldnt_trsh) self.assertIn('opt=(GDIIS)', ess_trsh_methods) - self.assertIn('opt=(GEDIIS)', ess_trsh_methods) - self.assertIn('opt=(maxcycle=200,GEDIIS)', trsh_keyword) - - # Gaussian: test 10 - part 5 - # 'MaxOptCycles', 'GL9999' - # Final test to ensure that it cannot troubleshoot the job further + self.assertIn('opt=(maxcycle=200,calcall,GDIIS)', trsh_keyword) + + # Gaussian: test 10 - part 6 - final step exhausts the minimization ladder job_status = {'keywords': ['MaxOptCycles', 'GL9999']} - ess_trsh_methods = ['int=(Acc2E=14)', 'opt=(maxcycle=200)', 'opt=(RFO)', 'opt=(GDIIS)', 'opt=(GEDIIS)'] + ess_trsh_methods = ['int=(Acc2E=14)', 'opt=(maxcycle=200)', 'opt=(recalcfc=5)', 'opt=(calcall)', + 'opt=(RFO)', 'opt=(GDIIS)', 'opt=(GEDIIS)'] output_errors, ess_trsh_methods, remove_checkfile, level_of_theory, software, job_type, fine, trsh_keyword, \ memory, shift, cpu_cores, couldnt_trsh = trsh.trsh_ess_job(label, level_of_theory, server, job_status, job_type, software, fine, memory_gb, @@ -834,6 +849,139 @@ def test_trsh_ess_job(self): num_heavy_atoms, cpu_cores, ess_trsh_methods, is_h=True, is_monoatomic=True) + def test_trsh_ess_job_gaussian_non_retryable_refusal(self): + """ + P1: Gaussian input/method errors that a resubmit cannot fix must refuse immediately + (couldnt_trsh=True) without appending int=(Acc2E=14) or any other remedy - previously + every Gaussian error picked up a spurious Acc2E=14 step and burned a resubmit. + """ + def call(job_status): + return trsh.trsh_ess_job('lbl', {'method': 'wb97xd', 'basis': 'def2tzvp'}, None, job_status, + 'opt', 'gaussian', False, 16, 2, 8, []) + non_retryable_cases = [ + (['Syntax'], 'There was a syntax error in the Gaussian input file.'), + (['InputError', 'GL101'], 'The blank line after the coordinate section is missing.'), + (['MP2', 'GL906'], 'The MP2 calculation has failed.'), + (['Scratch'], 'Wrongly specified the scratch directory.'), + ] + for keywords, error in non_retryable_cases: + out = call({'keywords': keywords, 'error': error}) + output_errors, ess_trsh_methods, couldnt_trsh = out[0], out[1], out[11] + self.assertTrue(couldnt_trsh, f'{keywords} should refuse (couldnt_trsh=True)') + self.assertEqual(ess_trsh_methods, [], f'{keywords} must not append any remedy') + self.assertTrue(any('non-retryable' in e for e in output_errors), + f'{keywords} should report a non-retryable error: {output_errors}') + + # ZMat (L716) is NOT non-retryable: it must get the opt=(cartesian) remedy, not a refusal. + zmat = call({'keywords': ['ZMat', 'GL716'], 'error': 'Angle in z-matrix outside the allowed range 0 < x < 180.'}) + self.assertFalse(zmat[11], 'ZMat must be troubleshot, not refused') + self.assertFalse(any('non-retryable' in e for e in zmat[0]), 'ZMat must not be reported non-retryable') + self.assertIn('cartesian', zmat[1]) + + # A genuine dead-end BasisSet error (GL301 "atomic number out of range") is STILL refused - + # via the dedicated BasisSet branch, not the non-retryable gate (so 'BasisSet' no longer + # needs to be in GAUSSIAN_NON_RETRYABLE_KEYWORDS). + self.assertNotIn('BasisSet', trsh.GAUSSIAN_NON_RETRYABLE_KEYWORDS) + bad_basis = call({'keywords': ['GL301', 'BasisSet'], + 'error': 'The basis set 6-311G is not appropriate for the this chemistry.'}) + self.assertTrue(bad_basis[11], 'genuine BasisSet dead-end must still be refused') + self.assertEqual(bad_basis[1], [], 'refused BasisSet must not append any remedy') + + # Legitimate, retryable classes must be UNAFFECTED (still get their remedies): + scf = call({'keywords': ['SCF', 'GL502', 'NoSymm'], 'error': 'Unconverged SCF'}) + self.assertFalse(scf[11]) + self.assertIn('scf=(qc)', scf[1]) + opt = call({'keywords': ['MaxOptCycles', 'GL9999'], 'error': 'steps exceeded'}) + self.assertFalse(opt[11]) + self.assertIn('opt=(maxcycle=200)', opt[1]) + + def test_trsh_ess_job_gaussian_zmat_cartesian_then_terminates(self): + """ + A ZMat (L716) error - the optimizer drove atoms collinear - must be troubleshot with + opt=(cartesian) on the first retry (same remedy L103 uses) and must terminate (not loop) + if it recurs after Cartesian has already been tried. Verified for both opt and conf_opt. + """ + for job_type in ('opt', 'conf_opt'): + ess_trsh_methods = list() + history = list() + for _ in range(5): + out = trsh.trsh_ess_job('lbl', {'method': 'wb97xd', 'basis': 'def2tzvp'}, None, + {'keywords': ['ZMat', 'GL716'], + 'error': 'Angle in z-matrix outside the allowed range 0 < x < 180.'}, + job_type, 'gaussian', False, 16, 2, 8, ess_trsh_methods) + ess_trsh_methods, trsh_keyword, couldnt_trsh = out[1], out[7], out[11] + history.append((list(ess_trsh_methods), list(trsh_keyword), couldnt_trsh)) + if couldnt_trsh or 'all_attempted' in ess_trsh_methods: + break + # First retry: Cartesian recorded and emitted, job is resubmitted (couldnt_trsh False). + self.assertIn('cartesian', history[0][0], f'{job_type}: first ZMat retry must record cartesian') + self.assertIn('opt=(cartesian)', history[0][1], f'{job_type}: first ZMat retry must emit opt=(cartesian)') + self.assertFalse(history[0][2], f'{job_type}: first ZMat retry must resubmit, not give up') + # Second retry (ZMat recurs after cartesian): must terminate, no loop. + self.assertTrue(history[-1][2], f'{job_type}: recurring ZMat after cartesian must terminate') + self.assertIn('all_attempted', history[-1][0], f'{job_type}: must reach all_attempted') + self.assertLessEqual(len(history), 2, f'{job_type}: cartesian tried once then terminate: {history}') + + def test_trsh_ess_job_gaussian_optorientation_nosymm_then_terminates(self): + """ + An l202 "OptOrientation" error (the standard orientation / point group changed mid-opt) is a + symmetry glitch that nosymm cures - the same remedy l101/l103 use - so it must be troubleshot + with nosymm on the first retry, NOT refused as a non-retryable dead-end, and must terminate + (not loop) if it recurs after nosymm has already been applied. + """ + # OptOrientation must no longer be gated as a non-retryable dead-end. + self.assertNotIn('OptOrientation', trsh.GAUSSIAN_NON_RETRYABLE_KEYWORDS) + ess_trsh_methods, history = list(), list() + for _ in range(5): + out = trsh.trsh_ess_job('lbl', {'method': 'wb97xd', 'basis': 'def2tzvp'}, None, + {'keywords': ['OptOrientation', 'GL202', 'NoSymm'], + 'error': 'During the optimization process, either the standard ' + 'orientation or the point group of the molecule has changed.'}, + 'opt', 'gaussian', False, 16, 2, 8, ess_trsh_methods) + output_errors, ess_trsh_methods, trsh_keyword, couldnt_trsh = out[0], out[1], out[7], out[11] + history.append((list(ess_trsh_methods), list(trsh_keyword), couldnt_trsh, + any('non-retryable' in e for e in output_errors))) + if couldnt_trsh or 'all_attempted' in ess_trsh_methods: + break + # First retry: nosymm recorded and emitted exactly once, job resubmitted, NOT refused. + self.assertIn('NoSymm', history[0][0], 'first OptOrientation retry must record NoSymm') + self.assertEqual(history[0][1].count('nosymm'), 1, 'nosymm must be emitted exactly once') + self.assertFalse(history[0][2], 'first OptOrientation retry must resubmit, not give up') + self.assertFalse(history[0][3], 'OptOrientation must not be reported non-retryable') + # Recurrence after nosymm already applied: terminate, no loop. + self.assertTrue(history[-1][2], 'recurring OptOrientation after nosymm must terminate') + self.assertIn('all_attempted', history[-1][0]) + self.assertLessEqual(len(history), 2, f'nosymm tried once then terminate: {history}') + + def test_trsh_ess_job_gaussian_gl401_projection_checkfile_then_terminates(self): + """ + A GL401 "projection from the old to the new basis set has failed" error is reclassified as + CheckFile (see determine_ess_status), so trsh_ess_job must remove the checkfile (drop + guess=read) and resubmit - NOT refuse it as a dead-end BasisSet error - and must terminate + (not loop) if it recurs after the checkfile was already removed. + """ + error = ('The projection from the old to the new basis set has failed; ' + 'removing the checkfile to restart from a fresh SCF guess.') + ess_trsh_methods, history = list(), list() + for _ in range(5): + out = trsh.trsh_ess_job('lbl', {'method': 'wb97xd', 'basis': 'def2tzvp'}, None, + {'keywords': ['CheckFile'], 'error': error}, + 'opt', 'gaussian', False, 16, 2, 8, ess_trsh_methods) + output_errors, ess_trsh_methods, remove_checkfile, couldnt_trsh = out[0], out[1], out[2], out[11] + history.append((list(ess_trsh_methods), remove_checkfile, couldnt_trsh, + any('non-retryable' in e for e in output_errors))) + if couldnt_trsh or 'all_attempted' in ess_trsh_methods: + break + # First pass: checkfile removed, job resubmitted, NOT refused. + self.assertTrue(history[0][1], 'first pass must remove the checkfile') + self.assertIn('checkfile=None', history[0][0]) + self.assertFalse(history[0][2], 'first pass must resubmit, not give up') + self.assertFalse(history[0][3], 'GL401 projection must not be reported non-retryable') + # Recurrence after checkfile already removed: terminate, no loop. + self.assertTrue(history[-1][2], 'recurring GL401 after checkfile removal must terminate') + self.assertIn('all_attempted', history[-1][0]) + self.assertLessEqual(len(history), 2, f'checkfile removed once then terminate: {history}') + def test_trsh_ess_job_terachem_trsh_attempt_only(self): """Isolate the terachem trsh_attempt-only case from Gaussian stateful flow.""" label = 'ethanol' @@ -932,6 +1080,200 @@ def test_trsh_ess_job_gaussian_memory_alternation_terminates(self): break self.assertTrue(couldnt_trsh) + def test_trsh_ess_job_gaussian_scf_qc_progression(self): + """ + Simulate a Gaussian job that persistently fails SCF convergence, feeding the returned + ess_trsh_methods back into trsh_ess_job() as the scheduler does on each retry: + - the job fails in l508 (the scf=(qc)/(xqc) quadratic-convergence link) whenever + 'scf=(qc)' is part of the attempted methods (the adapter upgrades qc to xqc), and + - fails in l502 (plain SCF convergence) otherwise. + + The cycle must progress monotonically: + - 'no_xqc' is recorded exactly once, + - 'scf=(qc)' is never re-added after 'no_xqc' has been recorded (no qc/no_xqc oscillation), + - the rest of the SCF ladder (NDamp, NoDIIS, guess=INDO, and the Fermi/Noincfock/NoVarAcc + last resort) is actually reached before the cycle declares 'all_attempted'. + """ + label = 'ethanol' + level_of_theory = {'method': 'wb97xd', 'basis': 'def2tzvp'} + server = None # server-independent: the SCF cycle under test never consults server settings + job_type = 'opt' + software = 'gaussian' + fine = False + memory_gb = 16 + num_heavy_atoms = 2 + cpu_cores = 8 + scf_status = {'keywords': ['SCF', 'GL502', 'NoSymm'], 'error': 'Unconverged SCF'} + xqc_status = {'keywords': ['no_xqc', 'GL508'], 'error': 'Unconverged'} + ess_trsh_methods = list() + for _ in range(30): + job_status = xqc_status if 'scf=(qc)' in ess_trsh_methods else scf_status + output_errors, ess_trsh_methods, remove_checkfile, level_of_theory, software, job_type, fine, \ + trsh_keyword, memory, shift, cpu_cores, couldnt_trsh = \ + trsh.trsh_ess_job(label, level_of_theory, server, job_status, + job_type, software, fine, memory_gb, + num_heavy_atoms, cpu_cores, ess_trsh_methods) + self.assertLessEqual(ess_trsh_methods.count('no_xqc'), 1, + f'no_xqc was re-appended (oscillation): {ess_trsh_methods}') + if 'no_xqc' in ess_trsh_methods: + self.assertNotIn('scf=(qc)', ess_trsh_methods, + f'scf=(qc) was re-added after no_xqc: {ess_trsh_methods}') + if couldnt_trsh or 'all_attempted' in ess_trsh_methods: + break + else: + self.fail(f'Gaussian SCF troubleshooting cycle did not terminate: {ess_trsh_methods}') + self.assertIn('no_xqc', ess_trsh_methods) + for method in ('scf=(NDamp=30)', 'scf=(NoDIIS)', 'guess=INDO', + 'scf=(Fermi)', 'scf=(Noincfock)', 'scf=(NoVarAcc)'): + self.assertIn(method, ess_trsh_methods, + f'the SCF ladder never reached {method}: {ess_trsh_methods}') + + def _run_gaussian_ladder(self, keywords, error, n=10, is_ts=False, fine=False): + """ + Drive trsh_ess_job() repeatedly for a Gaussian job that keeps failing with the same + (keywords, error), feeding ess_trsh_methods back in as the scheduler does. Returns the + list of (ess_trsh_methods snapshot, couldnt_trsh) tuples, one per retry, stopping once + the cycle gives up or emits 'all_attempted'. + """ + job_status = {'keywords': keywords, 'error': error} + ess_trsh_methods, history = list(), list() + for _ in range(n): + out = trsh.trsh_ess_job('lbl', {'method': 'wb97xd', 'basis': 'def2tzvp'}, None, job_status, + 'opt', 'gaussian', fine, 16, 2, 8, ess_trsh_methods, is_ts=is_ts) + ess_trsh_methods, fine, couldnt_trsh = out[1], out[6], out[11] + history.append((list(ess_trsh_methods), couldnt_trsh)) + if couldnt_trsh or 'all_attempted' in ess_trsh_methods: + break + return history + + @staticmethod + def _distinct_opts(history): + """Distinct opt=(...) remedies in first-seen (escalation) order across a ladder history.""" + distinct = [] + for snap, _ in history: + for m in snap: + if m.startswith('opt=') and m not in distinct: + distinct.append(m) + return distinct + + def test_trsh_ess_job_gaussian_inaccurate_quadrature_ladder(self): + """ + Pin the InaccurateQuadrature (Gaussian l502 CalDSu) remedy ladder so the removal of the + dead 'int=ultrafine' branch in trsh_keyword_inaccurate_quadrature is provably behavior + preserving. Expected escalation, one remedy per retry, then termination: + int=grid=300590 -> scf=(NoVarAcc) -> guess=INDO -> all_attempted. + """ + history = self._run_gaussian_ladder(['InaccurateQuadrature', 'GL502'], 'Inaccurate quadrature in CalDSu') + ess_snapshots = [snap for snap, _ in history] + self.assertEqual(ess_snapshots, [ + ['int=(Acc2E=14)', 'int=grid=300590'], + ['int=(Acc2E=14)', 'int=grid=300590', 'scf=(NoVarAcc)'], + ['int=(Acc2E=14)', 'int=grid=300590', 'scf=(NoVarAcc)', 'guess=INDO'], + ['int=(Acc2E=14)', 'int=grid=300590', 'scf=(NoVarAcc)', 'guess=INDO', 'all_attempted'], + ]) + self.assertTrue(history[-1][1]) # couldnt_trsh is True on the terminal retry + + def test_trsh_ess_job_gaussian_maxoptcycles_ladder_minimization(self): + """ + P3: MaxOptCycles ladder for a minimization (is_ts=False). Recompute the Hessian before any + step-algorithm flip, and keep the DIIS accelerators (legitimate for minima): + maxcycle=200 -> recalcfc=5 -> calcall -> RFO -> GDIIS -> GEDIIS -> all_attempted. + """ + history = self._run_gaussian_ladder(['MaxOptCycles', 'GL9999'], 'Maximum optimization cycles reached.') + self.assertEqual([snap for snap, _ in history][0], ['int=(Acc2E=14)', 'opt=(maxcycle=200)']) + self.assertEqual(self._distinct_opts(history), + ['opt=(maxcycle=200)', 'opt=(recalcfc=5)', 'opt=(calcall)', + 'opt=(RFO)', 'opt=(GDIIS)', 'opt=(GEDIIS)']) + self.assertIn('all_attempted', history[-1][0]) + + def test_trsh_ess_job_gaussian_maxoptcycles_ladder_ts(self): + """ + P3: MaxOptCycles ladder for a TS (is_ts=True). Hessian recompute comes before the algorithm + flip, and GDIIS/GEDIIS (minimization DIIS accelerators that can fall off the saddle) are + NOT applied - the ladder stops at RFO: + maxcycle=200 -> recalcfc=5 -> calcall -> RFO -> all_attempted. + """ + history = self._run_gaussian_ladder(['MaxOptCycles', 'GL9999'], + 'Maximum optimization cycles reached.', is_ts=True) + distinct = self._distinct_opts(history) + self.assertEqual(distinct, ['opt=(maxcycle=200)', 'opt=(recalcfc=5)', 'opt=(calcall)', 'opt=(RFO)']) + self.assertNotIn('opt=(GDIIS)', distinct) + self.assertNotIn('opt=(GEDIIS)', distinct) + # Hessian recompute must precede the RFO algorithm flip: + self.assertLess(distinct.index('opt=(recalcfc=5)'), distinct.index('opt=(RFO)')) + self.assertLess(distinct.index('opt=(calcall)'), distinct.index('opt=(RFO)')) + self.assertIn('all_attempted', history[-1][0]) + + @staticmethod + def _maxoptcycles_trsh(ess_trsh_methods, is_ts, fine): + """One trsh_ess_job() retry for a MaxOptCycles (l9999) Gaussian opt; returns + (ess_trsh_methods, trsh_keyword, couldnt_trsh).""" + job_status = {'keywords': ['MaxOptCycles', 'GL9999'], + 'error': 'Maximum optimization cycles reached.'} + out = trsh.trsh_ess_job('lbl', {'method': 'wb97xd', 'basis': 'def2tzvp'}, None, job_status, + 'opt', 'gaussian', fine, 16, 2, 8, ess_trsh_methods, is_ts=is_ts) + return out[1], out[7], out[11] + + def test_trsh_ess_job_gaussian_maxoptcycles_ladder_ts_fine_cartesian(self): + """ + A FINE TS opt (is_ts=True, fine=True) that dead-ends on the l9999 "Optimization stopped" + MaxOptCycles oscillation must switch to Cartesian coordinates EARLY - after the cheap + maxcycle bump but before the expensive Hessian/algorithm escalation: + maxcycle=200 -> cartesian -> recalcfc=5 -> calcall -> RFO -> all_attempted. + Cartesian is tried exactly once and is carried through (merged into) the rest of the route. + """ + history = self._run_gaussian_ladder(['MaxOptCycles', 'GL9999'], + 'Maximum optimization cycles reached.', + is_ts=True, fine=True) + snapshots = [snap for snap, _ in history] + final_methods = snapshots[-1] + # Cartesian is recorded (bare marker), exactly once, and after the maxcycle bump. + self.assertIn('cartesian', final_methods, + f'cartesian was never tried for the fine TS opt oscillation: {final_methods}') + self.assertEqual(final_methods.count('cartesian'), 1, + f'cartesian must be recorded exactly once: {final_methods}') + self.assertLess(final_methods.index('opt=(maxcycle=200)'), final_methods.index('cartesian')) + self.assertLess(final_methods.index('cartesian'), final_methods.index('opt=(recalcfc=5)')) + # The route keyword emitted on the retry that introduces cartesian merges it with maxcycle. + ess, trsh_keyword, couldnt = self._maxoptcycles_trsh( + ['int=(Acc2E=14)', 'opt=(maxcycle=200)'], is_ts=True, fine=True) + self.assertIn('cartesian', ess) + self.assertFalse(couldnt) + self.assertIn('opt=(cartesian,maxcycle=200)', trsh_keyword) + # The ladder still terminates and GDIIS/GEDIIS stay excluded for a TS. + self.assertIn('all_attempted', final_methods) + self.assertTrue(history[-1][1]) + self.assertFalse(any('GDIIS' in m for m in final_methods)) + + def test_trsh_ess_job_gaussian_maxoptcycles_cartesian_gated(self): + """ + The fine-TS-opt Cartesian remedy must NOT leak into the ground-state opt ladder + (is_ts=False) nor the coarse TS opt ladder (fine=False) - both are unchanged. The + recalcfc Hessian recompute (not cartesian) must be the second rung in those cases. + """ + for is_ts, fine, tag in [(False, True, 'ground-state fine opt'), + (True, False, 'coarse TS opt'), + (False, False, 'ground-state coarse opt')]: + ess, trsh_keyword, couldnt = self._maxoptcycles_trsh( + ['int=(Acc2E=14)', 'opt=(maxcycle=200)'], is_ts=is_ts, fine=fine) + self.assertNotIn('cartesian', ess, f'cartesian must not fire for a {tag}: {ess}') + self.assertNotIn('opt=(cartesian)', trsh_keyword, + f'cartesian route must not appear for a {tag}: {trsh_keyword}') + self.assertIn('opt=(recalcfc=5)', ess, f'{tag} must escalate to recalcfc: {ess}') + + def test_prioritize_opt_methods_ts_vs_min(self): + """P3 unit: TS keeps only RFO and the most aggressive Hessian directive; min keeps GEDIIS.""" + acc = ['maxcycle=200', 'recalcfc=5', 'calcall', 'RFO', 'GDIIS', 'GEDIIS', 'calcfc'] + ts = trsh.prioritize_opt_methods(list(acc), is_ts=True) + self.assertIn('RFO', ts) + self.assertNotIn('GDIIS', ts) + self.assertNotIn('GEDIIS', ts) + self.assertIn('calcall', ts) + self.assertNotIn('recalcfc=5', ts) # calcall (more aggressive) wins + self.assertNotIn('calcfc', ts) + min_ = trsh.prioritize_opt_methods(list(acc), is_ts=False) + self.assertEqual([m for m in min_ if m in ('GEDIIS', 'GDIIS', 'RFO')], ['GEDIIS']) + def test_determine_job_log_memory_issues(self): """Test the determine_job_log_memory_issues() function.""" job_log_path_1 = os.path.join(ARC_TESTING_PATH, 'job_log', 'no_issues.log') diff --git a/arc/testing/trsh/gaussian/l401_projection.out b/arc/testing/trsh/gaussian/l401_projection.out new file mode 100644 index 0000000000..6f90beb66e --- /dev/null +++ b/arc/testing/trsh/gaussian/l401_projection.out @@ -0,0 +1,237 @@ + Entering Gaussian System, Link 0=g09 + Initial command: + /usr/local/g09/l1.exe "/gtmp/calvin.p/scratch/g09/387588.zeus-master/Gau-642.inp" -scrdir="/gtmp/calvin.p/scratch/g09/387588.zeus-master/" + Entering Link 1 = /usr/local/g09/l1.exe PID= 643. + + Copyright (c) 1988,1990,1992,1993,1995,1998,2003,2009,2013, + Gaussian, Inc. All Rights Reserved. + + This is part of the Gaussian(R) 09 program. It is based on + the Gaussian(R) 03 system (copyright 2003, Gaussian, Inc.), + the Gaussian(R) 98 system (copyright 1998, Gaussian, Inc.), + the Gaussian(R) 94 system (copyright 1995, Gaussian, Inc.), + the Gaussian 92(TM) system (copyright 1992, Gaussian, Inc.), + the Gaussian 90(TM) system (copyright 1990, Gaussian, Inc.), + the Gaussian 88(TM) system (copyright 1988, Gaussian, Inc.), + the Gaussian 86(TM) system (copyright 1986, Carnegie Mellon + University), and the Gaussian 82(TM) system (copyright 1983, + Carnegie Mellon University). Gaussian is a federally registered + trademark of Gaussian, Inc. + + This software contains proprietary and confidential information, + including trade secrets, belonging to Gaussian, Inc. + + This software is provided under written license and may be + used, copied, transmitted, or stored only in accord with that + written license. + + The following legend is applicable only to US Government + contracts under FAR: + + RESTRICTED RIGHTS LEGEND + + Use, reproduction and disclosure by the US Government is + subject to restrictions as set forth in subparagraphs (a) + and (c) of the Commercial Computer Software - Restricted + Rights clause in FAR 52.227-19. + + Gaussian, Inc. + 340 Quinnipiac St., Bldg. 40, Wallingford CT 06492 + + + --------------------------------------------------------------- + Warning -- This program may not be used in any manner that + competes with the business of Gaussian, Inc. or will provide + assistance to any competitor of Gaussian, Inc. The licensee + of this program is prohibited from giving any competitor of + Gaussian, Inc. access to this program. By using this program, + the user acknowledges that Gaussian, Inc. is engaged in the + business of creating and licensing software in the field of + computational chemistry and represents and warrants to the + licensee that it is not a competitor of Gaussian, Inc. and that + it will not use this program in any manner prohibited above. + --------------------------------------------------------------- + + + Cite this work as: + Gaussian 09, Revision D.01, + M. J. Frisch, G. W. Trucks, H. B. Schlegel, G. E. Scuseria, + M. A. Robb, J. R. Cheeseman, G. Scalmani, V. Barone, B. Mennucci, + G. A. Petersson, H. Nakatsuji, M. Caricato, X. Li, H. P. Hratchian, + A. F. Izmaylov, J. Bloino, G. Zheng, J. L. Sonnenberg, M. Hada, + M. Ehara, K. Toyota, R. Fukuda, J. Hasegawa, M. Ishida, T. Nakajima, + Y. Honda, O. Kitao, H. Nakai, T. Vreven, J. A. Montgomery, Jr., + J. E. Peralta, F. Ogliaro, M. Bearpark, J. J. Heyd, E. Brothers, + K. N. Kudin, V. N. Staroverov, T. Keith, R. Kobayashi, J. Normand, + K. Raghavachari, A. Rendell, J. C. Burant, S. S. Iyengar, J. Tomasi, + M. Cossi, N. Rega, J. M. Millam, M. Klene, J. E. Knox, J. B. Cross, + V. Bakken, C. Adamo, J. Jaramillo, R. Gomperts, R. E. Stratmann, + O. Yazyev, A. J. Austin, R. Cammi, C. Pomelli, J. W. Ochterski, + R. L. Martin, K. Morokuma, V. G. Zakrzewski, G. A. Voth, + P. Salvador, J. J. Dannenberg, S. Dapprich, A. D. Daniels, + O. Farkas, J. B. Foresman, J. V. Ortiz, J. Cioslowski, + and D. J. Fox, Gaussian, Inc., Wallingford CT, 2013. + + ****************************************** + Gaussian 09: EM64L-G09RevD.01 24-Apr-2013 + 24-Apr-2023 + ****************************************** + %chk=check.chk + %mem=4096mb + %NProcShared=16 + Will use up to 16 processors via shared memory. + ---------------------------------------------------------------------- + #P opt=(calcfc, tight, maxstep=5) guess=read ub2plypd3/def2tzvp scf=(t + ight, direct) integral=(grid=ultrafine, Acc2E=12) IOp(2/9=2000) scf=xq + c + ---------------------------------------------------------------------- + 1/7=10,8=5,10=4,18=20,19=15,38=1/1,3; + 2/9=2000,12=2,17=6,18=5,40=1/2; + 3/5=44,7=101,11=2,16=1,25=1,30=1,71=2,74=-60,75=-5,116=2,140=1/1,2,3; + 4/5=1/1; + 5/5=2,8=3,13=1,32=2,38=6,87=12/2,8; + 8/6=3,8=1,10=1,19=11,30=-1,87=12/1; + 9/15=3,16=-3,87=12/6; + 11/6=1,8=1,15=11,17=12,24=-1,27=1,28=-2,29=300,32=6,42=3,87=12/1,2,10; + 10/6=2,21=1,87=12/2; + 8/6=4,8=1,10=1,19=11,30=-1,87=12/11,4; + 10/5=1,20=4,87=12/2; + 11/12=2,14=11,16=1,17=2,28=-2,42=3,87=12/2,10,12; + 6/7=2,8=2,9=2,10=2/1; + 7/10=1,12=2,25=1,44=2,87=12/1,2,3,16; + 1/7=10,8=5,10=4,18=20,19=15/3(2); + 2/9=2000/2; + 99//99; + 2/9=2000/2; + 3/5=44,7=101,11=2,16=1,25=1,30=1,71=1,74=-60,75=-5,116=2/1,2,3; + 4/5=5,16=3,69=1/1; + 5/5=2,8=3,13=1,32=2,38=5,87=12/2,8; + 8/6=4,10=1,87=12/1; + 9/15=2,16=-3,87=12/6; + 10/5=1,87=12/2; + 7/12=2,87=12/1,2,3,16; + 1/7=10,8=5,18=20,19=15/3(-8); + 2/9=2000/2; + 6/7=2,8=2,9=2,10=2/1; + 99//99; + Leave Link 1 at Mon Apr 24 20:17:17 2023, MaxMem= 536870912 cpu: 0.7 + (Enter /usr/local/g09/l101.exe) + --- + NH2 + --- + Symbolic Z-matrix: + Charge = 0 Multiplicity = 2 + N 0.00024 0.4234 0. + H -0.80505 -0.21124 0. + H 0.80481 -0.21216 0. + + NAtoms= 3 NQM= 3 NQMF= 0 NMMI= 0 NMMIF= 0 + NMic= 0 NMicF= 0. + Isotopes and Nuclear Properties: + (Nuclear quadrupole moments (NQMom) in fm**2, nuclear magnetic moments (NMagM) + in nuclear magnetons) + + Atom 1 2 3 + IAtWgt= 14 1 1 + AtmWgt= 14.0030740 1.0078250 1.0078250 + NucSpn= 2 1 1 + AtZEff= 0.0000000 0.0000000 0.0000000 + NQMom= 2.0440000 0.0000000 0.0000000 + NMagM= 0.4037610 2.7928460 2.7928460 + AtZNuc= 7.0000000 1.0000000 1.0000000 + Leave Link 101 at Mon Apr 24 20:17:17 2023, MaxMem= 536870912 cpu: 1.8 + (Enter /usr/local/g09/l103.exe) + + GradGradGradGradGradGradGradGradGradGradGradGradGradGradGradGradGradGrad + Berny optimization. + Initialization pass. + ---------------------------- + ! Initial Parameters ! + ! (Angstroms and Degrees) ! + -------------------------- -------------------------- + ! Name Definition Value Derivative Info. ! + -------------------------------------------------------------------------------- + ! R1 R(1,2) 1.0253 calculate D2E/DX2 analytically ! + ! R2 R(1,3) 1.0253 calculate D2E/DX2 analytically ! + ! A1 A(2,1,3) 103.4527 calculate D2E/DX2 analytically ! + -------------------------------------------------------------------------------- + Trust Radius=5.00D-02 FncErr=1.00D-07 GrdErr=1.00D-07 + Number of steps in this run= 20 maximum allowed number of steps= 100. + GradGradGradGradGradGradGradGradGradGradGradGradGradGradGradGradGradGrad + + Leave Link 103 at Mon Apr 24 20:17:17 2023, MaxMem= 536870912 cpu: 0.1 + (Enter /usr/local/g09/l202.exe) + Input orientation: + --------------------------------------------------------------------- + Center Atomic Atomic Coordinates (Angstroms) + Number Number Type X Y Z + --------------------------------------------------------------------- + 1 7 0 0.000243 0.423397 0.000000 + 2 1 0 -0.805053 -0.211237 0.000000 + 3 1 0 0.804810 -0.212160 0.000000 + --------------------------------------------------------------------- + Distance matrix (angstroms): + 1 2 3 + 1 N 0.000000 + 2 H 1.025311 0.000000 + 3 H 1.025310 1.609863 0.000000 + Stoichiometry H2N(2) + Framework group CS[SG(H2N)] + Deg. of freedom 3 + Full point group CS NOp 2 + Largest Abelian subgroup CS NOp 2 + Largest concise Abelian subgroup C1 NOp 1 + Standard orientation: + --------------------------------------------------------------------- + Center Atomic Atomic Coordinates (Angstroms) + Number Number Type X Y Z + --------------------------------------------------------------------- + 1 7 0 0.000000 0.141132 0.000000 + 2 1 0 0.804932 -0.493964 0.000000 + 3 1 0 -0.804932 -0.493963 0.000000 + --------------------------------------------------------------------- + Rotational constants (GHZ): 711.0962360 386.9760326 250.6002638 + Leave Link 202 at Mon Apr 24 20:17:17 2023, MaxMem= 536870912 cpu: 0.1 + (Enter /usr/local/g09/l301.exe) + Standard basis: def2TZVP (5D, 7F) + Ernie: Thresh= 0.10000D-02 Tol= 0.10000D-05 Strict=F. + There are 35 symmetry adapted cartesian basis functions of A' symmetry. + There are 13 symmetry adapted cartesian basis functions of A" symmetry. + There are 31 symmetry adapted basis functions of A' symmetry. + There are 12 symmetry adapted basis functions of A" symmetry. + 43 basis functions, 67 primitive gaussians, 48 cartesian basis functions + 5 alpha electrons 4 beta electrons + nuclear repulsion energy 7.5543077488 Hartrees. + IExCor= 419 DFT=T Ex+Corr=B2PLYPD3 ExCW=0 ScaHFX= 0.530000 + ScaDFX= 0.470000 0.470000 0.730000 0.730000 ScalE2= 0.270000 0.270000 + IRadAn= 5 IRanWt= -1 IRanGd= 0 ICorTp=0 IEmpDi=141 + NAtoms= 3 NActive= 3 NUniq= 3 SFac= 1.00D+00 NAtFMM= 60 NAOKFM=F Big=F + Integral buffers will be 131072 words long. + Raffenetti 2 integral format. + Two-electron integral symmetry is turned on. + R6Disp: Grimme-D3(BJ) Dispersion energy= -0.0003067212 Hartrees. + Nuclear repulsion after empirical dispersion term = 7.5540010277 Hartrees. + Leave Link 301 at Mon Apr 24 20:17:17 2023, MaxMem= 536870912 cpu: 0.8 + (Enter /usr/local/g09/l302.exe) + NPDir=0 NMtPBC= 1 NCelOv= 1 NCel= 1 NClECP= 1 NCelD= 1 + NCelK= 1 NCelE2= 1 NClLst= 1 CellRange= 0.0. + One-electron integrals computed using PRISM. + 1 Symmetry operations used in ECPInt. + ECPInt: NShTT= 190 NPrTT= 556 LenC2= 191 LenP2D= 543. + LDataN: DoStor=T MaxTD1= 6 Len= 172 + NBasis= 43 RedAO= T EigKep= 8.62D-03 NBF= 31 12 + NBsUse= 43 1.00D-06 EigRej= -1.00D+00 NBFU= 31 12 + Precomputing XC quadrature grid using + IXCGrd= 4 IRadAn= 5 IRanWt= -1 IRanGd= 0 AccXCQ= 0.00D+00. + Generated NRdTot= 0 NPtTot= 0 NUsed= 0 NTot= 32 + NSgBfM= 48 48 48 48 48 MxSgAt= 3 MxSgA2= 3. + Leave Link 302 at Mon Apr 24 20:17:17 2023, MaxMem= 536870912 cpu: 1.9 + (Enter /usr/local/g09/l303.exe) + DipDrv: MaxL=1. + Leave Link 303 at Mon Apr 24 20:17:17 2023, MaxMem= 536870912 cpu: 0.3 + (Enter /usr/local/g09/l401.exe) + Initial guess from the checkpoint file: "check.chk" + The projection from the old to the new basis set has failed. + Error termination via Lnk1e in /usr/local/g09/l401.exe at Mon Apr 24 20:17:17 2023. + Job cpu time: 0 days 0 hours 0 minutes 6.6 seconds. + File lengths (MBytes): RWF= 5 Int= 0 D2E= 0 Chk= 1 Scr= 1 From 72ccaf026bb5dc40bd6c349df208e81c4fdfe514 Mon Sep 17 00:00:00 2001 From: Calvin Pieters Date: Sat, 22 Aug 2026 10:03:27 +0300 Subject: [PATCH 3/4] Pass is_ts to trsh_ess_job so the opt-cycle ladder is TS-aware The MaxOptCycles remedy ladder in arc/job/trsh.py now branches on whether the job optimizes a transition state: GDIIS/GEDIIS are skipped for a saddle point (they can walk the optimization downhill into a nearby minimum), and a fine TS opt gets opt=(cartesian) as an early rung. That decision cannot be made inside trsh_ess_job, which sees only the job status and the software. Supply it from the one place that knows: the scheduler already holds the ARCSpecies for the label being troubleshot, so pass self.species_dict[label].is_ts alongside the is_h and is_monoatomic flags it already forwards. No other scheduler behaviour changes. --- arc/scheduler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/arc/scheduler.py b/arc/scheduler.py index 3c27829cf5..ecf90f0f72 100644 --- a/arc/scheduler.py +++ b/arc/scheduler.py @@ -3926,6 +3926,7 @@ def troubleshoot_ess(self, job_status=job.job_status[1], is_h=is_h, is_monoatomic=self.species_dict[label].is_monoatomic(), + is_ts=self.species_dict[label].is_ts, job_type=job.job_type, num_heavy_atoms=self.species_dict[label].number_of_heavy_atoms, software=job.job_adapter, From 4cf9fb06f0d8ddc671d027e74967dfd2a2f9cb40 Mon Sep 17 00:00:00 2001 From: Calvin Pieters Date: Sat, 22 Aug 2026 10:03:27 +0300 Subject: [PATCH 4/4] Fix Gaussian route construction: trsh keywords, integration grid, guess=mix Four defects in the route the Gaussian adapter renders, all of which made a troubleshooting resubmit either a no-op or actively wrong. Tests live in arc/job/adapters/gaussian_test.py and assert the specific keyword rather than a whole-route golden wherever the route is otherwise unrelated. The qc -> xqc upgrade could not be opted out of A requested scf=(qc) is upgraded to scf=(xqc), and an l508 failure is supposed to record 'no_xqc' to stop that. The check read 'no_xqc' in self.args['trsh'].values(), which can never match the shape the scheduler builds ({'trsh': [keyword_list]}), so the opt-out never fired and the SCF ladder oscillated between qc and no_xqc. Decide from ess_trsh_methods instead. int=(Acc2E=14) was silently dropped on non-fine jobs integral_algorithm was only emitted under `if self.fine`, so a non-fine opt or IRC whose only remedy was int=(Acc2E=14) produced a route byte-identical to the un-troubleshot one - a guaranteed no-op resubmit. Capture acc2e_requested and emit a bare integral=(Acc2E=14) through the route's fine slot for non-fine opt/IRC jobs. Fine jobs still fold Acc2E into the ultrafine grid, and non-fine jobs without the remedy are unchanged. Two conflicting DFT integration grids in one route The InaccurateQuadrature remedy escalates to a finer grid recorded as 'int=grid=NNNMMM', but a fine job also emits integral=(grid=ultrafine, ...). Both are the same Gaussian Int keyword, so the route carried two grid specs (ultrafine = (99,590) against the remedy's 300590 = (300,590)) and the finer grid was either rejected or silently overridden. Fold the remedy grid into the single integral=() keyword, replacing ultrafine, and drop the standalone int=grid= token. The grid is sourced from ess_trsh_methods so it persists while the ladder keeps escalating, and the non-fine opt/IRC path emits it too. job_18 loses its duplicated grid; job_20/21 now keep grid=300590 instead of reverting to ultrafine; job_19, which has no grid remedy, is unchanged, confirming the change is scoped to InaccurateQuadrature routes. Relatedly, the base route's calcfc is dropped when the MaxOptCycles ladder supplies recalcfc/calcall, so opt=() never carries conflicting Hessian directives; the job_23/job_24 route fixtures are updated to the new ladder. guess=mix is now gated on Ms = 0 broken symmetry only The adapter emitted guess=mix for every polyatomic species with no checkfile. The gate is now elif any(spc.multiplicity == 1 and spc.number_of_radicals is not None and spc.number_of_radicals > 1 for spc in self.species): This predicate deliberately supersedes two earlier attempts. An earlier commit on this branch gated on `spc.is_ts or (multiplicity == 1 and number_of_radicals > 1)`; draft PR #973 hoisted is_restricted(self) and gated on `not all(restricted_flags)`. Both are replaced. g16 does not ignore guess=mix on a restricted reference - it applies it. arc/testing/restart/1_restart_thermo/calcs/freq_a19031.out is a closed-shell singlet (9 alpha, 9 beta electrons; route `#P guess=mix wb97xd/def2tzvp`, no `u` prefix) whose L401 prints a single "Mixing orbitals, IMix= 1 ... Coef= 7.07106781D-01 7.07106781D-01", then IOpCl= 0 and a normal E(RwB97XD). Across the fixture tree a restricted reference prints one mixing line, an unrestricted one prints two (alpha +0.7071, beta -0.7071), and a job without the keyword prints the Harris functional line instead. On a restricted reference the keyword rotates HOMO into LUMO within the single orbital set: it is not inert, it perturbs a good Harris guess for no benefit. #973's log text "guess=mix only acts on an unrestricted reference" is therefore false and is not carried over. is_restricted is not usable as the gate. is_species_restricted() early- returns True for force_field, composite and semiempirical method types before any multiplicity check, so it answers "should the route omit the `u` prefix", not "is the reference restricted". arc/testing/composite/TS0_composite_2043.out is the counterexample: multiplicity 2 at cbs-qb3 with no `u` in the route, yet Gaussian prints two mixing lines and SCF Done: E(UB3LYP). The new predicate reads multiplicity and number_of_radicals directly and never consults is_restricted, which is left untouched. The physical rule is that guess=mix is wanted only where the reference is genuinely unrestricted and Ms = 0, i.e. a bi-radical singlet: only Ms = 0 still has an alpha-beta symmetry left to break, which is Gaussian's own documented use case ("producing UHF wavefunctions for singlet states"). For Ms != 0 systems Nalpha != Nbeta already, so guess=mix degenerates into a pure spatial symmetry-breaking request, which for delocalised radicals (allyl, benzyl, NO2) drives the classic artificial-localisation artifact. Doublets, triplets and non-singlet TSs - which the earlier `spc.is_ts` disjunct still admitted - therefore no longer get it. Honest consequence: number_of_radicals is user-input-only (ARC does not attempt to determine it), so where the user did not declare it the correct gate emits guess=mix never. That is accepted deliberately; no heuristic is invented to derive it. Whether singlet TSs deserve an unrestricted reference is a separate physics question under separate review. input_dict['job_type_1'] is rendered once and reused for every --link1-- section while ${restricted} is substituted per species, so a route-wide guess keyword lands on every section. The keyword stays route-wide and the predicate collapses with any(), the honest collapse for "does any species here need a broken-symmetry seed". Making guess per-species in the run_multi_species path would require restructuring the whole route-wide assembly (opt=, irc=, scf=, SCRF=, integral=, plus the Fix OPT / Fix SCF / Fix IRC recombination that follows) and is out of scope. The pre-existing `self.species[0].number_of_atoms > 1` guard is likewise left as found. guess=read (checkfile) and guess=INDO (troubleshooting) keep their existing precedence and ordering. TestGaussianAdapterGuessMixGating covers: bi-radical singlet -> guess=mix; closed-shell singlet, doublet and triplet -> none; singlet TS with number_of_radicals=None -> none (#973's target); doublet TS -> none (the earlier predicate's target); bi-radical singlet TS -> guess=mix; open-shell species at cbs-qb3 decided by multiplicity; checkfile -> guess=read; trsh guess=INDO -> guess=INDO only. The checkfile fixture lives in a tempfile.mkdtemp() directory registered with addCleanup rather than in ARC_TESTING_PATH/test_GaussianAdapter, which another module's tearDownClass rmtree's and which caused intermittent failures under `pytest arc/ -n 6 --dist worksteal`. --- arc/job/adapters/gaussian.py | 65 +++++-- arc/job/adapters/gaussian_test.py | 293 ++++++++++++++++++++++++++++-- 2 files changed, 323 insertions(+), 35 deletions(-) diff --git a/arc/job/adapters/gaussian.py b/arc/job/adapters/gaussian.py index 33b1e695bf..06b5aea942 100644 --- a/arc/job/adapters/gaussian.py +++ b/arc/job/adapters/gaussian.py @@ -250,8 +250,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 +276,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 +305,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) @@ -317,13 +329,13 @@ def write_input_file(self) -> None: 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 +356,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 +367,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 +388,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 +409,32 @@ 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.multiplicity == 1 and spc.number_of_radicals is not None and spc.number_of_radicals > 1 + for spc in self.species): + 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 96f65af276..6ab3f7a369 100644 --- a/arc/job/adapters/gaussian_test.py +++ b/arc/job/adapters/gaussian_test.py @@ -8,6 +8,7 @@ import math import os import shutil +import tempfile import unittest from arc.common import ARC_TESTING_PATH @@ -464,7 +465,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, \ @@ -487,7 +489,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, \ @@ -666,7 +669,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 @@ -796,7 +799,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 @@ -808,8 +811,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: @@ -836,7 +839,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 @@ -862,7 +865,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 @@ -888,7 +891,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 @@ -914,7 +917,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 @@ -940,7 +943,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 @@ -994,7 +997,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 @@ -1020,7 +1023,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 @@ -1046,7 +1049,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 @@ -1072,7 +1075,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 @@ -1099,7 +1102,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 @@ -1126,7 +1129,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 @@ -1153,7 +1156,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 @@ -1180,7 +1183,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 @@ -1260,5 +1263,257 @@ def test_malformed_marker_is_ignored(self): self.assertEqual(get_memory_headroom_fraction(['memory_headroom_notanumber', 'memory_headroom_0.75']), 0.75) +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): + """ + Contains unit tests for the guess keyword rendered into the Gaussian route. + + guess=mix is rendered only for a species with multiplicity 1 and more than one radical. + guess=read (checkfile present) and guess=INDO (troubleshooting) take precedence over it. + """ + + 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'] + TS_DOUBLET_XYZ = ['H 0.0 0.0 0.0\nH 0.0 0.0 0.93\nH 0.0 0.0 1.86'] + + def render(self, species, checkfile=None, args=None, level=None): + """Render the Gaussian route line for ``species`` and return it.""" + project_directory = os.path.join(ARC_TESTING_PATH, 'test_GaussianAdapterGuessMixGating') + self.addCleanup(shutil.rmtree, project_directory, ignore_errors=True) + level = level if level is not None else Level(method='wb97xd', basis='def2tzvp') + job = GaussianAdapter(execution_type='incore', job_type='opt', + level=level, 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_singlet_biradical_gets_guess_mix(self): + """A multiplicity 1 species with two radicals gets guess=mix.""" + 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_closed_shell_singlet_has_no_guess_mix(self): + """A multiplicity 1 species with no declared radicals gets no guess keyword.""" + route = self.render(ARCSpecies(label='C2H4', xyz=self.C2H4_XYZ, multiplicity=1)) + self.assertNotIn('guess=', route) + self.assertIn(' wb97xd', route) + + def test_doublet_radical_has_no_guess_mix(self): + """A multiplicity 2 species gets no guess keyword.""" + route = self.render(ARCSpecies(label='OH', xyz=self.OH_XYZ, multiplicity=2)) + self.assertNotIn('guess=', route) + self.assertIn('uwb97xd', route) + + def test_triplet_has_no_guess_mix(self): + """A multiplicity 3 species gets no guess keyword.""" + route = self.render(ARCSpecies(label='O2', xyz=self.O2_XYZ, multiplicity=3)) + self.assertNotIn('guess=', route) + self.assertIn('uwb97xd', route) + + def test_singlet_ts_without_number_of_radicals_has_no_guess_mix(self): + """A multiplicity 1 TS with an undeclared number_of_radicals gets no guess keyword.""" + ts = ARCSpecies(label='TS0', is_ts=True, multiplicity=1, xyz=self.TS_XYZ) + self.assertIsNone(ts.number_of_radicals) + route = self.render(ts) + self.assertNotIn('guess=', route) + self.assertIn('ts', route) + + def test_doublet_ts_has_no_guess_mix(self): + """A multiplicity 2 TS gets no guess keyword. + + Uses the H + H2 abstraction TS (three electrons), since a doublet requires + an odd electron count. + """ + route = self.render(ARCSpecies(label='TS0', is_ts=True, multiplicity=2, xyz=self.TS_DOUBLET_XYZ)) + self.assertNotIn('guess=', route) + self.assertIn('ts', route) + + def test_singlet_biradical_ts_gets_guess_mix(self): + """A multiplicity 1 TS with two declared radicals gets guess=mix.""" + route = self.render(ARCSpecies(label='TS0', is_ts=True, multiplicity=1, + number_of_radicals=2, xyz=self.TS_XYZ)) + self.assertIn('guess=mix', route) + + def test_composite_method_is_gated_by_multiplicity_not_method_type(self): + """At a composite method the guess keyword follows the multiplicity, not is_species_restricted. + + is_species_restricted returns True for every composite job, so it cannot be the gate: + a doublet at CBS-QB3 carries no 'u' prefix yet Gaussian runs UB3LYP for it. + """ + composite = Level(method='cbs-qb3') + doublet_route = self.render(ARCSpecies(label='OH', xyz=self.OH_XYZ, multiplicity=2), + level=composite) + self.assertNotIn('guess=', doublet_route) + self.assertIn('cbs-qb3', doublet_route) + self.assertNotIn('ucbs-qb3', doublet_route) + biradical_route = self.render(ARCSpecies(label='O2_singlet', xyz=self.O2_XYZ, + multiplicity=1, number_of_radicals=2), + level=composite) + self.assertIn('guess=mix', biradical_route) + + def test_checkfile_guess_read_takes_precedence(self): + """With a checkfile present, guess=read is rendered instead of guess=mix.""" + checkfile_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, checkfile_dir, ignore_errors=True) + checkfile = os.path.join(checkfile_dir, 'check.chk') + with open(checkfile, 'w') as f: + f.write('dummy') + for spc in [ARCSpecies(label='O2_singlet', xyz=self.O2_XYZ, multiplicity=1, number_of_radicals=2), + 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 is rendered instead of guess=mix.""" + route = self.render(ARCSpecies(label='O2_singlet', xyz=self.O2_XYZ, + multiplicity=1, number_of_radicals=2), + args={'trsh': {'trsh': ['guess=INDO']}}) + self.assertIn('guess=INDO', route) + self.assertNotIn('guess=mix', route) + self.assertNotIn('guess=read', route) + + if __name__ == '__main__': unittest.main(testRunner=unittest.TextTestRunner(verbosity=2))