Use the frequency log's geometry for the TS normal mode displacement … - #967
Open
calvinp0 wants to merge 1 commit into
Open
Use the frequency log's geometry for the TS normal mode displacement …#967calvinp0 wants to merge 1 commit into
calvinp0 wants to merge 1 commit into
Conversation
calvinp0
force-pushed
the
fix_nmd_freq_frame_geometry
branch
from
August 12, 2026 08:55
80d7504 to
ba0c867
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #967 +/- ##
==========================================
- Coverage 64.43% 64.41% -0.02%
==========================================
Files 119 119
Lines 39601 39617 +16
Branches 10269 10270 +1
==========================================
+ Hits 25515 25520 +5
- Misses 11109 11112 +3
- Partials 2977 2985 +8
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:
|
This was referenced Aug 12, 2026
calvinp0
force-pushed
the
fix_nmd_freq_frame_geometry
branch
2 times, most recently
from
August 14, 2026 19:16
3d672fb to
bad172c
Compare
calvinp0
force-pushed
the
fix_nmd_freq_frame_geometry
branch
from
August 15, 2026 13:48
bad172c to
e0aff0f
Compare
calvinp0
marked this pull request as ready for review
August 20, 2026 05:24
…analysis analyze_ts_normal_mode_displacement() displaces the TS as x + a*w*d, taking x from reaction.ts_species.get_xyz() and d from parser.parse_normal_mode_displacement() on the frequency job's log. Those are two different coordinate frames: 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. Fixing this in nmd.py rather than refreshing final_xyz from the frequency log in the scheduler 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 second 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 analyze_ts_normal_mode_displacement() now returns None rather than a bool when they differ. None is what distinguishes 'could not check' from 'checked and failed': Scheduler.check_freq_job() switches to another TS guess on `ts_checks['NMD'] is False`, so returning False here would discard transition states on the strength of indices known to be meaningless, while None leaves the guess in place. Canonicalising the atom order belongs at ingestion in the TS adapters and is not attempted here.
calvinp0
force-pushed
the
fix_nmd_freq_frame_geometry
branch
from
August 20, 2026 05:51
e0aff0f to
b5e1935
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 of a 3-PR stack — review this one first. Reading order: #967 → #968 → #970. All three change the head of
analyze_ts_normal_mode_displacement(); they are split by cause, not by file.What breaks
final_xyz; the modes came from the frequency job's log. Gaussian reports modes in that job's own orientation, which is not generally the orientation of the opt geometry.What the fix does
get_ts_xyz_in_normal_mode_frame()takes the TS geometry from the frequency job's output file — the same file the displacements are parsed from — so geometry and modes share a frame by construction.Standard orientation:block, falling back toInput orientation:. Both are right: the fallback only applies when Gaussian suppressed the standard orientation (e.g. undernosymm, which ARC's troubleshooting can add), and in that case the frequency analysis is done in the input orientation too.ARCSpecies.get_xyz()returnsNonefor a TS with nofinal_xyz/initial_xyz/conformer and no TS guess carrying aninitial_xyz— the parsed geometry is used rather than discarded. ReturningNonethere would hand the caller nothing to measure: it immediately doeslen(ts_xyz['symbols']).is_ts_atom_order_consistent_with_reactants()makes the analysis returnNonewhen the TS atom order differs from the concatenated reactant order. Nothing checked this before: the forming/breaking bond indices are indices into the reactant order, so on a mismatch the check silently scored the wrong atoms and still returned a confident verdict.Why this shape
None(analysis not performed) rather thanFalse(TS rejected).arc/job/trsh.py::trsh_negative_freqparses both from onelog_filebefore perturbing along the mode. The NMD check was the outlier, not the precedent.How it was verified
Σ mₐ(rₐ − R_com) × dₐ = 0, and that quantity is frame-dependent, so it is a direct test of whether the pairing is consistent: 73% of affected jobs exceed 3σ when evaluated in the opt frame, versus 0 of 111 in the freq frame.None) NMD verdict does not trigger a TS switch.arc/checks/passes in full — 60 tests.Open note for the reviewer
if n_ts != n_expected: return False, is the same class of missing precondition but still returnsFalse. It predates this PR and is left alone here; worth deciding whether it should becomeNonetoo.analyze_ts_normal_mode_displacement()still does not tolerate aNonegeometry, which after this change can only arise when the freq log and the species both yield nothing. Thatlen(ts_xyz['symbols'])on a possibly-Nonevalue is unchanged frommainand is left as-is rather than widened here.What I searched for
trsh_negative_freqdoes it inline for a different purpose; there was no reusable helper to import.arc/parserentry point rather than a new reader, and that the symbol-sequence comparison had no existing equivalent.