Skip to content

Commit 488eac4

Browse files
FIX: Full resname matching for nucleic acid analysis (#5365)
1 parent 2a3d7b2 commit 488eac4

3 files changed

Lines changed: 150 additions & 10 deletions

File tree

package/CHANGELOG

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ The rules for this file:
2222
* 2.11.0
2323

2424
Fixes
25+
* `MDAnalysis.analysis.nucleicacids.WatsonCrickDist`, `MinorPairDist`,
26+
and `MajorPairDist` now match residue names against the full resname
27+
instead of only the first character, fixing incorrect behaviour with
28+
multi-character names such as CHARMM ``DG``/``DA`` or ``GUA``/``ADE``
29+
(Issue #5360). Note: users relying on the previous truncation
30+
behaviour must now pass explicit residue names via the ``g_name``,
31+
``a_name``, etc. keyword arguments.
2532
* `MDAnalysis.analysis.atomicdistances.AtomicDistances` results are now
2633
consistent with expected `analysis` documentation data type = Results
2734
(Issue #4819, PR #5347)
@@ -57,10 +64,10 @@ Enhancements
5764
calculations and supports ``backend`` selection (e.g. ``"distopia"``)
5865
(PR #5182)
5966
* Added ASV benchmark for `MDAnalysis.analysis.contacts` (PR #5291)
60-
* Improved performance of inverse index mapping in AtomGroup using an optimized
67+
* Improved performance of inverse index mapping in AtomGroup using an optimized
6168
Cython implementation in lib._cutils.inverse_int_index()
6269
(Issue #3387, PR #5252)
63-
* Added documentation for all keyword in select_atoms() and
70+
* Added documentation for all keyword in select_atoms() and
6471
selections.rst (Issue #5317, PR #5325)
6572
* Added HydrogenBondAnalysis benchmark for performance tracking (PR #5309)
6673
* Added `select=None` in `analysis.rms.RMSD` to perform no selection on

package/MDAnalysis/analysis/nucleicacids.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,12 @@ def select_strand_atoms(
255255
256256
257257
.. versionadded:: 2.7.0
258+
259+
.. versionchanged:: 2.11.0
260+
Residue names are now matched exactly against the full resname
261+
rather than only its first character. Users with multi-character
262+
residue names (e.g. CHARMM ``"GUA"``, ``"DG"``) must pass the
263+
appropriate names via the keyword arguments.
258264
"""
259265
pyrimidines: List[str] = [c_name, t_name, u_name]
260266
purines: List[str] = [a_name, g_name]
@@ -263,9 +269,9 @@ def select_strand_atoms(
263269
sel2: List[mda.AtomGroup] = []
264270

265271
for pair in zip(strand1.residues, strand2.residues):
266-
if pair[0].resname[0] in pyrimidines:
272+
if pair[0].resname in pyrimidines:
267273
a1, a2 = a2_name, a1_name
268-
elif pair[0].resname[0] in purines:
274+
elif pair[0].resname in purines:
269275
a1, a2 = a1_name, a2_name
270276
else:
271277
raise ValueError(
@@ -431,6 +437,12 @@ class WatsonCrickDist(NucPairDist):
431437
:class:`~MDAnalysis.core.groups.ResidueGroup` as input.
432438
The previous input type, ``List[Residue]`` is still supported,
433439
but it is **deprecated** and will be removed in release 3.0.0.
440+
441+
.. versionchanged:: 2.11.0
442+
Residue names are now matched exactly against the full resname
443+
rather than only its first character. Users with multi-character
444+
residue names (e.g. CHARMM ``"GUA"``, ``"DG"``) must pass the
445+
appropriate names via the keyword arguments.
434446
"""
435447

436448
def __init__(
@@ -551,6 +563,12 @@ class MinorPairDist(NucPairDist):
551563
552564
553565
.. versionadded:: 2.7.0
566+
567+
.. versionchanged:: 2.11.0
568+
Residue names are now matched exactly against the full resname
569+
rather than only its first character. Users with multi-character
570+
residue names (e.g. CHARMM ``"GUA"``, ``"DG"``) must pass the
571+
appropriate names via the keyword arguments.
554572
"""
555573

556574
def __init__(
@@ -649,6 +667,12 @@ class MajorPairDist(NucPairDist):
649667
650668
651669
.. versionadded:: 2.7.0
670+
671+
.. versionchanged:: 2.11.0
672+
Residue names are now matched exactly against the full resname
673+
rather than only its first character. Users with multi-character
674+
residue names (e.g. CHARMM ``"GUA"``, ``"DG"``) must pass the
675+
appropriate names via the keyword arguments.
652676
"""
653677

654678
def __init__(

testsuite/MDAnalysisTests/analysis/test_nucleicacids.py

Lines changed: 115 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#
2323

2424
import MDAnalysis as mda
25+
import numpy as np
2526

2627
import pytest
2728

@@ -55,15 +56,24 @@ def test_empty_ag_error(strand):
5556
strand2 = ResidueGroup([strand.residues[1]])
5657

5758
with pytest.raises(ValueError, match="returns an empty AtomGroup"):
58-
NucPairDist.select_strand_atoms(strand1, strand2, "UNK1", "O2")
59+
NucPairDist.select_strand_atoms(
60+
strand1, strand2, "UNK1", "O2", g_name="GUA"
61+
)
5962

6063

6164
@pytest.fixture(scope="module")
6265
def wc_rna(strand, client_NucPairDist):
6366
strand1 = ResidueGroup([strand.residues[0], strand.residues[21]])
6467
strand2 = ResidueGroup([strand.residues[1], strand.residues[22]])
6568

66-
WC = WatsonCrickDist(strand1, strand2)
69+
WC = WatsonCrickDist(
70+
strand1,
71+
strand2,
72+
g_name="GUA",
73+
a_name="ADE",
74+
c_name="CYT",
75+
u_name="URA",
76+
)
6777
WC.run(**client_NucPairDist)
6878
return WC
6979

@@ -83,10 +93,18 @@ def test_wc_dist(wc_rna):
8393

8494
def test_wc_dist_invalid_residue_types(u):
8595
strand = u.select_atoms("resid 1-10")
96+
# residues[0]=GUA, residues[21]=POT (non-nucleic), residues[22]=POT
8697
strand1 = ResidueGroup([strand.residues[0], strand.residues[21]])
8798
strand2 = ResidueGroup([strand.residues[2], strand.residues[22]])
8899
with pytest.raises(ValueError, match="is not a valid nucleic acid"):
89-
WatsonCrickDist(strand1, strand2)
100+
WatsonCrickDist(
101+
strand1,
102+
strand2,
103+
g_name="GUA",
104+
a_name="ADE",
105+
c_name="CYT",
106+
u_name="URA",
107+
)
90108

91109

92110
def test_selection_length_mismatch(strand):
@@ -101,7 +119,14 @@ def test_wc_dist_deprecation_warning(strand):
101119
strand2 = [strand.residues[1], strand.residues[22]]
102120

103121
with pytest.deprecated_call():
104-
WatsonCrickDist(strand1, strand2)
122+
WatsonCrickDist(
123+
strand1,
124+
strand2,
125+
g_name="GUA",
126+
a_name="ADE",
127+
c_name="CYT",
128+
u_name="URA",
129+
)
105130

106131

107132
def test_wc_dist_strand_verification(strand):
@@ -122,7 +147,14 @@ def test_minor_dist(strand, client_NucPairDist):
122147
strand1 = ResidueGroup([strand.residues[2], strand.residues[19]])
123148
strand2 = ResidueGroup([strand.residues[16], strand.residues[4]])
124149

125-
MI = MinorPairDist(strand1, strand2)
150+
MI = MinorPairDist(
151+
strand1,
152+
strand2,
153+
g_name="GUA",
154+
a_name="ADE",
155+
c_name="CYT",
156+
u_name="URA",
157+
)
126158
MI.run(**client_NucPairDist)
127159

128160
assert MI.results.distances[0, 0] == approx(15.06506, rel=1e-3)
@@ -133,8 +165,85 @@ def test_major_dist(strand, client_NucPairDist):
133165
strand1 = ResidueGroup([strand.residues[1], strand.residues[4]])
134166
strand2 = ResidueGroup([strand.residues[11], strand.residues[8]])
135167

136-
MA = MajorPairDist(strand1, strand2)
168+
MA = MajorPairDist(
169+
strand1,
170+
strand2,
171+
g_name="GUA",
172+
a_name="ADE",
173+
c_name="CYT",
174+
u_name="URA",
175+
)
137176
MA.run(**client_NucPairDist)
138177

139178
assert MA.results.distances[0, 0] == approx(26.884272, rel=1e-3)
140179
assert MA.results.distances[0, 1] == approx(13.578535, rel=1e-3)
180+
181+
182+
@pytest.fixture(scope="module")
183+
def dna_u():
184+
"""Synthetic universe with 2-letter DNA resnames (DG, DC, DA, DT).
185+
186+
Used to test that WatsonCrickDist matches full resnames rather than only
187+
the first character.
188+
189+
Strand layout (one frame, all atoms at origin):
190+
residue 0 – DG (purine): atom N1
191+
residue 1 – DC (pyrimidine): atom N3
192+
residue 2 – DA (purine): atom N1
193+
residue 3 – DT (pyrimidine): atom N3
194+
"""
195+
n_atoms = 4
196+
n_residues = 4
197+
u = mda.Universe.empty(
198+
n_atoms,
199+
n_residues=n_residues,
200+
atom_resindex=[0, 1, 2, 3],
201+
trajectory=True,
202+
)
203+
u.add_TopologyAttr("name", ["N1", "N3", "N1", "N3"])
204+
u.add_TopologyAttr("resname", ["DG", "DC", "DA", "DT"])
205+
u.add_TopologyAttr("resid", [1, 2, 3, 4])
206+
coords = np.zeros((1, n_atoms, 3), dtype=np.float32)
207+
u.load_new(coords)
208+
return u
209+
210+
211+
def test_wc_dist_multichar_resnames(dna_u):
212+
"""WatsonCrickDist must match the full resname, not just resname[0]\.
213+
214+
With 2-letter names like DG/DA, the first character 'D' does not appear in
215+
the purine/pyrimidine lists, so the buggy resname[0] check previously
216+
rasied ValueError for every residue.
217+
"""
218+
strand1 = ResidueGroup([dna_u.residues[0], dna_u.residues[2]]) # DG, DA
219+
strand2 = ResidueGroup([dna_u.residues[1], dna_u.residues[3]]) # DC, DT
220+
221+
WC = WatsonCrickDist(
222+
strand1,
223+
strand2,
224+
g_name="DG",
225+
a_name="DA",
226+
c_name="DC",
227+
t_name="DT",
228+
)
229+
WC.run()
230+
assert WC.results.distances.shape == (1, 2)
231+
232+
233+
def test_select_strand_atoms_multichar_resnames(dna_u):
234+
"""select_strand_atoms must recognise multi-character resnames."""
235+
strand1 = ResidueGroup([dna_u.residues[0], dna_u.residues[2]]) # DG, DA
236+
strand2 = ResidueGroup([dna_u.residues[1], dna_u.residues[3]]) # DC, DT
237+
238+
sel1, sel2 = NucPairDist.select_strand_atoms(
239+
strand1,
240+
strand2,
241+
a1_name="N1",
242+
a2_name="N3",
243+
g_name="DG",
244+
a_name="DA",
245+
c_name="DC",
246+
t_name="DT",
247+
)
248+
assert len(sel1) == 2
249+
assert len(sel2) == 2

0 commit comments

Comments
 (0)