@@ -632,6 +632,15 @@ def interpolate_addition(rxn: 'ARCReaction',
632632 # product compositions, partially displace them now.
633633 ts_xyz = _migrate_h_between_fragments (
634634 ts_xyz , uni_mol , cut , multi_species , weight )
635+ # Revalidate: the migration may have introduced collisions
636+ # or other geometry defects not present in the pre-migration guess.
637+ is_valid , reason = _validate_ts_guess (
638+ ts_xyz , set (), cut , uni_mol ,
639+ label = f'rxn={ rxn .label } , frag-fallback-post-migrate' )
640+ if not is_valid :
641+ logger .debug (f'Linear (rxn={ rxn .label } , frag-fallback): '
642+ f'post-migration guess rejected — { reason } .' )
643+ continue
635644 ts_xyzs .append (ts_xyz )
636645
637646 # Deduplicate near-identical guesses.
@@ -801,8 +810,10 @@ def _migrate_h_between_fragments(ts_xyz: dict,
801810 2. Computes element compositions for each fragment.
802811 3. Compares with product compositions to find H surplus/deficit.
803812 4. For each surplus fragment, finds the H atom closest to the deficit
804- fragment and displaces it partially (scaled by ``weight``) toward
805- the nearest heavy atom in the deficit fragment.
813+ fragment and places it on the donor→acceptor axis at a TS-like
814+ distance (interpolated by ``weight``). Using the donor→acceptor
815+ axis instead of a direct H→acceptor line avoids near-collisions
816+ with other atoms in the source fragment (e.g. the C in a CO₂ group).
806817
807818 Args:
808819 ts_xyz: TS guess XYZ from ``_stretch_bond`` (already stretched).
@@ -930,19 +941,72 @@ def heavy_formula(f: Dict[str, int]) -> Dict[str, int]:
930941 h_dists .sort (key = lambda x : x [1 ])
931942
932943 for h_idx , _ , nearest_heavy in h_dists [:n_to_move ]:
933- # Displace H partially toward the nearest heavy atom in the deficit fragment.
934- # At weight=0.5 (default TS), move halfway. The H is between the two fragments.
935- direction = ts_coords [nearest_heavy ] - ts_coords [h_idx ]
936- dist = float (np .linalg .norm (direction ))
937- if dist < 1e-6 :
944+ # Find the donor heavy atom: the heavy atom bonded to this H in the
945+ # source fragment.
946+ donor_heavy = None
947+ for nbr in uni_mol .atoms [h_idx ].bonds .keys ():
948+ nbr_idx = atom_to_idx [nbr ]
949+ if symbols [nbr_idx ] != 'H' and nbr_idx in fragments [s_fi ]:
950+ donor_heavy = nbr_idx
951+ break
952+
953+ if donor_heavy is None :
938954 continue
939- direction /= dist
940- # Target: the H should be at a TS-like distance from the heavy atom.
941- target_dist = get_single_bond_length (symbols [nearest_heavy ], 'H' ) + _PAULING_DELTA
942- # Move the H so it's at target_dist from the heavy atom, scaled by weight.
943- move_dist = (dist - target_dist ) * 2.0 * (1.0 - weight )
944- if move_dist > 0 :
945- ts_coords [h_idx ] += direction * move_dist
955+
956+ # Triangulate: place H at the intersection of two spheres centred
957+ # on donor and acceptor with TS-like radii, choosing the point
958+ # closest to the current H position. This produces a non-collinear
959+ # D-H-A geometry that avoids passing through atoms between donor
960+ # and acceptor (e.g. the C in a CO₂ group).
961+ d_pos = ts_coords [donor_heavy ]
962+ a_pos = ts_coords [nearest_heavy ]
963+ h_pos = ts_coords [h_idx ]
964+ da_vec = a_pos - d_pos
965+ da_dist = float (np .linalg .norm (da_vec ))
966+ if da_dist < 1e-6 :
967+ continue
968+ da_hat = da_vec / da_dist
969+
970+ d_DH = get_single_bond_length (symbols [donor_heavy ], 'H' ) + _PAULING_DELTA
971+ d_AH = get_single_bond_length (symbols [nearest_heavy ], 'H' ) + _PAULING_DELTA
972+
973+ if da_dist <= d_DH + d_AH :
974+ # Spheres overlap → triangulate.
975+ x = (da_dist ** 2 + d_DH ** 2 - d_AH ** 2 ) / (2.0 * da_dist )
976+ h_sq = d_DH ** 2 - x ** 2
977+ h_perp = np .sqrt (max (h_sq , 0.0 ))
978+
979+ proj = d_pos + np .dot (h_pos - d_pos , da_hat ) * da_hat
980+ perp = h_pos - proj
981+ perp_norm = float (np .linalg .norm (perp ))
982+ if perp_norm > 1e-8 :
983+ n_perp = perp / perp_norm
984+ else :
985+ ref = np .array ([1.0 , 0.0 , 0.0 ]) if abs (da_hat [0 ]) < 0.9 \
986+ else np .array ([0.0 , 1.0 , 0.0 ])
987+ n_perp = np .cross (da_hat , ref )
988+ n_perp /= np .linalg .norm (n_perp )
989+
990+ cand_plus = d_pos + x * da_hat + h_perp * n_perp
991+ cand_minus = d_pos + x * da_hat - h_perp * n_perp
992+
993+ # Pick the candidate with greater clearance from source-fragment
994+ # heavy atoms (avoids crowding the TS ring interior).
995+ def _min_frag_dist (pos ):
996+ md = float ('inf' )
997+ for idx in fragments [s_fi ]:
998+ if idx == h_idx or idx == donor_heavy or symbols [idx ] == 'H' :
999+ continue
1000+ md = min (md , float (np .linalg .norm (pos - ts_coords [idx ])))
1001+ return md
1002+
1003+ new_h = cand_plus if _min_frag_dist (cand_plus ) >= _min_frag_dist (cand_minus ) \
1004+ else cand_minus
1005+ else :
1006+ # Spheres don't overlap → collinear placement at d_DH from donor.
1007+ new_h = d_pos + d_DH * da_hat
1008+
1009+ ts_coords [h_idx ] = new_h
9461010
9471011 result = {
9481012 'symbols' : ts_xyz ['symbols' ],
@@ -2231,21 +2295,55 @@ def _postprocess_h_migration(xyz: dict,
22312295 return xyz , migrating_hs
22322296
22332297
2298+ def _postprocess_group_shift (xyz : dict ,
2299+ r_mol : 'Molecule' ,
2300+ forming_bonds : List [Tuple [int , int ]],
2301+ breaking_bonds : List [Tuple [int , int ]],
2302+ r_label_map : Optional [Dict [str , int ]] = None ,
2303+ ) -> Tuple [dict , Set [int ]]:
2304+ """
2305+ Post-processing pipeline for group-migration and sigmatropic families.
2306+
2307+ These families involve a non-H group migrating between heavy atoms
2308+ (e.g. 1,2-shiftC, 1,2-shiftS, 1,3-sigmatropic rearrangements).
2309+ The pipeline applies forming-bond distance correction, umbrella
2310+ orientation, and universal H fixes, but omits H-transfer-specific
2311+ steps (donor terminal H staggering) that are not applicable when
2312+ the migrating atom is not hydrogen.
2313+
2314+ Args:
2315+ xyz: Raw TS guess XYZ dictionary.
2316+ r_mol: Reactant RMG Molecule (bond topology reference).
2317+ forming_bonds: List of (i, j) index pairs for bonds that form.
2318+ breaking_bonds: List of (i, j) index pairs for bonds that break.
2319+ r_label_map: Family-labelled atom map (unused in this handler).
2320+
2321+ Returns:
2322+ Tuple of (corrected XYZ dict, empty set — no migrating H atoms).
2323+ """
2324+ xyz = fix_forming_bond_distances (xyz , r_mol , forming_bonds )
2325+ xyz = fix_nonreactive_h_distances (xyz , r_mol , migrating_h_indices = set ())
2326+ xyz = fix_crowded_h_atoms (xyz , r_mol , skip_h_indices = set ())
2327+ xyz = fix_migrating_group_umbrella (xyz , r_mol , breaking_bonds , forming_bonds )
2328+ return xyz , set ()
2329+
2330+
22342331# Register H-migration families.
22352332for _fam in ('intra_H_migration' , 'Ketoenol' , 'Intra_Disproportionation' ,
22362333 'Intra_ene_reaction' , 'intra_OH_migration' ,
22372334 'Intra_RH_Add_Endocyclic' , 'Intra_RH_Add_Exocyclic' ,
22382335 'Concerted_Intra_Diels_alder_monocyclic_1,2_shiftH' ):
22392336 _FAMILY_POSTPROCESSORS [_fam ] = _postprocess_h_migration
22402337
2241- # Register group-migration and sigmatropic families — the umbrella
2242- # flip and forming-bond fix steps from the H-migration pipeline also
2243- # help these related transfer/rearrangement reactions.
2338+ # Register group-migration and sigmatropic families — these involve
2339+ # non-H group transfer (e.g. CH3, S, ring shifts). They benefit from
2340+ # forming-bond distance correction and umbrella orientation but do not
2341+ # need H-transfer-specific steps like donor terminal H staggering.
22442342for _fam in ('1,2_shiftC' , '1,2_shiftS' ,
22452343 '1,3_sigmatropic_rearrangement' ,
22462344 '6_membered_central_C-C_shift' ,
22472345 'intra_substitutionCS_isomerization' ):
2248- _FAMILY_POSTPROCESSORS [_fam ] = _postprocess_h_migration
2346+ _FAMILY_POSTPROCESSORS [_fam ] = _postprocess_group_shift
22492347
22502348
22512349# ---------------------------------------------------------------------------
@@ -2318,9 +2416,19 @@ def _postprocess_ts_guess(xyz: dict,
23182416 """
23192417 Dispatch to the appropriate family-specific post-processing handler.
23202418
2321- Looks up ``family`` in :data:`_FAMILY_POSTPROCESSORS`. If a handler is
2322- registered, it is called; otherwise :func:`_postprocess_generic` is used
2323- as the safe default (applies only universally safe H fixes).
2419+ Three handler tiers are available:
2420+
2421+ * :func:`_postprocess_h_migration` — for H-transfer families (intra_H_migration,
2422+ Ketoenol, etc.). Full pipeline: forming-bond triangulation, donor terminal H
2423+ staggering, non-reactive H distance fix, crowded-H redistribution, umbrella flip.
2424+ * :func:`_postprocess_group_shift` — for non-H group migrations (1,2_shiftC/S,
2425+ sigmatropic, etc.). Applies forming-bond fix, umbrella, and universal H fixes
2426+ but omits H-transfer-specific donor staggering.
2427+ * :func:`_postprocess_generic` — safe default for unknown families. Only universal
2428+ H fixes (distance and crowding).
2429+
2430+ Looks up ``family`` in :data:`_FAMILY_POSTPROCESSORS`. If no handler is
2431+ registered, :func:`_postprocess_generic` is used.
23242432
23252433 Args:
23262434 xyz: Raw TS guess XYZ dictionary.
0 commit comments