Skip to content

Commit e6bc096

Browse files
lilyminiumIAlibay
andauthored
Allow bonds etc to be additively guessed when present (#4761)
Allow bonds to be additively guessed (fixes #4759) --------- Co-authored-by: Irfan Alibay <IAlibay@users.noreply.github.com>
1 parent c9a3778 commit e6bc096

File tree

5 files changed

+214
-29
lines changed

5 files changed

+214
-29
lines changed

package/CHANGELOG

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ The rules for this file:
2323
* 2.8.0
2424

2525
Fixes
26+
* Allows bond/angle/dihedral connectivity to be guessed additively with
27+
`to_guess`, and as a replacement of existing values with `force_guess`.
28+
Also updates cached bond attributes when updating bonds. (Issue #4759, PR #4761)
2629
* Fixes bug where deleting connections by index would only delete
2730
one of multiple, if multiple are present (Issue #4762, PR #4763)
2831
* Changes error to warning on Universe creation if guessing fails

package/MDAnalysis/core/topologyattrs.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3117,7 +3117,6 @@ def _add_bonds(self, values, types=None, guessed=True, order=None):
31173117
guessed = itertools.cycle((guessed,))
31183118
if order is None:
31193119
order = itertools.cycle((None,))
3120-
31213120
existing = set(self.values)
31223121
for v, t, g, o in zip(values, types, guessed, order):
31233122
if v not in existing:

package/MDAnalysis/core/universe.py

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,10 @@
8484
Atom, Residue, Segment,
8585
AtomGroup, ResidueGroup, SegmentGroup)
8686
from .topology import Topology
87-
from .topologyattrs import AtomAttr, ResidueAttr, SegmentAttr, BFACTOR_WARNING
87+
from .topologyattrs import (
88+
AtomAttr, ResidueAttr, SegmentAttr,
89+
BFACTOR_WARNING, _Connection
90+
)
8891
from .topologyobjects import TopologyObject
8992
from ..guesser.base import get_guesser
9093

@@ -454,7 +457,10 @@ def __init__(self, topology=None, *coordinates, all_coordinates=False,
454457
"the previous Context values.",
455458
DeprecationWarning
456459
)
457-
force_guess = list(force_guess) + ['bonds', 'angles', 'dihedrals']
460+
# Original behaviour is to add additionally guessed bond info
461+
# this is achieved by adding to the `to_guess` list (unliked `force_guess`
462+
# which replaces existing bonds).
463+
to_guess = list(to_guess) + ['bonds', 'angles', 'dihedrals']
458464

459465
self.guess_TopologyAttrs(
460466
context, to_guess, force_guess, error_if_missing=False
@@ -1180,7 +1186,6 @@ def _add_topology_objects(self, object_type, values, types=None, guessed=False,
11801186
self.add_TopologyAttr(object_type, [])
11811187
attr = getattr(self._topology, object_type)
11821188

1183-
11841189
attr._add_bonds(indices, types=types, guessed=guessed, order=order)
11851190

11861191
def add_bonds(self, values, types=None, guessed=False, order=None):
@@ -1231,6 +1236,16 @@ def add_bonds(self, values, types=None, guessed=False, order=None):
12311236
"""
12321237
self._add_topology_objects('bonds', values, types=types,
12331238
guessed=guessed, order=order)
1239+
self._invalidate_bond_related_caches()
1240+
1241+
def _invalidate_bond_related_caches(self):
1242+
"""
1243+
Invalidate caches related to bonds and fragments.
1244+
1245+
This should be called whenever the Universe's bonds are modified.
1246+
1247+
.. versionadded: 2.8.0
1248+
"""
12341249
# Invalidate bond-related caches
12351250
self._cache.pop('fragments', None)
12361251
self._cache['_valid'].pop('fragments', None)
@@ -1307,7 +1322,7 @@ def _delete_topology_objects(self, object_type, values):
13071322
Parameters
13081323
----------
13091324
object_type : {'bonds', 'angles', 'dihedrals', 'impropers'}
1310-
The type of TopologyObject to add.
1325+
The type of TopologyObject to delete.
13111326
values : iterable of tuples, AtomGroups, or TopologyObjects; or TopologyGroup
13121327
An iterable of: tuples of atom indices, or AtomGroups,
13131328
or TopologyObjects.
@@ -1330,7 +1345,6 @@ def _delete_topology_objects(self, object_type, values):
13301345
attr = getattr(self._topology, object_type)
13311346
except AttributeError:
13321347
raise ValueError('There are no {} to delete'.format(object_type))
1333-
13341348
attr._delete_bonds(indices)
13351349

13361350
def delete_bonds(self, values):
@@ -1371,10 +1385,7 @@ def delete_bonds(self, values):
13711385
.. versionadded:: 1.0.0
13721386
"""
13731387
self._delete_topology_objects('bonds', values)
1374-
# Invalidate bond-related caches
1375-
self._cache.pop('fragments', None)
1376-
self._cache['_valid'].pop('fragments', None)
1377-
self._cache['_valid'].pop('fragindices', None)
1388+
self._invalidate_bond_related_caches()
13781389

13791390
def delete_angles(self, values):
13801391
"""Delete Angles from this Universe.
@@ -1613,7 +1624,12 @@ def guess_TopologyAttrs(
16131624
# in the same order that the user provided
16141625
total_guess = list(dict.fromkeys(total_guess))
16151626

1616-
objects = ['bonds', 'angles', 'dihedrals', 'impropers']
1627+
# Set of all Connectivity related attribute names
1628+
# used to special case attribute replacement after calling the guesser
1629+
objects = set(
1630+
topattr.attrname for topattr in _TOPOLOGY_ATTRS.values()
1631+
if issubclass(topattr, _Connection)
1632+
)
16171633

16181634
# Checking if the universe is empty to avoid errors
16191635
# from guesser methods
@@ -1640,23 +1656,32 @@ def guess_TopologyAttrs(
16401656
fg = attr in force_guess
16411657
try:
16421658
values = guesser.guess_attr(attr, fg)
1643-
except ValueError as e:
1659+
except NoDataError as e:
16441660
if error_if_missing or fg:
16451661
raise e
16461662
else:
16471663
warnings.warn(str(e))
16481664
continue
16491665

1650-
if values is not None:
1651-
if attr in objects:
1652-
self._add_topology_objects(
1653-
attr, values, guessed=True)
1654-
else:
1655-
guessed_attr = _TOPOLOGY_ATTRS[attr](values, True)
1656-
self.add_TopologyAttr(guessed_attr)
1657-
logger.info(
1658-
f'attribute {attr} has been guessed'
1659-
' successfully.')
1666+
# None indicates no additional guessing was done
1667+
if values is None:
1668+
continue
1669+
if attr in objects:
1670+
# delete existing connections if they exist
1671+
if fg and hasattr(self.atoms, attr):
1672+
group = getattr(self.atoms, attr)
1673+
self._delete_topology_objects(attr, group)
1674+
# this method appends any new bonds in values to existing bonds
1675+
self._add_topology_objects(
1676+
attr, values, guessed=True)
1677+
if attr == "bonds":
1678+
self._invalidate_bond_related_caches()
1679+
else:
1680+
guessed_attr = _TOPOLOGY_ATTRS[attr](values, True)
1681+
self.add_TopologyAttr(guessed_attr)
1682+
logger.info(
1683+
f'attribute {attr} has been guessed'
1684+
' successfully.')
16601685

16611686
else:
16621687
raise ValueError(f'{context} guesser can not guess the'

package/MDAnalysis/guesser/base.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@
3636
.. autofunction:: get_guesser
3737
3838
"""
39-
from .. import _GUESSERS
39+
from .. import _GUESSERS, _TOPOLOGY_ATTRS
40+
from ..core.topologyattrs import _Connection
4041
import numpy as np
41-
from .. import _TOPOLOGY_ATTRS
4242
import logging
4343
from typing import Dict
4444
import copy
@@ -136,21 +136,41 @@ def guess_attr(self, attr_to_guess, force_guess=False):
136136
NDArray of guessed values
137137
138138
"""
139+
try:
140+
top_attr = _TOPOLOGY_ATTRS[attr_to_guess]
141+
except KeyError:
142+
raise KeyError(
143+
f"{attr_to_guess} is not a recognized MDAnalysis "
144+
"topology attribute"
145+
)
146+
# make attribute to guess plural
147+
attr_to_guess = top_attr.attrname
148+
149+
try:
150+
guesser_method = self._guesser_methods[attr_to_guess]
151+
except KeyError:
152+
raise ValueError(f'{type(self).__name__} cannot guess this '
153+
f'attribute: {attr_to_guess}')
154+
155+
# Connection attributes should be just returned as they are always
156+
# appended to the Universe. ``force_guess`` handling should happen
157+
# at Universe level.
158+
if issubclass(top_attr, _Connection):
159+
return guesser_method()
139160

140161
# check if the topology already has the attribute to partially guess it
141162
if hasattr(self._universe.atoms, attr_to_guess) and not force_guess:
142163
attr_values = np.array(
143164
getattr(self._universe.atoms, attr_to_guess, None))
144165

145-
top_attr = _TOPOLOGY_ATTRS[attr_to_guess]
146-
147166
empty_values = top_attr.are_values_missing(attr_values)
148167

149168
if True in empty_values:
150169
# pass to the guesser_method boolean mask to only guess the
151170
# empty values
152-
attr_values[empty_values] = self._guesser_methods[attr_to_guess](
153-
indices_to_guess=empty_values)
171+
attr_values[empty_values] = guesser_method(
172+
indices_to_guess=empty_values
173+
)
154174
return attr_values
155175

156176
else:
@@ -159,7 +179,7 @@ def guess_attr(self, attr_to_guess, force_guess=False):
159179
f'not guess any new values for {attr_to_guess} attribute')
160180
return None
161181
else:
162-
return np.array(self._guesser_methods[attr_to_guess]())
182+
return np.array(guesser_method())
163183

164184

165185
def get_guesser(context, u=None, **kwargs):

testsuite/MDAnalysisTests/guesser/test_base.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,11 @@
2727
from MDAnalysis.core.topology import Topology
2828
from MDAnalysis.core.topologyattrs import Masses, Atomnames, Atomtypes
2929
import MDAnalysis.tests.datafiles as datafiles
30+
from MDAnalysis.exceptions import NoDataError
3031
from numpy.testing import assert_allclose, assert_equal
3132

33+
from MDAnalysis import _TOPOLOGY_ATTRS, _GUESSERS
34+
3235

3336
class TestBaseGuesser():
3437

@@ -101,6 +104,141 @@ def test_partial_guess_attr_with_unknown_no_value_label(self):
101104
u = mda.Universe(top, to_guess=['types'])
102105
assert_equal(u.atoms.types, ['', '', '', ''])
103106

107+
def test_guess_topology_objects_existing_read(self):
108+
u = mda.Universe(datafiles.CONECT)
109+
assert len(u.atoms.bonds) == 72
110+
assert list(u.bonds[0].indices) == [623, 630]
111+
112+
# delete some bonds
113+
u.delete_bonds(u.atoms.bonds[:10])
114+
assert len(u.atoms.bonds) == 62
115+
# first bond has changed
116+
assert list(u.bonds[0].indices) == [1545, 1552]
117+
# count number of (1545, 1552) bonds
118+
ag = u.atoms[[1545, 1552]]
119+
bonds = ag.bonds.atomgroup_intersection(ag, strict=True)
120+
assert len(bonds) == 1
121+
assert not bonds[0].is_guessed
122+
123+
all_indices = [tuple(x.indices) for x in u.bonds]
124+
assert (623, 630) not in all_indices
125+
126+
# test guessing new bonds doesn't remove old ones
127+
u.guess_TopologyAttrs("default", to_guess=["bonds"])
128+
assert len(u.atoms.bonds) == 1922
129+
old_bonds = ag.bonds.atomgroup_intersection(ag, strict=True)
130+
assert len(old_bonds) == 1
131+
# test guessing new bonds doesn't duplicate old ones
132+
assert not old_bonds[0].is_guessed
133+
134+
new_ag = u.atoms[[623, 630]]
135+
new_bonds = new_ag.bonds.atomgroup_intersection(new_ag, strict=True)
136+
assert len(new_bonds) == 1
137+
assert new_bonds[0].is_guessed
138+
139+
def test_guess_topology_objects_existing_in_universe(self):
140+
u = mda.Universe(datafiles.CONECT, to_guess=["bonds"])
141+
assert len(u.atoms.bonds) == 1922
142+
assert list(u.bonds[0].indices) == [0, 1]
143+
144+
# delete some bonds
145+
u.delete_bonds(u.atoms.bonds[:100])
146+
assert len(u.atoms.bonds) == 1822
147+
assert list(u.bonds[0].indices) == [94, 99]
148+
149+
all_indices = [tuple(x.indices) for x in u.bonds]
150+
assert (0, 1) not in all_indices
151+
152+
# guess old bonds back
153+
u.guess_TopologyAttrs("default", to_guess=["bonds"])
154+
assert len(u.atoms.bonds) == 1922
155+
# check TopologyGroup contains new (old) bonds
156+
assert list(u.bonds[0].indices) == [0, 1]
157+
158+
def test_guess_topology_objects_force(self):
159+
u = mda.Universe(datafiles.CONECT, force_guess=["bonds"])
160+
assert len(u.atoms.bonds) == 1922
161+
162+
with pytest.raises(NoDataError):
163+
u.atoms.angles
164+
165+
def test_guess_topology_objects_out_of_order_init(self):
166+
u = mda.Universe(
167+
datafiles.PDB_small,
168+
to_guess=["dihedrals", "angles", "bonds"],
169+
guess_bonds=False
170+
)
171+
assert len(u.atoms.angles) == 6123
172+
assert len(u.atoms.dihedrals) == 8921
173+
174+
def test_guess_topology_objects_out_of_order_guess(self):
175+
u = mda.Universe(datafiles.PDB_small)
176+
with pytest.raises(NoDataError):
177+
u.atoms.angles
178+
179+
u.guess_TopologyAttrs(
180+
"default",
181+
to_guess=["dihedrals", "angles", "bonds"]
182+
)
183+
assert len(u.atoms.angles) == 6123
184+
assert len(u.atoms.dihedrals) == 8921
185+
186+
def test_force_guess_overwrites_existing_bonds(self):
187+
u = mda.Universe(datafiles.CONECT)
188+
assert len(u.atoms.bonds) == 72
189+
190+
# This low radius should find no bonds
191+
vdw = dict.fromkeys(set(u.atoms.types), 0.1)
192+
u.guess_TopologyAttrs("default", to_guess=["bonds"], vdwradii=vdw)
193+
assert len(u.atoms.bonds) == 72
194+
195+
# Now force guess bonds
196+
u.guess_TopologyAttrs("default", force_guess=["bonds"], vdwradii=vdw)
197+
assert len(u.atoms.bonds) == 0
198+
199+
def test_guessing_angles_respects_bond_kwargs(self):
200+
u = mda.Universe(datafiles.PDB)
201+
assert not hasattr(u.atoms, "angles")
202+
203+
# This low radius should find no angles
204+
vdw = dict.fromkeys(set(u.atoms.types), 0.01)
205+
206+
u.guess_TopologyAttrs("default", to_guess=["angles"], vdwradii=vdw)
207+
assert len(u.atoms.angles) == 0
208+
209+
# set higher radii for lots of angles!
210+
vdw = dict.fromkeys(set(u.atoms.types), 1)
211+
u.guess_TopologyAttrs("default", force_guess=["angles"], vdwradii=vdw)
212+
assert len(u.atoms.angles) == 89466
213+
214+
def test_guessing_dihedrals_respects_bond_kwargs(self):
215+
u = mda.Universe(datafiles.CONECT)
216+
assert len(u.atoms.bonds) == 72
217+
218+
u.guess_TopologyAttrs("default", to_guess=["dihedrals"])
219+
assert len(u.atoms.dihedrals) == 3548
220+
assert not hasattr(u.atoms, "angles")
221+
222+
def test_guess_invalid_attribute(self):
223+
default_guesser = get_guesser("default")
224+
err = "not a recognized MDAnalysis topology attribute"
225+
with pytest.raises(KeyError, match=err):
226+
default_guesser.guess_attr('not_an_attribute')
227+
228+
def test_guess_unsupported_attribute(self):
229+
default_guesser = get_guesser("default")
230+
err = "cannot guess this attribute"
231+
with pytest.raises(ValueError, match=err):
232+
default_guesser.guess_attr('tempfactors')
233+
234+
def test_guess_singular(self):
235+
default_guesser = get_guesser("default")
236+
u = mda.Universe(datafiles.PDB, to_guess=[])
237+
assert not hasattr(u.atoms, "masses")
238+
239+
default_guesser._universe = u
240+
masses = default_guesser.guess_attr('mass')
241+
104242

105243
def test_Universe_guess_bonds_deprecated():
106244
with pytest.warns(

0 commit comments

Comments
 (0)