Skip to content

Support reactions where a species participates more than once (A + A) - #974

Closed
calvinp0 wants to merge 2 commits into
mainfrom
fix_repeated_reactant_species
Closed

Support reactions where a species participates more than once (A + A)#974
calvinp0 wants to merge 2 commits into
mainfrom
fix_repeated_reactant_species

Conversation

@calvinp0

@calvinp0 calvinp0 commented Aug 12, 2026

Copy link
Copy Markdown
Member

Base: main. This branch absorbs #975 ("Do not discard a TS when the normal mode displacement check cannot be run"), which was flattened into this PR and is now closed. Two commits, no file touched by more than one of them.

Support for reactions in which the same species participates more than once (A + A), across the reaction object, the combined geometries, the AutoTST adapter, the thermo library, and the TS normal mode displacement check.

ARCReaction.remove_dup_species() collapses r_species/p_species so each species is held once; the number of occurrences survives only in the reaction label and is recovered through get_species_count(). Five consumers were reading the deduplicated list at face value and so saw only one copy of a repeated participant. OH + OH <=> H2O + O is the running example.

The index space

This is the single fact the whole PR turns on:

  • The forming, breaking and changed bond indices, the atom map, find_equivalent_atoms() and the TS geometry all live in the per-occurrence expanded space produced by get_reactants_and_products().
  • The deduplicated r_species list does not. Anywhere the two are mixed, a repeated-reactant reaction silently disagrees with itself.

What this changes

  • get_reactants_xyz() / get_products_xyz() expand each species by its occurrence count. The combined well geometry was short by the repeated species' atoms and no longer matched the atom map. get_species_count() is 1 in the common case, which leaves every other reaction untouched.

  • The AutoTST adapter built the reverse reaction from rxn.products/rxn.reactants directly, yielding the atom-imbalanced P1 + P2 <=> R1 instead of P1 + P2 <=> R1 + R1, and aborting the reverse direction of every same-species H-abstraction. The reverse label lists are now expanded by get_species_count() as well.

  • The project thermo library collected every converged species, and Arkane keys thermo entries by adjacency list plus multiplicity, so identical reactants raised a DatabaseError that aborted the library for the whole project. Isomorphic, same-multiplicity duplicates are now skipped.

  • New get_repeated_species_atom_equivalences() (arc/checks/nmd.py) builds cross-copy atom-equivalence groups. identify_equivalent_atoms_in_molecule() only finds equivalence within a molecule, so it missed the equivalence between identical copies — the two O atoms and the two H atoms of OH + OH. The atom map can assign a reactive atom to one copy while the located TS uses the equivalent atom of the other, and without those groups the check cannot try the correct mapping. find_equivalent_atoms() now includes them for the reactant well.

  • The NMD atom-count gate takes its expected count from get_reactants_and_products() — the same call get_bonds() uses to build the bond indices — instead of sum(spc.number_of_atoms for spc in reaction.r_species). For OH + OH the TS has 4 atoms while the deduplicated reactants reported 2, so the gate never matched and the check bailed out before looking at the mode.

    To be clear about what the re-derivation does and does not do: the two derivations are numerically identical today, verified across six reaction shapes — A + B, A + A, A + A + A, a species appearing in both wells, A + A -> A + A, and unimolecular — each compared against the atom count get_bonds() actually indexes into. The gain is coupling, not behaviour: the counts now agree by construction rather than by a parallel re-derivation that can drift.

A count mismatch is an inability to check, not a verdict

The count mismatch also returned False, and the scheduler calls switch_ts() whenever the verdict is False. It now logs a warning naming both counts and returns None — the value the function already returns when no frequency job is given and when the normal mode displacements cannot be parsed.

False is not merely harsh, it is the wrong signal:

  • False's sole consumer is the scheduler, which responds by trying a different TS guess.
  • Every TSGuess is validated against the same expanded reactant well by ARCReaction.check_atom_balance(), so all guesses for a reaction have the same atom count. The condition is invariant under the remedy — switching guesses cannot change the atom count that made the analysis impossible.
  • So False sent the one signal whose only consumer responds with an action that cannot help, at the cost of the whole guess list (each guess a fresh optimisation and frequency job) plus a TS that had converged, had passed its imaginary-frequency check, and had never been examined.
  • A genuine atom imbalance is already a hard ReactionError from check_atom_balance(), raised at reaction construction and again from the scheduler, so a real imbalance never reaches this gate quietly.

None does not trigger switch_ts(), and ts_passed_checks() still treats it as not passed, so the TS is withheld from the checks it must pass without being destroyed. IRC validation remains an independent gate.

Both returns in the function were audited: the count mismatch was the only one that reported an inability to check as a negative verdict. The final return False, after every candidate mapping has been tried, is a genuine negative and is unchanged.

Gate order. The atom-count gate runs ahead of the atom-map gate main added meanwhile. The atom-map gate reads ARCReaction.atom_map, which calls check_atom_balance(raise_error=True) and raises ReactionError precisely for a TS that does not span the reactant atoms, so the cheaper and more specific diagnosis has to be made first. Both gates return None, so the order is observable only for a reaction that trips both, where the count message is the accurate one.

Validation

Validated on the real OH + OH -> H2O + O saddle (imaginary frequency -768 cm⁻¹) from the reproducibility runs: the normal mode displacement check returns True where it previously returned False, recovering benchmark reaction 05 and the other same-species H-abstractions.

Tests

  • A genuinely mismatched atom count yields None rather than False — fails without the fix with AssertionError: False is not None.
  • A check_freq_job()-level assertion that the same mismatch does not reach switch_ts() — fails without the fix with the same message.
  • An A + A reaction whose TS survives the count gate.

The scheduler-level test is deliberately built on a genuine count mismatch rather than on an A + A reaction: once the count fix is in place an A + A reaction clears the gate (4 == 4) and exits at a later None, so it never reaches the branch being changed and cannot discriminate this fix.

🤖 Generated with Claude Code

@calvinp0 calvinp0 changed the title fix repeated reactant species Support reactions where a species participates more than once (A + A) Aug 12, 2026
@calvinp0
calvinp0 force-pushed the fix_repeated_reactant_species branch from ac1323f to e5cf612 Compare August 12, 2026 17:02
@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.58%. Comparing base (9deb7b1) to head (a999954).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #974      +/-   ##
==========================================
+ Coverage   64.49%   64.58%   +0.08%     
==========================================
  Files         119      119              
  Lines       39707    39731      +24     
  Branches    10289    10294       +5     
==========================================
+ Hits        25610    25659      +49     
+ Misses      11114    11087      -27     
- Partials     2983     2985       +2     
Flag Coverage Δ
functionaltests 64.58% <ø> (+0.08%) ⬆️
unittests 64.58% <ø> (+0.08%) ⬆️

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_repeated_reactant_species branch from e5cf612 to de0105d Compare August 22, 2026 07:58
@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.

… + A)

ARCReaction deduplicates r_species/p_species and the reactants/products label lists, keeping
the multiplicity of a participant only in the reaction label (recoverable via
get_species_count). Three consumers took the deduplicated lists at face value and broke for
reactions such as OH + OH <=> H2O + O:

* get_reactants_xyz / get_products_xyz combined one geometry per unique species, so the
  combined well geometry was short by the repeated species' atoms and no longer matched the
  atom map. Each species is now expanded by its occurrence count in the well; the count is 1
  in the common case, leaving those reactions unchanged.

* The AutoTST adapter built the reverse reaction from rxn.products/rxn.reactants directly,
  yielding the atom-imbalanced 'P1 + P2 <=> R1' instead of 'P1 + P2 <=> R1 + R1' and aborting
  the reverse direction of every same-species H_Abstraction. The reverse label lists are now
  expanded by get_species_count as well.

* The project thermo library collected every converged species, and Arkane keys thermo
  entries by adjacency list plus multiplicity, so identical reactants raised a DatabaseError
  that aborted the library for the whole project. Isomorphic, same-multiplicity duplicates
  are now skipped.
…rding uncheckable TSs

For reactions where a reactant participates more than once (e.g. OH + OH -> H2O + O),
r_species is deduplicated, which broke the normal-mode-displacement check two ways.

The expected atom count was sum(number_of_atoms for r_species), i.e. the deduplicated count
(2 for OH + OH), so it never equalled the real TS atom count (4) and
analyze_ts_normal_mode_displacement() bailed out before even looking at the mode. The
forming, breaking and changed bonds it applies to the TS geometry come from
ARCReaction.get_bonds(), which indexes into get_reactants_and_products(), so the count is
now taken from that same call rather than from a parallel per-occurrence expansion alongside
the function that defines it; the two now agree by construction instead of being able to
drift.

find_equivalent_atoms() only found equivalence WITHIN a molecule, so it missed the
cross-molecule equivalence between identical copies (the two O's and the two H's of OH + OH).
The atom map can assign the reactive atom to one copy while the located TS uses the
equivalent atom of the other copy; without that equivalence the check cannot try the correct
mapping. The new get_repeated_species_atom_equivalences() supplies it: for a repeated
species, the atoms at the same position across copies are equivalent. Validated on the real
OH + OH -> H2O + O saddle (imaginary frequency -768 cm-1) from the reproducibility runs,
where the check now returns True where it returned False, recovering reaction 05 and the
other same-species H_Abstractions.

The count mismatch also returned False, and the scheduler calls switch_ts() whenever the
verdict is False. A count mismatch means the bond indices cannot be applied to this geometry
at all, which is an inability to perform the analysis rather than an observation about the
mode; switching to another TS guess cannot change the atom count that made the analysis
impossible, so False sent the one signal whose only consumer responds with an action that
cannot help, at the cost of a TS that had passed its imaginary-frequency check and had never
been examined. A genuine atom imbalance is already rejected loudly by
ARCReaction.check_atom_balance(), which raises at reaction construction and in the scheduler.
The mismatch now logs a warning naming both counts and returns None, the value the function
already returns when no job is given and when the normal mode displacements cannot be parsed.
None does not trigger switch_ts, and ts_passed_checks() still treats it as not passed, so the
TS is withheld from the checks it must pass without being destroyed.

Both returns were audited: the count mismatch was the only one that reported an inability to
check as a negative verdict. The final `return False` after every candidate mapping has been
tried is a genuine negative and is unchanged.

Tests: a genuinely mismatched atom count now yields None rather than False (fails without the
fix with "AssertionError: False is not None"); a check_freq_job()-level assertion that the
same mismatch does not reach switch_ts (fails without the fix with the same message); and an
A + A reaction whose TS survives the count gate.

The atom count gate runs ahead of the atom map gate that main added meanwhile. The atom map
gate reads ARCReaction.atom_map, which calls check_atom_balance(raise_error=True) and raises
ReactionError precisely for a TS that does not span the reactant atoms, so the cheaper and
more specific diagnosis has to be made first. Both gates return None, so the order is
observable only for a reaction that trips both, where the count message is the accurate one.
@calvinp0
calvinp0 force-pushed the fix_repeated_reactant_species branch from a999954 to 910eb48 Compare August 23, 2026 12:14
@calvinp0

Copy link
Copy Markdown
Member Author

Superseded by #1021, which carries these commits unchanged alongside the rest of the A + A handling. The three layers — per-occurrence participants in a well, the dedup-aware normal mode displacement check, and thermo recovery when Arkane declines to write its library — are one story, and the thermo recovery in particular only reads correctly with the context of why R1 == R2 reaches Arkane at all.

@calvinp0 calvinp0 closed this Aug 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants