diff --git a/arc/mapping/driver.py b/arc/mapping/driver.py index 395fdff36a..63a5dbd034 100644 --- a/arc/mapping/driver.py +++ b/arc/mapping/driver.py @@ -61,7 +61,7 @@ def try_mapping(r: ARCReaction) -> list[int] | None: except ValueError: return None if flip: - raw_map = try_mapping(rxn.flip_reaction()) + raw_map = try_mapping(prepare_flipped_reaction(rxn)) if raw_map is None: return None return check_atom_map_and_return(flip_map(raw_map)) @@ -80,6 +80,52 @@ def try_mapping(r: ARCReaction) -> list[int] | None: return check_atom_map_and_return(raw_map) +def prepare_flipped_reaction(rxn: ARCReaction) -> ARCReaction: + """ + Build the flipped reaction and give it a usable, forward-discovered family template. + + ``ARCReaction.flip_reaction`` resets the family, so the flipped copy re-derives its product dictionaries + lazily with the *default* family set. When the original reaction was matched only by a broader set - the + usual situation for a template discovered in reverse - that rederivation comes back empty and the flip + retry has nothing to map with. Widen the search in that case, and prefer a template that describes the + flipped reaction forward. + + Args: + rxn (ARCReaction): The reaction to flip. + + Returns: + ARCReaction: The flipped reaction, seeded with a family and product dictionaries where possible. + """ + flipped = rxn.flip_reaction() + try: + product_dicts = flipped.product_dicts or list() + except (ValueError, KeyError, AttributeError): + product_dicts = list() + if all(pd.get('discovered_in_reverse') for pd in product_dicts): + try: + widened = flipped.get_product_dicts(rmg_family_set='all') + except (ValueError, KeyError, AttributeError): + widened = list() + logger.debug(f'Widened the family search for {flipped.label} to all families, ' + f'got {len(widened)} product dictionaries.') + product_dicts = widened or product_dicts + forward_dicts = [pd for pd in product_dicts if not pd.get('discovered_in_reverse')] + product_dicts = forward_dicts or product_dicts + if product_dicts: + # Keep only the chosen family's dictionaries. get_reaction_family_products() concatenates matches + # across every family without grouping them, while ``family`` can only name one, so leaving the + # whole list in place lets map_rxn's product_dict_index retry pair one family's recipe with + # another's label map. Every family labels its atoms *1/*2/*3, so that mispairing resolves against + # the wrong atoms and still yields a permutation, which is all check_atom_map_and_return verifies. + # TODO: replace with ARCReaction.restrict_product_dicts_to_family() once #978 lands. + family = product_dicts[0]['family'] + product_dicts = [pd for pd in product_dicts if pd['family'] == family] + flipped.product_dicts = product_dicts + flipped.family = family + flipped.family_own_reverse = product_dicts[0]['own_reverse'] + return flipped + + def check_atom_map_and_return(atom_map: list[int] | None) -> list[int] | None: """ Check if the atom map is valid and return it. diff --git a/arc/mapping/driver_test.py b/arc/mapping/driver_test.py index cd13bf6356..4e9e65b33a 100644 --- a/arc/mapping/driver_test.py +++ b/arc/mapping/driver_test.py @@ -860,6 +860,49 @@ def test_map_ho2_elimination_from_peroxy_radical(self): self.assertIn(tuple(rxn_4.atom_map[5: 8]), list(permutations([3, 4, 5]))) self.assertEqual(rxn_4.atom_map[8:], [6, 9]) + def test_prepare_flipped_reaction_seeds_a_forward_template(self): + """Test that the flipped reaction is given a family that matches it in the forward direction. + + ``flip_reaction`` resets the family, so the flipped copy would otherwise re-derive its product + dictionaries with the default family set, come back empty, and leave the flip retry with nothing. + """ + rxn = ARCReaction(r_species=[ARCSpecies(label='C2H5Cl', smiles='CCCl')], + p_species=[ARCSpecies(label='C2H4', smiles='C=C'), + ARCSpecies(label='HCl', smiles='Cl')]) + rxn.product_dicts = rxn.get_product_dicts(rmg_family_set='all') + flipped = prepare_flipped_reaction(rxn) + self.assertEqual(flipped.family, 'XY_Addition_MultipleBond') + self.assertTrue(flipped.product_dicts) + self.assertFalse(flipped.product_dicts[0]['discovered_in_reverse']) + # Only the chosen family's dictionaries survive, so a recipe can never be paired with another + # family's label map. + self.assertEqual({pd['family'] for pd in flipped.product_dicts}, {'XY_Addition_MultipleBond'}) + # The template products of a forward discovery are isomorphic to that reaction's own products. + template_products = flipped.product_dicts[0]['products'] + self.assertEqual(len(template_products), len(flipped.p_species)) + self.assertTrue(any(flipped.p_species[0].is_isomorphic(mol) for mol in template_products)) + + def test_map_reaction_with_a_reverse_discovered_template(self): + """Test mapping an elimination whose family only matches it in the addition direction. + + ``XY_Addition_MultipleBond`` matches HX elimination only in reverse, so the template's 'products' + are isomorphic to the reaction's reactants. Both ``get_template_product_order`` and + ``reorder_p_label_map`` compare against the reaction's products, so the forward attempt fails until + the reaction is flipped first. + """ + rxn = ARCReaction(r_species=[ARCSpecies(label='C2H5Cl', smiles='CCCl')], + p_species=[ARCSpecies(label='C2H4', smiles='C=C'), + ARCSpecies(label='HCl', smiles='Cl')]) + rxn.product_dicts = rxn.get_product_dicts(rmg_family_set='all') + rxn.family = rxn.product_dicts[0]['family'] + rxn.family_own_reverse = rxn.product_dicts[0]['own_reverse'] + self.assertEqual(rxn.family, 'XY_Addition_MultipleBond') + self.assertTrue(rxn.product_dicts[0]['discovered_in_reverse']) + atom_map = map_reaction(rxn=rxn, backend='ARC') + self.assertIsNotNone(atom_map) + self.assertEqual(sorted(atom_map), list(range(len(atom_map)))) + self.assertTrue(check_atom_map(rxn)) + def test_map_flipped_reaction(self): """Test the map_flipped_reaction() function.""" c2h5o3_xyz = {'coords': ((-1.3476727508427788, -0.49923624257482285, -0.3366372557370102),