diff --git a/src/biotite/structure/bonds.pyx b/src/biotite/structure/bonds.pyx index 7a86f6590..0c2dfda51 100644 --- a/src/biotite/structure/bonds.pyx +++ b/src/biotite/structure/bonds.pyx @@ -306,6 +306,13 @@ class BondList(Copyable): "Input array containing bonds must be either of shape " "(n,2) or (n,3)" ) + # After per-row sorting a self-bond appears as a row whose two + # atom indices are equal. These cannot represent valid chemistry, + # so reject them at the construction boundary. + if (self._bonds[:, 0] == self._bonds[:, 1]).any(): + raise ValueError( + "Input contains a bond from an atom to itself" + ) self._remove_redundant_bonds() self._max_bonds_per_atom = self._get_max_bonds_per_atom() @@ -939,6 +946,11 @@ class BondList(Copyable): cdef uint32 index1 = _to_positive_index(atom_index1, self._atom_count) cdef uint32 index2 = _to_positive_index(atom_index2, self._atom_count) + if index1 == index2: + raise ValueError( + f"Cannot create a bond from an atom to itself " + f"(atom index {index1})" + ) _sort(&index1, &index2) cdef int i diff --git a/src/biotite/structure/io/pdbx/convert.py b/src/biotite/structure/io/pdbx/convert.py index 18e7965ed..aa2277b08 100644 --- a/src/biotite/structure/io/pdbx/convert.py +++ b/src/biotite/structure/io/pdbx/convert.py @@ -1179,6 +1179,15 @@ def _set_intra_residue_bonds(array, atom_site): # Take the residue name from the first atom index, as the residue # name is the same for both atoms, since we have only intra bonds comp_id = array.res_name[bond_array[:, 0]] + # Two distinct atom indices sharing the same (res_name, atom_name) + # annotations would surface in chem_comp_bond as a self-bond on the + # chemical component, which is structurally invalid. + if np.any(atom_id_1 == atom_id_2): + raise BadStructureError( + "Structure contains bonded atoms sharing the same " + "(res_name, atom_name) annotations, which cannot be " + "written to chem_comp_bond without producing a self-bond" + ) _, unique_indices = np.unique( np.stack([comp_id, atom_id_1, atom_id_2], axis=-1), axis=0, return_index=True ) diff --git a/tests/structure/io/test_pdbx.py b/tests/structure/io/test_pdbx.py index 4ab07d5ac..ad35639f3 100644 --- a/tests/structure/io/test_pdbx.py +++ b/tests/structure/io/test_pdbx.py @@ -260,6 +260,29 @@ def test_metal_coordination_bonds(): assert np.all(conn_type_id == "metalc") +def test_set_structure_self_bond_raises(): + """ + Two distinct atoms sharing the same ``(res_name, atom_name)`` annotation + with a bond between them would produce a ``chem_comp_bond`` self-bond. + ``set_structure()`` must raise :class:`BadStructureError` instead of + silently filtering the offending row. + """ + atoms = struc.AtomArray(2) + atoms.coord[:] = 0.0 + atoms.chain_id[:] = "A" + atoms.res_id[:] = 1 + atoms.res_name[:] = "ALA" + # Same atom_name on both atoms — the ambiguous annotation. + atoms.atom_name[:] = "CA" + atoms.element[:] = "C" + atoms.hetero[:] = False + atoms.bonds = struc.BondList(2, np.array([[0, 1]])) + + pdbx_file = pdbx.BinaryCIFFile() + with pytest.raises(struc.BadStructureError, match="sharing the same"): + pdbx.set_structure(pdbx_file, atoms) + + def test_bond_sparsity(): """ Ensure that only as much intra-residue bonds are written as necessary, diff --git a/tests/structure/test_bonds.py b/tests/structure/test_bonds.py index 0ac2608d9..022f90fb3 100644 --- a/tests/structure/test_bonds.py +++ b/tests/structure/test_bonds.py @@ -110,6 +110,29 @@ def test_invalid_creation(): ), ) + # Reject self-bonds at construction time + with pytest.raises(ValueError, match="atom to itself"): + struc.BondList(5, np.array([[2, 2]])) + # Self-bonds expressed via mixed positive and negative indices + # (-1 resolves to 4 with atom_count=5) should also be rejected. + with pytest.raises(ValueError, match="atom to itself"): + struc.BondList(5, np.array([[4, -1]])) + + +def test_add_self_bond_rejected(): + """ + ``BondList.add_bond`` must reject bonds whose two atom indices refer to + the same atom, regardless of how the indices are written (positive, + negative, or a mix that resolves to the same positive index). + """ + bond_list = struc.BondList(5) + with pytest.raises(ValueError, match="atom to itself"): + bond_list.add_bond(2, 2) + with pytest.raises(ValueError, match="atom to itself"): + bond_list.add_bond(4, -1) + # The list should remain empty after the rejected adds. + assert len(bond_list.as_array()) == 0 + def test_modification(bond_list): """