Skip to content

Displace the TS along the normal mode in Cartesian, not mass-weighted, coordinates - #970

Open
calvinp0 wants to merge 4 commits into
mainfrom
fix_nmd_mass_weighted_displacement
Open

Displace the TS along the normal mode in Cartesian, not mass-weighted, coordinates#970
calvinp0 wants to merge 4 commits into
mainfrom
fix_nmd_mass_weighted_displacement

Conversation

@calvinp0

@calvinp0 calvinp0 commented Aug 12, 2026

Copy link
Copy Markdown
Member

⚠️ This PR changes a physical quantity. It has had a quantum-chemistry review in addition to a code review. The calibration argument in Part 3 is the part to read closely.

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 geometry x + A·d that the entire analysis is measured on. They are split by cause, not by file.

# cause symptom
1 modes reported as (None, None) reach a subscript uncaught TypeError ends the whole ARC run
2 geometry and modes come from different coordinate frames verdict is not rotation-invariant
3 the displacement is mass-weighted where it must be Cartesian measures a different quantity, then reorders the bonds

Part 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 with except NotImplementedError, which no adapter ever raises, so the guard was dead code and (None, None) fell through to normal_mode_disp[0]TypeError: 'NoneType' object is not subscriptable. Nothing up the chain catches it: Scheduler.check_freq_jobpost_freq_actionscheck_tscheck_normal_mode_displacement → the analysis holds no try/except, nor does Scheduler.schedule_jobs, Scheduler.__init__, ARC.execute or ARC.py::main(). One Orca frequency job was enough to end the run.
  • The same defect sat at a second site. arc/job/trsh.py::trsh_negative_freq() unpacked the sentinel and evaluated len(normal_modes_disp)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, 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. Frequencies parse for those files while displacements do not (orca_neg_freq_ts.out reports 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.
  • New parser.get_normal_mode_displacement() returns the frequencies and displacements, or None, warning and naming the ESS via the existing determine_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 a JobAdapter because trsh_negative_freq() receives a path, and returns the frequencies alongside the displacements because that caller needs both.
  • Why not fix it in make_parser(). Making (None, None) trigger the documented NotImplementedError is the more correct contract but does not fix the bug: raise_error defaults to False and no production caller passes True, so callers would still receive the sentinel. Raising unconditionally would change the return contract of all fifteen parse_* entry points at once, including ts.py::get_rxn_zone_atom_indices(), which passes raise_error=False precisely to keep going.
  • Why the helper lives in arc/parser/parser.py — beside parse_normal_mode_displacement() whose sentinel it interprets and beside determine_ess() which it calls. Both call sites already import that module on main (arc/checks/nmd.py as from arc.parser import parser, arc/job/trsh.py as from arc.parser.parser import ...), so one shared implementation is reached at module level with no new import edge. Defining it in arc/checks/nmd.py would have made arc/job/trsh.py import arc.checks.nmd, an edge main does not have and one that closes a cycle, since nmd.py's imports reach arc.job.trsh back through arc/__init__.py's eager package imports (CodeQL py/unsafe-cyclic-import). Measured, not asserted: every module's top-level imports were walked on origin/main and on this branch (following if TYPE_CHECKING and try/except, excluding function bodies) — the production import graph is identical; the only difference anywhere is the test module arc/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 which check_freq_job never refreshes. The modes came from the frequency job's log, and Gaussian reports them in that job's own standard orientation. When get_xyz() falls through to initial_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 does len(ts_xyz['symbols']).

  • Per-ESS behaviour, verified against the shipped fixtures:

    ESS / path outcome
    Gaussian parse_geometry() returns the last Standard orientation: block — the frame the Frequencies -- vectors are printed in. On freq/TS_CH4_OH.log it reproduces the species geometry to a 0.000° rotation (fit RMSD 1e-16), so this is a no-op on the existing tests
    nosymm with no Standard orientation:, 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 / optfreq parse_composite_geo() and parse_opt_geo() set final_xyz from the same log check_freq_job() then reads, so those frames already agreed
    xtb modes come from 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 previous behaviour exactly
    Orca, Q-Chem, Molpro, TeraChem, CFOUR, Psi4 return (None, None), so the analysis cannot run either way. Molpro's parse_geometry() raises TypeError, which the fallback absorbs rather than turning into a new failure mode
  • Independently 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 refreshing final_xyz 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.

  • 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 against get_reactants_xyz(), and the analysis now returns None when they differ. Canonicalising the atom order belongs at ingestion in the TS adapters and is not attempted here.

None, not False, and why it is load-bearing

Scheduler.post_freq_actions() calls switch_ts() when ts_checks['NMD'] is False. Returning False for 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. 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. 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, since 0.25 × √12 = 0.87 ≈ 0.9. The recalibration restores the original author's effective scale rather than overriding it — keeping 0.25 is what would break it. The two changes are one change; shipping either alone is wrong.

Why √m has to go — five independent lines

  1. Gaussian prints Cartesian displacements. Per Vibrational Analysis in Gaussian (Ochterski, https://gaussian.com/vib/), and provable from the file itself: mass-weighted eigenvectors L satisfy Σ|L|² = 1; the Cartesian form is lₐ = Lₐ/√mₐ; the reduced mass is defined μ ≡ 1/Σₐ|lₐ|²; Gaussian prints dₐ = √μ·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%.
  2. So the old line added vectors from two different spaces.
  3. It moved the centre of mass. A vibration is orthogonal to translation (Σₐ 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.
  4. It reordered the bonds. Correlation with the first-order rate û·(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.
  5. The algebra shows it was never a rescaling√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%.

√m did not scale the measurement — it measured a different quantity. No choice of amplitude could compensate. That is why this is a correctness fix, not a tuning disagreement.

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 analyze_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:

    structure ν (cm⁻¹) acceptance floor
    reaction_08/freq_a5382 −1743.2 0.676
    reaction_08/freq_a9411 −1743.2 0.676
    r3_07/freq_a1758 −1466.8 0.680

    All three are rejected at 0.5 — and these are the transition states reaction_08 and r3_07 published 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 and check_normal_mode_displacement() in arc/checks/ts.py, so the two cannot drift apart again.

What 0.9 does not do

  • It does not separate genuine from doubtful. The last genuine acceptance is at 0.680 and the first doubtful one at 0.682 — 0.002 apart. That is a coincidence, not a threshold, and the constant should not be read as one.
  • Two structures are admitted at 0.75 and 0.9 alike and need IRC to adjudicate: r3_05/freq_a779 (ν = −662.8) and r3_15/freq_a666 (ν = −333.6). r3_15 looks wrong on mode character — labelled an H-migration, yet μ = 3.22 and the migrating H is not among the top three movers.
  • The required amplitude tracks how much of the imaginary mode sits in the secondary reactive coordinate, which is family-dependent: a clean single-H-transfer control has a floor of 0.467, while the ring-forming cases need 0.68. A single global constant may be the wrong shape long-term — a follow-up, not this PR.

How it was verified

  • Both new physical tests fail on the unfixed code, with the predicted numbers:
    • test_get_displaced_xyzs_conserves_the_center_of_mass0.03234 not less than 0.005
    • test_get_displaced_xyzs_moves_heavy_atoms_less_than_hydrogens0.008660254 != 0.0025, i.e. 0.008660 / 0.0025 = 3.4641 = √12, the carbon inflation measured directly.
  • A third test pins the separation itself — the property the amplitude governs, which the other two do not cover because they assert displacements rather than verdicts. On the labelled C3H7 fixture (TS3 is the correct TS for iC3H7 <=> nC3H7; TS4 and TS7 are wrong saddle points on the same PES) it is two-sided: DEFAULT_AMPLITUDE = 0.02AssertionError: False != True : TS3 (correct TS rejected); = 2.0AssertionError: True != False : TS4 (wrong saddle accepted).
  • Part 2: frames agree, a deliberately rotated geometry, an element symbol mismatch, a log with no geometry, a log that fails to parse, a species with no geometry at all, the atom-order guard (unit and end to end), and a scheduler-level test that an unknown (None) verdict does not trigger a TS switch.
  • Part 1: the helper's own contract is tested in arc/parser/parser_test.py 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; nmd_test.py covers the analysis returning None rather than raising for the six unreadable fixtures and still reaching a bool verdict for an xtb log; trsh_test.py asserts trsh_negative_freq() returns its four empty lists over those six fixtures rather than raising, proved failing before the fix with the TypeError quoted above.

What changed in the existing tests

  • 5 frozen golden-coordinate comparisons were removed. Every one had been computed from the buggy formula, so "correcting" them would have re-frozen numbers derived from the thing under repair. They are replaced by the definitional assertion x ± A·d, which pins the full coordinate array (atol=1e-10) and cannot drift with the implementation, plus the physical tests above.
  • The σ golden in the integration test became a bound, assertGreater(float(sigma), 10 * nmd.SIGMA_THRESHOLD), i.e. σ > 30. The removed byte-exact golden was σ = 14.7891 — the value the √m code 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 √m weighting ever comes back, without enshrining a float.
  • Downstream scalar goldens (baseline, std, the reactive bond diffs) were recomputed rather than removed, and checked for a preserved verdict.

Deliberately not addressed here

  • A second, independent √m displacer. arc/species/converter.py::displace_xyz() does the same operation with the same weighting (use_weights=True by default); its production caller is trsh_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.
  • The surviving bond-length weighting. get_weights_from_xyz() remains live; its result now feeds only get_bond_length_changes() and get_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ⱼ) with w = √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.
  • Two absolute-scale constants interact with the amplitude and are not recalibrated. STD_FLOOR = 1e-4 clamps 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)/floor and 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 into get_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 reuse get_normal_mode_displacement() when it is fixed.
  • An adjacent pre-existing bug: the analysis takes 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 to return None unless freqs[0] < 0.
  • The atom-count gate just above the new guards, if n_ts != n_expected: return False, is the same class of missing precondition but still returns False on this branch. It is addressed separately in Support reactions where a species participates more than once (A + A) #974.
  • A scoring redesign. Scoring each bond by the first-order rate 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

  • Every routine in ARC that displaces a geometry along a normal mode, by behaviour rather than name. There are two: get_displaced_xyzs() (fixed here) and converter.py::displace_xyz() (scoped out above). They are not consolidated because their callers want different things — one measures, one perturbs.
  • Whether weights=True was a deliberate, documented choice: the full history of arc/checks/nmd.py, every docstring, and the tests. The commit that introduced it has an empty body and nothing justifies it.
  • Whether a helper already resolved a geometry into the frame of a job's parsed modes — trsh_negative_freq does it inline for a different purpose; there was no reusable helper to import.
  • Whether any "is this ESS supported for this parse method" predicate or registry of adapter capabilities exists. None does — 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.
  • Reused get_element_mass from arc/common.py rather than adding a mass lookup, and routed vector-length maths in the new tests through arc/species/vectors.py::get_vector_length rather than inlining np.linalg.norm.

🤖 Generated with Claude Code


Also folds in feature_nmd_family_recipe

The 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]O the 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 and r_label_map, in reactant index space, using only forward-discovered product dicts — a reverse-discovered r_label_map is 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 to DEFAULT_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 at nmd.DEFAULT_AMPLITUDE and still discriminates: with get_bond_change_candidates reduced to the map-derived candidate, the genuine saddle is rejected at 0.9.

Comment thread arc/checks/nmd_test.py Fixed
@calvinp0
calvinp0 force-pushed the fix_nmd_mass_weighted_displacement branch 2 times, most recently from 37aef25 to 16be65e Compare August 12, 2026 10:20
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 64.66%. Comparing base (a08d314) to head (cc75dd0).
⚠️ Report is 1 commits behind head on main.

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     
Flag Coverage Δ
functionaltests 64.66% <ø> (+0.11%) ⬆️
unittests 64.66% <ø> (+0.11%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@calvinp0
calvinp0 force-pushed the fix_nmd_mass_weighted_displacement branch 4 times, most recently from ebe4925 to 8747bab Compare August 13, 2026 14:28
@calvinp0
calvinp0 force-pushed the fix_nmd_unsupported_ess_guard branch from e59da7d to 336ed3f Compare August 14, 2026 16:45
@calvinp0
calvinp0 force-pushed the fix_nmd_mass_weighted_displacement branch 2 times, most recently from e4b0b2c to 5a94bb7 Compare August 14, 2026 17:12
@calvinp0
calvinp0 force-pushed the fix_nmd_unsupported_ess_guard branch from 336ed3f to 2a02180 Compare August 14, 2026 19:16
@calvinp0
calvinp0 force-pushed the fix_nmd_mass_weighted_displacement branch from 5a94bb7 to 480b67a Compare August 14, 2026 19:16
@calvinp0
calvinp0 force-pushed the fix_nmd_unsupported_ess_guard branch from 2a02180 to 9d6d661 Compare August 15, 2026 13:48
@calvinp0
calvinp0 force-pushed the fix_nmd_mass_weighted_displacement branch from 480b67a to d387c15 Compare August 15, 2026 13:48
@calvinp0
calvinp0 force-pushed the fix_nmd_unsupported_ess_guard branch from 9d6d661 to 7d37f89 Compare August 15, 2026 14:06
@calvinp0
calvinp0 force-pushed the fix_nmd_mass_weighted_displacement branch from d387c15 to a74b0af Compare August 15, 2026 14:06
@calvinp0
calvinp0 force-pushed the fix_nmd_unsupported_ess_guard branch from 7d37f89 to af6ddf8 Compare August 20, 2026 05:51
@calvinp0
calvinp0 force-pushed the fix_nmd_mass_weighted_displacement branch from a74b0af to 287335c Compare August 20, 2026 05:51
@calvinp0
calvinp0 force-pushed the fix_nmd_unsupported_ess_guard branch from af6ddf8 to d1b67ea Compare August 22, 2026 06:23
@calvinp0
calvinp0 force-pushed the fix_nmd_mass_weighted_displacement branch from 287335c to 9f468e6 Compare August 22, 2026 06:23
@calvinp0
calvinp0 marked this pull request as ready for review August 22, 2026 08:53
Copilot AI lite review requested due to automatic review settings August 22, 2026 08:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@calvinp0
calvinp0 force-pushed the fix_nmd_mass_weighted_displacement branch from 9f468e6 to c86b5e0 Compare August 22, 2026 08:59
@github-actions github-actions Bot added the Module: trsh Troubleshooting label Aug 22, 2026
@calvinp0
calvinp0 changed the base branch from fix_nmd_unsupported_ess_guard to main August 22, 2026 09:05
@calvinp0
calvinp0 force-pushed the fix_nmd_mass_weighted_displacement branch from c86b5e0 to a312612 Compare August 22, 2026 09:46
@calvinp0
calvinp0 force-pushed the fix_nmd_mass_weighted_displacement branch 2 times, most recently from c7e7d2c to 4bc9793 Compare August 23, 2026 11:38
@alongd
alongd requested a lite review from Copilot August 23, 2026 13:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 11 changed files in this pull request and generated 2 comments.

Comment thread arc/checks/nmd.py Outdated
Comment thread arc/checks/ts.py Outdated
@calvinp0
calvinp0 force-pushed the fix_nmd_mass_weighted_displacement branch from 4bc9793 to d5695c1 Compare August 23, 2026 13:57
…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
calvinp0 force-pushed the fix_nmd_mass_weighted_displacement branch from d5695c1 to cc75dd0 Compare August 23, 2026 15:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Module: trsh Troubleshooting

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants