Displace the TS along the normal mode in Cartesian, not mass-weighted, coordinates - #970
Draft
calvinp0 wants to merge 1 commit into
Draft
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 @@
## fix_nmd_unsupported_ess_guard #970 +/- ##
==============================================================
Coverage 64.22% 64.23%
==============================================================
Files 119 119
Lines 39564 39565 +1
Branches 10264 10264
==============================================================
+ Hits 25410 25414 +4
- Misses 11179 11181 +2
+ Partials 2975 2970 -5
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_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_mass_weighted_displacement
branch
from
August 14, 2026 19:16
5a94bb7 to
480b67a
Compare
calvinp0
force-pushed
the
fix_nmd_mass_weighted_displacement
branch
from
August 15, 2026 13:48
480b67a to
d387c15
Compare
…, coordinates
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.
calvinp0
force-pushed
the
fix_nmd_mass_weighted_displacement
branch
from
August 15, 2026 14:06
d387c15 to
a74b0af
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.
Top of a 3-PR stack. Reading order: #967 → #968 → this. It sits on the other two only because all three touch the head of
analyze_ts_normal_mode_displacement(); it is independent in substance and is the only one of the three carrying physics, so it is the one to revert alone if the calibration is ever disputed.The 30-second version
get_displaced_xyzs()multiplied each atom's normal mode displacement by√mₐbefore adding it to a Cartesian geometry. That is a unit error — it adds vectors from two different spaces.0.25 → DEFAULT_AMPLITUDE = 0.9.0.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.Part 1 — why
√mhas to goFive independent lines. None of them is a preference.
Σ|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·dis a mass-weighted displacement; the coordinates it was added to are Cartesian.Σₐ mₐdₐ = 0), so displacing along one must not translate the molecule. 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%.Part 2 — 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.
√mwas inflating the step for heavy atoms, so0.25behaved like ~0.87 for a carbon-dominated reactive coordinate (a ring-closure C···C, where both reactive atoms are carbon, is the maximum-effect case). Remove the factor and keep0.25, and that probe shrinks 3.46×.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 now 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.Part 3 — 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 and fails if the constant leaves the safe band in either direction:DEFAULT_AMPLITUDE = 0.02→AssertionError: False != True : TS3(correct TS rejected)DEFAULT_AMPLITUDE = 2.0→AssertionError: True != False : TS4(wrong saddle accepted)arc/checks/passes in full — 68 tests.What changed in the 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:√mcode produces, so it had to be rewritten rather than checked by this PR;√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 isarc/job/trsh.py::trsh_negative_freq, which perturbs a geometry to seed a fresh optimization. This PR does not touch it. The Part 1 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 one production caller isanalyze_ts_normal_mode_displacement(), and its result now feeds only the bond-length machinery (get_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.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.analyze_ts_normal_mode_displacement()takesnormal_mode_disp[0]unconditionally andget_normal_mode_displacement()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.dr_ij/ds = û_ij·(dᵢ−dⱼ)is amplitude-free and mass-weighting-free, which would remove the calibration coupling in Part 2. It does not subsume Use the frequency log's geometry for the TS normal mode displacement … #967: both criteria are already exactly invariant to a common rigid rotation, whereas Use the frequency log's geometry for the TS normal mode displacement … #967 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) andarc/species/converter.py::displace_xyz()(scoped out above, with reasons). They are not consolidated in this PR 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.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.