From 206652bf6c0d4fd6cc95a4e8f450605b7e487564 Mon Sep 17 00:00:00 2001 From: Lily Wang Date: Tue, 29 Oct 2019 17:41:10 +1100 Subject: [PATCH] added add_TopologyObjects added delete_TopologyObjects --- package/CHANGELOG | 4 + package/MDAnalysis/core/groups.py | 6 +- package/MDAnalysis/core/topologyattrs.py | 71 ++++-- package/MDAnalysis/core/topologyobjects.py | 1 + package/MDAnalysis/core/universe.py | 236 ++++++++++++++++++ .../core/test_topologyattrs.py | 4 +- .../MDAnalysisTests/core/test_universe.py | 130 +++++++++- .../MDAnalysisTests/topology/test_top.py | 6 - 8 files changed, 429 insertions(+), 29 deletions(-) diff --git a/package/CHANGELOG b/package/CHANGELOG index dbef19f6d4a..77153dd6229 100644 --- a/package/CHANGELOG +++ b/package/CHANGELOG @@ -48,6 +48,10 @@ Enhancements * Added wrap/unwrap transformations (PR #2038) * Read TPR files from Gromacs 2020 (Issue #2412) + * Added add_TopologyObject/s and delete_TopologyObject/s (PR #2382) + * Added _add_TopologyObjects, _delete_TopologyObjects, and public convenience + methods to Universe. Added type and order checking to Bonds/Angles/Dihedrals + and type checking to Impropers. (PR #2382) Deprecations * analysis.hbonds.HydrogenBondAnalysis will be deprecated in 1.0 diff --git a/package/MDAnalysis/core/groups.py b/package/MDAnalysis/core/groups.py index 95e38e600a3..d5b9100013d 100644 --- a/package/MDAnalysis/core/groups.py +++ b/package/MDAnalysis/core/groups.py @@ -2950,15 +2950,15 @@ def get_TopAttr(u, name, cls): box = self.dimensions if self.dimensions.all() else None b = guess_bonds(self.atoms, self.atoms.positions, vdwradii=vdwradii, box=box) bondattr = get_TopAttr(self.universe, 'bonds', Bonds) - bondattr.add_bonds(b, guessed=True) + bondattr._add_bonds(b, guessed=True) a = guess_angles(self.bonds) angleattr = get_TopAttr(self.universe, 'angles', Angles) - angleattr.add_bonds(a, guessed=True) + angleattr._add_bonds(a, guessed=True) d = guess_dihedrals(self.angles) diheattr = get_TopAttr(self.universe, 'dihedrals', Dihedrals) - diheattr.add_bonds(d) + diheattr._add_bonds(d) @property def bond(self): diff --git a/package/MDAnalysis/core/topologyattrs.py b/package/MDAnalysis/core/topologyattrs.py index 60b49998df7..688e525cde4 100644 --- a/package/MDAnalysis/core/topologyattrs.py +++ b/package/MDAnalysis/core/topologyattrs.py @@ -1644,17 +1644,41 @@ def _get_named_segment(group, segid): transplants[SegmentGroup].append( ('_get_named_segment', _get_named_segment)) +def _check_connection_values(func): + """ + Checks values passed to _Connection methods for appropriate number of + atom indices and coerces them to tuples of ints. -class _Connection(AtomAttr): - """Base class for connectivity between atoms""" - def __init__(self, values, types=None, guessed=False, order=None): - values = [tuple(x) for x in values] + + .. versionadded:: 0.21.0 + + """ + @functools.wraps(func) + def wrapper(self, values, *args, **kwargs): if not all(len(x) == self._n_atoms - and all(isinstance(y, (int, np.integer)) for y in x) - for x in values): + and all(isinstance(y, (int, np.integer)) for y in x) + for x in values): raise ValueError(("{} must be an iterable of tuples with {}" - " atom indices").format(self.attrname, - self._n_atoms)) + " atom indices").format(self.attrname, + self._n_atoms)) + clean = [] + for v in values: + if v[0] > v[-1]: + v = v[::-1] + clean.append(tuple(v)) + + return func(self, clean, *args, **kwargs) + return wrapper + +class _Connection(AtomAttr): + """Base class for connectivity between atoms + + .. versionchanged:: 0.21.0 + Added type checking to atom index values. + """ + + @_check_connection_values + def __init__(self, values, types=None, guessed=False, order=None): self.values = values if types is None: types = [None] * len(values) @@ -1687,12 +1711,6 @@ def _bondDict(self): for b, t, g, o in zip(self.values, self.types, self._guessed, self.order): - # We always want the first index - # to be less than the last - # eg (0, 1) not (1, 0) - # and (4, 10, 8) not (8, 10, 4) - if b[0] > b[-1]: - b = b[::-1] for a in b: bd[a].append((b, t, g, o)) return bd @@ -1719,7 +1737,8 @@ def get_atoms(self, ag): guessed, order) - def add_bonds(self, values, types=None, guessed=True, order=None): + @_check_connection_values + def _add_bonds(self, values, types=None, guessed=True, order=None): if types is None: types = itertools.cycle((None,)) if guessed in (True, False): @@ -1739,7 +1758,26 @@ def add_bonds(self, values, types=None, guessed=True, order=None): del self._cache['bd'] except KeyError: pass - + + @_check_connection_values + def _delete_bonds(self, values): + """ + .. versionadded:: 0.21.0 + """ + to_check = set(values) & set(self.values) + idx = [self.values.index(v) for v in to_check] + for i in sorted(idx, reverse=True): + del self.values[i] + + for attr in ('types', '_guessed', 'order'): + arr = np.array(getattr(self, attr), dtype='object') + new = np.delete(arr, idx) + setattr(self, attr, list(new)) + # kill the old cache of bond Dict + try: + del self._cache['bd'] + except KeyError: + pass class Bonds(_Connection): """Bonds between two atoms @@ -1922,3 +1960,4 @@ class Impropers(_Connection): singular = 'impropers' transplants = defaultdict(list) _n_atoms = 4 + diff --git a/package/MDAnalysis/core/topologyobjects.py b/package/MDAnalysis/core/topologyobjects.py index 2a479c71651..d65a702e2cd 100644 --- a/package/MDAnalysis/core/topologyobjects.py +++ b/package/MDAnalysis/core/topologyobjects.py @@ -542,6 +542,7 @@ def __init__(self, bondidx, universe, btype=None, type=None, guessed=None, raise ValueError("Unsupported btype, use one of '{}'" "".format(', '.join(_BTYPE_TO_SHAPE))) + bondidx = np.asarray(bondidx) nbonds = len(bondidx) # remove duplicate bonds if type is None: diff --git a/package/MDAnalysis/core/universe.py b/package/MDAnalysis/core/universe.py index 4b7f47a3536..327df5aa4a1 100644 --- a/package/MDAnalysis/core/universe.py +++ b/package/MDAnalysis/core/universe.py @@ -127,6 +127,7 @@ AtomGroup, ResidueGroup, SegmentGroup) from .topology import Topology from .topologyattrs import AtomAttr, ResidueAttr, SegmentAttr +from .topologyobjects import TopologyObject logger = logging.getLogger("MDAnalysis.core.universe") @@ -992,6 +993,241 @@ def add_Segment(self, **attrs): self.segments = SegmentGroup(np.arange(self._topology.n_segments), self) # return the new segment return self.segments[segidx] + + def _add_topology_objects(self, object_type, values, types=None, guessed=False, + order=None): + """Add new TopologyObjects to this Universe + + Parameters + ---------- + object_type : {'bonds', 'angles', 'dihedrals', 'impropers'} + The type of TopologyObject to add. + values : iterable of tuples, AtomGroups, or TopologyObjects + An iterable of: tuples of atom indices, or AtomGroups, + or TopologyObjects. If every value is a TopologyObject, all + keywords are ignored. + types : iterable (optional, default None) + None, or an iterable of hashable values with the same length as ``values`` + guessed : bool or iterable (optional, default False) + bool, or an iterable of hashable values with the same length as ``values`` + order : iterable (optional, default None) + None, or an iterable of hashable values with the same length as ``values`` + + + .. versionadded:: 0.21.0 + + """ + if all(isinstance(x, TopologyObject) for x in values): + try: + types = [t.type for t in values] + except AttributeError: + types = None + guessed = [t.is_guessed for t in values] + order = [t.order for t in values] + + values = [x.indices if isinstance(x, (AtomGroup, TopologyObject)) + else x for x in values] + + try: + attr = getattr(self._topology, object_type) + except AttributeError: + self.add_TopologyAttr(object_type, []) + attr = getattr(self._topology, object_type) + + attr._add_bonds(values, types=types, guessed=guessed, order=order) + + def add_bonds(self, values, types=None, guessed=False, order=None): + """Add new Bonds to this Universe. + + Parameters + ---------- + values : iterable of tuples, AtomGroups, or Bonds + An iterable of: tuples of 2 atom indices, or AtomGroups with 2 atoms, + or Bonds. If every value is a Bond, all + keywords are ignored. + types : iterable (optional, default None) + None, or an iterable of hashable values with the same length as ``values`` + guessed : bool or iterable (optional, default False) + bool, or an iterable of hashable values with the same length as ``values`` + order : iterable (optional, default None) + None, or an iterable of hashable values with the same length as ``values`` + + + Example + ------- + + Adding TIP4P water bonds with a list of AtomGroups: + + >>> import MDAnalysis as mda + >>> from MDAnalysis.tests.datafiles import GRO + >>> u = mda.Universe(GRO) + >>> sol = u.select_atoms('resname SOL') + >>> ow_hw1 = sol.select_atoms('name OW or name HW1').split('residue') + >>> ow_hw2 = sol.select_atoms('name OW or name HW2').split('residue') + >>> ow_mw = sol.select_atoms('name OW or name MW').split('residue') + >>> u.add_bonds(ow_hw1 + ow_hw2 + ow_mw) + + + + .. versionadded:: 0.21.0 + + + """ + self._add_topology_objects('bonds', values, types=types, + guessed=guessed, order=order) + self._cache.pop('fragments', None) + + def add_angles(self, values, types=None, guessed=False): + """Add new Angles to this Universe. + + Parameters + ---------- + values : iterable of tuples, AtomGroups, or Angles + An iterable of: tuples of 3 atom indices, or AtomGroups with 3 atoms, + or Angles. If every value is a Angle, all + keywords are ignored. + types : iterable (optional, default None) + None, or an iterable of hashable values with the same length as ``values`` + guessed : bool or iterable (optional, default False) + bool, or an iterable of hashable values with the same length as ``values`` + + .. versionadded:: 0.21.0 + """ + self._add_topology_objects('angles', values, types=types, + guessed=guessed) + + def add_dihedrals(self, values, types=None, guessed=False): + """Add new Dihedrals to this Universe. + + Parameters + ---------- + values : iterable of tuples, AtomGroups, or Dihedrals + An iterable of: tuples of 4 atom indices, or AtomGroups with 4 atoms, + or Dihedrals. If every value is a Dihedral, all + keywords are ignored. + types : iterable (optional, default None) + None, or an iterable of hashable values with the same length as ``values`` + guessed : bool or iterable (optional, default False) + bool, or an iterable of hashable values with the same length as ``values`` + + + .. versionadded:: 0.21.0 + + """ + self._add_topology_objects('dihedrals', values, types=types, + guessed=guessed) + + def add_impropers(self, values, types=None, guessed=False): + """Add new Impropers to this Universe. + + Parameters + ---------- + values : iterable of tuples, AtomGroups, or Impropers + An iterable of: tuples of 4 atom indices, or AtomGroups with 4 atoms, + or Impropers. If every value is an Improper, all + keywords are ignored. + types : iterable (optional, default None) + None, or an iterable of hashable values with the same length as ``values`` + guessed : bool or iterable (optional, default False) + bool, or an iterable of hashable values with the same length as ``values`` + + + .. versionadded:: 0.21.0 + + """ + self._add_topology_objects('impropers', values, types=types, + guessed=guessed) + + + + + def _delete_topology_objects(self, object_type, values): + """Delete TopologyObjects from this Universe + + Parameters + ---------- + object_type : {'bonds', 'angles', 'dihedrals', 'impropers'} + The type of TopologyObject to add. + values : iterable of tuples, AtomGroups, or TopologyObjects + An iterable of: tuples of atom indices, or AtomGroups, + or TopologyObjects. + + .. versionadded:: 0.21.0 + """ + values = [x.indices if isinstance(x, (AtomGroup, TopologyObject)) + else x for x in values] + + try: + attr = getattr(self._topology, object_type) + except AttributeError: + raise ValueError('There are no {} to delete'.format(object_type)) + + attr._delete_bonds(values) + + def delete_bonds(self, values): + """Delete Bonds from this Universe. + + Parameters + ---------- + values : iterable of tuples, AtomGroups, or Bonds + An iterable of: tuples of 2 atom indices, or AtomGroups with 2 atoms, + or Bonds. + + + .. versionadded:: 0.21.0 + + """ + self._delete_topology_objects('bonds', values) + self._cache.pop('fragments', None) + + def delete_angles(self, values): + """Delete Angles from this Universe. + + Parameters + ---------- + values : iterable of tuples, AtomGroups, or Angles + An iterable of: tuples of 3 atom indices, or AtomGroups with 3 atoms, + or Angles. + + + .. versionadded:: 0.21.0 + + + """ + self._delete_topology_objects('angles', values) + + def delete_dihedrals(self, values): + """Delete Dihedrals from this Universe. + + Parameters + ---------- + values : iterable of tuples, AtomGroups, or Dihedrals + An iterable of: tuples of 4 atom indices, or AtomGroups with 4 atoms, + or Dihedrals. + + + .. versionadded:: 0.21.0 + + + """ + self._delete_topology_objects('dihedrals', values) + + def delete_impropers(self, values): + """Delete Impropers from this Universe. + + Parameters + ---------- + values : iterable of tuples, AtomGroups, or Impropers + An iterable of: tuples of 4 atom indices, or AtomGroups with 4 atoms, + or Impropers. + + + .. versionadded:: 0.21.0 + + + """ + self._delete_topology_objects('impropers', values) + # TODO: Maybe put this as a Bond attribute transplant # Problems: Can we transplant onto Universe? diff --git a/testsuite/MDAnalysisTests/core/test_topologyattrs.py b/testsuite/MDAnalysisTests/core/test_topologyattrs.py index 23c03172c3c..7c6d195c6f2 100644 --- a/testsuite/MDAnalysisTests/core/test_topologyattrs.py +++ b/testsuite/MDAnalysisTests/core/test_topologyattrs.py @@ -32,7 +32,7 @@ assert_almost_equal, ) import pytest -from MDAnalysisTests.datafiles import PSF, DCD +from MDAnalysisTests.datafiles import PSF, DCD, PRM from MDAnalysisTests import make_Universe, no_deprecated_call import MDAnalysis as mda @@ -559,4 +559,4 @@ def test_static_typing_from_empty(): u.add_TopologyAttr('masses', values=['1.0', '2.0', '3.0']) assert isinstance(u._topology.masses.values, np.ndarray) - assert isinstance(u.atoms[0].mass, float) + assert isinstance(u.atoms[0].mass, float) \ No newline at end of file diff --git a/testsuite/MDAnalysisTests/core/test_universe.py b/testsuite/MDAnalysisTests/core/test_universe.py index 08161790c91..2698e86a734 100644 --- a/testsuite/MDAnalysisTests/core/test_universe.py +++ b/testsuite/MDAnalysisTests/core/test_universe.py @@ -59,6 +59,7 @@ from MDAnalysis.topology.base import TopologyReaderBase from MDAnalysis.transformations import translate from MDAnalysisTests import assert_nowarns +from MDAnalysis.exceptions import NoDataError class IOErrorParser(TopologyReaderBase): @@ -636,10 +637,9 @@ def test_add_charges(self, universe, toadd, attrname, default): ('bonds', set([(1, 0), (1, 2)])), ('angles', [(1, 0, 2), (1, 2, 3), (2, 1, 4)]), ('dihedrals', [[1, 2, 3, 1], (3, 1, 5, 2)]), - ('impropers', [[1, 2, 3, 1], (3, 1, 5, 2)]), ) ) - def test_add_connection(self, universe, attr, values): + def test_add_symmetric_connection(self, universe, attr, values): universe.add_TopologyAttr(attr, values) assert hasattr(universe, attr) attrgroup = getattr(universe, attr) @@ -663,9 +663,135 @@ def test_add_connection(self, universe, attr, values): def add_connection_error(self, universe, attr, values): with pytest.raises(ValueError): universe.add_TopologyAttr(attr, values) + + +class TestAddTopologyObjects(object): + @pytest.fixture() + def universe(self): + return make_Universe() + + def test_add_bond_indices(self, universe): + idx = [(1, 0), (1, 2)] + + assert not hasattr(universe, 'bonds') + universe.add_bonds(idx) + assert len(universe.bonds) == len(idx) + + def test_add_dihedrals(self, universe): + idx = [[1, 2, 3, 1], [3, 1, 5, 2]] + dih = [universe.atoms[i].dihedral for i in idx] + + assert not hasattr(universe, 'dihedrals') + universe.add_dihedrals(dih) + assert len(universe.dihedrals) == len(idx) + + def test_add_atomgroups(self, universe): + idx = [[1, 2, 3, 1], [3, 1, 5, 2], [3, 1, 5, 2]] + imp = [universe.atoms[i] for i in idx] + + assert not hasattr(universe, 'impropers') + universe.add_impropers(imp) + assert len(universe.impropers) == 2 + + def test_add_topologygroups(self, universe): + arr = np.array([[1, 0, 2], [1, 2, 3]]) + tg = mda.core.topologyobjects.TopologyGroup(arr, universe) + + assert not hasattr(universe, 'angles') + assert len(tg) == 2 + universe.add_angles(tg) + assert len(universe.angles) == 2 + + ua = universe.angles[-1] + tga = tg[0] + assert ua.is_guessed == tga.is_guessed + assert ua.order == tga.order + + assert list(ua.indices) == list(arr[-1]) + + def test_add_topologygroup_error(self, universe): + arr = np.array([(1, 0), (1, 2)]) + tg = mda.core.topologyobjects.TopologyGroup(arr, universe) + + with pytest.raises(ValueError): + universe.add_angles(tg) + def test_add_angles_wrong_number_of_atoms_error(self, universe): + indices = [(0, 1, 2), (2, 3, 0), (4, 1)] + with pytest.raises(ValueError): + universe.add_angles(indices) + + def test_add_bonds_refresh_fragments(self, universe): + with pytest.raises(NoDataError): + getattr(universe.atoms, 'fragments') + + universe.add_bonds([universe.atoms[:2]]) + assert len(universe.atoms.fragments) == len(universe.atoms)-1 + + universe.add_bonds([universe.atoms[2:4]]) + assert len(universe.atoms.fragments) == len(universe.atoms)-2 + +class TestDeleteTopologyObjects(object): + + TOP = {'bonds': [(0, 1), (2, 3), (3, 4), (4, 5), (7, 8)], + 'angles': [(0, 1, 2), (3, 4, 5), (8, 2, 4)], + 'dihedrals': [(9, 2, 3, 4), (1, 3, 4, 2)], + 'impropers': [(1, 3, 5, 2), (1, 6, 7, 2), (5, 3, 4, 2)]} + @pytest.fixture() + def universe(self): + u = make_Universe() + for attr, values in self.TOP.items(): + u._add_topology_objects(attr, values) + return u + + def test_delete_bond_indices(self, universe): + assert len(universe.bonds) == 5 + universe.delete_bonds([(0, 1), (2, 3)]) + assert len(universe.bonds) == 3 + + def test_delete_angles(self, universe): + indices = [(0, 1, 2), (3, 4, 5)] + angles = [universe.atoms[[i, j, k]].angle for i, j, k in indices] + assert len(universe.angles) == 3 + universe.delete_angles(angles) + assert len(universe.angles) == 1 + + def test_delete_atomgroups(self, universe): + assert len(universe.dihedrals) == 2 + ag = universe.atoms[[1, 3, 4, 2]] + universe.delete_dihedrals([ag]) + assert len(universe.dihedrals) == 1 + + def test_delete_mixed_topologyobjects_type(self, universe): + mixed = [universe.atoms[[1, 3, 5, 2]].improper, (5, 3, 4, 2)] + universe.delete_impropers(mixed) + assert len(universe.impropers) == 1 + + def test_delete_angles_wrong_number_of_atoms_error(self, universe): + indices = [(0, 1, 2), (2, 3, 0), (4, 1)] + with pytest.raises(ValueError): + universe.delete_angles(indices) + + def test_ignore_topologyobjects_not_in_universe(self, universe): + n_bonds = len(universe.bonds) + bonds = [(0, 1), (99, 100), (22, 2)] + universe.delete_bonds(bonds) + assert len(universe.bonds) == n_bonds-1 + + def test_delete_bonds_refresh_fragments(self, universe): + n_fragments = len(universe.atoms.fragments) + universe.delete_bonds([universe.atoms[[2, 3]]]) + assert len(universe.atoms.fragments) == n_fragments + 1 + + def test_delete_bonds_empty_universe(self): + u = make_Universe() + assert not hasattr(u, 'bonds') + bonds = [(0, 1), (99, 100), (22, 2)] + with pytest.raises(ValueError): + u.delete_bonds(bonds) + class TestAllCoordinatesKwarg(object): @pytest.fixture(scope='class') def u_GRO_TRR(self): diff --git a/testsuite/MDAnalysisTests/topology/test_top.py b/testsuite/MDAnalysisTests/topology/test_top.py index babe91048b2..0af19029dc0 100644 --- a/testsuite/MDAnalysisTests/topology/test_top.py +++ b/testsuite/MDAnalysisTests/topology/test_top.py @@ -120,12 +120,6 @@ def test_dihedral_atoms_bonded(self, top): for b in ((dih[0], dih[1]), (dih[1], dih[2]), (dih[2], dih[3])): assert (b in vals) or (b[::-1] in vals) - def test_improper_atoms_bonded(self, top): - vals = top.bonds.values - for imp in top.impropers.values: - for b in ((imp[0], imp[2]), (imp[1], imp[2]), (imp[2], imp[3])): - assert (b in vals) or (b[::-1] in vals) - class TestPRMParser(TOPBase): ref_filename = PRM