Displace the TS along the normal mode in Cartesian, not mass-weighted, coordinates - #970
Open
calvinp0 wants to merge 4 commits into
Open
Displace the TS along the normal mode in Cartesian, not mass-weighted, coordinates#970calvinp0 wants to merge 4 commits into
calvinp0 wants to merge 4 commits into
Conversation
calvinp0
force-pushed
the
fix_nmd_mass_weighted_displacement
branch
2 times, most recently
from
August 12, 2026 10:20
37aef25 to
16be65e
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #970 +/- ##
==========================================
+ Coverage 64.55% 64.66% +0.11%
==========================================
Files 119 119
Lines 39788 39872 +84
Branches 10307 10326 +19
==========================================
+ Hits 25684 25785 +101
+ Misses 11123 11105 -18
- Partials 2981 2982 +1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
calvinp0
force-pushed
the
fix_nmd_mass_weighted_displacement
branch
4 times, most recently
from
August 13, 2026 14:28
ebe4925 to
8747bab
Compare
calvinp0
force-pushed
the
fix_nmd_unsupported_ess_guard
branch
from
August 14, 2026 16:45
e59da7d to
336ed3f
Compare
calvinp0
force-pushed
the
fix_nmd_mass_weighted_displacement
branch
2 times, most recently
from
August 14, 2026 17:12
e4b0b2c to
5a94bb7
Compare
This was referenced Aug 14, 2026
calvinp0
force-pushed
the
fix_nmd_unsupported_ess_guard
branch
from
August 14, 2026 19:16
336ed3f to
2a02180
Compare
calvinp0
force-pushed
the
fix_nmd_mass_weighted_displacement
branch
from
August 14, 2026 19:16
5a94bb7 to
480b67a
Compare
calvinp0
force-pushed
the
fix_nmd_unsupported_ess_guard
branch
from
August 15, 2026 13:48
2a02180 to
9d6d661
Compare
calvinp0
force-pushed
the
fix_nmd_mass_weighted_displacement
branch
from
August 15, 2026 13:48
480b67a to
d387c15
Compare
calvinp0
force-pushed
the
fix_nmd_unsupported_ess_guard
branch
from
August 15, 2026 14:06
9d6d661 to
7d37f89
Compare
calvinp0
force-pushed
the
fix_nmd_mass_weighted_displacement
branch
from
August 15, 2026 14:06
d387c15 to
a74b0af
Compare
calvinp0
force-pushed
the
fix_nmd_unsupported_ess_guard
branch
from
August 20, 2026 05:51
7d37f89 to
af6ddf8
Compare
calvinp0
force-pushed
the
fix_nmd_mass_weighted_displacement
branch
from
August 20, 2026 05:51
a74b0af to
287335c
Compare
calvinp0
force-pushed
the
fix_nmd_unsupported_ess_guard
branch
from
August 22, 2026 06:23
af6ddf8 to
d1b67ea
Compare
calvinp0
force-pushed
the
fix_nmd_mass_weighted_displacement
branch
from
August 22, 2026 06:23
287335c to
9f468e6
Compare
calvinp0
marked this pull request as ready for review
August 22, 2026 08:53
calvinp0
force-pushed
the
fix_nmd_mass_weighted_displacement
branch
from
August 22, 2026 08:59
9f468e6 to
c86b5e0
Compare
calvinp0
force-pushed
the
fix_nmd_mass_weighted_displacement
branch
from
August 22, 2026 09:46
c86b5e0 to
a312612
Compare
calvinp0
force-pushed
the
fix_nmd_mass_weighted_displacement
branch
2 times, most recently
from
August 23, 2026 11:38
c7e7d2c to
4bc9793
Compare
calvinp0
force-pushed
the
fix_nmd_mass_weighted_displacement
branch
from
August 23, 2026 13:57
4bc9793 to
d5695c1
Compare
…e sentinel
parse_normal_mode_displacement() returns the sentinel `(None, None)` when it cannot read normal
mode displacements, and every consumer of it in ARC treats that tuple as data. Orca, Molpro,
Q-Chem, TeraChem, CFOUR and Psi4 each return the sentinel for every file, and Gaussian, xtb and
the YAML adapter return it when a file holds no modes. No adapter raises NotImplementedError, so
the `except NotImplementedError` that arc/checks/nmd.py wrapped the call in could never fire, and
the sentinel flowed straight through it to `normal_mode_disp[0]`:
TypeError: 'NoneType' object is not subscriptable
Reproduced on the shipped fixtures freq/orca_neg_freq_ts.out (a genuine negative-frequency Orca
TS), freq/orca_example_freq.log, freq/CH2O_freq_molpro.out, freq/C2H6_freq_QChem.out and
freq/CH2O_freq_terachem.dat. make_parser() only consults `raise_error` when the adapter result is
None, and `(None, None)` is not None, so the documented error is never raised.
get_normal_mode_displacement() returns the frequencies and the displacements, or None, warning and
naming the ESS via the existing determine_ess() when a file yields none. It is keyed on the
returned value rather than on a list of which adapters implement the method, so an adapter that
gains normal mode displacement parsing is picked up with no change here, and a supported ESS whose
log happens to hold no modes is skipped by the same path.
Fixed here rather than in make_parser(). Making `(None, None)` trigger the documented
NotImplementedError for every consumer is the more correct contract, but it does not fix the bug:
`raise_error` defaults to False and no production caller of any make_parser() product passes True,
so the callers would still receive the sentinel. Fixing it there would mean raising
unconditionally, which changes the return contract of all fifteen parse_* entry points at once,
including ts.py's get_rxn_zone_atom_indices(), which passes raise_error=False precisely to keep
going, for no gain over a local guard.
The helper lives in arc/parser/parser.py, beside parse_normal_mode_displacement whose sentinel it
interprets and beside determine_ess which it calls, so that both of its call sites, arc/checks/nmd.py
and arc/job/trsh.py, reach one shared implementation through an import that already exists on main:
arc/checks/nmd.py imports `from arc.parser import parser` and arc/job/trsh.py imports
`from arc.parser.parser import ...`. The module-level import graph of the arc package is therefore
unchanged, verified by walking every module's top-level imports on both revisions. Defining the
helper in arc/checks/nmd.py instead would have made arc/job/trsh.py import arc.checks.nmd, an edge
main does not have and one that closes a cycle, since arc/checks/nmd.py's own imports reach
arc.job.trsh back through arc/__init__.py's eager package imports. arc.parser.parser reaches
neither arc.checks nor arc.job.trsh at module level, in either direction.
Searched for an existing helper before adding one, by behaviour rather than by name: for any
"is this ESS supported for this parse method" predicate, for any guard against a parse method that
silently returns nothing, and for any registry of adapter capabilities. None exists. settings'
supported_ess lists the programs ARC can run jobs on, not what each parser adapter can read from
their output, and cannot answer this. determine_ess() is reused for the ESS name in the warning.
The helper's contract is tested next to it, over a Gaussian, a YAML and an xtb log for the parsed
case and over the six unreadable fixtures plus freq/yml_no_freqs.yml for the None case.
ts.py can reuse the helper when get_rxn_zone_atom_indices(), which passes the same sentinel into
get_rms_from_normal_mode_disp(), is fixed. That third site is left alone here because it needs a
decision about what an empty reaction zone should mean, not a guard.
…ormal modes
trsh_negative_freq() unpacked `freqs, normal_modes_disp` from parse_normal_mode_displacement()
and immediately evaluated `len(normal_modes_disp)`. For the ESS output files that yield no normal
mode displacements the parser returns the sentinel `(None, None)`, so that line raised:
TypeError: object of type 'NoneType' has no len()
Reproduced on the shipped fixtures freq/orca_neg_freq_ts.out, freq/orca_example_freq.log,
freq/orca6_example.out, freq/CH2O_freq_molpro.out, freq/C2H6_freq_QChem.out and
freq/CH2O_freq_terachem.dat.
It is reachable for any non-TS species: Scheduler.check_freq_job() and parse_composite_geo() both
call troubleshoot_negative_freq() when a frequency job converges with an imaginary frequency and
trsh_ess_jobs is on, and neither the scheduler nor ARC.execute catches it, so a single frequency
job run on one of those programs aborts the whole ARC run, discarding every other species and
reaction in it. The frequencies parse for those files while the displacements do not --
freq/orca_neg_freq_ts.out reports 15 frequencies with a minimum of -1271.62 cm^-1 -- so the
negative frequency is detected and the troubleshooter is entered on exactly the files whose
displacements are missing.
Routed through get_normal_mode_displacement(), which reports the missing displacements as None.
That helper takes the log file path rather than the job object so that both of its call sites can
use it; trsh_negative_freq() receives a path, not a JobAdapter. Its existing "Could not
troubleshoot negative frequency" branch is now reached instead of the exception, so the species is
left for the ordinary ESS troubleshooter.
NMD (check_normal_mode_displacement) only needs the reactive bond set in reactant index space - which is canonically given by the RMG family recipe (BREAK_BOND / FORM_BOND / CHANGE_BOND actions) plus r_label_map. Previously NMD routed through reaction.get_bonds, which hard-required self.atom_map and raised otherwise, so any atom-mapping failure killed the whole TS validation even though the reactive bonds were directly computable. Three changes: - get_bonds: move the atom_map check below the r_bonds_only=True short- circuit. Reactant bonds never touch atom_map, so r_bonds_only should not require one. - _get_reactive_bonds_from_family: new helper that reads the family's actions (via ReactionFamily(...).actions) and resolves labels through r_label_map into reactant-indexed bond tuples. - get_formed_and_broken_bonds / get_changed_bonds: when self.atom_map is None but family + product_dicts are present, use the helper. Otherwise unchanged. Emits a warning so the fallback path is diagnosable. Making the helper public and rejecting reverse-discovered matches. ARCReaction._get_reactive_bonds_from_family is named get_reactive_bonds_from_family so that the NMD check in arc/checks/nmd.py can use it. get_reactive_bonds_from_family() read product_dicts[0] unconditionally and applied the family recipe to its r_label_map. When that product dict was discovered in the reverse direction its r_label_map is in product global index space, and the recipe actions describe the reverse direction, so the emitted bonds referred to atom pairs that are not bonded in the reactant. For C=C[CH]CCC + CC=CCCC >> C=CC(CCC)C(C)[CH]CCC matched by Retroene, three of the six reported bonds did not exist, two of them H-H. Select the first forward-discovered product dict of the reaction's family instead, and return None when there is none, which routes the caller to the atom-map-derived bonds it already falls back to. Translating a reverse label map into reactant index space would require a graph isomorphism between the family's template products and the reaction's reactants, which is the atom mapping this method exists to bypass, and would additionally have to swap the formed and broken roles.
…rtesian frame
Three defects of analyze_ts_normal_mode_displacement() are fixed together, because each of them
misplaces the same displaced geometry x + a*w*d that the whole analysis is measured on.
The geometry and the modes came from different coordinate frames.
x was taken from reaction.ts_species.get_xyz() and d from parser.parse_normal_mode_displacement()
on the frequency job's log. get_xyz() returns final_xyz, which is written only by the opt handlers
(Scheduler.parse_opt_geo and parse_composite_geo) and is never refreshed by check_freq_job, while
Gaussian reports its normal modes in the standard orientation of the frequency job itself. When
get_xyz() falls through to initial_xyz, a conformer or a TS guess, the frames are unrelated
altogether. Adding a mode vector expressed in one frame to a coordinate expressed in another
corrupts the cross term of every displaced bond length, so the displaced geometry is not a rotation
of the correct one and the verdict is not rotation invariant. Any non-identity rotation between the
two frames breaks it, proper or improper; a pure translation is harmless.
get_ts_xyz_in_normal_mode_frame() sources the geometry from the same file the modes are parsed
from, and falls back to the species geometry, with a warning, when that file yields no geometry,
when parsing it raises, or when the parsed element symbol sequence differs from the species' one,
so mismatched atoms are never silently compared. The fallback is one-directional: when the species
has no geometry to compare against, the parsed geometry is used rather than discarded, since it is
the geometry the analysis exists to obtain and its absence would leave the caller nothing to
measure.
Verified against the shipped fixtures:
- Gaussian: parse_geometry() returns the last 'Standard orientation:' block, the frame the
'Frequencies --' mode vectors are printed in. For freq/TS_CH4_OH.log it reproduces the species
geometry to a 0.000 degree rotation (fit RMSD 1e-16), so the change is a no-op on the existing
tests.
- nosymm: with no 'Standard orientation:' present, parse_geometry() falls back to
'Input orientation:', which is the mode frame when Gaussian does not reorient and is also the
geometry ARC submitted, i.e. final_xyz.
- composite and optfreq: parse_composite_geo() and parse_opt_geo() set final_xyz from the same log
that check_freq_job() then reads, so those frames already agreed.
- xtb: parse_normal_mode_displacement() reads a sibling g98.out, and parse_geometry() returns None
for the freq output.out of normal_mode/HO2 and normal_mode/TS_0, so the fallback preserves the
previous behaviour exactly.
- Orca, Q-Chem, Molpro, TeraChem, CFOUR and Psi4 return (None, None) from
parse_normal_mode_displacement(), so this analysis cannot run for them either way. Molpro's
parse_geometry() raises TypeError, which the fallback absorbs rather than turning into a new
failure mode.
Fixed in nmd.py rather than by refreshing final_xyz from the frequency log in the scheduler, which
keeps the blast radius to the one consumer that needs the mode frame. final_xyz is the geometry ARC
reports, saves to the restart and output files, passes to Arkane and feeds to every subsequent job;
rewriting it with a reoriented copy would rotate all of those for no benefit, since none of them
depend on the frame.
Correcting the frame also exposes a precondition that was never enforced. The forming, breaking and
changed bond indices are indices into the concatenated reactant geometry, while a TS geometry may
order its atoms differently, for instance heavy atoms first where the reactant concatenation
interleaves them. The indices then address the wrong atoms entirely, and the resulting bond lengths
can happen to agree with the expected pattern, so the check returned a confident verdict about
atoms it was not looking at. Sharpening the geometry turns some of those accidental passes into
equally unfounded rejections. is_ts_atom_order_consistent_with_reactants() compares the TS element
symbol sequence against get_reactants_xyz(), which concatenates in the same order as the existing
atom count check above it, and the analysis now returns None rather than a bool when they differ.
Canonicalising the atom order belongs at ingestion in the TS adapters and is not attempted here.
The modes were not always there to displace along.
The call was guarded by an `except NotImplementedError` that can never fire, because the ESS parser
adapters return the sentinel `(None, None)` instead of raising, and that tuple reached
`normal_mode_disp[0]` as a TypeError. The analysis now goes through
parser.get_normal_mode_displacement() and returns None when the file yields no modes. Nothing up
the chain catches the exception it replaces: Scheduler.check_freq_job -> post_freq_actions ->
check_ts -> check_normal_mode_displacement -> analyze_ts_normal_mode_displacement holds no
try/except, nor does Scheduler.schedule_jobs, Scheduler.__init__, ARC.execute or ARC.py's main().
None rather than False is load-bearing for both of the new skip paths.
Scheduler.post_freq_actions() calls switch_ts() when ts_checks['NMD'] is False, so returning False
would discard converged transition states on the strength of indices known to be meaningless, or
search for a replacement that ARC cannot read either way. None is what distinguishes 'could not
check' from 'checked and failed', and is also the value populate_ts_checks() initialises the entry
to, so an unrun check and an uncheckable one now agree.
The displacement was mass-weighted where it should have been Cartesian.
get_displaced_xyzs() scaled each atom's normal mode displacement by the square root of its atomic
mass before adding it to the TS Cartesian coordinates. The normal mode displacements an ESS reports
are Cartesian displacements, so this scaling converted them into mass-weighted coordinates and then
added them to a Cartesian geometry.
The Cartesian convention is an exact identity rather than an empirical observation. Gaussian's
mass-weighted eigenvectors L satisfy sum(|L|^2) = 1, the Cartesian form is l_a = L_a / sqrt(m_a),
the reduced mass is defined as mu = 1 / sum(|l_a|^2), and Gaussian prints d_a = sqrt(mu) * l_a so
that sum(|d|^2) = 1. Hence sum(m_a * |d_a|^2) = mu exactly, and only for Cartesian displacements
normalized to unit length. Measured against the reduced mass the same files print, the median error
is 0.77% over 136 real modes.
The scaling was not a rescaling of the measured quantity. For a bond between atoms i and j,
sqrt(m_i) * d_i - sqrt(m_j) * d_j = sqrt(m_i) * (d_i - d_j) + (sqrt(m_i) - sqrt(m_j)) * d_j
and the second term is proportional to the absolute displacement of one atom rather than to the
relative displacement of the pair. A rigidly translating fragment has a true rate of exactly zero,
yet the weighted probe reports a non-zero change for any heteronuclear bond. Over 456 bonds the
spurious term exceeds the true one for 63.4% of bonds, with a median ratio of 2.46, and the best
single scale factor per structure still leaves a median residual of 20.6%. No choice of amplitude
could have compensated for that.
Two consequences follow. The scaled displacement translates the molecule, although a vibration is
orthogonal to translation: the median center of mass drift per unit amplitude is 0.001 Angstrom as
printed and 0.037 Angstrom when scaled, up to 0.299 Angstrom. And it reorders the bonds, which is
fatal for a test that ranks reactive against spectator bonds: the correlation with the first order
rate u.(d_i - d_j) over 927 bonds is 0.99999 as printed and 0.789 when scaled.
The displacement amplitude is recalibrated in the same change, because the original constant was
calibrated against a probe that included the mass factor. A bond counts as reactive only if its
length changes by more than 5% of the bond length, and that change is proportional to the
amplitude, so the amplitude sets the effective threshold on the underlying rate. For carbon the
factor is sqrt(12), so the original amplitude of 0.25 was an effective 0.87 for a carbon-dominated
reactive coordinate such as the C...C bond of a ring closure. Removing the factor while keeping
0.25 would shrink that probe by 3.46 and reject genuine transition states. The amplitude therefore
moves to 0.9, which restores the original effective scale, and is expressed as the module constant
DEFAULT_AMPLITUDE used by both the analysis default and check_normal_mode_displacement().
Three IRC-validated transition states corroborate the value: their acceptance floors are 0.676,
0.676 and 0.680, all of them rejected at 0.5. The upper edge of the usable band is about 1.25,
where second order contamination begins to reorder bonds, so 0.9 sits inside it. The constant is
not a discriminator between genuine and doubtful structures: the last genuine acceptance is at
0.680 and the first doubtful one at 0.682.
The amplitude interacts with two further absolute-scale constants that this change does not touch.
STD_FLOOR clamps the spread, so for the structures whose MAD falls below it the sigma test scales
with the amplitude rather than being invariant to it, and DIRECTIONALITY_MIN_DELTA is likewise an
absolute threshold.
The weights argument is removed from get_displaced_xyzs() rather than defaulted off, because no
per-atom weight is meaningful when a mode is added to a Cartesian coordinate. The weights computed
by analyze_ts_normal_mode_displacement() still scale the compared bond lengths, which is a separate
use and is left unchanged.
nmd_test.py covers the analysis level of all three: that the analysis returns None rather than
raising for the six unreadable ESS fixtures, that it still reaches a bool verdict for an xtb log,
and that the scheduler does not switch the TS on an unknown verdict. The contract of
parser.get_normal_mode_displacement() itself is tested beside it in arc/parser/parser_test.py.
Validating the TS mode against family-recipe bonds, not only atom-map-derived bonds.
The reaction atom map is only determined up to permutations of chemically
equivalent atoms. For reaction CCO[O] <=> C=C + [O]O (HO2 elimination,
benchmark reaction 04) the map assigns the heavy atoms and the transferring
beta-H correctly, but crosses the four spectator H atoms between the two
carbons of the ethylene product (all four H positions there are equivalent,
so the map is chemically valid). Pulling product bonds back through such a
permuted map inflates the formed/broken sets to 5 formed + 6 broken bonds
(instead of 1 formed + 2 broken + 1 changed), and NMD then falsely rejected
the genuine concerted-elimination saddle (imag ~-1074 1/cm) because the
spurious spectator C-H 'reactive' bonds show no motion along the mode.
Fix: derive candidate reactive-bond sets in NMD via the new
get_bond_change_candidates(), which tries the RMG-family-recipe-derived
bonds (canonical, atom-map independent; the path already existed for the
atom_map-is-None case) first, keeping the atom-map-derived sets as a
fallback candidate. Renamed ARCReaction._get_reactive_bonds_from_family to
the public get_reactive_bonds_from_family so NMD can use it.
Verified against the real benchmark saddles: the -1074 saddle now passes
NMD while the spurious -2144, -238, and -531 saddles of the same TS search
still fail; all existing NMD fixtures classify unchanged.
get_bond_change_candidates() now skips the atom-map-derived candidate when no atom
map is available, since reaching it without one raises ReactionError.
The HO2 elimination regression test probes at DEFAULT_AMPLITUDE rather than at the 0.25
it was written against, because that constant was calibrated against the mass-weighted
probe this same change removes. Measured on the two shipped saddles, the genuine one is
accepted from 0.5 upward and rejected at 0.25, and the wrong one is rejected at 0.25,
0.5, 0.676, 0.9, 1.0 and 1.25 alike. The test still discriminates what it was written to
discriminate: with get_bond_change_candidates() reduced to the atom-map-derived candidate
alone, the genuine saddle is rejected at DEFAULT_AMPLITUDE.
check_normal_mode_displacement() selects DEFAULT_AMPLITUDE only for an amplitude of None.
The parameter is typed float | list, and analyze_ts_normal_mode_displacement() treats both an
amplitude of 0 and an empty list as "no amplitude to probe": a scalar is wrapped into a
single-entry list, entries that are falsy are skipped by the loop, and an exhausted list yields
False. Selecting the default on any falsy value therefore replaced two values the analysis defines
a meaning for, and the substitution left no trace in the log. Every value other than None is now
passed through as given.
calvinp0
force-pushed
the
fix_nmd_mass_weighted_displacement
branch
from
August 23, 2026 15:25
d5695c1 to
cc75dd0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Base:
main. This branch absorbs #967 (frequency-log geometry frame) and #968 (ESSs that report no normal modes), both flattened into this PR and now closed. Three commits, no file touched by more than one of them.All three fix defects at the head of
analyze_ts_normal_mode_displacement(), and each of them misplaces the same displaced geometryx + A·dthat the entire analysis is measured on. They are split by cause, not by file.(None, None)reach a subscriptTypeErrorends the whole ARC runPart 1 — an ESS that reports no modes must not kill the run
parse_normal_mode_displacement()returns the sentinel(None, None)when it cannot read displacements, and every consumer in ARC treated that tuple as data. Only 3 of ARC's 9 concrete adapters return real data (Gaussian, xtb, YAML); CFour, Molpro, Orca, Psi4, QChem and TeraChem return the sentinel for every file, and Gaussian, xtb and YAML return it for a file with no modes.analyze_ts_normal_mode_displacement()guarded the parse withexcept NotImplementedError, which no adapter ever raises, so the guard was dead code and(None, None)fell through tonormal_mode_disp[0]→TypeError: 'NoneType' object is not subscriptable. Nothing up the chain catches it:Scheduler.check_freq_job→post_freq_actions→check_ts→check_normal_mode_displacement→ the analysis holds notry/except, nor doesScheduler.schedule_jobs,Scheduler.__init__,ARC.executeorARC.py::main(). One Orca frequency job was enough to end the run.arc/job/trsh.py::trsh_negative_freq()unpacked the sentinel and evaluatedlen(normal_modes_disp)→TypeError: object of type 'NoneType' has no len(). Reproduced on the shipped fixturesfreq/orca_neg_freq_ts.out,freq/orca_example_freq.log,freq/orca6_example.out,freq/CH2O_freq_molpro.out,freq/C2H6_freq_QChem.out,freq/CH2O_freq_terachem.dat. It is reachable for any non-TS species:Scheduler.check_freq_job()andparse_composite_geo()both calltroubleshoot_negative_freq()when a frequency job converges with an imaginary frequency andtrsh_ess_jobsis on. Frequencies parse for those files while displacements do not (orca_neg_freq_ts.outreports 15 frequencies, minimum −1271.62 cm⁻¹), so the troubleshooter is entered on exactly the files whose displacements are missing. It now reaches its existing "Could not troubleshoot negative frequency" branch and the species is left to the ordinary ESS troubleshooter.parser.get_normal_mode_displacement()returns the frequencies and displacements, orNone, warning and naming the ESS via the existingdetermine_ess(). It is keyed on the returned value, not on a list of which adapters implement the method, so an adapter that gains parsing is picked up with no change here, and a supported ESS whose log happens to hold no modes is skipped by the same path. It takes a log file path rather than aJobAdapterbecausetrsh_negative_freq()receives a path, and returns the frequencies alongside the displacements because that caller needs both.make_parser(). Making(None, None)trigger the documentedNotImplementedErroris the more correct contract but does not fix the bug:raise_errordefaults toFalseand no production caller passesTrue, so callers would still receive the sentinel. Raising unconditionally would change the return contract of all fifteenparse_*entry points at once, includingts.py::get_rxn_zone_atom_indices(), which passesraise_error=Falseprecisely to keep going.arc/parser/parser.py— besideparse_normal_mode_displacement()whose sentinel it interprets and besidedetermine_ess()which it calls. Both call sites already import that module onmain(arc/checks/nmd.pyasfrom arc.parser import parser,arc/job/trsh.pyasfrom arc.parser.parser import ...), so one shared implementation is reached at module level with no new import edge. Defining it inarc/checks/nmd.pywould have madearc/job/trsh.pyimportarc.checks.nmd, an edgemaindoes not have and one that closes a cycle, sincenmd.py's imports reacharc.job.trshback througharc/__init__.py's eager package imports (CodeQLpy/unsafe-cyclic-import). Measured, not asserted: every module's top-level imports were walked onorigin/mainand on this branch (followingif TYPE_CHECKINGandtry/except, excluding function bodies) — the production import graph is identical; the only difference anywhere is the test modulearc/checks/nmd_test.py. Import order was also checked both ways in fresh interpreters run from an isolated directory.Part 2 — the geometry must come from the frequency log
The geometry came from
reaction.ts_species.get_xyz(), i.e.final_xyz, which only the opt handlers (Scheduler.parse_opt_geo,parse_composite_geo) write and whichcheck_freq_jobnever refreshes. The modes came from the frequency job's log, and Gaussian reports them in that job's own standard orientation. Whenget_xyz()falls through toinitial_xyz, a conformer or a TS guess, the frames are unrelated altogether.Measured over 996 real benchmark frequency jobs: 82% agree to within 1°, but 17.6% differ by ~180° — exact rigid rotations (fit RMSD ~1e-16), not numerical noise. Adding a mode vector expressed in one frame to a coordinate expressed in another corrupts the cross term of every displaced bond length, so the rotation-invariance of a bond length does not rescue the result. Any non-identity rotation breaks it, proper or improper; a pure translation is harmless.
get_ts_xyz_in_normal_mode_frame()sources the geometry from the same file the modes are parsed from. It falls back to the species geometry, with a warning, when that file yields no geometry, when parsing it raises, or when the parsed element symbol sequence differs from the species' one, so mismatched atoms are never silently compared. The fallback is one-directional: when the species has no geometry to compare against, the parsed geometry is used rather than discarded — it is the geometry the analysis exists to obtain, and the caller immediately doeslen(ts_xyz['symbols']).Per-ESS behaviour, verified against the shipped fixtures:
parse_geometry()returns the lastStandard orientation:block — the frame theFrequencies --vectors are printed in. Onfreq/TS_CH4_OH.logit reproduces the species geometry to a 0.000° rotation (fit RMSD 1e-16), so this is a no-op on the existing testsnosymmStandard orientation:, falls back toInput orientation:, which is the mode frame when Gaussian does not reorient and is also the geometry ARC submitted, i.e.final_xyzparse_composite_geo()andparse_opt_geo()setfinal_xyzfrom the same logcheck_freq_job()then reads, so those frames already agreedg98.outandparse_geometry()returnsNonefor the freqoutput.outofnormal_mode/HO2andnormal_mode/TS_0, so the fallback preserves previous behaviour exactly(None, None), so the analysis cannot run either way. Molpro'sparse_geometry()raisesTypeError, which the fallback absorbs rather than turning into a new failure modeIndependently confirmed by the Eckart condition. Modes expressed in their own frame satisfy
Σ mₐ(rₐ − R_com) × dₐ = 0, and that quantity is frame-dependent, so it directly tests whether the pairing is consistent: 73% of affected jobs exceed 3σ in the opt frame, versus 0 of 111 in the freq frame.Fixed in
nmd.py, not by refreshingfinal_xyzin the scheduler, which keeps the blast radius to the one consumer that needs the mode frame.final_xyzis the geometry ARC reports, saves to the restart and output files, passes to Arkane and feeds to every subsequent job; rewriting it with a reoriented copy would rotate all of those for no benefit.Correcting the frame exposed a precondition that was never enforced. The forming, breaking and changed bond indices index into the concatenated reactant geometry, while a TS geometry may order its atoms differently — heavy atoms first, say, where the reactant concatenation interleaves them. The indices then address the wrong atoms entirely, and the resulting bond lengths can happen to agree with the expected pattern, so the check returned a confident verdict about atoms it was not looking at. Sharpening the geometry turns some of those accidental passes into equally unfounded rejections.
is_ts_atom_order_consistent_with_reactants()compares the TS element symbol sequence againstget_reactants_xyz(), and the analysis now returnsNonewhen they differ. Canonicalising the atom order belongs at ingestion in the TS adapters and is not attempted here.None, notFalse, and why it is load-bearingScheduler.post_freq_actions()callsswitch_ts()whents_checks['NMD']isFalse. ReturningFalsefor either new skip path would discard converged transition states on the strength of indices known to be meaningless, or search for a replacement that ARC cannot read either way.Noneis what distinguishes "could not check" from "checked and failed", and is also the valuepopulate_ts_checks()initialises the entry to, so an unrun check and an uncheckable one now agree. An ESS that cannot report modes is telling us nothing about the TS; it must not count as evidence against it.Part 3 — the displacement was mass-weighted where it must be Cartesian
get_displaced_xyzs()scaled each atom's displacement by√mₐbefore adding it to a Cartesian geometry. That is a unit error — it adds vectors from two different spaces. Removing it alone would shrink the probe step 3.46× for carbon and start rejecting genuine transition states, so the amplitude moves with it:0.25 → DEFAULT_AMPLITUDE = 0.9, since0.25 × √12 = 0.87 ≈ 0.9. The recalibration restores the original author's effective scale rather than overriding it — keeping0.25is what would break it. The two changes are one change; shipping either alone is wrong.Why
√mhas to go — five independent linesΣ|L|² = 1; the Cartesian form islₐ = Lₐ/√mₐ; the reduced mass is definedμ ≡ 1/Σₐ|lₐ|²; Gaussian printsdₐ = √μ·lₐ, soΣ|d|² = 1. ThereforeΣₐ mₐ|dₐ|² = μexactly — an identity that holds only for Cartesian unit-normalised vectors. Checked against the reduced mass the same files print, over 136 real modes: median error 0.77%, 99.2% within 2%.Σₐ mₐdₐ = 0). COM drift per unit amplitude: 0.001 Å median as printed vs 0.037 Å median / 0.299 Å max with√m— a 35× discrepancy.û·(dᵢ−dⱼ)over 927 bonds: 0.99999 as printed vs 0.789 with√m. For a test whose entire job is to rank reactive against spectator bonds, reordering is fatal.√mᵢ·dᵢ − √mⱼ·dⱼ = √mᵢ(dᵢ − dⱼ) + (√mᵢ − √mⱼ)·dⱼ. The second term is proportional to the absolute displacement of one atom, not the relative one, so a rigidly translating fragment (true rate exactly zero) reports non-zero for any heteronuclear bond. Measured|spurious|/|true|over 456 bonds: median 2.46, greater than 1 for 63.4% of bonds; the best single scale factor per structure still leaves a median residual of 20.6%.The
weightsargument is removed fromget_displaced_xyzs()rather than defaulted off, because no per-atom weight is meaningful when a mode is added to a Cartesian coordinate. The weightsanalyze_ts_normal_mode_displacement()computes still scale the compared bond lengths, which is a separate use and is left unchanged.Why the amplitude moves with it
A bond counts as reactive only if its length changes by more than 5% of the bond length, and that change is proportional to the amplitude — so the amplitude sets the effective threshold on the underlying rate.
Corroborated on IRC-validated structures. Acceptance floors, bisected, verdict monotone in amplitude:
reaction_08/freq_a5382reaction_08/freq_a9411r3_07/freq_a1758All three are rejected at 0.5 — and these are the transition states
reaction_08andr3_07published kinetics for. 0.9 clears the 0.68 floor with margin.Upper edge. On the labelled C3H7 fixture, separation holds from 0.1 through 1.5 and breaks at 2.0. Independently, agreement between the finite-step bond change and the first-order rate stays above r = 0.99 for all 109 structures up to amplitude 1.0, degrading for 1.8% at 1.25, 11% at 1.5 and 24% at 2.0 as second-order contamination starts reordering bonds. The usable band is roughly 0.68 – 1.25; 0.9 sits inside it.
The amplitude is the module constant
DEFAULT_AMPLITUDE, used by both the analysis default andcheck_normal_mode_displacement()inarc/checks/ts.py, so the two cannot drift apart again.What 0.9 does not do
r3_05/freq_a779(ν = −662.8) andr3_15/freq_a666(ν = −333.6).r3_15looks wrong on mode character — labelled an H-migration, yet μ = 3.22 and the migrating H is not among the top three movers.How it was verified
test_get_displaced_xyzs_conserves_the_center_of_mass→0.03234 not less than 0.005test_get_displaced_xyzs_moves_heavy_atoms_less_than_hydrogens→0.008660254 != 0.0025, i.e.0.008660 / 0.0025 = 3.4641 = √12, the carbon inflation measured directly.iC3H7 <=> nC3H7; TS4 and TS7 are wrong saddle points on the same PES) it is two-sided:DEFAULT_AMPLITUDE = 0.02→AssertionError: False != True : TS3(correct TS rejected);= 2.0→AssertionError: True != False : TS4(wrong saddle accepted).None) verdict does not trigger a TS switch.arc/parser/parser_test.pyover a Gaussian, a YAML and an xtb log for the parsed case and over the six unreadable fixtures plusfreq/yml_no_freqs.ymlfor theNonecase;nmd_test.pycovers the analysis returningNonerather than raising for the six unreadable fixtures and still reaching aboolverdict for an xtb log;trsh_test.pyassertstrsh_negative_freq()returns its four empty lists over those six fixtures rather than raising, proved failing before the fix with theTypeErrorquoted above.What changed in the existing tests
x ± A·d, which pins the full coordinate array (atol=1e-10) and cannot drift with the implementation, plus the physical tests above.assertGreater(float(sigma), 10 * nmd.SIGMA_THRESHOLD), i.e. σ > 30. The removed byte-exact golden was σ = 14.7891 — the value the√mcode produces, so it had to be rewritten rather than checked by this PR; the corrected code gives σ ≈ 122 on this fixture (105.7 – 128.0 across the platforms measured — BLAS differences move the trailing digits, which is why it is a bound and not a golden). The bound sits far above the regression value and far below the corrected one, so it fails loudly if the√mweighting ever comes back, without enshrining a float.baseline,std, the reactive bond diffs) were recomputed rather than removed, and checked for a preserved verdict.Deliberately not addressed here
√mdisplacer.arc/species/converter.py::displace_xyz()does the same operation with the same weighting (use_weights=Trueby default); its production caller istrsh_negative_freq(), which perturbs a geometry to seed a fresh optimization. The Part 3 argument does not transfer unchanged: that code does not measure relative bond rates, it only needs a step that leaves the saddle, and its amplitude ladder (0.25 → 2.5) is itself calibrated against the weighted step. Correcting it is a separate change with its own calibration.get_weights_from_xyz()remains live; its result now feeds onlyget_bond_length_changes()andget_bond_length_changes_baseline_and_std(). Whether that weighting is correct is an open question this PR neither settles nor endorses.(mᵢmⱼ)^¼is not√μand is not a reduced-mass proxy — it is√(wᵢwⱼ)withw = √m, the geometric mean of √mass; normalised to C–C it gives C–H 0.538 where√μgives 0.394. Measured over 99 structures it inflates the reactive bond in 47.5% of cases and suppresses in only 5% (median ratio 1.000), so it biases toward false accepts, not against X–H.STD_FLOOR = 1e-4clamps the spread: 53/99 structures have a MAD below it (19/99 have a single background bond, MAD ≡ 0), so for those σ is(min − baseline)/floorand therefore scales with the amplitude rather than being invariant to it.DIRECTIONALITY_MIN_DELTA = 0.005 Åis likewise absolute. A principled pass should address all three together.arc/checks/ts.py::get_rxn_zone_atom_indices()passes the same sentinel intoget_rms_from_normal_mode_disp(). Left alone because it needs a decision about what an empty reaction zone should mean for the caller, not a guard — unlike the two sites fixed here, which both already had a "give up cleanly" branch that the crash was jumping over. It can reuseget_normal_mode_displacement()when it is fixed.normal_mode_disp[0]unconditionally and never checks the sign of the corresponding frequency, so on a structure with no imaginary frequency it silently analyses the lowest real mode and returns a confident verdict. The guard would be toreturn Noneunlessfreqs[0] < 0.if n_ts != n_expected: return False, is the same class of missing precondition but still returnsFalseon this branch. It is addressed separately in Support reactions where a species participates more than once (A + A) #974.dr_ij/ds = û_ij·(dᵢ−dⱼ)is amplitude-free and mass-weighting-free, which would remove the calibration coupling in Part 3. It does not subsume Part 2: both criteria are already exactly invariant to a common rigid rotation, whereas Part 2 concerns relative misorientation between a geometry taken from one file and a mode taken from another.What I searched for
get_displaced_xyzs()(fixed here) andconverter.py::displace_xyz()(scoped out above). They are not consolidated because their callers want different things — one measures, one perturbs.weights=Truewas a deliberate, documented choice: the full history ofarc/checks/nmd.py, every docstring, and the tests. The commit that introduced it has an empty body and nothing justifies it.trsh_negative_freqdoes it inline for a different purpose; there was no reusable helper to import.settings['supported_ess']lists the programs ARC can run, not what each parser adapter can read.determine_ess()is reused for the ESS name in the warning.get_element_massfromarc/common.pyrather than adding a mass lookup, and routed vector-length maths in the new tests througharc/species/vectors.py::get_vector_lengthrather than inliningnp.linalg.norm.🤖 Generated with Claude Code
Also folds in
feature_nmd_family_recipeThe reaction atom map is only determined up to permutations of chemically equivalent atoms, and a valid-but-permuted map inflates the map-derived formed/broken sets with spurious pairs no real mode can satisfy. On
CCO[O] <=> C=C + [O]Othe map crosses the four ethylene spectator hydrogens, yielding 5 formed + 6 broken instead of 1 formed + 2 broken + 1 changed — and NMD rejected the genuine −1074 cm⁻¹ concerted-elimination saddle.ARCReaction.get_reactive_bonds_from_family()now derives the reactive bonds straight from the RMG family recipe andr_label_map, in reactant index space, using only forward-discovered product dicts — a reverse-discoveredr_label_mapis in product space and produced three non-existent bonds, two of them H–H, on a Retroene case.get_bond_change_candidates()tries that first and keeps the map-derived set as a fallback, which also lets NMD run at all when atom mapping fails. Two Gaussian frequency fixtures ship with it.One interaction with this PR's recalibration, worth noting. The folded branch's HO₂-elimination test pinned
amplitude=0.25. With the √mass factor removed and the default moved toDEFAULT_AMPLITUDE = 0.9, the genuine saddle is rejected at 0.25 — measured good/wrong = False/False at 0.25, then True/False at 0.5, 0.676, 0.9, 1.0 and 1.25. The test now probes atnmd.DEFAULT_AMPLITUDEand still discriminates: withget_bond_change_candidatesreduced to the map-derived candidate, the genuine saddle is rejected at 0.9.