diff --git a/package/CHANGELOG b/package/CHANGELOG index eb14bbd77f6..04615d57bcc 100644 --- a/package/CHANGELOG +++ b/package/CHANGELOG @@ -30,6 +30,8 @@ Enhancements * added support for Tinker TXYZ and ARC files * libmdaxdr and libdcd classes can now be pickled (PR #1680) * speed up atomgroup.rotate when point = (0, 0, 0) (Issue #1707) + * Universe.add_TopologyAttr now accepts strings to add a given attribute + to the Universe (Issue #1092, PR #1186) Deprecations @@ -81,6 +83,8 @@ Changes tuple (filename_or_array, trajectory_type) (Issue #1613) * docs: URLs to (www|docs).mdanalysis.org now link to SSL-encrypted site (see issue MDAnalysis/MDAnalysis.github.io#61) + * attributes can not be assigned to AtomGroups (and similar objects) unless + they are part of the Universe topology (Issue #1092 PR #1186) 06/29/17 richardjgowers, rathann, jbarnoud, orbeckst, utkbansal @@ -965,7 +969,7 @@ Fixes 04/01/14 orbeckst, jandom, zhuyi.xue, xdeupi, tyler.je.reddy, manuel.nuno.melo, danny.parton, sebastien.buchoux, denniej0, - rmcgibbo, richardjgowers, lennardvanderfeltz, bernardin.alejandro + rmcgibbo, richardjgowers, lennardvanderfeltz, bernardin.alejandro matthieu.chavent * 0.8.1 @@ -1480,7 +1484,7 @@ Fixes alternative atoms and insertion codes that are not needed for handling MD simulation data). - The default behaviour of MDAnalysis can be set through the flag - MDAnalysis.core.flag['permissive_pdb_reader']. The default is True. + MDAnalysis.core.flag['permissive_pdb_reader']. The default is True. - One can always manually select the PDB reader by providing the permissive keyword to Universe; e.g. Universe(...,permissive=False) will read the input file with the Bio.PDB reader. This might be diff --git a/package/MDAnalysis/__init__.py b/package/MDAnalysis/__init__.py index 1eef86fe4cb..fc57737d868 100644 --- a/package/MDAnalysis/__init__.py +++ b/package/MDAnalysis/__init__.py @@ -164,6 +164,8 @@ _MULTIFRAME_WRITERS = {} _PARSERS = {} _SELECTION_WRITERS = {} +# Registry of TopologyAttributes +_TOPOLOGY_ATTRS = {} # Storing anchor universes for unpickling groups import weakref diff --git a/package/MDAnalysis/core/groups.py b/package/MDAnalysis/core/groups.py index c5c5931cda3..ab8ba30a9ac 100644 --- a/package/MDAnalysis/core/groups.py +++ b/package/MDAnalysis/core/groups.py @@ -153,15 +153,14 @@ def make_classes(): # The 'GBase' middle man is needed so that a single topologyattr # patching applies automatically to all groups. - GBase = bases[GroupBase] = _TopologyAttrContainer._subclass() + GBase = bases[GroupBase] = _TopologyAttrContainer._subclass(is_group=True) for cls in groups: - bases[cls] = GBase._subclass() - - # In the current Group-centered topology scheme no attributes apply only - # to ComponentBase, so no need to have a 'CB' middle man. - #CBase = _TopologyAttrContainer(singular=True) + bases[cls] = GBase._subclass(is_group=True) + # CBase for patching all components + CBase = bases[ComponentBase] = _TopologyAttrContainer._subclass( + is_group=False) for cls in components: - bases[cls] = _TopologyAttrContainer._subclass(singular=True) + bases[cls] = CBase._subclass(is_group=False) # Initializes the class cache. for cls in groups + components: @@ -184,27 +183,37 @@ class _TopologyAttrContainer(object): The mixed subclasses become the final container classes specific to each :class:`Universe`. """ - _singular = False - @classmethod - def _subclass(cls, singular=None): + def _subclass(cls, is_group): """Factory method returning :class:`_TopologyAttrContainer` subclasses. Parameters ---------- - singular : bool - The :attr:`_singular` of the returned class will be set to - *singular*. It controls the type of :class:`TopologyAttr` addition. + is_group : bool + The :attr:`_is_group` of the returned class will be set to + *is_group*. This is used to distinguish between Groups (AtomGroup + etc.) and Components (Atom etc.) in internal methods when + considering actions such as addition between objects, adding + TopologyAttributes to them. Returns ------- type A subclass of :class:`_TopologyAttrContainer`, with the same name. """ - if singular is not None: - return type(cls.__name__, (cls,), {'_singular': bool(singular)}) + newcls = type(cls.__name__, (cls,), {'_is_group': bool(is_group)}) + if is_group: + newcls._SETATTR_WHITELIST = { + 'positions', 'velocities', 'forces', 'dimensions', + 'atoms', 'residue', 'residues', 'segment', 'segments', + } else: - return type(cls.__name__, (cls,), {}) + newcls._SETATTR_WHITELIST = { + 'position', 'velocity', 'force', 'dimensions', + 'atoms', 'residue', 'residues', 'segment', + } + + return newcls @classmethod def _mix(cls, other): @@ -249,12 +258,26 @@ def getter(self): def setter(self, values): return attr.__setitem__(self, values) - if cls._singular: - setattr(cls, attr.singular, - property(getter, setter, None, attr.singledoc)) - else: + if cls._is_group: setattr(cls, attr.attrname, property(getter, setter, None, attr.groupdoc)) + cls._SETATTR_WHITELIST.add(attr.attrname) + else: + setattr(cls, attr.singular, + property(getter, setter, None, attr.singledoc)) + cls._SETATTR_WHITELIST.add(attr.singular) + + def __setattr__(self, attr, value): + # `ag.this = 42` calls setattr(ag, 'this', 42) + if not (attr.startswith('_') or # 'private' allowed + attr in self._SETATTR_WHITELIST or # known attributes allowed + hasattr(self, attr)): # preexisting (eg properties) allowed + raise AttributeError( + "Cannot set arbitrary attributes to a {}".format( + 'Group' if self._is_group else 'Component')) + # if it is, we allow the setattr to proceed by deferring to the super + # behaviour (ie do it) + super(_TopologyAttrContainer, self).__setattr__(attr, value) class _MutableBase(object): @@ -429,7 +452,7 @@ def __hash__(self): return hash((self._u, self.__class__, tuple(self.ix.tolist()))) def __len__(self): - return len(self._ix) + return len(self.ix) def __getitem__(self, item): # supports @@ -2780,7 +2803,7 @@ def __init__(self, base_group, selections, strings): # its check, no self.attribute access can be made before this line self._u = base_group.universe self._selections = selections - self.selection_strings = strings + self._selection_strings = strings self._base_group = base_group self._lastupdate = None self._derived_class = base_group._derived_class @@ -2857,8 +2880,9 @@ def _ensure_updated(self): def __getattribute__(self, name): # ALL attribute access goes through here - # If the requested attribute isn't in the shortcut list, update ourselves - if not name in _UAG_SHORTCUT_ATTRS: + # If the requested attribute is public (not starting with '_') and + # isn't in the shortcut list, update ourselves + if not (name.startswith('_') or name in _UAG_SHORTCUT_ATTRS): self._ensure_updated() # Going via object.__getattribute__ then bypasses this check stage return object.__getattribute__(self, name) @@ -2869,13 +2893,13 @@ def __reduce__(self): # - recreate UAG as created through select_atoms (basegroup and selstrs) # even if base_group is a UAG this will work through recursion return (_unpickle_uag, - (self._base_group.__reduce__(), self._selections, self.selection_strings)) + (self._base_group.__reduce__(), self._selections, self._selection_strings)) def __repr__(self): basestr = super(UpdatingAtomGroup, self).__repr__() - if not self.selection_strings: + if not self._selection_strings: return basestr - sels = "'{}'".format("' + '".join(self.selection_strings)) + sels = "'{}'".format("' + '".join(self._selection_strings)) # Cheap comparison. Might fail for corner cases but this is # mostly cosmetic. if self._base_group is self.universe.atoms: @@ -2884,7 +2908,7 @@ def __repr__(self): basegrp = "another AtomGroup." # With a shorthand to conditionally append the 's' in 'selections'. return "{}, with selection{} {} on {}>".format(basestr[:-1], - "s"[len(self.selection_strings)==1:], sels, basegrp) + "s"[len(self._selection_strings)==1:], sels, basegrp) # Define relationships between these classes # with Level objects diff --git a/package/MDAnalysis/core/topologyattrs.py b/package/MDAnalysis/core/topologyattrs.py index 9b29f3533bb..f9e284fcb23 100644 --- a/package/MDAnalysis/core/topologyattrs.py +++ b/package/MDAnalysis/core/topologyattrs.py @@ -31,6 +31,7 @@ These are usually read by the TopologyParser. """ from __future__ import division, absolute_import +import six from six.moves import zip, range import Bio.Seq @@ -54,6 +55,7 @@ from .groups import (ComponentBase, GroupBase, Atom, Residue, Segment, AtomGroup, ResidueGroup, SegmentGroup) +from .. import _TOPOLOGY_ATTRS def _check_length(func): @@ -163,13 +165,29 @@ def _wronglevel_error(attr, group): )) -class TopologyAttr(object): +class _TopologyAttrMeta(type): + # register TopologyAttrs + def __init__(cls, name, bases, classdict): + type.__init__(type, name, bases, classdict) + for attr in ['attrname', 'singular']: + try: + attrname = classdict[attr] + except KeyError: + pass + else: + _TOPOLOGY_ATTRS[attrname] = cls + + +class TopologyAttr(six.with_metaclass(_TopologyAttrMeta, object)): """Base class for Topology attributes. - .. note:: This class is intended to be subclassed, and mostly amounts to - a skeleton. The methods here should be present in all - :class:`TopologyAttr` child classes, but by default they raise - appropriate exceptions. + Note + ---- + This class is intended to be subclassed, and mostly amounts to + a skeleton. The methods here should be present in all + :class:`TopologyAttr` child classes, but by default they raise + appropriate exceptions. + Attributes ---------- @@ -187,6 +205,8 @@ class TopologyAttr(object): singular = 'topologyattr' per_object = None # ie Resids per_object = 'residue' top = None # pointer to Topology object + transplants = defaultdict(list) + target_classes = [] groupdoc = None singledoc = None @@ -195,6 +215,36 @@ def __init__(self, values, guessed=False): self.values = values self._guessed = guessed + @staticmethod + def _gen_initial_values(n_atoms, n_residues, n_segments): + """Populate an initial empty data structure for this Attribute + + The only provided parameters are the "shape" of the Universe + + Eg for charges, provide np.zeros(n_atoms) + """ + raise NotImplementedError("No default values") + + @classmethod + def from_blank(cls, n_atoms=None, n_residues=None, n_segments=None, + values=None): + """Create a blank version of this TopologyAttribute + + Parameters + ---------- + n_atoms : int, optional + Size of the TopologyAttribute atoms + n_residues: int, optional + Size of the TopologyAttribute residues + n_segments : int, optional + Size of the TopologyAttribute segments + values : optional + Initial values for the TopologyAttribute + """ + if values is None: + values = cls._gen_initial_values(n_atoms, n_residues, n_segments) + return cls(values) + def __len__(self): """Length of the TopologyAttr at its intrinsic level.""" return len(self.values) @@ -262,7 +312,7 @@ class Atomindices(TopologyAttr): """ attrname = 'indices' singular = 'index' - target_classes = [Atom] + target_classes = [AtomGroup, ResidueGroup, SegmentGroup, Atom] def __init__(self): self._guessed = False @@ -271,13 +321,13 @@ def set_atoms(self, ag, values): raise AttributeError("Atom indices are fixed; they cannot be reset") def get_atoms(self, ag): - return ag._ix + return ag.ix def get_residues(self, rg): - return list(self.top.tt.residues2atoms_2d(rg._ix)) + return list(self.top.tt.residues2atoms_2d(rg.ix)) def get_segments(self, sg): - return list(self.top.tt.segments2atoms_2d(sg._ix)) + return list(self.top.tt.segments2atoms_2d(sg.ix)) class Resindices(TopologyAttr): @@ -294,22 +344,22 @@ class Resindices(TopologyAttr): """ attrname = 'resindices' singular = 'resindex' - target_classes = [Atom, Residue] + target_classes = [AtomGroup, ResidueGroup, SegmentGroup, Atom, Residue] def __init__(self): self._guessed = False def get_atoms(self, ag): - return self.top.tt.atoms2residues(ag._ix) + return self.top.tt.atoms2residues(ag.ix) def get_residues(self, rg): - return rg._ix + return rg.ix def set_residues(self, rg, values): raise AttributeError("Residue indices are fixed; they cannot be reset") def get_segments(self, sg): - return list(self.top.tt.segments2residues_2d(sg._ix)) + return list(self.top.tt.segments2residues_2d(sg.ix)) class Segindices(TopologyAttr): @@ -327,19 +377,20 @@ class Segindices(TopologyAttr): """ attrname = 'segindices' singular = 'segindex' - target_classes = [Atom, Residue, Segment] + target_classes = [AtomGroup, ResidueGroup, SegmentGroup, + Atom, Residue, Segment] def __init__(self): self._guessed = False def get_atoms(self, ag): - return self.top.tt.atoms2segments(ag._ix) + return self.top.tt.atoms2segments(ag.ix) def get_residues(self, rg): - return self.top.tt.residues2segments(rg._ix) + return self.top.tt.residues2segments(rg.ix) def get_segments(self, sg): - return sg._ix + return sg.ix def set_segments(self, sg, values): raise AttributeError("Segment indices are fixed; they cannot be reset") @@ -353,14 +404,14 @@ class AtomAttr(TopologyAttr): """ attrname = 'atomattrs' singular = 'atomattr' - target_classes = [Atom] + target_classes = [AtomGroup, ResidueGroup, SegmentGroup, Atom] def get_atoms(self, ag): - return self.values[ag._ix] + return self.values[ag.ix] @_check_length def set_atoms(self, ag, values): - self.values[ag._ix] = values + self.values[ag.ix] = values def get_residues(self, rg): """By default, the values for each atom present in the set of residues @@ -368,7 +419,7 @@ def get_residues(self, rg): attributes. """ - aixs = self.top.tt.residues2atoms_2d(rg._ix) + aixs = self.top.tt.residues2atoms_2d(rg.ix) return [self.values[aix] for aix in aixs] def set_residues(self, rg, values): @@ -380,7 +431,7 @@ def get_segments(self, sg): attributes. """ - aixs = self.top.tt.segments2atoms_2d(sg._ix) + aixs = self.top.tt.segments2atoms_2d(sg.ix) return [self.values[aix] for aix in aixs] def set_segments(self, sg, values): @@ -395,6 +446,10 @@ class Atomids(AtomAttr): singular = 'id' per_object = 'atom' + @staticmethod + def _gen_initial_values(na, nr, ns): + return np.arange(1, na + 1) + # TODO: update docs to property doc class Atomnames(AtomAttr): @@ -405,6 +460,10 @@ class Atomnames(AtomAttr): per_object = 'atom' transplants = defaultdict(list) + @staticmethod + def _gen_initial_values(na, nr, ns): + return np.array(['' for _ in range(na)], dtype=object) + def getattr__(atomgroup, name): try: return atomgroup._get_named_atom(name) @@ -564,6 +623,10 @@ class Atomtypes(AtomAttr): singular = 'type' per_object = 'atom' + @staticmethod + def _gen_initial_values(na, nr, ns): + return np.array(['' for _ in range(na)], dtype=object) + # TODO: update docs to property doc class Elements(AtomAttr): @@ -571,6 +634,10 @@ class Elements(AtomAttr): attrname = 'elements' singular = 'element' + @staticmethod + def _gen_initial_values(na, nr, ns): + return np.array(['' for _ in range(na)], dtype=object) + # TODO: update docs to property doc class Radii(AtomAttr): @@ -579,6 +646,10 @@ class Radii(AtomAttr): singular = 'radius' per_object = 'atom' + @staticmethod + def _gen_initial_values(na, nr, ns): + return np.zeros(na) + class ChainIDs(AtomAttr): """ChainID per atom @@ -591,6 +662,10 @@ class ChainIDs(AtomAttr): singular = 'chainID' per_object = 'atom' + @staticmethod + def _gen_initial_values(na, nr, ns): + return np.array(['' for _ in range(na)], dtype=object) + class Tempfactors(AtomAttr): """Tempfactor for atoms""" @@ -598,12 +673,17 @@ class Tempfactors(AtomAttr): singular = 'tempfactor' per_object = 'atom' + @staticmethod + def _gen_initial_values(na, nr, ns): + return np.zeros(na) + class Masses(AtomAttr): attrname = 'masses' singular = 'mass' per_object = 'atom' - target_classes = [Atom, Residue, Segment] + target_classes = [AtomGroup, ResidueGroup, SegmentGroup, + Atom, Residue, Segment] transplants = defaultdict(list) groupdoc = """Mass of each component in the Group. @@ -620,8 +700,12 @@ def __init__(self, values, guessed=False): self.values = np.asarray(values, dtype=np.float64) self._guessed = guessed + @staticmethod + def _gen_initial_values(na, nr, ns): + return np.zeros(na) + def get_residues(self, rg): - resatoms = self.top.tt.residues2atoms_2d(rg._ix) + resatoms = self.top.tt.residues2atoms_2d(rg.ix) if isinstance(rg._ix, numbers.Integral): # for a single residue @@ -635,7 +719,7 @@ def get_residues(self, rg): return masses def get_segments(self, sg): - segatoms = self.top.tt.segments2atoms_2d(sg._ix) + segatoms = self.top.tt.segments2atoms_2d(sg.ix) if isinstance(sg._ix, numbers.Integral): # for a single segment @@ -943,11 +1027,16 @@ class Charges(AtomAttr): attrname = 'charges' singular = 'charge' per_object = 'atom' - target_classes = [Atom, Residue, Segment] + target_classes = [AtomGroup, ResidueGroup, SegmentGroup, + Atom, Residue, Segment] transplants = defaultdict(list) + @staticmethod + def _gen_initial_values(na, nr, ns): + return np.zeros(na) + def get_residues(self, rg): - resatoms = self.top.tt.residues2atoms_2d(rg._ix) + resatoms = self.top.tt.residues2atoms_2d(rg.ix) if isinstance(rg._ix, numbers.Integral): charges = self.values[resatoms].sum() @@ -959,7 +1048,7 @@ def get_residues(self, rg): return charges def get_segments(self, sg): - segatoms = self.top.tt.segments2atoms_2d(sg._ix) + segatoms = self.top.tt.segments2atoms_2d(sg.ix) if isinstance(sg._ix, numbers.Integral): # for a single segment @@ -987,6 +1076,10 @@ class Bfactors(AtomAttr): singular = 'bfactor' per_object = 'atom' + @staticmethod + def _gen_initial_values(na, nr, ns): + return np.zeros(na) + # TODO: update docs to property doc class Occupancies(AtomAttr): @@ -994,6 +1087,10 @@ class Occupancies(AtomAttr): singular = 'occupancy' per_object = 'atom' + @staticmethod + def _gen_initial_values(na, nr, ns): + return np.zeros(na) + # TODO: update docs to property doc class AltLocs(AtomAttr): @@ -1002,36 +1099,30 @@ class AltLocs(AtomAttr): singular = 'altLoc' per_object = 'atom' + @staticmethod + def _gen_initial_values(na, nr, ns): + return np.array(['' for _ in range(na)], dtype=object) -# residue attributes class ResidueAttr(TopologyAttr): - """Base class for Topology attributes. - - .. note:: This class is intended to be subclassed, and mostly amounts to - a skeleton. The methods here should be present in all - :class:`TopologyAttr` child classes, but by default they raise - appropriate exceptions. - - """ attrname = 'residueattrs' singular = 'residueattr' - target_classes = [Residue] + target_classes = [AtomGroup, ResidueGroup, SegmentGroup, Residue] per_object = 'residue' def get_atoms(self, ag): - rix = self.top.tt.atoms2residues(ag._ix) + rix = self.top.tt.atoms2residues(ag.ix) return self.values[rix] def set_atoms(self, ag, values): raise _wronglevel_error(self, ag) def get_residues(self, rg): - return self.values[rg._ix] + return self.values[rg.ix] @_check_length def set_residues(self, rg, values): - self.values[rg._ix] = values + self.values[rg.ix] = values def get_segments(self, sg): """By default, the values for each residue present in the set of @@ -1039,7 +1130,7 @@ def get_segments(self, sg): in child attributes. """ - rixs = self.top.tt.segments2residues_2d(sg._ix) + rixs = self.top.tt.segments2residues_2d(sg.ix) return [self.values[rix] for rix in rixs] def set_segments(self, sg, values): @@ -1051,16 +1142,24 @@ class Resids(ResidueAttr): """Residue ID""" attrname = 'resids' singular = 'resid' - target_classes = [Atom, Residue] + target_classes = [AtomGroup, ResidueGroup, SegmentGroup, Atom, Residue] + + @staticmethod + def _gen_initial_values(na, nr, ns): + return np.arange(1, nr + 1) # TODO: update docs to property doc class Resnames(ResidueAttr): attrname = 'resnames' singular = 'resname' - target_classes = [Atom, Residue] + target_classes = [AtomGroup, ResidueGroup, SegmentGroup, Atom, Residue] transplants = defaultdict(list) + @staticmethod + def _gen_initial_values(na, nr, ns): + return np.array(['' for _ in range(nr)], dtype=object) + def getattr__(residuegroup, resname): try: return residuegroup._get_named_residue(resname) @@ -1216,7 +1315,11 @@ def sequence(self, **kwargs): class Resnums(ResidueAttr): attrname = 'resnums' singular = 'resnum' - target_classes = [Atom, Residue] + target_classes = [AtomGroup, ResidueGroup, SegmentGroup, Atom, Residue] + + @staticmethod + def _gen_initial_values(na, nr, ns): + return np.arange(1, nr + 1) class ICodes(ResidueAttr): @@ -1224,6 +1327,10 @@ class ICodes(ResidueAttr): attrname = 'icodes' singular = 'icode' + @staticmethod + def _gen_initial_values(na, nr, ns): + return np.array(['' for _ in range(nr)], dtype=object) + class Moltypes(ResidueAttr): """Name of the molecule type @@ -1232,7 +1339,7 @@ class Moltypes(ResidueAttr): """ attrname = 'moltypes' singular = 'moltype' - target_classes = [Atom, Residue] + target_classes = [AtomGroup, ResidueGroup, SegmentGroup, Atom, Residue] # segment attributes @@ -1243,38 +1350,43 @@ class SegmentAttr(TopologyAttr): """ attrname = 'segmentattrs' singular = 'segmentattr' - target_classes = [Segment] + target_classes = [AtomGroup, ResidueGroup, SegmentGroup, Segment] per_object = 'segment' def get_atoms(self, ag): - six = self.top.tt.atoms2segments(ag._ix) + six = self.top.tt.atoms2segments(ag.ix) return self.values[six] def set_atoms(self, ag, values): raise _wronglevel_error(self, ag) def get_residues(self, rg): - six = self.top.tt.residues2segments(rg._ix) + six = self.top.tt.residues2segments(rg.ix) return self.values[six] def set_residues(self, rg, values): raise _wronglevel_error(self, rg) def get_segments(self, sg): - return self.values[sg._ix] + return self.values[sg.ix] @_check_length def set_segments(self, sg, values): - self.values[sg._ix] = values + self.values[sg.ix] = values # TODO: update docs to property doc class Segids(SegmentAttr): attrname = 'segids' singular = 'segid' - target_classes = [Atom, Residue, Segment] + target_classes = [AtomGroup, ResidueGroup, SegmentGroup, + Atom, Residue, Segment] transplants = defaultdict(list) + @staticmethod + def _gen_initial_values(na, nr, ns): + return np.array(['' for _ in range(ns)], dtype=object) + def getattr__(segmentgroup, segid): try: return segmentgroup._get_named_segment(segid) @@ -1373,17 +1485,17 @@ def set_atoms(self, ag): def get_atoms(self, ag): try: unique_bonds = set(itertools.chain( - *[self._bondDict[a] for a in ag._ix])) + *[self._bondDict[a] for a in ag.ix])) except TypeError: # maybe we got passed an Atom - unique_bonds = self._bondDict[ag._ix] + unique_bonds = self._bondDict[ag.ix] bond_idx, types, guessed, order = np.hsplit( np.array(sorted(unique_bonds)), 4) bond_idx = np.array(bond_idx.ravel().tolist(), dtype=np.int32) types = types.ravel() guessed = guessed.ravel() order = order.ravel() - return TopologyGroup(bond_idx, ag._u, + return TopologyGroup(bond_idx, ag.universe, self.singular[:-1], types, guessed, @@ -1430,7 +1542,7 @@ class Bonds(_Connection): def bonded_atoms(self): """An AtomGroup of all atoms bonded to this Atom""" idx = [b.partner(self).index for b in self.bonds] - return self._u.atoms[idx] + return self.universe.atoms[idx] transplants[Atom].append( ('bonded_atoms', property(bonded_atoms, None, None, diff --git a/package/MDAnalysis/core/universe.py b/package/MDAnalysis/core/universe.py index 67775c24034..fb8c006e468 100644 --- a/package/MDAnalysis/core/universe.py +++ b/package/MDAnalysis/core/universe.py @@ -93,14 +93,15 @@ import MDAnalysis import sys -from .. import _ANCHOR_UNIVERSES +from .. import _ANCHOR_UNIVERSES, _TOPOLOGY_ATTRS from ..exceptions import NoDataError from ..lib import util from ..lib.log import ProgressMeter, _set_verbose from ..lib.util import cached, NamedStream, isstream from . import groups from ._get_readers import get_reader_for, get_parser_for -from .groups import (GroupBase, Atom, Residue, Segment, +from .groups import (ComponentBase, GroupBase, + Atom, Residue, Segment, AtomGroup, ResidueGroup, SegmentGroup) from .topology import Topology from .topologyattrs import AtomAttr, ResidueAttr, SegmentAttr @@ -671,8 +672,52 @@ def trajectory(self, value): del self._trajectory # guarantees that files are closed (?) self._trajectory = value - def add_TopologyAttr(self, topologyattr): - """Add a new topology attribute.""" + def add_TopologyAttr(self, topologyattr, values=None): + """Add a new topology attribute to the Universe + + Adding a TopologyAttribute to the Universe makes it available to + all AtomGroups etc throughout the Universe. + + Parameters + ---------- + topologyattr : TopologyAttr or string + Either a MDAnalysis TopologyAttr object or the name of a possible + topology attribute. + values : np.ndarray, optional + If initiating an attribute from a string, the initial values to + use. If not supplied, the new TopologyAttribute will have empty + or zero values. + + Example + ------- + For example to add bfactors to a Universe: + + >>> u.add_TopologyAttr('bfactors') + >>> u.atoms.bfactors + array([ 0., 0., 0., ..., 0., 0., 0.]) + + .. versionchanged:: 0.17.0 + Can now also add TopologyAttrs with a string of the name of the + attribute to add (eg 'charges'), can also supply initial values + using values keyword. + """ + if isinstance(topologyattr, six.string_types): + try: + tcls = _TOPOLOGY_ATTRS[topologyattr] + except KeyError: + raise ValueError( + "Unrecognised topology attribute name: '{}'." + " Possible values: '{}'\n" + "To raise an issue go to: http://issues.mdanalysis.org" + "".format( + topologyattr, ', '.join(sorted(_TOPOLOGY_ATTRS.keys()))) + ) + else: + topologyattr = tcls.from_blank( + n_atoms=self._topology.n_atoms, + n_residues=self._topology.n_residues, + n_segments=self._topology.n_segments, + values=values) self._topology.add_TopologyAttr(topologyattr) self._process_attr(topologyattr) @@ -697,28 +742,18 @@ def _process_attr(self, attr): n=n_dict[attr.per_object], m=len(attr))) - self._class_bases[GroupBase]._add_prop(attr) - for cls in attr.target_classes: - try: - self._class_bases[cls]._add_prop(attr) - except (KeyError, AttributeError): - pass - - try: - transplants = attr.transplants - except AttributeError: - # not every Attribute will have a transplant dict - pass - else: - # Group transplants - for cls in (Atom, Residue, Segment, GroupBase, - AtomGroup, ResidueGroup, SegmentGroup): - for funcname, meth in transplants[cls]: - setattr(self._class_bases[cls], funcname, meth) - # Universe transplants - for funcname, meth in transplants['Universe']: - setattr(self.__class__, funcname, meth) + self._class_bases[cls]._add_prop(attr) + + # TODO: Try and shove this into cls._add_prop + # Group transplants + for cls in (Atom, Residue, Segment, GroupBase, + AtomGroup, ResidueGroup, SegmentGroup): + for funcname, meth in attr.transplants[cls]: + setattr(self._class_bases[cls], funcname, meth) + # Universe transplants + for funcname, meth in attr.transplants['Universe']: + setattr(self.__class__, funcname, meth) def add_Residue(self, segment=None, **attrs): """Add a new Residue to this Universe diff --git a/testsuite/MDAnalysisTests/core/test_groups.py b/testsuite/MDAnalysisTests/core/test_groups.py index 1afbe6aa61b..09c4628574b 100644 --- a/testsuite/MDAnalysisTests/core/test_groups.py +++ b/testsuite/MDAnalysisTests/core/test_groups.py @@ -1016,3 +1016,77 @@ def test_SegmentGroup_warn_getattr(self, u): def test_SegmentGroup_nowarn_getitem(self, u): with no_deprecated_call(): u.segments[0] + + +@pytest.fixture() +def attr_universe(): + return make_Universe(('names', 'resids', 'segids')) + +class TestAttributeSetting(object): + @pytest.mark.parametrize('groupname', ['atoms', 'residues', 'segments']) + def test_setting_group_fail(self, attr_universe, groupname): + group = getattr(attr_universe, groupname) + + with pytest.raises(AttributeError): + group.this = 'that' + + @pytest.mark.parametrize('groupname', ['atoms', 'residues', 'segments']) + def test_setting_component_fails(self, attr_universe, groupname): + component = getattr(attr_universe, groupname)[0] + + with pytest.raises(AttributeError): + component.this = 'that' + + @pytest.mark.parametrize('attr', ['name', 'resid', 'segid']) + @pytest.mark.parametrize('groupname', ['atoms', 'residues', 'segments']) + def test_group_set_singular(self, attr_universe, attr, groupname): + # this should fail as you can't set the 'name' of a 'ResidueGroup' + group = getattr(attr_universe, groupname) + with pytest.raises(AttributeError): + setattr(group, attr, 24) + + def test_atom_set_name(self, attr_universe): + attr_universe.atoms[0].name = 'this' + assert attr_universe.atoms[0].name == 'this' + + def test_atom_set_resid(self, attr_universe): + with pytest.raises(NotImplementedError): + attr_universe.atoms[0].resid = 24 + + def test_atom_set_segid(self, attr_universe): + with pytest.raises(NotImplementedError): + attr_universe.atoms[0].segid = 'this' + + def test_residue_set_name(self, attr_universe): + with pytest.raises(AttributeError): + attr_universe.residues[0].name = 'this' + + def test_residue_set_resid(self, attr_universe): + attr_universe.residues[0].resid = 24 + assert attr_universe.residues[0].resid == 24 + + def test_residue_set_segid(self, attr_universe): + with pytest.raises(NotImplementedError): + attr_universe.residues[0].segid = 'this' + + def test_segment_set_name(self, attr_universe): + with pytest.raises(AttributeError): + attr_universe.segments[0].name = 'this' + + def test_segment_set_resid(self, attr_universe): + with pytest.raises(AttributeError): + attr_universe.segments[0].resid = 24 + + def test_segment_set_segid(self, attr_universe): + attr_universe.segments[0].segid = 'this' + assert attr_universe.segments[0].segid == 'this' + + @pytest.mark.parametrize('attr', ['names', 'resids', 'segids']) + @pytest.mark.parametrize('groupname', ['atoms', 'residues', 'segments']) + def test_component_set_plural(self, attr, groupname): + # this should fail as you can't set the 'Names' of an 'Atom' + u = make_Universe(('names', 'resids', 'segids')) + group = getattr(u, groupname) + comp = group[0] + with pytest.raises(AttributeError): + setattr(comp, attr, 24) diff --git a/testsuite/MDAnalysisTests/core/test_universe.py b/testsuite/MDAnalysisTests/core/test_universe.py index a4d75c9e829..3fd053e5ac0 100644 --- a/testsuite/MDAnalysisTests/core/test_universe.py +++ b/testsuite/MDAnalysisTests/core/test_universe.py @@ -25,7 +25,6 @@ from six.moves import cPickle import os -from unittest import TestCase try: from cStringIO import StringIO @@ -285,7 +284,7 @@ def test_chainid_quick_select(): assert len(u.D.atoms) == 7 -class TestGuessBonds(TestCase): +class TestGuessBonds(object): """Test the AtomGroup methed guess_bonds This needs to be done both from Universe creation (via kwarg) and AtomGroup @@ -295,11 +294,9 @@ class TestGuessBonds(TestCase): - fail properly if not - work again if vdwradii are passed. """ - def setUp(self): - self.vdw = {'A':1.05, 'B':0.4} - - def tearDown(self): - del self.vdw + @pytest.fixture() + def vdw(self): + return {'A': 1.05, 'B': 0.4} def _check_universe(self, u): """Verify that the Universe is created correctly""" @@ -325,13 +322,13 @@ def test_universe_guess_bonds_no_vdwradii(self): with pytest.raises(ValueError): mda.Universe(two_water_gro_nonames, guess_bonds = True) - def test_universe_guess_bonds_with_vdwradii(self): + def test_universe_guess_bonds_with_vdwradii(self, vdw): """Unknown atom types, but with vdw radii here to save the day""" u = mda.Universe(two_water_gro_nonames, guess_bonds=True, - vdwradii=self.vdw) + vdwradii=vdw) self._check_universe(u) assert u.kwargs['guess_bonds'] - assert_equal(self.vdw, u.kwargs['vdwradii']) + assert_equal(vdw, u.kwargs['vdwradii']) def test_universe_guess_bonds_off(self): u = mda.Universe(two_water_gro_nonames, guess_bonds=False) @@ -372,11 +369,11 @@ def test_atomgroup_guess_bonds_no_vdwradii(self): with pytest.raises(ValueError): ag.guess_bonds() - def test_atomgroup_guess_bonds_with_vdwradii(self): + def test_atomgroup_guess_bonds_with_vdwradii(self, vdw): u = mda.Universe(two_water_gro_nonames) ag = u.atoms[:3] - ag.guess_bonds(vdwradii=self.vdw) + ag.guess_bonds(vdwradii=vdw) self._check_atomgroup(ag, u) @@ -528,3 +525,46 @@ def test_custom_both(self): u = mda.Universe(TRZ_psf, TRZ, format=MDAnalysis.coordinates.TRZ.TRZReader, topology_format=MDAnalysis.topology.PSFParser.PSFParser) assert_equal(len(u.atoms), 8184) + + +class TestAddTopologyAttr(object): + @pytest.fixture() + def universe(self): + return make_Universe() + + def test_add_TA_fail(self, universe): + with pytest.raises(ValueError): + universe.add_TopologyAttr('silly') + + def test_nodefault_fail(self, universe): + with pytest.raises(NotImplementedError): + universe.add_TopologyAttr('bonds') + + @pytest.mark.parametrize( + 'toadd,attrname,default', ( + ['charge', 'charges', 0.0], ['charges', 'charges', 0.0], + ['name', 'names', ''], ['names', 'names', ''], + ['type', 'types', ''], ['types', 'types', ''], + ['element', 'elements', ''], ['elements', 'elements', ''], + ['radius', 'radii', 0.0], ['radii', 'radii', 0.0], + ['chainID', 'chainIDs', ''], ['chainIDs', 'chainIDs', ''], + ['tempfactor', 'tempfactors', 0.0], + ['tempfactors', 'tempfactors', 0.0], + ['mass', 'masses', 0.0], ['masses', 'masses', 0.0], + ['charge', 'charges', 0.0], ['charges', 'charges', 0.0], + ['bfactor', 'bfactors', 0.0], ['bfactors', 'bfactors', 0.0], + ['occupancy', 'occupancies', 0.0], + ['occupancies', 'occupancies', 0.0], + ['altLoc', 'altLocs', ''], ['altLocs', 'altLocs', ''], + ['resid', 'resids', 1], ['resids', 'resids', 1], + ['resname', 'resnames', ''], ['resnames', 'resnames', ''], + ['resnum', 'resnums', 1], ['resnums', 'resnums', 1], + ['icode', 'icodes', ''], ['icodes', 'icodes', ''], + ['segid', 'segids', ''], ['segids', 'segids', ''], + ) + ) + def test_add_charges(self, universe, toadd, attrname, default): + universe.add_TopologyAttr(toadd) + + assert hasattr(universe.atoms, attrname) + assert getattr(universe.atoms, attrname)[0] == default