Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions arc/mapping/driver_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions arc/mapping/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@
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
from arc.molecule.resonance import generate_resonance_structures_safely
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
Expand Down Expand Up @@ -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


Expand Down
2 changes: 1 addition & 1 deletion arc/species/conformers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions arc/species/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions arc/species/converter_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
5 changes: 2 additions & 3 deletions arc/species/perceive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])):
Expand Down
Loading