Resolve H_Abstraction well ordering per product dict; skip linear-segment torsions - #1020
Open
calvinp0 wants to merge 2 commits into
Open
Resolve H_Abstraction well ordering per product dict; skip linear-segment torsions#1020calvinp0 wants to merge 2 commits into
calvinp0 wants to merge 2 commits into
Conversation
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 Report✅ All modified and coverable lines are covered by tests. 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
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 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.
Two independent fixes, one per commit, touching disjoint files.
1. H_Abstraction well ordering, resolved per product dict
h_abstractionresolves the orientation of the reactant and product wells relative to the RMGH_Abstraction template
R(*1)-H(*2) + R(*3)j <=> R(*1)j + R(*3)-H(*2)once per reaction, pinned toproduct_dicts[0], then reuses it for every product dict.With identical reactants the product dicts label the transferring hydrogen
*2in differentreactants, so the single reaction-level value indexes
h1into the wrong one.OH + OH <=> H2O + O:That raises
IndexErrorfromfind_distant_neighbor, andh_abstractioncatches onlyValueErrorandSpeciesError, so the heuristics adapter fails for the whole reaction. Resolved per product dict, thereaction 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_mapvalues are zero-based global indices over the concatenatedreactants, so an index equal to the length of the first reactant already belongs to the second.
len(r_species[0].mol.atoms) < r_star_2is written the other way round and misses*2sitting on thefirst atom of the second reactant — one of the two product dicts of every
Rj + H2 <=> RH + Habstraction. 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_mapindexes the concatenatedproduct_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 wasdecided 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 coordinatesa species carries by TS-guess time — and in a real run every well has
final_xyz. Isoxazole perceivedfrom its optimized geometry can return as the charge-separated aromatic
[n-]1ccc[o+]1, not isomorphicto the neutral
c1ccno1the template matched; the correspondence flips and the isoxazole dict productis paired with the propargyl product, so
map_two_speciesraises on the formula mismatch forO1[C]=CC=N1 + C#CC <=> c1ccno1 + C#C[CH2]. The orientation is resolved against the reaction's ownspecies instances; only an atom count is read from the reactants, and that is invariant under
re-perception.
Family membership.
h_abstractionreadproduct_dict['r_label_map']['*2']for every entry with nofamily filter, while
product_dictsis H_Abstraction-only when the family was derived from it:determine_familytakesproduct_dicts[0]['family'], but thefamilysetter marks the familydetermined without consulting
_product_dicts, andget_reaction_family_productsscans every family. Adisproportionation constructed as
ARCReaction(..., family='H_Abstraction')carries threeDisproportionationproduct dicts whose*2is a carbon; the loop either raisesKeyErroron'*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
*2does 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 secondconjunct 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 dictproduct rather than the first one, so it fails to subtract when the first dict product is the smaller.
Graph.splitalways leaves the transferred H last — 0 hits across 2904 template product dicts — and noreaction 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/keteneO=C=Cbackbone, whose dihedral angle is geometrically undefined — and returnsNone. That is abenign no-op, but it was logged at
WARNING, andarc/mapping/engine.py's backbone-alignment loop fedthe 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_lineartests both constituent atom triplets through the existingarc/common.py::is_angle_linearandarc/species/vectors.py::calculate_angle. It lives inarc/species/vectors.py, andset_dihedral()— whose guard was the same two lines inline — calls it,so the predicate has one definition rather than two.