From b16c77d644a03acea2b920ec1166b03fa12f41ea Mon Sep 17 00:00:00 2001 From: kfir4444 Date: Sun, 21 Jun 2026 15:20:02 +0300 Subject: [PATCH 1/3] Test: broken on remote? --- arc/mapping/driver_test.py | 47 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/arc/mapping/driver_test.py b/arc/mapping/driver_test.py index 3f4c63833f..d2952c69fb 100644 --- a/arc/mapping/driver_test.py +++ b/arc/mapping/driver_test.py @@ -875,6 +875,53 @@ def test_map_isomerization_reaction(self): self.assertIn(atom_map[7], [6, 7]) self.assertIn(atom_map[8], [7, 8]) + def test_map_c7h5_isomerization(self): + """ + Regression test for the lazy-pybel import fix (arc/species/conformers.py, + converter.py, perceive.py). + + C7H5 -> C7H5-2 is an ethynylcyclopentadienyl isomerization where the radical + migrates around the ring and the ring bond pattern changes. Before the fix, + a bare `from openbabel import pybel` at module level in conformers.py crashed + at import time with ValueError when the OpenBabel plugin directory is absent, + blocking arc.mapping.engine from loading entirely. The same ValueError was + also unguarded in the runtime call sites in converter.py and perceive.py. + """ + r_adjlist = """multiplicity 2 +1 *2 C u0 p0 c0 {2,S} {5,S} {6,S} {8,S} +2 C u0 p0 c0 {1,S} {3,D} {9,S} +3 C u0 p0 c0 {2,D} {4,S} {10,S} +4 C u0 p0 c0 {3,S} {5,D} {11,S} +5 *3 C u1 p0 c0 {1,S} {4,D} +6 *1 C u0 p0 c0 {1,S} {7,T} +7 C u0 p0 c0 {6,T} {12,S} +8 H u0 p0 c0 {1,S} +9 H u0 p0 c0 {2,S} +10 H u0 p0 c0 {3,S} +11 H u0 p0 c0 {4,S} +12 H u0 p0 c0 {7,S} +""" + p_adjlist = """multiplicity 2 +1 *2 C u0 p0 c0 {2,S} {3,D} {6,S} +2 *3 C u1 p0 c0 {1,S} {4,S} {8,S} +3 C u0 p0 c0 {1,D} {5,S} {11,S} +4 C u0 p0 c0 {2,S} {5,D} {9,S} +5 C u0 p0 c0 {3,S} {4,D} {10,S} +6 *1 C u0 p0 c0 {1,S} {7,T} +7 C u0 p0 c0 {6,T} {12,S} +8 H u0 p0 c0 {2,S} +9 H u0 p0 c0 {4,S} +10 H u0 p0 c0 {5,S} +11 H u0 p0 c0 {3,S} +12 H u0 p0 c0 {7,S} +""" + reactant = ARCSpecies(label='C7H5', adjlist=r_adjlist) + product = ARCSpecies(label='C7H5-2', adjlist=p_adjlist) + rxn = ARCReaction(r_species=[reactant], p_species=[product]) + atom_map = map_rxn(rxn, backend='ARC') + self.assertIsNotNone(atom_map) + self.assertTrue(check_atom_map(rxn)) + def test_map_pairs_failure_condition_guarded_in_map_rxn(self): """ Test the condition guarded by map_rxn(): a fragment pair that cannot be mapped From 8f063e9d8b888c3fe96c1af6e4a8b80a4fbf151a Mon Sep 17 00:00:00 2001 From: kfir4444 Date: Sun, 21 Jun 2026 15:21:47 +0300 Subject: [PATCH 2/3] fixup --- arc/species/conformers.py | 2 +- arc/species/converter.py | 4 ++-- arc/species/converter_test.py | 20 ++++++++++++++++++++ arc/species/perceive.py | 5 ++--- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/arc/species/conformers.py b/arc/species/conformers.py index f2cfac3693..e928f9e79c 100644 --- a/arc/species/conformers.py +++ b/arc/species/conformers.py @@ -42,7 +42,6 @@ from itertools import product from openbabel import openbabel as ob -from openbabel import pybel as pyb from rdkit import Chem from rdkit.Chem.rdchem import EditableMol as RDMol @@ -1399,6 +1398,7 @@ def openbabel_force_field(label, mol, num_confs=None, xyz=None, force_field='GAF elif num_confs is not None: obmol, ob_atom_ids = to_ob_mol(mol, return_mapping=True) + from openbabel import pybel as pyb pybmol = pyb.Molecule(obmol) pybmol.make3D() obmol = pybmol.OBMol diff --git a/arc/species/converter.py b/arc/species/converter.py index 8e7ae42270..477b45feb2 100644 --- a/arc/species/converter.py +++ b/arc/species/converter.py @@ -11,7 +11,6 @@ from ase import Atoms from scipy.spatial.transform import Rotation from openbabel import openbabel as ob -from openbabel import pybel from rdkit import Chem from rdkit.Chem import rdMolTransforms as rdMT from rdkit.Chem import AllChem, SDWriter @@ -1263,8 +1262,9 @@ def xyz_to_pybel_mol(xyz: dict): return None xyz = check_xyz_dict(xyz) try: + from openbabel import pybel pybel_mol = pybel.readstring('xyz', xyz_to_xyz_file_format(xyz)) - except (IOError, InputError): + except (IOError, ImportError, ValueError, InputError): return None return pybel_mol diff --git a/arc/species/converter_test.py b/arc/species/converter_test.py index 6135051b92..6f7eba5b60 100644 --- a/arc/species/converter_test.py +++ b/arc/species/converter_test.py @@ -5230,6 +5230,26 @@ def test_order_mol_by_atom_map_full_reversal(self): self.assertEqual([a.element.symbol for a in result.atoms], list(reversed(ref_symbols))) + def test_xyz_to_pybel_mol_returns_none_when_pybel_broken(self): + """xyz_to_pybel_mol() must return None gracefully when the pybel import fails + (e.g. broken OpenBabel plugin directory raises ValueError at import time) + rather than propagating the exception to the caller.""" + import sys + from unittest.mock import patch + from arc.species.converter import xyz_to_pybel_mol + ch4_xyz = {'symbols': ('C', 'H', 'H', 'H', 'H'), + 'isotopes': (12, 1, 1, 1, 1), + 'coords': ((0.0, 0.0, 0.0), + (0.63, 0.63, 0.63), + (-0.63, -0.63, 0.63), + (-0.63, 0.63, -0.63), + (0.63, -0.63, -0.63))} + # Remove any cached pybel so the lazy import inside xyz_to_pybel_mol fires. + # Patch it to raise ValueError, simulating a broken OB plugin directory. + with patch.dict(sys.modules, {'openbabel.pybel': None}): + result = xyz_to_pybel_mol(ch4_xyz) + self.assertIsNone(result) + @classmethod def tearDownClass(cls): """ diff --git a/arc/species/perceive.py b/arc/species/perceive.py index ceed7a0e99..c9b97b8560 100644 --- a/arc/species/perceive.py +++ b/arc/species/perceive.py @@ -8,8 +8,6 @@ from math import dist from typing import Any -from openbabel import pybel - from arc.common import NUMBER_BY_SYMBOL, distance_matrix, get_bonds_from_dmat, get_logger, get_single_bond_length from arc.exceptions import AtomTypeError, InputError, SanitizationError from arc.molecule.filtration import get_octet_deviation @@ -932,8 +930,9 @@ def alternative_perception( mol_3 = mol.copy(deep=True) try: xyz_file_format = str(len(xyz['symbols'])) + '\n\n' + xyz_to_str(xyz) + '\n' + from openbabel import pybel pybel_mol = pybel.readstring('xyz', xyz_file_format) - except (IOError, InputError): + except (IOError, ImportError, ValueError, InputError): pybel_mol = None if pybel_mol is not None: if bool(len([atom.is_hydrogen() for atom in mol_3.atoms])): From 378ecd077a0d7c98a8d898fcff4b404567a16eb1 Mon Sep 17 00:00:00 2001 From: kfir4444 Date: Sun, 21 Jun 2026 17:42:47 +0300 Subject: [PATCH 3/3] Solve linear torsion issue? --- arc/mapping/engine.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/arc/mapping/engine.py b/arc/mapping/engine.py index 98fa590f03..01f257ddcd 100644 --- a/arc/mapping/engine.py +++ b/arc/mapping/engine.py @@ -12,7 +12,7 @@ from itertools import product from typing import TYPE_CHECKING -from arc.common import convert_list_index_0_to_1, extremum_list, get_angle_in_180_range, logger, signed_angular_diff +from arc.common import convert_list_index_0_to_1, extremum_list, get_angle_in_180_range, is_angle_linear, logger, signed_angular_diff from arc.exceptions import AtomTypeError, ConformerError, InputError, SpeciesError from arc.family import ReactionFamily from arc.molecule import Molecule @@ -20,7 +20,7 @@ from arc.species import ARCSpecies from arc.species.conformers import determine_chirality from arc.species.converter import compare_confs, sort_xyz_using_indices, xyz_from_data -from arc.species.vectors import calculate_dihedral_angle, get_delta_angle +from arc.species.vectors import calculate_angle, calculate_dihedral_angle, get_delta_angle if TYPE_CHECKING: from arc.molecule.molecule import Atom @@ -537,10 +537,16 @@ def get_backbone_dihedral_angles(spc_1: ARCSpecies, torsion_2 = [backbone_map[t_1] for t_1 in torsion_1] if all(pivot_2 in [torsion_2[1], torsion_2[2]] for pivot_2 in [rotor_dict_2['torsion'][1], rotor_dict_2['torsion'][2]]): + xyz_1, xyz_2 = spc_1.get_xyz(), spc_2.get_xyz() + if is_angle_linear(calculate_angle(coords=xyz_1, atoms=torsion_1[:3], index=0)) \ + or is_angle_linear(calculate_angle(coords=xyz_1, atoms=torsion_1[1:], index=0)) \ + or is_angle_linear(calculate_angle(coords=xyz_2, atoms=torsion_2[:3], index=0)) \ + or is_angle_linear(calculate_angle(coords=xyz_2, atoms=torsion_2[1:], index=0)): + continue torsions.append({'torsion 1': torsion_1, 'torsion 2': torsion_2, - 'angle 1': calculate_dihedral_angle(coords=spc_1.get_xyz(), torsion=torsion_1), - 'angle 2': calculate_dihedral_angle(coords=spc_2.get_xyz(), torsion=torsion_2)}) + 'angle 1': calculate_dihedral_angle(coords=xyz_1, torsion=torsion_1), + 'angle 2': calculate_dihedral_angle(coords=xyz_2, torsion=torsion_2)}) return torsions