Skip to content

Resolve H_Abstraction well ordering per product dict; skip linear-segment torsions - #1020

Open
calvinp0 wants to merge 2 commits into
mainfrom
fix_habs_heuristics_and_linear_torsions
Open

Resolve H_Abstraction well ordering per product dict; skip linear-segment torsions#1020
calvinp0 wants to merge 2 commits into
mainfrom
fix_habs_heuristics_and_linear_torsions

Conversation

@calvinp0

Copy link
Copy Markdown
Member

Two independent fixes, one per commit, touching disjoint files.

1. H_Abstraction well ordering, resolved per product dict

h_abstraction resolves the orientation of the reactant and product wells relative to the RMG
H_Abstraction template R(*1)-H(*2) + R(*3)j <=> R(*1)j + R(*3)-H(*2) once per reaction, pinned to
product_dicts[0], then reuses it for every product dict.

With identical reactants the product dicts label the transferring hydrogen *2 in different
reactants, so the single reaction-level value indexes h1 into the wrong one. OH + OH <=> H2O + O:

dict 0: global *2=1   ->  OH, index 1  ->  H of OH
dict 1: global *2=3   ->  OH, index 3  ->  OUT OF RANGE (2 atoms)

That raises IndexError from find_distant_neighbor, and h_abstraction catches only ValueError and
SpeciesError, so the heuristics adapter fails for the whole reaction. Resolved per product dict, the
reaction yields two collinear guesses: O–H 1.16 Å on both sides, O–H–O 180.0°, O···O 2.33 Å.

Resolving it for every product dict reaches three cases product_dicts[0] does not.

The reactant boundary. r_label_map values are zero-based global indices over the concatenated
reactants, so an index equal to the length of the first reactant already belongs to the second.
len(r_species[0].mol.atoms) < r_star_2 is written the other way round and misses *2 sitting on the
first atom of the second reactant — one of the two product dicts of every Rj + H2 <=> RH + H
abstraction. CH3 + H2, OH + H2 and NH2 + H2 are all affected.

The dict-product boundary. The same inversion, spelled len(products[0].atoms) >= p_star_2,
disagreed on that boundary with the identical predicate spelled h2 < len(product_dict['products'][0].atoms)
at the call site. It is computed once and returned as a third element the call site consumes.
p_label_map indexes the concatenated product_dict['products'], not the reaction's product wells;
the docstring stated one index space for both and now states both.

Re-perception under copy(). The correspondence between dict products and reaction products was
decided by isomorphism against get_reactants_and_products(return_copies=True). ARCSpecies.copy()
round-trips through as_dict()/from_dict(), re-perceiving the 2D graph from the Cartesian coordinates
a species carries by TS-guess time — and in a real run every well has final_xyz. Isoxazole perceived
from its optimized geometry can return as the charge-separated aromatic [n-]1ccc[o+]1, not isomorphic
to the neutral c1ccno1 the template matched; the correspondence flips and the isoxazole dict product
is paired with the propargyl product, so map_two_species raises on the formula mismatch for
O1[C]=CC=N1 + C#CC <=> c1ccno1 + C#C[CH2]. The orientation is resolved against the reaction's own
species instances; only an atom count is read from the reactants, and that is invariant under
re-perception.

Family membership. h_abstraction read product_dict['r_label_map']['*2'] for every entry with no
family filter, while product_dicts is H_Abstraction-only when the family was derived from it:
determine_family takes product_dicts[0]['family'], but the family setter marks the family
determined without consulting _product_dicts, and get_reaction_family_products scans every family. A
disproportionation constructed as ARCReaction(..., family='H_Abstraction') carries three
Disproportionation product dicts whose *2 is a carbon; the loop either raises KeyError on '*2'
or applies another family's label map. Only H_Abstraction entries are processed, and a reaction left
with none is reported by name in a warning. Since nothing downstream checks that the resolved index is
a hydrogen, an entry whose *2 does not resolve to one is skipped with a warning as well.

Also removed: reactants_reversed and h1 >= len(reactants[0].mol.atoms) at the call site, whose second
conjunct is identical to the first and therefore dead.

Left in place: h2 >= len(product_atom_map) tests the offset against the length of the H-bearing dict
product rather than the first one, so it fails to subtract when the first dict product is the smaller.
Graph.split always leaves the transferred H last — 0 hits across 2904 template product dicts — and no
reaction was found whose template orders the products that way, so it could not be given a test of the
kind the other cases have.

Nine tests: the symmetric-reactant pairing and its TS geometry, the reactant boundary, the dict-product
boundary, the charge-separated perception, and the family and hydrogen guards including the warnings
they emit. Each fails against the current behaviour for a behavioural reason.

2. Linear-segment torsions in backbone alignment

ARCSpecies.set_dihedral() refuses to rotate a dihedral spanning a linear segment — a cumulene/ketene
O=C=C backbone, whose dihedral angle is geometrically undefined — and returns None. That is a
benign no-op, but it was logged at WARNING, and arc/mapping/engine.py's backbone-alignment loop fed
the same torsion in repeatedly, so one such geometry produced a burst of identical warnings.

Those torsions are skipped before the loop attempts them, and the no-op is logged at debug.
is_torsion_linear tests both constituent atom triplets through the existing
arc/common.py::is_angle_linear and arc/species/vectors.py::calculate_angle. It lives in
arc/species/vectors.py, and set_dihedral() — whose guard was the same two lines inline — calls it,
so the predicate has one definition rather than two.

Copilot AI lite review requested due to automatic review settings August 23, 2026 12:07

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.

ARCSpecies.set_dihedral() correctly refuses to rotate a dihedral that
spans a linear segment (e.g. a collinear O=C=C cumulene) and returns
None. This is a benign no-op, but it was logged at WARNING level and
the mapping engine's backbone-alignment loop re-attempted the same
linear torsion on every iteration, producing repeated warnings.

- set_dihedral(): downgrade the linear-segment message to logger.debug.
  The is_angle_linear guard and the return None are unchanged.
- get_backbone_dihedral_angles(): skip backbone torsions that span a
  linear segment (new is_torsion_linear helper, mirroring the guard) so
  they are never fed into the set_dihedral alignment loop. This is a
  no-op for non-linear torsions and does not change alignment results.

Adds focused tests for both behaviors.
``h_abstraction`` resolves the orientation of the reactant and product wells relative to the RMG
H_Abstraction template R(*1)-H(*2) + R(*3)j <=> R(*1)j + R(*3)-H(*2) once per reaction, pinned to
``product_dicts[0]``, and reuses it for every product dict. With identical reactants the product
dicts label the transferring hydrogen (*2) in different reactants, so a single reaction-level value
indexes ``h1`` into the wrong reactant: OH + OH <=> H2O + O raises "IndexError: list index out of
range" from ``find_distant_neighbor``. The orientation is resolved per product dict.

Resolving it for every product dict reaches three cases in ``are_h_abs_wells_reversed`` that
``product_dicts[0]`` does not:

* The atom indices in a product dict are zero-based global indices into the concatenated wells, so
  an index equal to the length of the first well already belongs to the second one. The reactant
  boundary test ``len(r_species[0].mol.atoms) < r_star_2`` is written the other way round and
  misses *2 sitting on the first atom of the second reactant, which is what one of the two product
  dicts of every "Rj + H2 <=> RH + H" abstraction produces. CH3 + H2, OH + H2 and NH2 + H2 all
  raise IndexError with the orientation resolved per product dict, and all three yield TS guesses
  with the boundary corrected.
* The dict-product boundary test carries the same inversion, spelled
  ``len(products[0].atoms) >= p_star_2``, and disagrees on that boundary with the identical
  predicate spelled ``h2 < len(product_dict['products'][0].atoms)`` at the call site. The predicate
  is computed once and returned as a third element that the call site consumes.
* The correspondence between the dict products and the reaction products is decided by isomorphism
  against ``rxn.get_reactants_and_products(return_copies=True)``. ``ARCSpecies.copy()`` round-trips
  through ``as_dict()``/``from_dict()``, which re-perceives the 2D graph from the Cartesian
  coordinates a species carries by TS-guess time. Isoxazole perceived from its optimized geometry
  can come back as the charge-separated aromatic [n-]1ccc[o+]1, which is not isomorphic to the
  neutral c1ccno1 the family template was matched against; the correspondence then flips,
  ``h_abstraction`` pairs the isoxazole dict product with the ARC propargyl product, and
  ``map_two_species`` raises "The two species sent for mapping have different molecular formula.
  Got: c1ccno1 / C#C[CH2]" for O1[C]=CC=N1 + C#CC <=> c1ccno1 + C#C[CH2]. The orientation is
  resolved against the reaction's own species instances; only an atom count is read from the
  reactants, and that is invariant under re-perception. Which Lewis structure is perceived is
  selected by PYTHONHASHSEED, so the regression test forces the charge-separated perception rather
  than relying on it.

``h_abstraction`` also reads ``product_dict['r_label_map']['*2']`` for every entry of
``reaction.product_dicts`` with no family filter, while ``product_dicts`` is only guaranteed to be
H_Abstraction-only when the family was derived from it: ``determine_family`` takes
``product_dicts[0]['family']``, but the ``family`` setter marks the family determined without
consulting ``_product_dicts``, so ``ARCReaction(..., family='H_Abstraction')`` and a
restart-restored reaction pin the family while ``product_dicts`` is computed independently over all
families. A disproportionation constructed that way carries three Disproportionation product dicts
whose '*2' is a carbon, and the loop either raises KeyError on '*2' or applies another family's
label map. Only the H_Abstraction entries are processed, and a reaction left with none of them is
reported by name in a warning and yields no guesses instead of failing silently.

Nothing downstream validates that the resolved index is a hydrogen: ``find_distant_neighbor`` and
the redundant-atom combination take the neighbors of whichever atom the index selects, so a label
map that survives the family filter but does not describe an H abstraction produces a silent
non-result. A product dict whose *2 label does not resolve to a hydrogen atom of the respective
reactant is skipped with a warning that names the reaction.

Also removed: ``reactants_reversed and h1 >= len(reactants[0].mol.atoms)`` at the call site, whose
second conjunct is identical to the first and therefore dead.

Left in place: the guard ``h2 >= len(product_atom_map)`` at the call site tests the same offset
against the length of the H bearing dict product rather than of the first one, and so fails to
subtract when the first dict product is the smaller of the two. It is unreachable for the same
reason as the dict-product boundary, and no reaction was found whose template even orders the
products that way, so it could not be given a test of the kind the other cases have.
@codecov

codecov Bot commented Aug 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 64.62%. Comparing base (45d73a0) to head (425d73f).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1020      +/-   ##
==========================================
+ Coverage   64.60%   64.62%   +0.01%     
==========================================
  Files         119      119              
  Lines       39785    39792       +7     
  Branches    10307    10309       +2     
==========================================
+ Hits        25703    25715      +12     
+ Misses      11105    11097       -8     
- Partials     2977     2980       +3     
Flag Coverage Δ
functionaltests 64.62% <ø> (+0.01%) ⬆️
unittests 64.62% <ø> (+0.01%) ⬆️

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.

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