From d3a052b9bed351ed585e8aa50b81f4f35f454a8c Mon Sep 17 00:00:00 2001 From: Irfan Alibay Date: Mon, 10 Feb 2020 17:23:03 +0000 Subject: [PATCH 01/34] removes deprecated keywords --- package/MDAnalysis/analysis/waterdynamics.py | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/package/MDAnalysis/analysis/waterdynamics.py b/package/MDAnalysis/analysis/waterdynamics.py index 666d19b5059..238c6b41343 100644 --- a/package/MDAnalysis/analysis/waterdynamics.py +++ b/package/MDAnalysis/analysis/waterdynamics.py @@ -1237,7 +1237,10 @@ class SurvivalProbability(object): .. versionadded:: 0.11.0 - + .. versionchanged:: 1.0.0 + Removes support for the deprecated `t0`, `tf`, and `dtmax` keywords. + These should instead be passed to :meth:`SurvivalProbability.run` as + the `start`, `stop`, and `tau_max` keywords respectively. """ def __init__(self, universe, selection, t0=None, tf=None, dtmax=None, verbose=False): @@ -1245,20 +1248,6 @@ def __init__(self, universe, selection, t0=None, tf=None, dtmax=None, verbose=Fa self.selection = selection self.verbose = verbose - # backward compatibility - self.start = self.stop = self.tau_max = None - if t0 is not None: - self.start = t0 - warnings.warn("t0 is deprecated, use run(start=t0) instead", category=DeprecationWarning) - - if tf is not None: - self.stop = tf - warnings.warn("tf is deprecated, use run(stop=tf) instead", category=DeprecationWarning) - - if dtmax is not None: - self.tau_max = dtmax - warnings.warn("dtmax is deprecated, use run(tau_max=dtmax) instead", category=DeprecationWarning) - def print(self, verbose, *args): if self.verbose: print(args) From 5efc8cbb4625145d09ac33d193ef35f19c13a6d9 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 10 Feb 2020 17:55:10 +0000 Subject: [PATCH 02/34] Aligns traj control to AnalysisBase methods --- package/MDAnalysis/analysis/waterdynamics.py | 64 +++++++++++-------- .../analysis/test_waterdynamics.py | 22 +++---- 2 files changed, 47 insertions(+), 39 deletions(-) diff --git a/package/MDAnalysis/analysis/waterdynamics.py b/package/MDAnalysis/analysis/waterdynamics.py index 238c6b41343..4e96532c9cf 100644 --- a/package/MDAnalysis/analysis/waterdynamics.py +++ b/package/MDAnalysis/analysis/waterdynamics.py @@ -1305,55 +1305,63 @@ def _correct_intermittency(self, intermittency, selected_ids): seen_frames_ago[atomid] = 0 - def run(self, tau_max=20, start=0, stop=None, step=1, residues=False, intermittency=0, verbose=False): + def run(self, tau_max=20, start=None, stop=None, step=None, residues=False, + intermittency=0, verbose=False): """ Computes and returns the Survival Probability (SP) timeseries Parameters ---------- start : int, optional - Zero-based index of the first frame to be analysed + Zero-based index of the first frame to be analysed, Default: None + (first frame). stop : int, optional - Zero-based index of the last frame to be analysed (inclusive) + Zero-based index of the last frame to be analysed (exclusive), + Default: None (last frame). step : int, optional - Jump every `step`-th frame. This is compatible but independant of the taus used, and it is good to consider - using the `step` equal to `tau_max` to remove the overlap. - Note that `step` and `tau_max` work consistently with intermittency. + Jump every `step`-th frame. This is compatible but independant of + the taus used, and it is good to consider using the `step` equal + to `tau_max` to remove the overlap. Note that `step` and `tau_max` + work consistently with intermittency. Default: None + (use every frame). tau_max : int, optional - Survival probability is calculated for the range 1 <= `tau` <= `tau_max` + Survival probability is calculated for the range + 1 <= `tau` <= `tau_max`. residues : Boolean, optional - If true, the analysis will be carried out on the residues (.resids) rather than on atom (.ids). - A single atom is sufficient to classify the residue as within the distance. + If true, the analysis will be carried out on the residues + (.resids) rather than on atom (.ids). A single atom is sufficient + to classify the residue as within the distance. intermittency : int, optional - The maximum number of consecutive frames for which an atom can leave but be counted as present if it returns - at the next frame. An intermittency of `0` is equivalent to a continuous survival probability, which does - not allow for the leaving and returning of atoms. For example, for `intermittency=2`, any given atom may - leave a region of interest for up to two consecutive frames yet be treated as being present at all frames. - The default is continuous (0). + The maximum number of consecutive frames for which an atom can + leave but be counted as present if it returns at the next frame. + An intermittency of `0` is equivalent to a continuous survival + probability, which does not allow for the leaving and returning of + atoms. For example, for `intermittency=2`, any given atom may leave + a region of interest for up to two consecutive frames yet be + treated as being present at all frames. The default is continuous + (0). verbose : Boolean, optional - Print the progress to the console + Print the progress to the console. Returns ------- tau_timeseries : list tau from 1 to `tau_max`. Saved in the field tau_timeseries. sp_timeseries : list - survival probability for each value of `tau`. Saved in the field sp_timeseries. - """ + survival probability for each value of `tau`. Saved in the field + sp_timeseries. - # backward compatibility (and priority) - start = self.start if self.start is not None else start - stop = self.stop if self.stop is not None else stop - tau_max = self.tau_max if self.tau_max is not None else tau_max - # sanity checks - if stop is not None and stop >= len(self.universe.trajectory): - raise ValueError("\"stop\" must be smaller than the number of frames in the trajectory.") + .. versionchanged:: 1.0.0 + To math other analysis methods, the `stop` keyword is now exclusive + rather than inclusive. + """ - if stop is None: - stop = len(self.universe.trajectory) - else: - stop = stop + 1 + start, stop, step = self.universe.trajectory.check_slice_indices( + start, + stop, + step + ) if tau_max > (stop - start): raise ValueError("Too few frames selected for given tau_max.") diff --git a/testsuite/MDAnalysisTests/analysis/test_waterdynamics.py b/testsuite/MDAnalysisTests/analysis/test_waterdynamics.py index e6dc29b0f35..9d9b6d98048 100644 --- a/testsuite/MDAnalysisTests/analysis/test_waterdynamics.py +++ b/testsuite/MDAnalysisTests/analysis/test_waterdynamics.py @@ -91,7 +91,7 @@ def test_SurvivalProbability_t0tf(universe): ids = [(0, ), (0, ), (7, 6, 5), (6, 5, 4), (5, 4, 3), (4, 3, 2), (3, 2, 1), (0, )] select_atoms_mock.side_effect = lambda selection: Mock(ids=ids.pop(2)) # atom IDs fed set by set sp = waterdynamics.SurvivalProbability(universe, "") - sp.run(tau_max=3, start=2, stop=6) + sp.run(tau_max=3, start=2, stop=7) assert_almost_equal(sp.sp_timeseries, [2 / 3.0, 1 / 3.0, 0]) @@ -100,7 +100,7 @@ def test_SurvivalProbability_definedTaus(universe): ids = [(9, 8, 7), (8, 7, 6), (7, 6, 5), (6, 5, 4), (5, 4, 3), (4, 3, 2), (3, 2, 1)] select_atoms_mock.side_effect = lambda selection: Mock(ids=ids.pop()) # atom IDs fed set by set sp = waterdynamics.SurvivalProbability(universe, "") - sp.run(tau_max=3, start=0, stop=6, verbose=True) + sp.run(tau_max=3, start=0, stop=7, verbose=True) assert_almost_equal(sp.sp_timeseries, [2 / 3.0, 1 / 3.0, 0]) @@ -108,7 +108,7 @@ def test_SurvivalProbability_zeroMolecules(universe): # no atom IDs found with patch.object(universe, 'select_atoms', return_value=Mock(ids=[])) as select_atoms_mock: sp = waterdynamics.SurvivalProbability(universe, "") - sp.run(tau_max=3, start=3, stop=6, verbose=True) + sp.run(tau_max=3, start=3, stop=7, verbose=True) assert all(np.isnan(sp.sp_timeseries)) @@ -116,7 +116,7 @@ def test_SurvivalProbability_alwaysPresent(universe): # always the same atom IDs found, 7 and 8 with patch.object(universe, 'select_atoms', return_value=Mock(ids=[7, 8])) as select_atoms_mock: sp = waterdynamics.SurvivalProbability(universe, "") - sp.run(tau_max=3, start=0, stop=6, verbose=True) + sp.run(tau_max=3, start=0, stop=7, verbose=True) assert all(np.equal(sp.sp_timeseries, 1)) @@ -124,7 +124,7 @@ def test_SurvivalProbability_stepLargerThanDtmax(universe): # Testing if the frames are skipped correctly with patch.object(universe, 'select_atoms', return_value=Mock(ids=(1,))) as select_atoms_mock: sp = waterdynamics.SurvivalProbability(universe, "") - sp.run(tau_max=2, step=5, stop=9, verbose=True) + sp.run(tau_max=2, step=5, stop=10, verbose=True) assert_equal(sp.sp_timeseries, [1, 1]) # with tau_max=2 for all the frames we only read 6 of them # this is because the frames which are not used are skipped, and therefore 'select_atoms' @@ -135,7 +135,7 @@ def test_SurvivalProbability_stepLargerThanDtmax(universe): def test_SurvivalProbability_stepEqualDtMax(universe): with patch.object(universe, 'select_atoms', return_value=Mock(ids=(1,))) as select_atoms_mock: sp = waterdynamics.SurvivalProbability(universe, "") - sp.run(tau_max=4, step=5, stop=9, verbose=True) + sp.run(tau_max=4, step=5, stop=10, verbose=True) # all frames from 0, with 9 inclusive assert_equal(select_atoms_mock.call_count, 10) @@ -149,7 +149,7 @@ def test_SurvivalProbability_intermittency1and2(universe): ids = [(9, 8), (), (8,), (9,), (8,), (), (9,8), (), (8,), (9,8,)] select_atoms_mock.side_effect = lambda selection: Mock(ids=ids.pop()) # atom IDs fed set by set sp = waterdynamics.SurvivalProbability(universe, "") - sp.run(tau_max=3, stop=9, verbose=True, intermittency=2) + sp.run(tau_max=3, stop=10, verbose=True, intermittency=2) assert all((x == set([9, 8]) for x in sp.selected_ids)) assert_almost_equal(sp.sp_timeseries, [1, 1, 1]) @@ -162,7 +162,7 @@ def test_SurvivalProbability_intermittency2lacking(universe): ids = [(9,), (), (), (), (9,), (), (), (), (9,)] select_atoms_mock.side_effect = lambda selection: Mock(ids=ids.pop()) # atom IDs fed set by set sp = waterdynamics.SurvivalProbability(universe, "") - sp.run(tau_max=3, stop=8, verbose=True, intermittency=2) + sp.run(tau_max=3, stop=9, verbose=True, intermittency=2) assert_almost_equal(sp.sp_timeseries, [0, 0, 0]) @@ -175,7 +175,7 @@ def test_SurvivalProbability_intermittency1_step5_noSkipping(universe): ids = [(2, 3), (3,), (2, 3), (3,), (2,), (3,), (2, 3), (3,), (2, 3), (2, 3)] select_atoms_mock.side_effect = lambda selection: Mock(ids=ids.pop()) # atom IDs fed set by set sp = waterdynamics.SurvivalProbability(universe, "") - sp.run(tau_max=2, stop=9, verbose=True, intermittency=1, step=5) + sp.run(tau_max=2, stop=10, verbose=True, intermittency=1, step=5) assert all((x == set([2, 3]) for x in sp.selected_ids)) assert_almost_equal(sp.sp_timeseries, [1, 1]) @@ -189,7 +189,7 @@ def test_SurvivalProbability_intermittency1_step5_Skipping(universe): beforepopsing = len(ids) - 2 select_atoms_mock.side_effect = lambda selection: Mock(ids=ids.pop()) # atom IDs fed set by set sp = waterdynamics.SurvivalProbability(universe, "") - sp.run(tau_max=1, stop=9, verbose=True, intermittency=1, step=5) + sp.run(tau_max=1, stop=10, verbose=True, intermittency=1, step=5) assert all((x == set([1]) for x in sp.selected_ids)) assert len(sp.selected_ids) == beforepopsing - assert_almost_equal(sp.sp_timeseries, [1]) \ No newline at end of file + assert_almost_equal(sp.sp_timeseries, [1]) From dd16e4b7e9e436b7f6afdb2f17da7807bb7e1f96 Mon Sep 17 00:00:00 2001 From: Irfan Alibay Date: Mon, 10 Feb 2020 18:03:01 +0000 Subject: [PATCH 03/34] keyword removal --- package/MDAnalysis/analysis/waterdynamics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package/MDAnalysis/analysis/waterdynamics.py b/package/MDAnalysis/analysis/waterdynamics.py index 4e96532c9cf..6ac40d8b959 100644 --- a/package/MDAnalysis/analysis/waterdynamics.py +++ b/package/MDAnalysis/analysis/waterdynamics.py @@ -1243,7 +1243,7 @@ class SurvivalProbability(object): the `start`, `stop`, and `tau_max` keywords respectively. """ - def __init__(self, universe, selection, t0=None, tf=None, dtmax=None, verbose=False): + def __init__(self, universe, selection, verbose=False): self.universe = universe self.selection = selection self.verbose = verbose From 9c9e453f072f050beda390d6abaf9caefe3333ec Mon Sep 17 00:00:00 2001 From: Irfan Alibay Date: Mon, 10 Feb 2020 18:14:20 +0000 Subject: [PATCH 04/34] Update CHANGELOG --- package/CHANGELOG | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/package/CHANGELOG b/package/CHANGELOG index c4f2e4afa85..9be85315678 100644 --- a/package/CHANGELOG +++ b/package/CHANGELOG @@ -82,6 +82,11 @@ Enhancements * Improve the distance search in water bridge analysis with capped_distance (PR #2480) Changes + * Removes the deprecated `t0`, `tf`, and `dtmax` from + :class:Waterdynamics.SurvivalProbability. Instead the `start`, `stop` and + `tau_max` keywords should be passed to + :meth:`Waterdynamics.SurvivalProbability.run`. Furthermore, the `stop` + keyword is now exclusive instead of inclusive (Issue #2524). * Removes `save()` function from contacts, diffusionmap, hole, LinearDensity, and rms (Issue #1745). * Removes; `save_table()` from :class:`HydrogenBondAnalysis`, From a0bd19ce036a38fd91fed8b594604b5d8ee04e36 Mon Sep 17 00:00:00 2001 From: William Glass Date: Sat, 23 May 2020 01:50:19 +0100 Subject: [PATCH 05/34] Ensure principal axes follow right hand convention (PR #2686) * Fixes #2637 * Added a check when calculating the principal axes to ensure that they follow the right handed convention. * Added a test with PDB code 6msm that returned a triple product of -1.0. Now returns 1.0 * update doc string * update CHANGELOG * update AUTHORS --- package/AUTHORS | 1 + package/CHANGELOG | 3 ++- package/MDAnalysis/core/topologyattrs.py | 14 ++++++++++++-- .../MDAnalysisTests/core/test_atomgroup.py | 12 ++++++------ .../MDAnalysisTests/core/test_topologyattrs.py | 10 +++++++++- testsuite/MDAnalysisTests/data/6msm.pdb.bz2 | Bin 0 -> 147966 bytes testsuite/MDAnalysisTests/datafiles.py | 4 +++- 7 files changed, 33 insertions(+), 11 deletions(-) create mode 100644 testsuite/MDAnalysisTests/data/6msm.pdb.bz2 diff --git a/package/AUTHORS b/package/AUTHORS index d92d663b01d..53091b39756 100644 --- a/package/AUTHORS +++ b/package/AUTHORS @@ -145,6 +145,7 @@ Chronological list of authors - Ameya Harmalkar - Shakul Pathak - Andrea Rizzi + - William Glass External code ------------- diff --git a/package/CHANGELOG b/package/CHANGELOG index b46587bfe73..b22b45dd6b9 100644 --- a/package/CHANGELOG +++ b/package/CHANGELOG @@ -17,11 +17,12 @@ mm/dd/yy richardjgowers, kain88-de, lilyminium, p-j-smith, bdice, joaomcteixeira PicoCentauri, davidercruz, jbarnoud, RMeli, IAlibay, mtiberti, CCook96, Yuan-Yu, xiki-tempula, HTian1997, Iv-Hristov, hmacdope, AnshulAngaria, ss62171, Luthaf, yuxuanzhuang, abhishandy, mlnance, shfrz, orbeckst, - wvandertoorn, cbouy, AmeyaHarmalkar, Oscuro-Phoenix, andrrizzi + wvandertoorn, cbouy, AmeyaHarmalkar, Oscuro-Phoenix, andrrizzi, WG150 * 0.21.0 Fixes + * Ensures principal_axes() returns axes with the right hand convention (Issue #2637) * Fixed retrieval of auxiliary information after getting the last timestep of the trajectory (Issue #2674, PR #2683). * n_components correctly selects PCA components (Issue #2623) diff --git a/package/MDAnalysis/core/topologyattrs.py b/package/MDAnalysis/core/topologyattrs.py index 13dd7f4940b..339f9b89aa1 100644 --- a/package/MDAnalysis/core/topologyattrs.py +++ b/package/MDAnalysis/core/topologyattrs.py @@ -1021,6 +1021,8 @@ def principal_axes(group, pbc=False): corresponds to the highest eigenvalue and is thus the first principal axes. + The eigenvectors form a right-handed coordinate system. + Parameters ---------- pbc : bool, optional @@ -1035,6 +1037,8 @@ def principal_axes(group, pbc=False): .. versionchanged:: 0.8 Added *pbc* keyword + .. versionchanged:: 1.0.0 + Always return principal axes in right-hand convention. """ atomgroup = group.atoms @@ -1042,8 +1046,14 @@ def principal_axes(group, pbc=False): # Sort indices = np.argsort(e_val)[::-1] - # Return transposed in more logical form. See Issue 33. - return e_vec[:, indices].T + # Make transposed in more logical form. See Issue 33. + e_vec = e_vec[:, indices].T + + # Make sure the right hand convention is followed + if np.dot(np.cross(e_vec[0], e_vec[1]), e_vec[2]) < 0: + e_vec *= -1 + + return e_vec transplants[GroupBase].append( ('principal_axes', principal_axes)) diff --git a/testsuite/MDAnalysisTests/core/test_atomgroup.py b/testsuite/MDAnalysisTests/core/test_atomgroup.py index 42b0a153d34..881a4818621 100644 --- a/testsuite/MDAnalysisTests/core/test_atomgroup.py +++ b/testsuite/MDAnalysisTests/core/test_atomgroup.py @@ -941,9 +941,9 @@ def ref_noPBC(self): dtype=np.float32), 'BSph': (173.40482, np.array([4.23789883, 0.62429816, 2.43123484], dtype=np.float32)), 'PAxes': np.array([ - [-0.78787867, -0.26771575, 0.55459488], - [0.40611024, 0.45112859, 0.7947059], - [0.46294889, -0.85135849, 0.24671249]]) + [ 0.78787867, 0.26771575, -0.55459488], + [-0.40611024, -0.45112859, -0.7947059 ], + [-0.46294889, 0.85135849, -0.24671249]]) } @pytest.fixture() @@ -962,9 +962,9 @@ def ref_PBC(self): dtype=np.float32), 'BSph': (47.923367, np.array([26.82960892, 31.5592289, 30.98238945], dtype=np.float32)), 'PAxes': np.array([ - [-0.85911708, 0.19258726, 0.4741603], - [-0.07520116, -0.96394227, 0.25526473], - [-0.50622389, -0.18364489, -0.84262206]]) + [ 0.85911708, -0.19258726, -0.4741603 ], + [ 0.07520116, 0.96394227, -0.25526473], + [ 0.50622389, 0.18364489, 0.84262206]]) } @pytest.fixture() diff --git a/testsuite/MDAnalysisTests/core/test_topologyattrs.py b/testsuite/MDAnalysisTests/core/test_topologyattrs.py index 93e4e716e5d..4c865a6a794 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, PDB_CHECK_RIGHTHAND_PA from MDAnalysisTests import make_Universe, no_deprecated_call import MDAnalysis as mda @@ -335,6 +335,14 @@ def test_principal_axes(self, ag): [1.20986911e-02, 9.98951474e-01, -4.41539838e-02], [-9.99925632e-01, 1.21546132e-02, 9.98264877e-04]])) + @pytest.fixture() + def universe_pa(self): + return mda.Universe(PDB_CHECK_RIGHTHAND_PA) + + def test_principal_axes_handedness(self, universe_pa): + e_vec = universe_pa.atoms.principal_axes() + assert_almost_equal(np.dot(np.cross(e_vec[0], e_vec[1]), e_vec[2]), 1.0) + def test_align_principal_axes_with_self(self, ag): pa = ag.principal_axes() ag.align_principal_axis(0, pa[0]) diff --git a/testsuite/MDAnalysisTests/data/6msm.pdb.bz2 b/testsuite/MDAnalysisTests/data/6msm.pdb.bz2 new file mode 100644 index 0000000000000000000000000000000000000000..a37ecc4e857b3159c25bbf8ba20154a8e04b0b53 GIT binary patch literal 147966 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 literal 0 HcmV?d00001 diff --git a/testsuite/MDAnalysisTests/datafiles.py b/testsuite/MDAnalysisTests/datafiles.py index 8ca23f07454..6611b566322 100644 --- a/testsuite/MDAnalysisTests/datafiles.py +++ b/testsuite/MDAnalysisTests/datafiles.py @@ -186,7 +186,8 @@ "GMX_DIR", # GROMACS directory "GMX_TOP_BAD", # file with an #include that doesn't exist "ITP_no_endif", # file missing an #endif - "PDB_CRYOEM_BOX" #Issue 2599 + "PDB_CRYOEM_BOX", #Issue 2599 + "PDB_CHECK_RIGHTHAND_PA" # for testing right handedness of principal_axes ] from pkg_resources import resource_filename @@ -273,6 +274,7 @@ PDB_singleconect = resource_filename(__name__, 'data/SINGLECONECT.pdb') PDB_icodes = resource_filename(__name__, 'data/1osm.pdb.gz') PDB_CRYOEM_BOX = resource_filename(__name__, 'data/5a7u.pdb') +PDB_CHECK_RIGHTHAND_PA = resource_filename(__name__, 'data/6msm.pdb.bz2') GRO = resource_filename(__name__, 'data/adk_oplsaa.gro') GRO_velocity = resource_filename(__name__, 'data/sample_velocity_file.gro') From 336d3ff65abd12693d3b781b397a56a8eb027ef5 Mon Sep 17 00:00:00 2001 From: Tyler Reddy Date: Sun, 24 May 2020 11:45:27 -0600 Subject: [PATCH 06/34] CI: add 32-bit Win to CI. --- azure-pipelines.yml | 49 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 azure-pipelines.yml diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 00000000000..2f5c803ae0c --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,49 @@ +trigger: + # start a new build for every push + batch: False + branches: + include: + - develop + paths: + include: + - '*' + +pr: + branches: + include: + - '*' # must quote since "*" is a YAML reserved character; we want a string + + +jobs: +- job: Windows + condition: and(succeeded(), ne(variables['Build.SourceBranch'], 'refs/heads/master')) # skip for PR merges + pool: + vmImage: 'VS2017-Win2016' + strategy: + maxParallel: 4 + matrix: + Python37-32bit-full: + PYTHON_VERSION: '3.7' + PYTHON_ARCH: 'x86' + BITS: 32 + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: $(PYTHON_VERSION) + addToPath: true + architecture: $(PYTHON_ARCH) + - script: python -m pip install --upgrade pip setuptools wheel + displayName: 'Install tools' + - script: python -m pip install numpy scipy cython pytest pytest-xdist matplotlib + displayName: 'Install dependencies' + - powershell: | + cd package + python setup.py install + cd ..\testsuite + python setup.py install + cd .. + displayName: 'Build MDAnalysis' + - powershell: | + cd testsuite + pytest .\MDAnalysisTests --disable-pytest-warnings -n 2 + displayName: 'Run MDAnalysis Test Suite' From 522f9aeaec6b535a566c62da6528a6fc2d1f5e2c Mon Sep 17 00:00:00 2001 From: Tyler Reddy Date: Sun, 24 May 2020 11:00:12 -0600 Subject: [PATCH 07/34] MAINT: 32-bit windows support * changes needed to pass full test suite on 32-bit Windows * primarily, use `np.intp` indexing types in appropriate locations instead of forcing `np.int64` --- package/MDAnalysis/core/selection.py | 2 +- package/MDAnalysis/core/topologyattrs.py | 4 ++-- package/MDAnalysis/lib/NeighborSearch.py | 2 +- package/MDAnalysis/lib/_augment.pyx | 6 +++--- package/MDAnalysis/lib/_cutil.pyx | 4 ++-- package/MDAnalysis/lib/distances.py | 12 ++++++------ package/MDAnalysis/lib/nsgrid.pyx | 2 +- package/MDAnalysis/lib/pkdtree.py | 10 +++++----- testsuite/MDAnalysisTests/analysis/test_density.py | 4 ++++ testsuite/MDAnalysisTests/analysis/test_hole2.py | 4 ++++ testsuite/MDAnalysisTests/core/test_fragments.py | 2 +- testsuite/MDAnalysisTests/lib/test_augment.py | 2 +- testsuite/MDAnalysisTests/lib/test_cutil.py | 2 +- testsuite/MDAnalysisTests/lib/test_distances.py | 4 ++-- testsuite/MDAnalysisTests/topology/test_tprparser.py | 2 +- 15 files changed, 35 insertions(+), 27 deletions(-) diff --git a/package/MDAnalysis/core/selection.py b/package/MDAnalysis/core/selection.py index b68ee0d5bfd..c8991334912 100644 --- a/package/MDAnalysis/core/selection.py +++ b/package/MDAnalysis/core/selection.py @@ -226,7 +226,7 @@ class ByResSelection(UnarySelection): def apply(self, group): res = self.sel.apply(group) - unique_res = unique_int_1d(res.resindices.astype(np.int64)) + unique_res = unique_int_1d(res.resindices.astype(np.intp)) mask = np.in1d(group.resindices, unique_res) return group[mask].unique diff --git a/package/MDAnalysis/core/topologyattrs.py b/package/MDAnalysis/core/topologyattrs.py index 339f9b89aa1..c805be3734d 100644 --- a/package/MDAnalysis/core/topologyattrs.py +++ b/package/MDAnalysis/core/topologyattrs.py @@ -1474,7 +1474,7 @@ class Molnums(ResidueAttr): attrname = 'molnums' singular = 'molnum' target_classes = [AtomGroup, ResidueGroup, Atom, Residue] - dtype = np.int64 + dtype = np.intp # segment attributes @@ -1730,7 +1730,7 @@ def fragindices(self): .. versionadded:: 0.20.0 """ fragdict = self.universe._fragdict - return np.array([fragdict[aix].ix for aix in self.ix], dtype=np.int64) + return np.array([fragdict[aix].ix for aix in self.ix], dtype=np.intp) def fragment(self): """An :class:`~MDAnalysis.core.groups.AtomGroup` representing the diff --git a/package/MDAnalysis/lib/NeighborSearch.py b/package/MDAnalysis/lib/NeighborSearch.py index 8260a7b8a5a..3020fc59f1f 100644 --- a/package/MDAnalysis/lib/NeighborSearch.py +++ b/package/MDAnalysis/lib/NeighborSearch.py @@ -93,7 +93,7 @@ def search(self, atoms, radius, level='A'): radius, box=self._box, return_distances=False) if pairs.size > 0: - unique_idx = unique_int_1d(np.asarray(pairs[:, 1], dtype=np.int64)) + unique_idx = unique_int_1d(np.asarray(pairs[:, 1], dtype=np.intp)) return self._index2level(unique_idx, level) def _index2level(self, indices, level): diff --git a/package/MDAnalysis/lib/_augment.pyx b/package/MDAnalysis/lib/_augment.pyx index 56115464714..82debd5d768 100644 --- a/package/MDAnalysis/lib/_augment.pyx +++ b/package/MDAnalysis/lib/_augment.pyx @@ -294,12 +294,12 @@ def augment_coordinates(float[:, ::1] coordinates, float[:] box, float r): output.push_back(coord[j] - shiftZ[j]) indices.push_back(i) n = indices.size() - return np.asarray(output, dtype=np.float32).reshape(n, 3), np.asarray(indices, dtype=np.int64) + return np.asarray(output, dtype=np.float32).reshape(n, 3), np.asarray(indices, dtype=np.intp) @cython.boundscheck(False) @cython.wraparound(False) -def undo_augment(np.int64_t[:] results, np.int64_t[:] translation, int nreal): +def undo_augment(np.intp_t[:] results, np.intp_t[:] translation, int nreal): """Translate augmented indices back to original indices. Parameters @@ -339,4 +339,4 @@ def undo_augment(np.int64_t[:] results, np.int64_t[:] translation, int nreal): for i in range(N): if results[i] >= nreal: results[i] = translation[results[i] - nreal] - return np.asarray(results, dtype=np.int64) + return np.asarray(results, dtype=np.intp) diff --git a/package/MDAnalysis/lib/_cutil.pyx b/package/MDAnalysis/lib/_cutil.pyx index 1ee666b7c26..1af54d1179c 100644 --- a/package/MDAnalysis/lib/_cutil.pyx +++ b/package/MDAnalysis/lib/_cutil.pyx @@ -49,7 +49,7 @@ ctypedef cmap[int, intset] intmap @cython.boundscheck(False) # turn off bounds-checking for entire function @cython.wraparound(False) # turn off negative index wrapping for entire function -def unique_int_1d(np.int64_t[:] values): +def unique_int_1d(np.intp_t[:] values): """Find the unique elements of a 1D array of integers. This function is optimal on sorted arrays. @@ -71,7 +71,7 @@ def unique_int_1d(np.int64_t[:] values): cdef int i = 0 cdef int j = 0 cdef int n_values = values.shape[0] - cdef np.int64_t[:] result = np.empty(n_values, dtype=np.int64) + cdef np.intp_t[:] result = np.empty(n_values, dtype=np.intp) if n_values == 0: return np.array(result) diff --git a/package/MDAnalysis/lib/distances.py b/package/MDAnalysis/lib/distances.py index df599be5810..c0d994862c2 100644 --- a/package/MDAnalysis/lib/distances.py +++ b/package/MDAnalysis/lib/distances.py @@ -526,7 +526,7 @@ def _bruteforce_capped(reference, configuration, max_cutoff, min_cutoff=None, ``configuration[pairs[k, 1]]``. """ # Default return values (will be overwritten only if pairs are found): - pairs = np.empty((0, 2), dtype=np.int64) + pairs = np.empty((0, 2), dtype=np.intp) distances = np.empty((0,), dtype=np.float64) if len(reference) > 0 and len(configuration) > 0: @@ -605,7 +605,7 @@ def _pkdtree_capped(reference, configuration, max_cutoff, min_cutoff=None, from .pkdtree import PeriodicKDTree # must be here to avoid circular import # Default return values (will be overwritten only if pairs are found): - pairs = np.empty((0, 2), dtype=np.int64) + pairs = np.empty((0, 2), dtype=np.intp) distances = np.empty((0,), dtype=np.float64) if len(reference) > 0 and len(configuration) > 0: @@ -685,7 +685,7 @@ def _nsgrid_capped(reference, configuration, max_cutoff, min_cutoff=None, ``configuration[pairs[k, 1]]``. """ # Default return values (will be overwritten only if pairs are found): - pairs = np.empty((0, 2), dtype=np.int64) + pairs = np.empty((0, 2), dtype=np.intp) distances = np.empty((0,), dtype=np.float64) if len(reference) > 0 and len(configuration) > 0: @@ -919,7 +919,7 @@ def _bruteforce_capped_self(reference, max_cutoff, min_cutoff=None, box=None, Added `return_distances` keyword. """ # Default return values (will be overwritten only if pairs are found): - pairs = np.empty((0, 2), dtype=np.int64) + pairs = np.empty((0, 2), dtype=np.intp) distances = np.empty((0,), dtype=np.float64) N = len(reference) @@ -996,7 +996,7 @@ def _pkdtree_capped_self(reference, max_cutoff, min_cutoff=None, box=None, from .pkdtree import PeriodicKDTree # must be here to avoid circular import # Default return values (will be overwritten only if pairs are found): - pairs = np.empty((0, 2), dtype=np.int64) + pairs = np.empty((0, 2), dtype=np.intp) distances = np.empty((0,), dtype=np.float64) # We're searching within a single coordinate set, so we need at least two @@ -1068,7 +1068,7 @@ def _nsgrid_capped_self(reference, max_cutoff, min_cutoff=None, box=None, Added `return_distances` keyword. """ # Default return values (will be overwritten only if pairs are found): - pairs = np.empty((0, 2), dtype=np.int64) + pairs = np.empty((0, 2), dtype=np.intp) distances = np.empty((0,), dtype=np.float64) # We're searching within a single coordinate set, so we need at least two diff --git a/package/MDAnalysis/lib/nsgrid.pyx b/package/MDAnalysis/lib/nsgrid.pyx index 1a9d477945b..373c48c538a 100644 --- a/package/MDAnalysis/lib/nsgrid.pyx +++ b/package/MDAnalysis/lib/nsgrid.pyx @@ -385,7 +385,7 @@ cdef class NSResults(object): and initial atom coordinates of shape ``(N, 2)`` """ - return np.asarray(self.pairs_buffer, dtype=np.int64).reshape(self.npairs, 2) + return np.asarray(self.pairs_buffer, dtype=np.intp).reshape(self.npairs, 2) def get_pair_distances(self): """Returns all the distances corresponding to each pair of neighbors diff --git a/package/MDAnalysis/lib/pkdtree.py b/package/MDAnalysis/lib/pkdtree.py index 635fc5afd6e..36dbf355400 100644 --- a/package/MDAnalysis/lib/pkdtree.py +++ b/package/MDAnalysis/lib/pkdtree.py @@ -189,7 +189,7 @@ def search(self, centers, radius): radius)) self._indices = np.array(list( itertools.chain.from_iterable(indices)), - dtype=np.int64) + dtype=np.intp) if self._indices.size > 0: self._indices = undo_augment(self._indices, self.mapping, @@ -200,7 +200,7 @@ def search(self, centers, radius): radius)) self._indices = np.array(list( itertools.chain.from_iterable(indices)), - dtype=np.int64) + dtype=np.intp) self._indices = np.asarray(unique_int_1d(self._indices)) return self._indices @@ -234,7 +234,7 @@ def search_pairs(self, radius): if self.cutoff < radius: raise RuntimeError('Set cutoff greater or equal to the radius.') - pairs = np.array(list(self.ckdt.query_pairs(radius)), dtype=np.int64) + pairs = np.array(list(self.ckdt.query_pairs(radius)), dtype=np.intp) if self.pbc: if len(pairs) > 1: pairs[:, 0] = undo_augment(pairs[:, 0], self.mapping, @@ -294,7 +294,7 @@ class initialization other_tree = cKDTree(wrapped_centers, leafsize=self.leafsize) pairs = other_tree.query_ball_tree(self.ckdt, radius) pairs = np.array([[i, j] for i, lst in enumerate(pairs) for j in lst], - dtype=np.int64) + dtype=np.intp) if pairs.size > 0: pairs[:, 1] = undo_augment(pairs[:, 1], self.mapping, @@ -303,7 +303,7 @@ class initialization other_tree = cKDTree(centers, leafsize=self.leafsize) pairs = other_tree.query_ball_tree(self.ckdt, radius) pairs = np.array([[i, j] for i, lst in enumerate(pairs) for j in lst], - dtype=np.int64) + dtype=np.intp) if pairs.size > 0: pairs = unique_rows(pairs) return pairs diff --git a/testsuite/MDAnalysisTests/analysis/test_density.py b/testsuite/MDAnalysisTests/analysis/test_density.py index 5b4b191c71e..7fd3f93b87f 100644 --- a/testsuite/MDAnalysisTests/analysis/test_density.py +++ b/testsuite/MDAnalysisTests/analysis/test_density.py @@ -398,6 +398,8 @@ class TestNotWithin(object): def u(): return mda.Universe(GRO) + @pytest.mark.skipif(sys.maxsize <= 2**32, + reason="non-kdtree density too large for 32-bit") def test_within(self, u): vers1 = density.notwithin_coordinates_factory(u, 'resname SOL', 'protein', 2, @@ -410,6 +412,8 @@ def test_within(self, u): assert_equal(vers1, vers2) + @pytest.mark.skipif(sys.maxsize <= 2**32, + reason="non-kdtree density too large for 32-bit") def test_not_within(self, u): vers1 = density.notwithin_coordinates_factory(u, 'resname SOL', 'protein', 2, diff --git a/testsuite/MDAnalysisTests/analysis/test_hole2.py b/testsuite/MDAnalysisTests/analysis/test_hole2.py index 87fa6d1232b..fb8fff261a3 100644 --- a/testsuite/MDAnalysisTests/analysis/test_hole2.py +++ b/testsuite/MDAnalysisTests/analysis/test_hole2.py @@ -73,6 +73,8 @@ def test_relative(self): fixed = check_and_fix_long_filename(abspath) assert fixed == self.filename + @pytest.mark.skipif(os.name == 'nt' and sys.maxsize <= 2**32, + reason="FileNotFoundError on Win 32-bit") def test_symlink_dir(self, tmpdir): dirname = 'really_'*20 +'long_name' short_name = self.filename[-20:] @@ -86,6 +88,8 @@ def test_symlink_dir(self, tmpdir): assert os.path.islink(fixed) assert fixed.endswith(short_name) + @pytest.mark.skipif(os.name == 'nt' and sys.maxsize <= 2**32, + reason="OSError: symbolic link privilege not held") def test_symlink_file(self, tmpdir): long_name = 'a'*10 + self.filename diff --git a/testsuite/MDAnalysisTests/core/test_fragments.py b/testsuite/MDAnalysisTests/core/test_fragments.py index 98548e4edd8..fca796308df 100644 --- a/testsuite/MDAnalysisTests/core/test_fragments.py +++ b/testsuite/MDAnalysisTests/core/test_fragments.py @@ -129,7 +129,7 @@ def test_total_frags(self, u): # number of unique fragindices must correspond to number of fragments: assert len(np.unique(fragindices)) == len(fragments) # check fragindices dtype: - assert fragindices.dtype == np.int64 + assert fragindices.dtype == np.intp #check n_fragments assert u.atoms.n_fragments == len(fragments) diff --git a/testsuite/MDAnalysisTests/lib/test_augment.py b/testsuite/MDAnalysisTests/lib/test_augment.py index 8b580150ea7..bdedd0ec56d 100644 --- a/testsuite/MDAnalysisTests/lib/test_augment.py +++ b/testsuite/MDAnalysisTests/lib/test_augment.py @@ -105,5 +105,5 @@ def test_undoaugment(b, qres): q = apply_PBC(q, b) aug, mapping = augment_coordinates(q, b, radius) for idx, val in enumerate(aug): - imageid = np.asarray([len(q) + idx], dtype=np.int64) + imageid = np.asarray([len(q) + idx], dtype=np.intp) assert_equal(mapping[idx], undo_augment(imageid, mapping, len(q))[0]) diff --git a/testsuite/MDAnalysisTests/lib/test_cutil.py b/testsuite/MDAnalysisTests/lib/test_cutil.py index 6f4dbd9ab83..3fce8ea2853 100644 --- a/testsuite/MDAnalysisTests/lib/test_cutil.py +++ b/testsuite/MDAnalysisTests/lib/test_cutil.py @@ -38,7 +38,7 @@ [1, 2, 2, 6, 4, 4, ], # duplicates, non-monotonic )) def test_unique_int_1d(values): - array = np.array(values, dtype=np.int64) + array = np.array(values, dtype=np.intp) ref = np.unique(array) res = unique_int_1d(array) assert_equal(res, ref) diff --git a/testsuite/MDAnalysisTests/lib/test_distances.py b/testsuite/MDAnalysisTests/lib/test_distances.py index 43e0be4964a..cb02dbd8cf2 100644 --- a/testsuite/MDAnalysisTests/lib/test_distances.py +++ b/testsuite/MDAnalysisTests/lib/test_distances.py @@ -1245,7 +1245,7 @@ def test_output_type_capped_distance(self, incoords, min_cut, box, met, else: pairs = res assert type(pairs) == np.ndarray - assert pairs.dtype.type == np.int64 + assert pairs.dtype.type == np.intp assert pairs.ndim == 2 assert pairs.shape[1] == 2 if ret_dist: @@ -1270,7 +1270,7 @@ def test_output_type_self_capped_distance(self, incoords, min_cut, box, else: pairs = res assert type(pairs) == np.ndarray - assert pairs.dtype.type == np.int64 + assert pairs.dtype.type == np.intp assert pairs.ndim == 2 assert pairs.shape[1] == 2 if ret_dist: diff --git a/testsuite/MDAnalysisTests/topology/test_tprparser.py b/testsuite/MDAnalysisTests/topology/test_tprparser.py index 5685e630919..2063ee70ebc 100644 --- a/testsuite/MDAnalysisTests/topology/test_tprparser.py +++ b/testsuite/MDAnalysisTests/topology/test_tprparser.py @@ -55,7 +55,7 @@ def test_moltypes(self, top): def test_molnums(self, top): molnums = top.molnums.values assert_equal(molnums, self.ref_molnums) - assert molnums.dtype == np.int64 + assert molnums.dtype == np.intp class TestTPR(TPRAttrs): From 2392c35c8eb5699e7dcdb91117c1fbcea12444da Mon Sep 17 00:00:00 2001 From: Tyler Reddy Date: Mon, 25 May 2020 11:46:46 -0600 Subject: [PATCH 08/34] MAINT: PR 2696 revisions * we now conditionally `xfail` a test that is problematic on 32-bit Windows: `MDAnalysisTests/formats/test_libdcd.py::test_written_remarks_property` * `hypothesis` only occasionally produces a failure for this test on 32-bit Windows --- testsuite/MDAnalysisTests/formats/test_libdcd.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/testsuite/MDAnalysisTests/formats/test_libdcd.py b/testsuite/MDAnalysisTests/formats/test_libdcd.py index 852c9701032..0028a80bd3b 100644 --- a/testsuite/MDAnalysisTests/formats/test_libdcd.py +++ b/testsuite/MDAnalysisTests/formats/test_libdcd.py @@ -19,6 +19,7 @@ from collections import namedtuple import os +import sys import string import struct @@ -316,6 +317,8 @@ def write_dcd(in_name, out_name, remarks='testing', header=None): f_out.write(xyz=frame.xyz, box=frame.unitcell) +@pytest.mark.xfail(os.name == 'nt' and sys.maxsize <= 2**32, + reason="occasional fail on 32-bit windows") @given(remarks=strategies.text( alphabet=string.printable, min_size=0, max_size=239)) # handle the printable ASCII strings From d6ea367725f7b61fe8d91f9b6866388583824df2 Mon Sep 17 00:00:00 2001 From: Tyler Reddy Date: Mon, 25 May 2020 20:12:39 -0600 Subject: [PATCH 09/34] MAINT: PR 2696 revisions * remove an extraneous type coercion in `selection.py` based on reviewer feedback --- package/MDAnalysis/core/selection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package/MDAnalysis/core/selection.py b/package/MDAnalysis/core/selection.py index c8991334912..2b121ebd708 100644 --- a/package/MDAnalysis/core/selection.py +++ b/package/MDAnalysis/core/selection.py @@ -226,7 +226,7 @@ class ByResSelection(UnarySelection): def apply(self, group): res = self.sel.apply(group) - unique_res = unique_int_1d(res.resindices.astype(np.intp)) + unique_res = unique_int_1d(res.resindices) mask = np.in1d(group.resindices, unique_res) return group[mask].unique From 55afb4c36328e79c1390c0f4364b2cf5c1aff481 Mon Sep 17 00:00:00 2001 From: Rocco Meli Date: Tue, 26 May 2020 23:24:33 +0200 Subject: [PATCH 10/34] ignore duecredit output (#2697) updated .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 9feb1b80471..189c031e99f 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,9 @@ authors.py # ignore files from tests .hypothesis/ +# duecredit +.duecredit.p + # editors and IDEs *.sw[a-z] *~ From 701c0afa6dee63b6f468bea264aa6f4fe1690f32 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 27 May 2020 06:12:10 +0100 Subject: [PATCH 11/34] Removes NUMBER_TO_ELEMENTS --- package/MDAnalysis/topology/TOPParser.py | 4 +- package/MDAnalysis/topology/_elements.py | 144 ------------------ package/MDAnalysis/topology/tables.py | 1 - testsuite/MDAnalysisTests/data/test.aln | 54 +++++++ testsuite/MDAnalysisTests/data/test.dnd | 1 + .../MDAnalysisTests/topology/test_top.py | 3 +- 6 files changed, 59 insertions(+), 148 deletions(-) delete mode 100644 package/MDAnalysis/topology/_elements.py create mode 100644 testsuite/MDAnalysisTests/data/test.aln create mode 100644 testsuite/MDAnalysisTests/data/test.dnd diff --git a/package/MDAnalysis/topology/TOPParser.py b/package/MDAnalysis/topology/TOPParser.py index b16ea022eac..53fde99a02a 100644 --- a/package/MDAnalysis/topology/TOPParser.py +++ b/package/MDAnalysis/topology/TOPParser.py @@ -93,7 +93,7 @@ import itertools from . import guessers -from .tables import NUMBER_TO_ELEMENT +from .tables import Z2SYMB from ..lib.util import openany, FORTRANReader from .base import TopologyReaderBase from ..core.topology import Topology @@ -433,7 +433,7 @@ def parse_elements(self, num_per_record, numlines): vals = self.parsesection_mapper( numlines, - lambda x: NUMBER_TO_ELEMENT[int(x)] if int(x) > 0 else "DUMMY") + lambda x: Z2SYMB[int(x)] if int(x) > 0 else "DUMMY") attr = Elements(np.array(vals, dtype=object)) return attr diff --git a/package/MDAnalysis/topology/_elements.py b/package/MDAnalysis/topology/_elements.py deleted file mode 100644 index 35a55c3e225..00000000000 --- a/package/MDAnalysis/topology/_elements.py +++ /dev/null @@ -1,144 +0,0 @@ -# -*- Mode: python; tab-width: 4; indent-tabs-mode:nil; coding:utf-8 -*- -# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 -# -# MDAnalysis --- https://www.mdanalysis.org -# Copyright (c) 2006-2017 The MDAnalysis Development Team and contributors -# (see the file AUTHORS for the full list of names) -# -# Released under the GNU Public Licence, v2 or any higher version -# -# Please cite your use of MDAnalysis in published work: -# -# R. J. Gowers, M. Linke, J. Barnoud, T. J. E. Reddy, M. N. Melo, S. L. Seyler, -# D. L. Dotson, J. Domanski, S. Buchoux, I. M. Kenney, and O. Beckstein. -# MDAnalysis: A Python package for the rapid analysis of molecular dynamics -# simulations. In S. Benthall and S. Rostrup editors, Proceedings of the 15th -# Python in Science Conference, pages 102-109, Austin, TX, 2016. SciPy. -# doi: 10.25080/majora-629e541a-00e -# -# N. Michaud-Agrawal, E. J. Denning, T. B. Woolf, and O. Beckstein. -# MDAnalysis: A Toolkit for the Analysis of Molecular Dynamics Simulations. -# J. Comput. Chem. 32 (2011), 2319--2327, doi:10.1002/jcc.21787 -# - -# atomic number to element dict -NUMBER_TO_ELEMENT = { - 1: 'H', - 2: 'He', - 3: 'Li', - 4: 'Be', - 5: 'B', - 6: 'C', - 7: 'N', - 8: 'O', - 9: 'F', - 10: 'Ne', - 11: 'Na', - 12: 'Mg', - 13: 'Al', - 14: 'Si', - 15: 'P', - 16: 'S', - 17: 'Cl', - 18: 'Ar', - 19: 'K', - 20: 'Ca', - 21: 'Sc', - 22: 'Ti', - 23: 'V', - 24: 'Cr', - 25: 'Mn', - 26: 'Fe', - 27: 'Co', - 28: 'Ni', - 29: 'Cu', - 30: 'Zn', - 31: 'Ga', - 32: 'Ge', - 33: 'As', - 34: 'Se', - 35: 'Br', - 36: 'Kr', - 37: 'Rb', - 38: 'Sr', - 39: 'Y', - 40: 'Zr', - 41: 'Nb', - 42: 'Mo', - 43: 'Tc', - 44: 'Ru', - 45: 'Rh', - 46: 'Pd', - 47: 'Ag', - 48: 'Cd', - 49: 'In', - 50: 'Sn', - 51: 'Sb', - 52: 'Te', - 53: 'I', - 54: 'Xe', - 55: 'Cs', - 56: 'Ba', - 57: 'La', - 58: 'Ce', - 59: 'Pr', - 60: 'Nd', - 61: 'Pm', - 62: 'Sm', - 63: 'Eu', - 64: 'Gd', - 65: 'Tb', - 66: 'Dy', - 67: 'Ho', - 68: 'Er', - 69: 'Tm', - 70: 'Yb', - 71: 'Lu', - 72: 'Hf', - 73: 'Ta', - 74: 'W', - 75: 'Re', - 76: 'Os', - 77: 'Ir', - 78: 'Pt', - 79: 'Au', - 80: 'Hg', - 81: 'Tl', - 82: 'Pb', - 83: 'Bi', - 84: 'Po', - 85: 'At', - 86: 'Rn', - 87: 'Fr', - 88: 'Ra', - 89: 'Ac', - 90: 'Th', - 91: 'Pa', - 92: 'U', - 93: 'Np', - 94: 'Pu', - 95: 'Am', - 96: 'Cm', - 97: 'Bk', - 98: 'Cf', - 99: 'Es', - 100: 'Fm', - 101: 'Md', - 102: 'No', - 103: 'Lr', - 104: 'Rf', - 105: 'Db', - 106: 'Sg', - 107: 'Bh', - 108: 'Hs', - 109: 'Mt', - 110: 'Ds', - 111: 'Rg', - 112: 'Cn', - 113: 'Uut', - 114: 'Fl', - 115: 'Uup', - 116: 'Lv', - 117: 'Uus', - 118: 'Uuo', -} diff --git a/package/MDAnalysis/topology/tables.py b/package/MDAnalysis/topology/tables.py index 51419217892..97efb74c45c 100644 --- a/package/MDAnalysis/topology/tables.py +++ b/package/MDAnalysis/topology/tables.py @@ -46,7 +46,6 @@ .. autodata:: TABLE_VDWRADII """ from __future__ import absolute_import -from ._elements import NUMBER_TO_ELEMENT def kv2dict(s, convertor=str): diff --git a/testsuite/MDAnalysisTests/data/test.aln b/testsuite/MDAnalysisTests/data/test.aln new file mode 100644 index 00000000000..4e411c8ec90 --- /dev/null +++ b/testsuite/MDAnalysisTests/data/test.aln @@ -0,0 +1,54 @@ +CLUSTAL 2.1 multiple sequence alignment + + +Aqui_model.pdb_ ------MKRVVVDPVTRIEGHLRIEIMVDEETGQVKDALSAGTMWRGIEL +Desulfubrio_start.pdb_ TPQSTFTGPIVVDPITRIEGHLR--IMVEVENGKVKDAWSSSQLFRGLEI + :****:******** ***: *.*:**** *:. ::**:*: + +Aqui_model.pdb_ IVRNRDPRDVWAFTQRICGVCTSIHALASLRAVEDALEITIPKNANYIRN +Desulfubrio_start.pdb_ ILKGRDPRDAQHFTQRACGVCTYVHALASSRCVDDAVKVSIPANARMMRN + *::.*****. **** ***** :***** *.*:**::::** **. :** + +Aqui_model.pdb_ IMYGSLQVHDHVVHFYHLHALDWVSPVEALKADPVATAALANKILEKYGV +Desulfubrio_start.pdb_ LVMASQYLHDHLVHFYHLHALDWVDVTAALKADPNKAAKLAASIDTAR-- + :: .* :***:************. . ****** :* ** .* + +Aqui_model.pdb_ LNEFMPDFLGHRAYPKKFPKATPGYFREFQKKIKKLVESGQLGIFA-AHW +Desulfubrio_start.pdb_ ------------------TGNSEKALKAVQDKLKAFVESGQLGIFTNAYF + . : :: .*.*:* :*********: *:: + +Aqui_model.pdb_ WDHPDYQMLPPEVHLIGIAHYLNMLDVQRELFIPQVVFGGKNPHPHYIVG +Desulfubrio_start.pdb_ LGGHKAYYLPPEVNLIATAHYLEALHMQVKAASAMAILGGKNPHTQFTVV + . . *****:**. ****: *.:* : . .::******.:: * + +Aqui_model.pdb_ GVNCSISMDDMNAPVNAERLAVVEDAIYTQVESTDFFYIPDILAIADIYL +Desulfubrio_start.pdb_ GG-CSNYQGLTKDPL-ANYLALSKEVCQFVNEC----YIPDLLAVAGFYK + * ** . : *: *: **: ::. *. ****:**:*.:* + +Aqui_model.pdb_ NQHNWFYGGGLSKKRVIGYGDYPDEPYTGIKNGDYHKKILWHSNGVVEDF +Desulfubrio_start.pdb_ ---DWGGIGGTSN--YLAFGEFATDDSS-------PEKHLATS-----QF + :* ** *: :.:*::. : : :* * * :* + +Aqui_model.pdb_ YKGVEKAKFYNLEGKDFTDPEQIQEFVTHSWYKYPDETKGLHPWDGITEP +Desulfubrio_start.pdb_ PSGVITGR--DLGKVDNVDLGAIYEDVKYSWYAPGGDGK--HPYDGVTDP + .** ..: :* * .* * * *.:*** .: * **:**:*:* + +Aqui_model.pdb_ NYTGPKEGTKTHWKYLDENGKYSWIKAPRWRGKACEVGPLARYIIVYTKV +Desulfubrio_start.pdb_ KYT-----------KLDDKDHYSWMKAPRYKGKAMEVGPLARTFIAYAKG + :** **::.:***:****::*** ******* :*.*:* + +Aqui_model.pdb_ KQGHIKPTWVDELIVNQIDTVSKILNLPPEKWLPTTVGRTIARALEAQMS +Desulfubrio_start.pdb_ QPDFKK----------VVDMVLGKLSVP-ATALHSTLGRTAARGIETAIV + : .. * :* * *.:* . * :*:*** **.:*: : + +Aqui_model.pdb_ AHTNLYWMKKLYDNIKAGDTSVANMEKWDPSTWPKEAKGVGLTEAPRGAL +Desulfubrio_start.pdb_ CANMEKWIKEMADSGAKDNTLCA---KWE---MPEESKGVGLADAPRGSL + . . *:*:: *. .:* * **: *:*:*****::****:* + +Aqui_model.pdb_ GHWVIIKDGKVANYQCVVPTTWNGSPKDPKGQHGAFEESMIDTKVKVPEK +Desulfubrio_start.pdb_ SHWIRIKGKKIDNFQLVVPSTWNLGPRGPQGDKSPVEEALIGTPIADPKR + .**: **. *: *:* ***:*** .*:.*:*::...**::*.* : *:: + +Aqui_model.pdb_ PLEVLRGIHSFDPCLACSTH +Desulfubrio_start.pdb_ PVEILRTVHAFDPCIACGVH + *:*:** :*:****:**..* diff --git a/testsuite/MDAnalysisTests/data/test.dnd b/testsuite/MDAnalysisTests/data/test.dnd new file mode 100644 index 00000000000..4acdcda8745 --- /dev/null +++ b/testsuite/MDAnalysisTests/data/test.dnd @@ -0,0 +1 @@ +(Aqui_model.pdb_:0.2965,Desulfubrio_start.pdb_:0.2965); diff --git a/testsuite/MDAnalysisTests/topology/test_top.py b/testsuite/MDAnalysisTests/topology/test_top.py index 8b015c00b3c..e0ece85adc2 100644 --- a/testsuite/MDAnalysisTests/topology/test_top.py +++ b/testsuite/MDAnalysisTests/topology/test_top.py @@ -41,6 +41,8 @@ ATOMIC_NUMBER_MSG = ("ATOMIC_NUMBER record not found, guessing atom elements " "based on their atom types") COORDINATE_READER_MSG = ("No coordinate reader found") + + class TOPBase(ParserBase): parser = mda.topology.TOPParser.TOPParser expected_attrs = [ @@ -344,7 +346,6 @@ def test_warning(self, filename): assert len(record) == 2 assert str(record[0].message.args[0]) == ATOMIC_NUMBER_MSG assert COORDINATE_READER_MSG in str(record[1].message.args[0]) - class TestPRMNCRST(TOPBase): From aacae3da34420a1f5b717e9ac4d8bb9223629111 Mon Sep 17 00:00:00 2001 From: Irfan Alibay Date: Wed, 27 May 2020 09:52:59 +0100 Subject: [PATCH 12/34] improve element testing --- .../MDAnalysisTests/topology/test_top.py | 59 ++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/testsuite/MDAnalysisTests/topology/test_top.py b/testsuite/MDAnalysisTests/topology/test_top.py index e0ece85adc2..91a1ea758e5 100644 --- a/testsuite/MDAnalysisTests/topology/test_top.py +++ b/testsuite/MDAnalysisTests/topology/test_top.py @@ -23,7 +23,8 @@ from __future__ import absolute_import import MDAnalysis as mda import pytest - +import numpy as np +from numpy.testing import assert_equal from MDAnalysisTests.topology.base import ParserBase from MDAnalysisTests.datafiles import ( PRM, # ache.prmtop @@ -131,7 +132,7 @@ def test_improper_atoms_bonded(self, top): forward = ((imp[0], imp[2]), (imp[1], imp[2]), (imp[2], imp[3])) backward = ((imp[0], imp[1]), (imp[1], imp[2]), (imp[1], imp[3])) for a, b in zip(forward, backward): - assert ((b in vals) or (b[::-1] in vals) or + assert ((b in vals) or (b[::-1] in vals) or (a in vals) or (a[::-1] in vals)) @@ -235,6 +236,60 @@ class TestPRM12Parser(TOPBase): (338, 337, 335, 354), (351, 337, 335, 354)) atom_zero_improper_values = () atom_i_improper_values = ((335, 337, 338, 351),) + elems_ranges = [[0, 403], ] + expected_elems = np.array(["H", "O", "C", "H", "H", "C", "H", "O", "C", + "H", "N", "C", "H", "N", "C", "C", "O", "N", + "H", "C", "N", "H", "H", "N", "C", "C", "H", + "C", "H", "H", "O", "P", "O", "O", "O", "C", + "H", "H", "C", "H", "O", "C", "H", "N", "C", + "H", "N", "C", "C", "O", "N", "H", "C", "N", + "H", "H", "N", "C", "C", "H", "C", "H", "H", + "O", "P", "O", "O", "O", "C", "H", "H", "C", + "H", "O", "C", "H", "N", "C", "H", "N", "C", + "C", "O", "N", "H", "C", "N", "H", "H", "N", + "C", "C", "H", "C", "H", "H", "O", "H", "H", + "O", "C", "H", "H", "C", "H", "O", "C", "H", + "N", "C", "H", "N", "C", "C", "O", "N", "H", + "C", "N", "H", "H", "N", "C", "C", "H", "C", + "H", "H", "O", "P", "O", "O", "O", "C", "H", + "H", "C", "H", "O", "C", "H", "N", "C", "H", + "N", "C", "C", "O", "N", "H", "C", "N", "H", + "H", "N", "C", "C", "H", "C", "H", "H", "O", + "P", "O", "O", "O", "C", "H", "H", "C", "H", + "O", "C", "H", "N", "C", "H", "N", "C", "C", + "O", "N", "H", "C", "N", "H", "H", "N", "C", + "C", "H", "C", "H", "H", "O", "H", "H", "O", + "C", "H", "H", "C", "H", "O", "C", "H", "N", + "C", "H", "N", "C", "C", "O", "N", "H", "C", + "N", "H", "H", "N", "C", "C", "H", "C", "H", + "H", "O", "P", "O", "O", "O", "C", "H", "H", + "C", "H", "O", "C", "H", "N", "C", "H", "N", + "C", "C", "O", "N", "H", "C", "N", "H", "H", + "N", "C", "C", "H", "C", "H", "H", "O", "P", + "O", "O", "O", "C", "H", "H", "C", "H", "O", + "C", "H", "N", "C", "H", "N", "C", "C", "O", + "N", "H", "C", "N", "H", "H", "N", "C", "C", + "H", "C", "H", "H", "O", "H", "H", "O", "C", + "H", "H", "C", "H", "O", "C", "H", "N", "C", + "H", "N", "C", "C", "O", "N", "H", "C", "N", + "H", "H", "N", "C", "C", "H", "C", "H", "H", + "O", "P", "O", "O", "O", "C", "H", "H", "C", + "H", "O", "C", "H", "N", "C", "H", "N", "C", + "C", "O", "N", "H", "C", "N", "H", "H", "N", + "C", "C", "H", "C", "H", "H", "O", "P", "O", + "O", "O", "C", "H", "H", "C", "H", "O", "C", + "H", "N", "C", "H", "N", "C", "C", "O", "N", + "H", "C", "N", "H", "H", "N", "C", "C", "H", + "C", "H", "H", "O", "H", "Na", "Na", "Na", + "Na", "Na", "Na", "Na", "Na", "O", "H", "H"], + dtype=object) + + def test_elements(self, top): + """Loops over ranges of the topology elements list and compared + against a provided list of expected values""" + for elem_range in self.elems_ranges: + assert_equal(top.elements.values[1:403], + self.expected_elems) class TestParm7Parser(TOPBase): From 2f632c1089b9d04c5950a93cf68fb925f540ed99 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 27 May 2020 10:13:27 +0100 Subject: [PATCH 13/34] Fixes tests --- .../MDAnalysisTests/topology/test_top.py | 69 +++++-------------- 1 file changed, 18 insertions(+), 51 deletions(-) diff --git a/testsuite/MDAnalysisTests/topology/test_top.py b/testsuite/MDAnalysisTests/topology/test_top.py index 91a1ea758e5..0dc22a0e11d 100644 --- a/testsuite/MDAnalysisTests/topology/test_top.py +++ b/testsuite/MDAnalysisTests/topology/test_top.py @@ -236,60 +236,27 @@ class TestPRM12Parser(TOPBase): (338, 337, 335, 354), (351, 337, 335, 354)) atom_zero_improper_values = () atom_i_improper_values = ((335, 337, 338, 351),) - elems_ranges = [[0, 403], ] - expected_elems = np.array(["H", "O", "C", "H", "H", "C", "H", "O", "C", - "H", "N", "C", "H", "N", "C", "C", "O", "N", - "H", "C", "N", "H", "H", "N", "C", "C", "H", - "C", "H", "H", "O", "P", "O", "O", "O", "C", - "H", "H", "C", "H", "O", "C", "H", "N", "C", - "H", "N", "C", "C", "O", "N", "H", "C", "N", - "H", "H", "N", "C", "C", "H", "C", "H", "H", - "O", "P", "O", "O", "O", "C", "H", "H", "C", - "H", "O", "C", "H", "N", "C", "H", "N", "C", - "C", "O", "N", "H", "C", "N", "H", "H", "N", - "C", "C", "H", "C", "H", "H", "O", "H", "H", - "O", "C", "H", "H", "C", "H", "O", "C", "H", - "N", "C", "H", "N", "C", "C", "O", "N", "H", - "C", "N", "H", "H", "N", "C", "C", "H", "C", - "H", "H", "O", "P", "O", "O", "O", "C", "H", - "H", "C", "H", "O", "C", "H", "N", "C", "H", - "N", "C", "C", "O", "N", "H", "C", "N", "H", - "H", "N", "C", "C", "H", "C", "H", "H", "O", - "P", "O", "O", "O", "C", "H", "H", "C", "H", - "O", "C", "H", "N", "C", "H", "N", "C", "C", - "O", "N", "H", "C", "N", "H", "H", "N", "C", - "C", "H", "C", "H", "H", "O", "H", "H", "O", - "C", "H", "H", "C", "H", "O", "C", "H", "N", - "C", "H", "N", "C", "C", "O", "N", "H", "C", - "N", "H", "H", "N", "C", "C", "H", "C", "H", - "H", "O", "P", "O", "O", "O", "C", "H", "H", - "C", "H", "O", "C", "H", "N", "C", "H", "N", - "C", "C", "O", "N", "H", "C", "N", "H", "H", - "N", "C", "C", "H", "C", "H", "H", "O", "P", - "O", "O", "O", "C", "H", "H", "C", "H", "O", - "C", "H", "N", "C", "H", "N", "C", "C", "O", - "N", "H", "C", "N", "H", "H", "N", "C", "C", - "H", "C", "H", "H", "O", "H", "H", "O", "C", - "H", "H", "C", "H", "O", "C", "H", "N", "C", - "H", "N", "C", "C", "O", "N", "H", "C", "N", - "H", "H", "N", "C", "C", "H", "C", "H", "H", - "O", "P", "O", "O", "O", "C", "H", "H", "C", - "H", "O", "C", "H", "N", "C", "H", "N", "C", - "C", "O", "N", "H", "C", "N", "H", "H", "N", - "C", "C", "H", "C", "H", "H", "O", "P", "O", - "O", "O", "C", "H", "H", "C", "H", "O", "C", - "H", "N", "C", "H", "N", "C", "C", "O", "N", - "H", "C", "N", "H", "H", "N", "C", "C", "H", - "C", "H", "H", "O", "H", "Na", "Na", "Na", - "Na", "Na", "Na", "Na", "Na", "O", "H", "H"], - dtype=object) + elems_ranges = [[0, 36], [351, 403]] + expected_elems = [np.array(["H", "O", "C", "H", "H", "C", "H", "O", "C", + "H", "N", "C", "H", "N", "C", "C", "O", "N", + "H", "C", "N", "H", "H", "N", "C", "C", "H", + "C", "H", "H", "O", "P", "O", "O", "O", "C"], + dtype=object), + np.array(["C", "C", "H", "C", "H", "H", "O", "P", "O", + "O", "O", "C", "H", "H", "C", "H", "O", "C", + "H", "N", "C", "H", "N", "C", "C", "O", "N", + "H", "C", "N", "H", "H", "N", "C", "C", "H", + "C", "H", "H", "O", "H", "Na", "Na", "Na", + "Na", "Na", "Na", "Na", "Na", "O", "H", "H"], + dtype=object)] def test_elements(self, top): """Loops over ranges of the topology elements list and compared - against a provided list of expected values""" - for elem_range in self.elems_ranges: - assert_equal(top.elements.values[1:403], - self.expected_elems) + against a provided list of expected values. + """ + for erange, evals in zip(self.elems_ranges, self.expected_elems): + assert_equal(top.elements.values[erange[0]:erange[1]], evals, + "unexpected element match") class TestParm7Parser(TOPBase): From e064c2baed234f84bcb7ff1fefee88d451217091 Mon Sep 17 00:00:00 2001 From: Irfan Alibay Date: Wed, 27 May 2020 10:27:33 +0100 Subject: [PATCH 14/34] PEP/FLAKE8 fixes --- testsuite/MDAnalysisTests/topology/test_top.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/testsuite/MDAnalysisTests/topology/test_top.py b/testsuite/MDAnalysisTests/topology/test_top.py index 0dc22a0e11d..1ab1247e151 100644 --- a/testsuite/MDAnalysisTests/topology/test_top.py +++ b/testsuite/MDAnalysisTests/topology/test_top.py @@ -241,15 +241,15 @@ class TestPRM12Parser(TOPBase): "H", "N", "C", "H", "N", "C", "C", "O", "N", "H", "C", "N", "H", "H", "N", "C", "C", "H", "C", "H", "H", "O", "P", "O", "O", "O", "C"], - dtype=object), + dtype=object), np.array(["C", "C", "H", "C", "H", "H", "O", "P", "O", "O", "O", "C", "H", "H", "C", "H", "O", "C", "H", "N", "C", "H", "N", "C", "C", "O", "N", "H", "C", "N", "H", "H", "N", "C", "C", "H", "C", "H", "H", "O", "H", "Na", "Na", "Na", "Na", "Na", "Na", "Na", "Na", "O", "H", "H"], - dtype=object)] - + dtype=object)] + def test_elements(self, top): """Loops over ranges of the topology elements list and compared against a provided list of expected values. From 52e10774c3ae56aec979ef4c835f666f2cfb4e5f Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 27 May 2020 10:37:50 +0100 Subject: [PATCH 15/34] Remove extra .aln and .dnd files --- testsuite/MDAnalysisTests/data/test.aln | 54 ------------------------- testsuite/MDAnalysisTests/data/test.dnd | 1 - 2 files changed, 55 deletions(-) delete mode 100644 testsuite/MDAnalysisTests/data/test.aln delete mode 100644 testsuite/MDAnalysisTests/data/test.dnd diff --git a/testsuite/MDAnalysisTests/data/test.aln b/testsuite/MDAnalysisTests/data/test.aln deleted file mode 100644 index 4e411c8ec90..00000000000 --- a/testsuite/MDAnalysisTests/data/test.aln +++ /dev/null @@ -1,54 +0,0 @@ -CLUSTAL 2.1 multiple sequence alignment - - -Aqui_model.pdb_ ------MKRVVVDPVTRIEGHLRIEIMVDEETGQVKDALSAGTMWRGIEL -Desulfubrio_start.pdb_ TPQSTFTGPIVVDPITRIEGHLR--IMVEVENGKVKDAWSSSQLFRGLEI - :****:******** ***: *.*:**** *:. ::**:*: - -Aqui_model.pdb_ IVRNRDPRDVWAFTQRICGVCTSIHALASLRAVEDALEITIPKNANYIRN -Desulfubrio_start.pdb_ ILKGRDPRDAQHFTQRACGVCTYVHALASSRCVDDAVKVSIPANARMMRN - *::.*****. **** ***** :***** *.*:**::::** **. :** - -Aqui_model.pdb_ IMYGSLQVHDHVVHFYHLHALDWVSPVEALKADPVATAALANKILEKYGV -Desulfubrio_start.pdb_ LVMASQYLHDHLVHFYHLHALDWVDVTAALKADPNKAAKLAASIDTAR-- - :: .* :***:************. . ****** :* ** .* - -Aqui_model.pdb_ LNEFMPDFLGHRAYPKKFPKATPGYFREFQKKIKKLVESGQLGIFA-AHW -Desulfubrio_start.pdb_ ------------------TGNSEKALKAVQDKLKAFVESGQLGIFTNAYF - . : :: .*.*:* :*********: *:: - -Aqui_model.pdb_ WDHPDYQMLPPEVHLIGIAHYLNMLDVQRELFIPQVVFGGKNPHPHYIVG -Desulfubrio_start.pdb_ LGGHKAYYLPPEVNLIATAHYLEALHMQVKAASAMAILGGKNPHTQFTVV - . . *****:**. ****: *.:* : . .::******.:: * - -Aqui_model.pdb_ GVNCSISMDDMNAPVNAERLAVVEDAIYTQVESTDFFYIPDILAIADIYL -Desulfubrio_start.pdb_ GG-CSNYQGLTKDPL-ANYLALSKEVCQFVNEC----YIPDLLAVAGFYK - * ** . : *: *: **: ::. *. ****:**:*.:* - -Aqui_model.pdb_ NQHNWFYGGGLSKKRVIGYGDYPDEPYTGIKNGDYHKKILWHSNGVVEDF -Desulfubrio_start.pdb_ ---DWGGIGGTSN--YLAFGEFATDDSS-------PEKHLATS-----QF - :* ** *: :.:*::. : : :* * * :* - -Aqui_model.pdb_ YKGVEKAKFYNLEGKDFTDPEQIQEFVTHSWYKYPDETKGLHPWDGITEP -Desulfubrio_start.pdb_ PSGVITGR--DLGKVDNVDLGAIYEDVKYSWYAPGGDGK--HPYDGVTDP - .** ..: :* * .* * * *.:*** .: * **:**:*:* - -Aqui_model.pdb_ NYTGPKEGTKTHWKYLDENGKYSWIKAPRWRGKACEVGPLARYIIVYTKV -Desulfubrio_start.pdb_ KYT-----------KLDDKDHYSWMKAPRYKGKAMEVGPLARTFIAYAKG - :** **::.:***:****::*** ******* :*.*:* - -Aqui_model.pdb_ KQGHIKPTWVDELIVNQIDTVSKILNLPPEKWLPTTVGRTIARALEAQMS -Desulfubrio_start.pdb_ QPDFKK----------VVDMVLGKLSVP-ATALHSTLGRTAARGIETAIV - : .. * :* * *.:* . * :*:*** **.:*: : - -Aqui_model.pdb_ AHTNLYWMKKLYDNIKAGDTSVANMEKWDPSTWPKEAKGVGLTEAPRGAL -Desulfubrio_start.pdb_ CANMEKWIKEMADSGAKDNTLCA---KWE---MPEESKGVGLADAPRGSL - . . *:*:: *. .:* * **: *:*:*****::****:* - -Aqui_model.pdb_ GHWVIIKDGKVANYQCVVPTTWNGSPKDPKGQHGAFEESMIDTKVKVPEK -Desulfubrio_start.pdb_ SHWIRIKGKKIDNFQLVVPSTWNLGPRGPQGDKSPVEEALIGTPIADPKR - .**: **. *: *:* ***:*** .*:.*:*::...**::*.* : *:: - -Aqui_model.pdb_ PLEVLRGIHSFDPCLACSTH -Desulfubrio_start.pdb_ PVEILRTVHAFDPCIACGVH - *:*:** :*:****:**..* diff --git a/testsuite/MDAnalysisTests/data/test.dnd b/testsuite/MDAnalysisTests/data/test.dnd deleted file mode 100644 index 4acdcda8745..00000000000 --- a/testsuite/MDAnalysisTests/data/test.dnd +++ /dev/null @@ -1 +0,0 @@ -(Aqui_model.pdb_:0.2965,Desulfubrio_start.pdb_:0.2965); From 791e5a71e1a1d9c2abcbaa31c15edcc68931f724 Mon Sep 17 00:00:00 2001 From: Irfan Alibay Date: Wed, 27 May 2020 13:02:16 +0100 Subject: [PATCH 16/34] Update CHANGELOG --- package/CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/package/CHANGELOG b/package/CHANGELOG index b22b45dd6b9..0b91443e91d 100644 --- a/package/CHANGELOG +++ b/package/CHANGELOG @@ -133,6 +133,7 @@ Enhancements the capability to allow intermittent behaviour (PR #2256) Changes + * Removes duplicate `NUMBER_TO_ELEMENTS` table (Issue #2699) * Deprecated :class:`ProgressMeter` and replaced it with :class:`ProgressBar` using the tqdm package (Issue #928, PR #2617). Also fixes issue #2504. * Removed `details` from `ClusteringMethod`s (Issue #2575, PR #2620) From 0b560f54713447e6b45b980361b3f845906be493 Mon Sep 17 00:00:00 2001 From: Tyler Reddy Date: Wed, 27 May 2020 19:45:38 -0600 Subject: [PATCH 17/34] DOC: relnote for gh-2696. --- package/CHANGELOG | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package/CHANGELOG b/package/CHANGELOG index b22b45dd6b9..a282fab6ed0 100644 --- a/package/CHANGELOG +++ b/package/CHANGELOG @@ -17,7 +17,8 @@ mm/dd/yy richardjgowers, kain88-de, lilyminium, p-j-smith, bdice, joaomcteixeira PicoCentauri, davidercruz, jbarnoud, RMeli, IAlibay, mtiberti, CCook96, Yuan-Yu, xiki-tempula, HTian1997, Iv-Hristov, hmacdope, AnshulAngaria, ss62171, Luthaf, yuxuanzhuang, abhishandy, mlnance, shfrz, orbeckst, - wvandertoorn, cbouy, AmeyaHarmalkar, Oscuro-Phoenix, andrrizzi, WG150 + wvandertoorn, cbouy, AmeyaHarmalkar, Oscuro-Phoenix, andrrizzi, WG150, + tylerjereddy * 0.21.0 @@ -87,6 +88,7 @@ Fixes * Contact Analysis class respects PBC (Issue #2368) Enhancements + * vastly improved support for 32-bit Windows (PR #2696) * Added methods to compute the root-mean-square-inner-product of subspaces and the cumulative overlap of a vector in a subspace for PCA (PR #2613) * Added .frames and .times arrays to AnalysisBase (Issue #2661) From 401901133b3d95e355278092b9e440542ef2921f Mon Sep 17 00:00:00 2001 From: Irfan Alibay Date: Thu, 28 May 2020 03:18:23 +0100 Subject: [PATCH 18/34] More CHANGELOG details --- package/CHANGELOG | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package/CHANGELOG b/package/CHANGELOG index 0b91443e91d..5a87d761929 100644 --- a/package/CHANGELOG +++ b/package/CHANGELOG @@ -133,7 +133,8 @@ Enhancements the capability to allow intermittent behaviour (PR #2256) Changes - * Removes duplicate `NUMBER_TO_ELEMENTS` table (Issue #2699) + * Removes duplicate `NUMBER_TO_ELEMENTS` table from topology._elements, + `Z2SYMB` from topology.tables should now be used instead (Issue #2699) * Deprecated :class:`ProgressMeter` and replaced it with :class:`ProgressBar` using the tqdm package (Issue #928, PR #2617). Also fixes issue #2504. * Removed `details` from `ClusteringMethod`s (Issue #2575, PR #2620) From 41ebfdf5b12dc1753b99c177e4e7404b6e1288f2 Mon Sep 17 00:00:00 2001 From: Irfan Alibay Date: Sat, 30 May 2020 00:01:23 +0100 Subject: [PATCH 19/34] Change `align.fasta2select` default file behaviour (#2702) * Fixes #2701 * Change the behaviour of default alnfilename and treefilename in fasta2select to write to the current working directory, rather than where fastafilename or alnfilename were located respectively. * Adds tmpdir use to test_fasta2select_file to prevent persistent temporary files. * Some PEP8 changes (> 79 chars) around test_fasta2select_file. --- package/CHANGELOG | 2 ++ package/MDAnalysis/analysis/align.py | 8 ++++-- .../MDAnalysisTests/analysis/test_align.py | 27 +++++++++++-------- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/package/CHANGELOG b/package/CHANGELOG index b03db1587eb..e3c5e01ff44 100644 --- a/package/CHANGELOG +++ b/package/CHANGELOG @@ -135,6 +135,8 @@ Enhancements the capability to allow intermittent behaviour (PR #2256) Changes + * :meth:`align.fasta2select` now writes `alnfilename` and `treefilename` to + the current working directory (Issue #2701) * Removes duplicate `NUMBER_TO_ELEMENTS` table from topology._elements, `Z2SYMB` from topology.tables should now be used instead (Issue #2699) * Deprecated :class:`ProgressMeter` and replaced it with :class:`ProgressBar` using diff --git a/package/MDAnalysis/analysis/align.py b/package/MDAnalysis/analysis/align.py index c0bfe5eda66..a7ef3853242 100644 --- a/package/MDAnalysis/analysis/align.py +++ b/package/MDAnalysis/analysis/align.py @@ -1033,6 +1033,10 @@ def fasta2select(fastafilename, is_aligned=False, .. _ClustalW: http://www.clustal.org/ .. _STAMP: http://www.compbio.dundee.ac.uk/manuals/stamp.4.2/ + .. versionchanged:: 1.0.0 + Passing `alnfilename` or `treefilename` as `None` will create a file in + the current working directory. + """ if is_aligned: logger.info("Using provided alignment {}".format(fastafilename)) @@ -1042,10 +1046,10 @@ def fasta2select(fastafilename, is_aligned=False, else: if alnfilename is None: filepath, ext = os.path.splitext(fastafilename) - alnfilename = filepath + '.aln' + alnfilename = os.path.basename(filepath) + '.aln' if treefilename is None: filepath, ext = os.path.splitext(alnfilename) - treefilename = filepath + '.dnd' + treefilename = os.path.basename(filepath) + '.dnd' run_clustalw = Bio.Align.Applications.ClustalwCommandline( clustalw, infile=fastafilename, diff --git a/testsuite/MDAnalysisTests/analysis/test_align.py b/testsuite/MDAnalysisTests/analysis/test_align.py index 618f9e5466b..3c5ad8d0895 100644 --- a/testsuite/MDAnalysisTests/analysis/test_align.py +++ b/testsuite/MDAnalysisTests/analysis/test_align.py @@ -470,25 +470,29 @@ def test_fasta2select_aligned(self): """test align.fasta2select() on aligned FASTA (Issue 112)""" sel = align.fasta2select(self.seq, is_aligned=True) # length of the output strings, not residues or anything real... - assert len( - sel['reference']) == 30623, "selection string has unexpected length" + assert len(sel['reference']) == 30623, ("selection string has" + "unexpected length") assert len( sel['mobile']) == 30623, "selection string has unexpected length" @pytest.mark.skipif(executable_not_found("clustalw2"), reason="Test skipped because clustalw2 executable not found") def test_fasta2select_file(self, tmpdir): - sel = align.fasta2select(self.seq, is_aligned=False, - alnfilename=None, treefilename=None) - assert len( - sel['reference']) == 23080, "selection string has unexpected length" - assert len( - sel['mobile']) == 23090, "selection string has unexpected length" + """test align.fasta2select() on a non-aligned FASTA with default + filenames""" + with tmpdir.as_cwd(): + sel = align.fasta2select(self.seq, is_aligned=False, + alnfilename=None, treefilename=None) + assert len(sel['reference']) == 23080, ("selection string has" + "unexpected length") + assert len(sel['mobile']) == 23090, ("selection string has" + "unexpected length") @pytest.mark.skipif(executable_not_found("clustalw2"), reason="Test skipped because clustalw2 executable not found") def test_fasta2select_ClustalW(self, tmpdir): - """MDAnalysis.analysis.align: test fasta2select() with ClustalW (Issue 113)""" + """MDAnalysis.analysis.align: test fasta2select() with ClustalW + (Issue 113)""" alnfile = str(tmpdir.join('alignmentprocessing.aln')) treefile = str(tmpdir.join('alignmentprocessing.dnd')) sel = align.fasta2select(self.seq, is_aligned=False, @@ -496,11 +500,12 @@ def test_fasta2select_ClustalW(self, tmpdir): # numbers computed from alignment with clustalw 2.1 on Mac OS X # [orbeckst] length of the output strings, not residues or anything # real... - assert len( - sel['reference']) == 23080, "selection string has unexpected length" + assert len(sel['reference']) == 23080, ("selection string has" + "unexpected length") assert len( sel['mobile']) == 23090, "selection string has unexpected length" + def test_sequence_alignment(): u = mda.Universe(PSF) reference = u.atoms From 63680fe656aaa3a0732c3c67772524396135e2a6 Mon Sep 17 00:00:00 2001 From: Irfan Alibay Date: Sat, 30 May 2020 08:43:15 +0100 Subject: [PATCH 20/34] Adds documentation of exclusive behaviour Adds more documentation to detail that :class:`SurvivalProbability` now has an exclusive behaviour unike the other :mod:`MDAnalysis.analysis.waterdynamics` classes. --- package/MDAnalysis/analysis/waterdynamics.py | 23 ++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/package/MDAnalysis/analysis/waterdynamics.py b/package/MDAnalysis/analysis/waterdynamics.py index a3c86b5e13d..b7f0e206677 100644 --- a/package/MDAnalysis/analysis/waterdynamics.py +++ b/package/MDAnalysis/analysis/waterdynamics.py @@ -289,7 +289,7 @@ universe = MDAnalysis.Universe(pdb, trajectory) select = "byres name OH2 and sphzone 12.3 (resid 42 or resid 26) " sp = SP(universe, select, verbose=True) - sp.run(start=0, stop=100, tau_max=20) + sp.run(start=0, stop=101, tau_max=20) tau_timeseries = sp.tau_timeseries sp_timeseries = sp.sp_timeseries @@ -304,6 +304,12 @@ plt.plot(tau_timeseries, sp_timeseries) plt.show() +One should note that the `stop` keyword as used in the above example has an +`exclusive` behaviour, i.e. here the final frame used will be 100 not 101. +This behaviour is aligned with :class:`AnalysisBase` but currently differs from +other :mod:`MDAnalysis.analysis.waterdynamics` classes, which all exhibit +`inclusive` behaviour for their final frame selections. + Another example applies to the situation where you work with many different "residues". Here we calculate the SP of a potassium ion around each lipid in a membrane and average the results. In this example, if the SP analysis were run without treating each lipid @@ -1171,6 +1177,16 @@ class SurvivalProbability(object): When True, prints progress and comments to the console. + Notes + ----- + Currently :class:`SurvivalProbability` is the only on in + :mod:`MDAnalysis.analysis.waterdynamics` to support an `exclusive` + behaviour (i.e. similar to the current behaviour of :class:`AnalysisBase` + to the `stop` keyword passed to :meth:`SurvivalProbability.run`. Unlike + other :mod:`MDAnalysis.analysis.waterdynamics` final frame definitions + which are `inclusive`. + + .. versionadded:: 0.11.0 .. versionchanged:: 1.0.0 Using the MDAnalysis.lib.correlations.py to carry out the intermittency @@ -1178,7 +1194,10 @@ class SurvivalProbability(object): Changed `selection` keyword to `select`. Removed support for the deprecated `t0`, `tf`, and `dtmax` keywords. These should instead be passed to :meth:`SurvivalProbability.run` as - the `start`, `stop`, and `tau_max` keywords respectively. + the `start`, `stop`, and `tau_max` keywords respectively. + The `stop` keyword as passed to :meth:`SurvivalProbability.run` has now + changed behaviour and will act in an `exclusive` manner (instead of it's + previous `inclusive` behaviour), """ def __init__(self, universe, select, verbose=False): From 9b5d8d06a759136a79278f4549cd4522baceb715 Mon Sep 17 00:00:00 2001 From: Lily Wang Date: Mon, 1 Jun 2020 21:16:01 +1000 Subject: [PATCH 21/34] select dihedrals faster --- package/MDAnalysis/analysis/dihedrals.py | 111 ++++++++++++++------ package/MDAnalysis/core/topologyattrs.py | 123 ++++++++++++++++------- 2 files changed, 170 insertions(+), 64 deletions(-) diff --git a/package/MDAnalysis/analysis/dihedrals.py b/package/MDAnalysis/analysis/dihedrals.py index 8cb0a50be7a..8966ba30278 100644 --- a/package/MDAnalysis/analysis/dihedrals.py +++ b/package/MDAnalysis/analysis/dihedrals.py @@ -209,6 +209,7 @@ class Dihedral(AnalysisBase): it must be given as a list of one atomgroup. """ + def __init__(self, atomgroups, **kwargs): """Parameters ---------- @@ -221,7 +222,8 @@ def __init__(self, atomgroups, **kwargs): If any atomgroups do not contain 4 atoms """ - super(Dihedral, self).__init__(atomgroups[0].universe.trajectory, **kwargs) + super(Dihedral, self).__init__( + atomgroups[0].universe.trajectory, **kwargs) self.atomgroups = atomgroups if any([len(ag) != 4 for ag in atomgroups]): @@ -261,7 +263,9 @@ class Ramachandran(AnalysisBase): selection cannot be made, that residue will be removed from the analysis. """ - def __init__(self, atomgroup, **kwargs): + + def __init__(self, atomgroup, c_name='C', n_name='N', ca_name='CA', + **kwargs): """Parameters ---------- atomgroup : AtomGroup or ResidueGroup @@ -274,7 +278,8 @@ def __init__(self, atomgroup, **kwargs): If the selection of residues is not contained within the protein """ - super(Ramachandran, self).__init__(atomgroup.universe.trajectory, **kwargs) + super(Ramachandran, self).__init__( + atomgroup.universe.trajectory, **kwargs) self.atomgroup = atomgroup residues = self.atomgroup.residues protein = self.atomgroup.universe.select_atoms("protein").residues @@ -288,26 +293,71 @@ def __init__(self, atomgroup, **kwargs): "or last residues") residues = residues.difference(protein[[0, -1]]) - phi_sel = [res.phi_selection() for res in residues] - psi_sel = [res.psi_selection() for res in residues] - # phi_selection() and psi_selection() currently can't handle topologies - # with an altloc attribute so this removes any residues that have either - # angle return none instead of a value - if any(sel is None for sel in phi_sel): - warnings.warn("Some residues in selection do not have phi selections") - remove = [i for i, sel in enumerate(phi_sel) if sel is None] - phi_sel = [sel for i, sel in enumerate(phi_sel) if i not in remove] - psi_sel = [sel for i, sel in enumerate(psi_sel) if i not in remove] - if any(sel is None for sel in psi_sel): - warnings.warn("Some residues in selection do not have psi selections") - remove = [i for i, sel in enumerate(psi_sel) if sel is None] - phi_sel = [sel for i, sel in enumerate(phi_sel) if i not in remove] - psi_sel = [sel for i, sel in enumerate(psi_sel) if i not in remove] - self.ag1 = mda.AtomGroup([atoms[0] for atoms in phi_sel]) - self.ag2 = mda.AtomGroup([atoms[1] for atoms in phi_sel]) - self.ag3 = mda.AtomGroup([atoms[2] for atoms in phi_sel]) - self.ag4 = mda.AtomGroup([atoms[3] for atoms in phi_sel]) - self.ag5 = mda.AtomGroup([atoms[3] for atoms in psi_sel]) + # find previous/next residues + u = residues[0].universe + nxt = u.residues[residues.ix[:-1]+1] # residues is ordered + if residues[-1].ix != len(u.residues): + nxt += u.residues[residues[-1].ix+1] + else: + residues = residues[:-1] + prev = u.residues[residues.ix-1] + psid = residues.segids + prid = residues.resids-1 + nrid = residues.resids+1 + sel = 'segid {} and resid {}' + + # delete wrong ones + pix = np.where((prev.segids != psid) | (prev.resids != prid))[0] + nix = np.where((nxt.segids != psid) | (nxt.resids != nrid))[0] + delete = [] # probably better way to do this + if len(pix): + prevls = list(prev) + for s, r, p in zip(psid[pix], prid[pix], pix): + try: + prevls[p] = u.select_atoms(sel.format(s, r)).residues[0] + except IndexError: + delete.append(p) + prev = sum(prevls) + + if len(nix): + nxtls = list(nxt) + for s, r, n in zip(psid[nix], nrid[nix], nix): + try: + nxtls[n] = u.select_atoms(sel.format(s, r)).residues[0] + except IndexError: + delete.append(n) + nxt = sum(nxtls) + + if len(delete): + warnings.warn("Some residues in selection do not have " + "phi or psi selections") + keep = np.ones_like(residues, dtype=bool) + keep[delete] = False + prev = prev[keep] + nxt = nxt[keep] + residues = residues[keep] + + + # find n, c, ca + keep_prev = [sum(r.atoms.names==c_name)==1 for r in prev] + rnames = [n_name, c_name, ca_name] + keep_res = [all(sum(r.atoms.names==n)==1 for n in rnames) + for r in residues] + keep_next = [sum(r.atoms.names==n_name)==1 for r in nxt] + + # alright we'll keep these + keep = np.array(keep_prev) & np.array(keep_res) & np.array(keep_next) + prev = prev[keep] + res = residues[keep] + nxt = nxt[keep] + + rnames = res.atoms.names + self.ag1 = prev.atoms[prev.atoms.names == c_name] + self.ag2 = res.atoms[rnames == n_name] + self.ag3 = res.atoms[rnames == ca_name] + self.ag4 = res.atoms[rnames == c_name] + self.ag5 = nxt.atoms[nxt.atoms.names == n_name] + def _prepare(self): self.angles = [] @@ -325,7 +375,6 @@ def _single_frame(self): def _conclude(self): self.angles = np.rad2deg(np.array(self.angles)) - def plot(self, ax=None, ref=False, **kwargs): """Plots data into standard ramachandran plot. Each time step in :attr:`Ramachandran.angles` is plotted onto the same graph. @@ -348,20 +397,22 @@ def plot(self, ax=None, ref=False, **kwargs): """ if ax is None: ax = plt.gca() - ax.axis([-180,180,-180,180]) + ax.axis([-180, 180, -180, 180]) ax.axhline(0, color='k', lw=1) ax.axvline(0, color='k', lw=1) ax.set(xticks=range(-180, 181, 60), yticks=range(-180, 181, 60), xlabel=r"$\phi$ (deg)", ylabel=r"$\psi$ (deg)") if ref == True: - X, Y = np.meshgrid(np.arange(-180, 180, 4), np.arange(-180, 180, 4)) + X, Y = np.meshgrid(np.arange(-180, 180, 4), + np.arange(-180, 180, 4)) levels = [1, 17, 15000] colors = ['#A1D4FF', '#35A1FF'] ax.contourf(X, Y, np.load(Rama_ref), levels=levels, colors=colors) a = self.angles.reshape(np.prod(self.angles.shape[:2]), 2) - ax.scatter(a[:,0], a[:,1], **kwargs) + ax.scatter(a[:, 0], a[:, 1], **kwargs) return ax + class Janin(Ramachandran): """Calculate :math:`\chi_1` and :math:`\chi_2` dihedral angles of selected residues. @@ -380,6 +431,7 @@ class Janin(Ramachandran): selection and must be removed. """ + def __init__(self, atomgroup, **kwargs): """Parameters ---------- @@ -397,7 +449,8 @@ def __init__(self, atomgroup, **kwargs): selection, usually due to missing atoms or alternative locations """ - super(Ramachandran, self).__init__(atomgroup.universe.trajectory, **kwargs) + super(Ramachandran, self).__init__( + atomgroup.universe.trajectory, **kwargs) self.atomgroup = atomgroup residues = atomgroup.residues protein = atomgroup.universe.select_atoms("protein").residues @@ -463,5 +516,5 @@ def plot(self, ax=None, ref=False, **kwargs): colors = ['#A1D4FF', '#35A1FF'] ax.contourf(X, Y, np.load(Janin_ref), levels=levels, colors=colors) a = self.angles.reshape(np.prod(self.angles.shape[:2]), 2) - ax.scatter(a[:,0], a[:,1], **kwargs) + ax.scatter(a[:, 0], a[:, 1], **kwargs) return ax diff --git a/package/MDAnalysis/core/topologyattrs.py b/package/MDAnalysis/core/topologyattrs.py index c805be3734d..c6451f515c2 100644 --- a/package/MDAnalysis/core/topologyattrs.py +++ b/package/MDAnalysis/core/topologyattrs.py @@ -490,19 +490,30 @@ def phi_selection(residue): 4-atom selection in the correct order. If no C' found in the previous residue (by resid) then this method returns ``None``. """ - # TODO: maybe this can be reformulated into one selection string without - # the additions later - sel_str = "segid {} and resid {} and name C".format( - residue.segment.segid, residue.resid - 1) - sel = (residue.universe.select_atoms(sel_str) + - residue.atoms.select_atoms('name N', 'name CA', 'name C')) - - # select_atoms doesnt raise errors if nothing found, so check size - if len(sel) == 4: - return sel - else: + # fnmatch is expensive. try the obv candidate first + prev = residue.universe.residues[residue.ix-1] + sid = residue.segment.segid + rid = residue.resid-1 + if not (prev.segment.segid == sid and prev.resid == rid): + sel = 'segid {} and resid {}'.format(sid, rid) + try: + prev = residue.universe.select_atoms(sel).residues[0] + except IndexError: + return None + c_ = prev.atoms[prev.atoms.names == 'C'] + if not c_: return None + # ncac = residue.atoms.select_atoms('name N', 'name CA', 'name C') + ncac = residue.atoms[np.in1d(residue.atoms.names, ['N', 'CA', 'C'])] + if len(ncac) != 3: + return None + + # sel = c_+ncac + names = ncac.names + sel = c_+ncac[names == 'N']+ncac[names == 'CA']+ncac[names == 'C'] + return sel + transplants[Residue].append(('phi_selection', phi_selection)) def psi_selection(residue): @@ -515,17 +526,39 @@ def psi_selection(residue): 4-atom selection in the correct order. If no N' found in the following residue (by resid) then this method returns ``None``. """ - sel_str = "segid {} and resid {} and name N".format( - residue.segment.segid, residue.resid + 1) - sel = (residue.atoms.select_atoms('name N', 'name CA', 'name C') + - residue.universe.select_atoms(sel_str)) - - if len(sel) == 4: - return sel + # fnmatch is expensive. try the obv candidate first + _manual_sel = False + sid = residue.segment.segid + rid = residue.resid+1 + try: + nxt = residue.universe.residues[residue.ix+1] + except IndexError: + _manual_sel = True else: + if not (nxt.segment.segid == sid and nxt.resid == rid): + _manual_sel = True + + if _manual_sel: + sel = 'segid {} and resid {}'.format(sid, rid) + try: + nxt = residue.universe.select_atoms(sel).residues[0] + except IndexError: + return None + n_ = nxt.atoms[nxt.atoms.names == 'N'] + if not n_: return None + # ncac = residue.atoms.select_atoms('name N', 'name CA', 'name C') + ncac = residue.atoms[np.in1d(residue.atoms.names, ['N', 'CA', 'C'])] + if len(ncac) != 3: + return None + + # sel = c_+ncac + names = ncac.names + sel = ncac[names == 'N']+ncac[names == 'CA']+ncac[names == 'C']+n_ + return sel + transplants[Residue].append(('psi_selection', psi_selection)) def omega_selection(residue): @@ -830,9 +863,11 @@ def moment_of_inertia(group, pbc=False, **kwargs): unwrap = kwargs.pop('unwrap', False) compound = kwargs.pop('compound', 'group') - com = atomgroup.center_of_mass(pbc=pbc, unwrap=unwrap, compound=compound) + com = atomgroup.center_of_mass( + pbc=pbc, unwrap=unwrap, compound=compound) if compound != 'group': - com = (com * group.masses[:, None]).sum(axis=0) / group.masses.sum() + com = (com * group.masses[:, None] + ).sum(axis=0) / group.masses.sum() if pbc: pos = atomgroup.pack_into_box(inplace=False) - com @@ -942,7 +977,8 @@ def shape_parameter(group, pbc=False, **kwargs): recenteredpos[x, :]) tensor /= atomgroup.total_mass() eig_vals = np.linalg.eigvalsh(tensor) - shape = 27.0 * np.prod(eig_vals - np.mean(eig_vals)) / np.power(np.sum(eig_vals), 3) + shape = 27.0 * np.prod(eig_vals - np.mean(eig_vals) + ) / np.power(np.sum(eig_vals), 3) return shape @@ -985,9 +1021,11 @@ def asphericity(group, pbc=False, unwrap=None, compound='group'): atomgroup = group.atoms masses = atomgroup.masses - com = atomgroup.center_of_mass(pbc=pbc, unwrap=unwrap, compound=compound) + com = atomgroup.center_of_mass( + pbc=pbc, unwrap=unwrap, compound=compound) if compound != 'group': - com = (com * group.masses[:, None]).sum(axis=0) / group.masses.sum() + com = (com * group.masses[:, None] + ).sum(axis=0) / group.masses.sum() if pbc: recenteredpos = (atomgroup.pack_into_box(inplace=False) - com) @@ -1200,6 +1238,7 @@ class AltLocs(AtomAttr): def _gen_initial_values(na, nr, ns): return np.array(['' for _ in range(na)], dtype=object) + class GBScreens(AtomAttr): """Generalized Born screening factor""" attrname = 'gbscreens' @@ -1211,6 +1250,7 @@ class GBScreens(AtomAttr): def _gen_initial_values(na, nr, ns): return np.zeros(na) + class SolventRadii(AtomAttr): """Intrinsic solvation radius""" attrname = 'solventradii' @@ -1222,6 +1262,7 @@ class SolventRadii(AtomAttr): def _gen_initial_values(na, nr, ns): return np.zeros(na) + class NonbondedIndices(AtomAttr): """Nonbonded index (AMBER)""" attrname = 'nbindices' @@ -1232,7 +1273,8 @@ class NonbondedIndices(AtomAttr): @staticmethod def _gen_initial_values(na, nr, ns): return np.zeros(na, dtype=np.int32) - + + class RMins(AtomAttr): """The Rmin/2 LJ parameter""" attrname = 'rmins' @@ -1244,6 +1286,7 @@ class RMins(AtomAttr): def _gen_initial_values(na, nr, ns): return np.zeros(na) + class Epsilons(AtomAttr): """The epsilon LJ parameter""" attrname = 'epsilons' @@ -1255,6 +1298,7 @@ class Epsilons(AtomAttr): def _gen_initial_values(na, nr, ns): return np.zeros(na) + class RMin14s(AtomAttr): """The Rmin/2 LJ parameter for 1-4 interactions""" attrname = 'rmin14s' @@ -1266,6 +1310,7 @@ class RMin14s(AtomAttr): def _gen_initial_values(na, nr, ns): return np.zeros(na) + class Epsilon14s(AtomAttr): """The epsilon LJ parameter for 1-4 interactions""" attrname = 'epsilon14s' @@ -1277,6 +1322,7 @@ class Epsilon14s(AtomAttr): def _gen_initial_values(na, nr, ns): return np.zeros(na) + class ResidueAttr(TopologyAttr): attrname = 'residueattrs' singular = 'residueattr' @@ -1414,13 +1460,14 @@ def sequence(self, **kwargs): format = kwargs.pop("format", "SeqRecord") if format not in formats: raise TypeError("Unknown format='{0}': must be one of: {1}".format( - format, ", ".join(formats))) + format, ", ".join(formats))) try: - sequence = "".join([convert_aa_code(r) for r in self.residues.resnames]) + sequence = "".join([convert_aa_code(r) + for r in self.residues.resnames]) except KeyError as err: six.raise_from(ValueError("AtomGroup contains a residue name '{0}' that " - "does not have a IUPAC protein 1-letter " - "character".format(err.message)), None) + "does not have a IUPAC protein 1-letter " + "character".format(err.message)), None) if format == "string": return sequence seq = Bio.Seq.Seq(sequence) @@ -1478,6 +1525,7 @@ class Molnums(ResidueAttr): # segment attributes + class SegmentAttr(TopologyAttr): """Base class for segment attributes. @@ -1535,12 +1583,12 @@ def _check_connection_values(func): """ @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): + if not all(len(x) == self._n_atoms + 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]: @@ -1550,9 +1598,10 @@ def wrapper(self, values, *args, **kwargs): 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. """ @@ -1638,7 +1687,7 @@ 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): """ @@ -1668,6 +1717,7 @@ def _delete_bonds(self, values): except KeyError: pass + class Bonds(_Connection): """Bonds between two atoms @@ -1820,6 +1870,7 @@ def n_fragments(self): ('n_fragments', property(n_fragments, None, None, n_fragments.__doc__))) + class UreyBradleys(_Connection): """Angles between two atoms @@ -1834,6 +1885,7 @@ class UreyBradleys(_Connection): transplants = defaultdict(list) _n_atoms = 2 + class Angles(_Connection): """Angles between three atoms @@ -1863,6 +1915,7 @@ class Impropers(_Connection): transplants = defaultdict(list) _n_atoms = 4 + class CMaps(_Connection): """ A connection between five atoms From 56fa4e84bcaae36b414d5c473e45bd56a2000416 Mon Sep 17 00:00:00 2001 From: Lily Wang Date: Tue, 2 Jun 2020 00:58:35 +1000 Subject: [PATCH 22/34] added residuegroup dihedrals and refactored residue dihedrals --- package/MDAnalysis/core/topologyattrs.py | 383 ++++++++++++++++-- .../MDAnalysisTests/core/test_atomgroup.py | 208 ++++++++-- .../MDAnalysisTests/core/test_residuegroup.py | 7 + 3 files changed, 537 insertions(+), 61 deletions(-) diff --git a/package/MDAnalysis/core/topologyattrs.py b/package/MDAnalysis/core/topologyattrs.py index c6451f515c2..90afc138aa1 100644 --- a/package/MDAnalysis/core/topologyattrs.py +++ b/package/MDAnalysis/core/topologyattrs.py @@ -480,15 +480,28 @@ class Atomnames(AtomAttr): def _gen_initial_values(na, nr, ns): return np.array(['' for _ in range(na)], dtype=object) - def phi_selection(residue): - """AtomGroup corresponding to the phi protein backbone dihedral + def phi_selection(residue, c_name='C', n_name='N', ca_name='CA'): + """Select AtomGroup corresponding to the phi protein backbone dihedral C'-N-CA-C. + Parameters + ---------- + c_name: str (optional) + name for the backbone C atom + n_name: str (optional) + name for the backbone N atom + ca_name: str (optional) + name for the alpha-carbon atom + Returns ------- AtomGroup 4-atom selection in the correct order. If no C' found in the previous residue (by resid) then this method returns ``None``. + + .. versionchanged:: 1.0.0 + Added arguments for flexible atom names and refactored code for + faster atom matching with boolean arrays. """ # fnmatch is expensive. try the obv candidate first prev = residue.universe.residues[residue.ix-1] @@ -500,31 +513,106 @@ def phi_selection(residue): prev = residue.universe.select_atoms(sel).residues[0] except IndexError: return None - c_ = prev.atoms[prev.atoms.names == 'C'] - if not c_: + c_ = prev.atoms[prev.atoms.names == c_name] + if not len(c_) == 1: return None - # ncac = residue.atoms.select_atoms('name N', 'name CA', 'name C') - ncac = residue.atoms[np.in1d(residue.atoms.names, ['N', 'CA', 'C'])] - if len(ncac) != 3: + atnames = residue.atoms.names + ncac_names = [n_name, ca_name, c_name] + ncac = [residue.atoms[atnames == n] for n in ncac_names] + if not all(len(ag) == 1 for ag in ncac): return None - # sel = c_+ncac - names = ncac.names - sel = c_+ncac[names == 'N']+ncac[names == 'CA']+ncac[names == 'C'] + sel = c_+sum(ncac) return sel transplants[Residue].append(('phi_selection', phi_selection)) - def psi_selection(residue): - """AtomGroup corresponding to the psi protein backbone dihedral + def phi_selections(residues, c_name='C', n_name='N', ca_name='CA'): + """Select list of AtomGroups corresponding to the phi protein + backbone dihedral C'-N-CA-C. + + Parameters + ---------- + c_name: str (optional) + name for the backbone C atom + n_name: str (optional) + name for the backbone N atom + ca_name: str (optional) + name for the alpha-carbon atom + + Returns + ------- + list of AtomGroups + 4-atom selections in the correct order. If no C' found in the + previous residue (by resid) then corresponding item in the list + is ``None``. + + .. versionadded:: 1.0.0 + """ + + u = residues[0].universe + prev = u.residues[residues.ix-1] # obv candidates first + rsid = residues.segids + prid = residues.resids-1 + ncac_names = [n_name, ca_name, c_name] + sel = 'segid {} and resid {}' + + # replace wrong residues + wix = np.where((prev.segids != rsid) | (prev.resids != prid))[0] + invalid = [] + if len(wix): + prevls = list(prev) + for s, r, i in zip(rsid[wix], prid[wix], wix): + try: + prevls[i] = u.select_atoms(sel.format(s, r)).residues[0] + except IndexError: + invalid.append(i) + prev = sum(prevls) + + keep_prev = [sum(r.atoms.names == c_name) == 1 for r in prev] + keep_res = [all(sum(r.atoms.names == n) == 1 for n in ncac_names) + for r in residues] + keep = np.array(keep_prev) & np.array(keep_res) + keep[invalid] = False + results = np.zeros_like(residues, dtype=object) + results[~keep] = None + prev = prev[keep] + residues = residues[keep] + keepix = np.where(keep)[0] + + c_ = prev.atoms[prev.atoms.names == c_name] + n = residues.atoms[residues.atoms.names == n_name] + ca = residues.atoms[residues.atoms.names == ca_name] + c = residues.atoms[residues.atoms.names == c_name] + results[keepix] = [sum(atoms) for atoms in zip(c_, n, ca, c)] + return list(results) + + transplants[ResidueGroup].append(('phi_selections', phi_selections)) + + + def psi_selection(residue, c_name='C', n_name='N', ca_name='CA'): + """Select AtomGroup corresponding to the psi protein backbone dihedral N-CA-C-N'. + Parameters + ---------- + c_name: str (optional) + name for the backbone C atom + n_name: str (optional) + name for the backbone N atom + ca_name: str (optional) + name for the alpha-carbon atom + Returns ------- AtomGroup 4-atom selection in the correct order. If no N' found in the following residue (by resid) then this method returns ``None``. + + .. versionchanged:: 1.0.0 + Added arguments for flexible atom names and refactored code for + faster atom matching with boolean arrays. """ # fnmatch is expensive. try the obv candidate first @@ -545,52 +633,232 @@ def psi_selection(residue): nxt = residue.universe.select_atoms(sel).residues[0] except IndexError: return None - n_ = nxt.atoms[nxt.atoms.names == 'N'] - if not n_: + n_ = nxt.atoms[nxt.atoms.names == n_name] + if not len(n_) == 1: return None - # ncac = residue.atoms.select_atoms('name N', 'name CA', 'name C') - ncac = residue.atoms[np.in1d(residue.atoms.names, ['N', 'CA', 'C'])] - if len(ncac) != 3: + atnames = residue.atoms.names + ncac_names = [n_name, ca_name, c_name] + ncac = [residue.atoms[atnames == n] for n in ncac_names] + if not all(len(ag) == 1 for ag in ncac): return None - # sel = c_+ncac - names = ncac.names - sel = ncac[names == 'N']+ncac[names == 'CA']+ncac[names == 'C']+n_ + sel = sum(ncac) + n_ return sel transplants[Residue].append(('psi_selection', psi_selection)) - def omega_selection(residue): - """AtomGroup corresponding to the omega protein backbone dihedral + def _get_next_residues_by_resid(residues): + """Select list of Residues corresponding to the next resid for each + residue in `residues`. + + Returns + ------- + List of Residues + The next residue in Universe. If not found, the corresponding + item in the list is ``None``. + + .. versionadded:: 1.0.0 + """ + u = residues[0].universe + nxres = rview = np.array([None]*len(residues), dtype=object) + # no guarantee residues is ordered or unique + last = max(residues.ix) + if last == len(u.residues)-1: + notlast = residues.ix != last + rview = nxres[notlast] + residues = residues[notlast] + + rview[:] = nxt = u.residues[residues.ix+1] + rsid = residues.segids + nrid = residues.resids+1 + sel = 'segid {} and resid {}' + invalid = [] + + # replace wrong residues + wix = np.where((nxt.segids != rsid) | (nxt.resids != nrid))[0] + if len(wix): + for s, r, i in zip(rsid[wix], nrid[wix], wix): + try: + rview[i] = u.select_atoms(sel.format(s, r)).residues[0] + except IndexError: + rview[i] = None + return nxres + + transplants[ResidueGroup].append(('_get_next_residues_by_resid', + _get_next_residues_by_resid)) + + def psi_selections(residues, c_name='C', n_name='N', ca_name='CA'): + """Select list of AtomGroups corresponding to the psi protein + backbone dihedral N-CA-C-N'. + + Parameters + ---------- + c_name: str (optional) + name for the backbone C atom + n_name: str (optional) + name for the backbone N atom + ca_name: str (optional) + name for the alpha-carbon atom + + Returns + ------- + List of AtomGroups + 4-atom selections in the correct order. If no N' found in the + following residue (by resid) then the corresponding item in the + list is ``None``. + + .. versionadded:: 1.0.0 + """ + results = np.array([None]*len(residues), dtype=object) + nxtres = residues._get_next_residues_by_resid() + rix = np.where(nxtres)[0] + nxt = sum(nxtres[rix]) + residues = residues[rix] + ncac_names = [n_name, ca_name, c_name] + + keep_nxt = [sum(r.atoms.names == n_name) == 1 for r in nxt] + keep_res = [all(sum(r.atoms.names == n) == 1 for n in ncac_names) + for r in residues] + keep = np.array(keep_nxt) & np.array(keep_res) + nxt = nxt[keep] + residues = residues[keep] + keepix = np.where(keep)[0] + + n = residues.atoms[residues.atoms.names == n_name] + ca = residues.atoms[residues.atoms.names == ca_name] + c = residues.atoms[residues.atoms.names == c_name] + n_ = nxt.atoms[nxt.atoms.names == n_name] + results[rix[keepix]] = [sum(atoms) for atoms in zip(n, ca, c, n_)] + return list(results) + + transplants[ResidueGroup].append(('psi_selections', psi_selections)) + + def omega_selection(residue, c_name='C', n_name='N', ca_name='CA'): + """Select AtomGroup corresponding to the omega protein backbone dihedral CA-C-N'-CA'. omega describes the -C-N- peptide bond. Typically, it is trans (180 degrees) although cis-bonds (0 degrees) are also occasionally observed (especially near Proline). + Parameters + ---------- + c_name: str (optional) + name for the backbone C atom + n_name: str (optional) + name for the backbone N atom + ca_name: str (optional) + name for the alpha-carbon atom + Returns ------- AtomGroup 4-atom selection in the correct order. If no C' found in the previous residue (by resid) then this method returns ``None``. + .. versionchanged:: 1.0.0 + Added arguments for flexible atom names and refactored code for + faster atom matching with boolean arrays. """ - nextres = residue.resid + 1 - segid = residue.segment.segid - sel = (residue.atoms.select_atoms('name CA', 'name C') + - residue.universe.select_atoms( - 'segid {} and resid {} and name N'.format(segid, nextres), - 'segid {} and resid {} and name CA'.format(segid, nextres))) - if len(sel) == 4: - return sel + # fnmatch is expensive. try the obv candidate first + _manual_sel = False + sid = residue.segment.segid + rid = residue.resid+1 + try: + nxt = residue.universe.residues[residue.ix+1] + except IndexError: + _manual_sel = True else: + if not (nxt.segment.segid == sid and nxt.resid == rid): + _manual_sel = True + + if _manual_sel: + sel = 'segid {} and resid {}'.format(sid, rid) + try: + nxt = residue.universe.select_atoms(sel).residues[0] + except IndexError: + return None + + ca = residue.atoms[residue.atoms.names == ca_name] + c = residue.atoms[residue.atoms.names == c_name] + n_ = nxt.atoms[nxt.atoms.names == n_name] + ca_ = nxt.atoms[nxt.atoms.names == ca_name] + + if not all(len(ag) == 1 for ag in [ca_, n_, ca, c]): return None + return ca+c+n_+ca_ + transplants[Residue].append(('omega_selection', omega_selection)) - def chi1_selection(residue): - """AtomGroup corresponding to the chi1 sidechain dihedral N-CA-CB-CG. + def omega_selections(residues, c_name='C', n_name='N', ca_name='CA'): + """Select list of AtomGroups corresponding to the omega protein + backbone dihedral CA-C-N'-CA'. + + omega describes the -C-N- peptide bond. Typically, it is trans (180 + degrees) although cis-bonds (0 degrees) are also occasionally observed + (especially near Proline). + + Parameters + ---------- + c_name: str (optional) + name for the backbone C atom + n_name: str (optional) + name for the backbone N atom + ca_name: str (optional) + name for the alpha-carbon atom + + Returns + ------- + List of AtomGroups + 4-atom selections in the correct order. If no C' found in the + previous residue (by resid) then the corresponding item in the + list is ``None``. + + .. versionadded:: 1.0.0 + """ + results = np.array([None]*len(residues), dtype=object) + nxtres = residues._get_next_residues_by_resid() + rix = np.where(nxtres)[0] + nxt = sum(nxtres[rix]) + residues = residues[rix] + + nxtatoms = [ca_name, n_name] + resatoms = [ca_name, c_name] + keep_nxt = [all(sum(r.atoms.names == n) == 1 for n in nxtatoms) + for r in nxt] + keep_res = [all(sum(r.atoms.names == n) == 1 for n in resatoms) + for r in residues] + keep = np.array(keep_nxt) & np.array(keep_res) + nxt = nxt[keep] + residues = residues[keep] + keepix = np.where(keep)[0] + + c = residues.atoms[residues.atoms.names == c_name] + ca = residues.atoms[residues.atoms.names == ca_name] + n_ = nxt.atoms[nxt.atoms.names == n_name] + ca_ = nxt.atoms[nxt.atoms.names == ca_name] + + results[rix[keepix]] = [sum(atoms) for atoms in zip(ca, c, n_, ca_)] + return list(results) + + transplants[ResidueGroup].append(('omega_selections', omega_selections)) + + def chi1_selection(residue, n_name='N', ca_name='CA', cb_name='CB', + cg_name='CG'): + """Select AtomGroup corresponding to the chi1 sidechain dihedral N-CA-CB-CG. + + Parameters + ---------- + c_name: str (optional) + name for the backbone C atom + ca_name: str (optional) + name for the alpha-carbon atom + cb_name: str (optional) + name for the beta-carbon atom + cg_name: str (optional) + name for the gamma-carbon atom Returns ------- @@ -598,17 +866,58 @@ def chi1_selection(residue): 4-atom selection in the correct order. If no CB and/or CG is found then this method returns ``None``. + .. versionchanged:: 1.0.0 + Added arguments for flexible atom names and refactored code for + faster atom matching with boolean arrays. + .. versionadded:: 0.7.5 """ - ag = residue.atoms.select_atoms('name N', 'name CA', - 'name CB', 'name CG') - if len(ag) == 4: - return ag - else: + names = [n_name, ca_name, cb_name, cg_name] + ags = [residue.atoms[residue.atoms.names == n] for n in names] + if any(len(ag)!= 1 for ag in ags): return None + return sum(ags) transplants[Residue].append(('chi1_selection', chi1_selection)) + def chi1_selections(residues, n_name='N', ca_name='CA', cb_name='CB', + cg_name='CG'): + """Select list of AtomGroups corresponding to the chi1 sidechain dihedral + N-CA-CB-CG. + + Parameters + ---------- + c_name: str (optional) + name for the backbone C atom + ca_name: str (optional) + name for the alpha-carbon atom + cb_name: str (optional) + name for the beta-carbon atom + cg_name: str (optional) + name for the gamma-carbon atom + + Returns + ------- + List of AtomGroups + 4-atom selections in the correct order. If no CB and/or CG is found + then the corresponding item in the list is ``None``. + + .. versionadded:: 1.0.0 + """ + results = np.array([None]*len(residues)) + names = [n_name, ca_name, cb_name, cg_name] + keep = [all(sum(r.atoms.names == n) == 1 for n in names) + for r in residues] + keepix = np.where(keep)[0] + residues = residues[keep] + + atnames = residues.atoms.names + ags = [residues.atoms[atnames == n] for n in names] + results[keepix] = [sum(atoms) for atoms in zip(*ags)] + return list(results) + + transplants[ResidueGroup].append(('chi1_selections', chi1_selections)) + # TODO: update docs to property doc class Atomtypes(AtomAttr): diff --git a/testsuite/MDAnalysisTests/core/test_atomgroup.py b/testsuite/MDAnalysisTests/core/test_atomgroup.py index 881a4818621..24f8a0bd524 100644 --- a/testsuite/MDAnalysisTests/core/test_atomgroup.py +++ b/testsuite/MDAnalysisTests/core/test_atomgroup.py @@ -744,51 +744,211 @@ def test_adding_empty_ags(self): class TestDihedralSelections(object): dih_prec = 2 + @staticmethod + @pytest.fixture(scope='module') + def GRO(): + return mda.Universe(GRO) + @staticmethod @pytest.fixture(scope='module') def PSFDCD(): return mda.Universe(PSF, DCD) - def test_phi_selection(self, PSFDCD): - phisel = PSFDCD.segments[0].residues[9].phi_selection() + @staticmethod + @pytest.fixture(scope='class') + def resgroup(GRO): + return GRO.segments[0].residues[8:10] + + def test_phi_selection(self, GRO): + phisel = GRO.segments[0].residues[9].phi_selection() assert_equal(phisel.names, ['C', 'N', 'CA', 'C']) assert_equal(phisel.residues.resids, [9, 10]) assert_equal(phisel.residues.resnames, ['PRO', 'GLY']) - def test_psi_selection(self, PSFDCD): - psisel = PSFDCD.segments[0].residues[9].psi_selection() + @pytest.mark.parametrize('kwargs,names', [ + ({'c_name': 'O'}, ['O', 'N', 'CA', 'O']), + ({'n_name': 'O'}, ['C', 'O', 'CA', 'C']), + ({'ca_name': 'O'}, ['C', 'N', 'O', 'C']) + ]) + def test_phi_selection_name(self, GRO, kwargs, names): + phisel = GRO.segments[0].residues[9].phi_selection(**kwargs) + assert_equal(phisel.names, names) + assert_equal(phisel.residues.resids, [9, 10]) + assert_equal(phisel.residues.resnames, ['PRO', 'GLY']) + + def test_phi_selections_single(self, GRO): + rgsel = GRO.segments[0].residues[[9]].phi_selections() + assert len(rgsel) == 1 + phisel = rgsel[0] + assert_equal(phisel.names, ['C', 'N', 'CA', 'C']) + assert_equal(phisel.residues.resids, [9, 10]) + assert_equal(phisel.residues.resnames, ['PRO', 'GLY']) + + def test_phi_selections(self, resgroup): + rgsel = resgroup.phi_selections() + rssel = [r.phi_selection() for r in resgroup] + assert_equal(rgsel, rssel) + + @pytest.mark.parametrize('kwargs,names', [ + ({'c_name': 'O'}, ['O', 'N', 'CA', 'O']), + ({'n_name': 'O'}, ['C', 'O', 'CA', 'C']), + ({'ca_name': 'O'}, ['C', 'N', 'O', 'C']) + ]) + def test_phi_selections_name(self, resgroup, kwargs, names): + rgsel = resgroup.phi_selections(**kwargs) + for ag in rgsel: + assert_equal(ag.names, names) + + def test_psi_selection(self, GRO): + psisel = GRO.segments[0].residues[9].psi_selection() assert_equal(psisel.names, ['N', 'CA', 'C', 'N']) assert_equal(psisel.residues.resids, [10, 11]) assert_equal(psisel.residues.resnames, ['GLY', 'ALA']) - def test_omega_selection(self, PSFDCD): - osel = PSFDCD.segments[0].residues[7].omega_selection() + @pytest.mark.parametrize('kwargs,names', [ + ({'c_name': 'O'}, ['N', 'CA', 'O', 'N']), + ({'n_name': 'O'}, ['O', 'CA', 'C', 'O']), + ({'ca_name': 'O'}, ['N', 'O', 'C', 'N']), + ]) + def test_psi_selection_name(self, GRO, kwargs, names): + psisel = GRO.segments[0].residues[9].psi_selection(**kwargs) + assert_equal(psisel.names, names) + assert_equal(psisel.residues.resids, [10, 11]) + assert_equal(psisel.residues.resnames, ['GLY', 'ALA']) + + def test_psi_selections_single(self, GRO): + rgsel = GRO.segments[0].residues[[9]].psi_selections() + assert len(rgsel) == 1 + psisel = rgsel[0] + assert_equal(psisel.names, ['N', 'CA', 'C', 'N']) + assert_equal(psisel.residues.resids, [10, 11]) + assert_equal(psisel.residues.resnames, ['GLY', 'ALA']) + + def test_psi_selections(self, resgroup): + rgsel = resgroup.psi_selections() + rssel = [r.psi_selection() for r in resgroup] + assert_equal(rgsel, rssel) + + @pytest.mark.parametrize('kwargs,names', [ + ({'c_name': 'O'}, ['N', 'CA', 'O', 'N']), + ({'n_name': 'O'}, ['O', 'CA', 'C', 'O']), + ({'ca_name': 'O'}, ['N', 'O', 'C', 'N']), + ]) + def test_psi_selections_name(self, resgroup, kwargs, names): + rgsel = resgroup.psi_selections(**kwargs) + for ag in rgsel: + assert_equal(ag.names, names) + + def test_omega_selection(self, GRO): + osel = GRO.segments[0].residues[7].omega_selection() assert_equal(osel.names, ['CA', 'C', 'N', 'CA']) assert_equal(osel.residues.resids, [8, 9]) assert_equal(osel.residues.resnames, ['ALA', 'PRO']) - def test_chi1_selection(self, PSFDCD): - sel = PSFDCD.segments[0].residues[12].chi1_selection() # LYS + @pytest.mark.parametrize('kwargs,names', [ + ({'c_name': 'O'}, ['CA', 'O', 'N', 'CA']), + ({'n_name': 'O'}, ['CA', 'C', 'O', 'CA']), + ({'ca_name': 'O'}, ['O', 'C', 'N', 'O']), + ]) + def test_omega_selection_name(self, GRO, kwargs, names): + osel = GRO.segments[0].residues[7].omega_selection(**kwargs) + assert_equal(osel.names, names) + assert_equal(osel.residues.resids, [8, 9]) + assert_equal(osel.residues.resnames, ['ALA', 'PRO']) + + def test_omega_selections_single(self, GRO): + rgsel = GRO.segments[0].residues[[7]].omega_selections() + assert len(rgsel) == 1 + osel = rgsel[0] + assert_equal(osel.names, ['CA', 'C', 'N', 'CA']) + assert_equal(osel.residues.resids, [8, 9]) + assert_equal(osel.residues.resnames, ['ALA', 'PRO']) + + def test_omega_selections(self, resgroup): + rgsel = resgroup.omega_selections() + rssel = [r.omega_selection() for r in resgroup] + assert_equal(rgsel, rssel) + + @pytest.mark.parametrize('kwargs,names', [ + ({'c_name': 'O'}, ['CA', 'O', 'N', 'CA']), + ({'n_name': 'O'}, ['CA', 'C', 'O', 'CA']), + ({'ca_name': 'O'}, ['O', 'C', 'N', 'O']), + ]) + def test_omega_selections_name(self, resgroup, kwargs, names): + rgsel = resgroup.omega_selections(**kwargs) + for ag in rgsel: + assert_equal(ag.names, names) + + def test_chi1_selection(self, GRO): + sel = GRO.segments[0].residues[12].chi1_selection() # LYS + assert_equal(sel.names, ['N', 'CA', 'CB', 'CG']) + assert_equal(sel.residues.resids, [13]) + assert_equal(sel.residues.resnames, ['LYS']) + + @pytest.mark.parametrize('kwargs,names', [ + ({'n_name': 'O'}, ['O', 'CA', 'CB', 'CG']), + ({'ca_name': 'O'}, ['N', 'O', 'CB', 'CG']), + ({'cb_name': 'O'}, ['N', 'CA', 'O', 'CG']), + ({'cg_name': 'O'}, ['N', 'CA', 'CB', 'O']), + ]) + def test_chi1_selection_name(self, GRO, kwargs, names): + sel = GRO.segments[0].residues[12].chi1_selection(**kwargs) # LYS + assert_equal(sel.names, names) + assert_equal(sel.residues.resids, [13]) + assert_equal(sel.residues.resnames, ['LYS']) + + def test_chi1_selections_single(self, GRO): + rgsel = GRO.segments[0].residues[[12]].chi1_selections() + assert len(rgsel) == 1 + sel = rgsel[0] assert_equal(sel.names, ['N', 'CA', 'CB', 'CG']) assert_equal(sel.residues.resids, [13]) assert_equal(sel.residues.resnames, ['LYS']) - def test_phi_sel_fail(self, PSFDCD): - sel = PSFDCD.residues[0].phi_selection() + def test_chi1_selections(self, resgroup): + rgsel = resgroup.chi1_selections() + rssel = [r.chi1_selection() for r in resgroup] + assert_equal(rgsel, rssel) + + def test_phi_sel_fail(self, GRO): + sel = GRO.residues[0].phi_selection() assert sel is None - def test_psi_sel_fail(self, PSFDCD): - sel = PSFDCD.residues[-1].psi_selection() + def test_phi_sels_fail(self, GRO): + rgsel = GRO.residues[212:216].phi_selections() + assert rgsel[0] is not None + assert rgsel[1] is not None + assert_equal(rgsel[-2:], [None, None]) + + def test_psi_sel_fail(self, GRO): + sel = GRO.residues[-1].psi_selection() assert sel is None - def test_omega_sel_fail(self, PSFDCD): - sel = PSFDCD.residues[-1].omega_selection() + def test_psi_sels_fail(self, GRO): + rgsel = GRO.residues[211:215].psi_selections() + assert rgsel[0] is not None + assert rgsel[1] is not None + assert_equal(rgsel[-2:], [None, None]) + + def test_omega_sel_fail(self, GRO): + sel = GRO.residues[-1].omega_selection() assert sel is None - def test_ch1_sel_fail(self, PSFDCD): - sel = PSFDCD.segments[0].residues[7].chi1_selection() + def test_omega_sels_fail(self, GRO): + rgsel = GRO.residues[211:215].omega_selections() + assert rgsel[0] is not None + assert rgsel[1] is not None + assert_equal(rgsel[-2:], [None, None]) + + def test_ch1_sel_fail(self, GRO): + sel = GRO.segments[0].residues[7].chi1_selection() assert sel is None # ALA + def test_chi1_sels_fail(self, GRO): + rgsel = GRO.residues[12:14].chi1_selections() + assert rgsel[0] is not None + assert rgsel[1] is None + def test_dihedral_phi(self, PSFDCD): phisel = PSFDCD.segments[0].residues[9].phi_selection() assert_almost_equal(phisel.dihedral.value(), -168.57384, self.dih_prec) @@ -805,21 +965,21 @@ def test_dihedral_chi1(self, PSFDCD): sel = PSFDCD.segments[0].residues[12].chi1_selection() # LYS assert_almost_equal(sel.dihedral.value(), -58.428127, self.dih_prec) - def test_phi_nodep(self, PSFDCD): + def test_phi_nodep(self, GRO): with no_deprecated_call(): - phisel = PSFDCD.segments[0].residues[9].phi_selection() + phisel = GRO.segments[0].residues[9].phi_selection() - def test_psi_nodep(self, PSFDCD): + def test_psi_nodep(self, GRO): with no_deprecated_call(): - psisel = PSFDCD.segments[0].residues[9].psi_selection() + psisel = GRO.segments[0].residues[9].psi_selection() - def test_omega_nodep(self, PSFDCD): + def test_omega_nodep(self, GRO): with no_deprecated_call(): - osel = PSFDCD.segments[0].residues[7].omega_selection() + osel = GRO.segments[0].residues[7].omega_selection() - def test_chi1_nodep(self, PSFDCD): + def test_chi1_nodep(self, GRO): with no_deprecated_call(): - sel = PSFDCD.segments[0].residues[12].chi1_selection() # LYS + sel = GRO.segments[0].residues[12].chi1_selection() # LYS class TestUnwrapFlag(object): diff --git a/testsuite/MDAnalysisTests/core/test_residuegroup.py b/testsuite/MDAnalysisTests/core/test_residuegroup.py index 4adcb3a1250..81fbd5cf680 100644 --- a/testsuite/MDAnalysisTests/core/test_residuegroup.py +++ b/testsuite/MDAnalysisTests/core/test_residuegroup.py @@ -265,3 +265,10 @@ def test_set_masses(self, universe): def test_atom_order(self, universe): assert_equal(universe.residues.atoms.indices, sorted(universe.residues.atoms.indices)) + + def test_get_next_residue(self, rg): + unsorted_rep_res = rg[[0, 1, 8, 3, 4, 0, 3, 1]] + next_res = sum(unsorted_rep_res._get_next_residues_by_resid()) + resids = unsorted_rep_res.resids+1 + assert_equal(len(next_res), len(unsorted_rep_res)) + assert_equal(next_res.resids, resids) From fb29974183ffd91360c3e6f123012d51f622c574 Mon Sep 17 00:00:00 2001 From: Lily Wang Date: Tue, 2 Jun 2020 01:57:05 +1000 Subject: [PATCH 23/34] updated Rama and added function to get previous residues --- package/MDAnalysis/analysis/dihedrals.py | 84 +++++++------------ package/MDAnalysis/core/topologyattrs.py | 51 +++++++++-- .../analysis/test_dihedrals.py | 13 ++- .../MDAnalysisTests/core/test_residuegroup.py | 19 ++++- 4 files changed, 98 insertions(+), 69 deletions(-) diff --git a/package/MDAnalysis/analysis/dihedrals.py b/package/MDAnalysis/analysis/dihedrals.py index 8966ba30278..cfb49b042dd 100644 --- a/package/MDAnalysis/analysis/dihedrals.py +++ b/package/MDAnalysis/analysis/dihedrals.py @@ -265,12 +265,21 @@ class Ramachandran(AnalysisBase): """ def __init__(self, atomgroup, c_name='C', n_name='N', ca_name='CA', - **kwargs): + check_protein=True, **kwargs): """Parameters ---------- atomgroup : AtomGroup or ResidueGroup atoms for residues for which :math:`\phi` and :math:`\psi` are calculated + c_name: str (optional) + name for the backbone C atom + n_name: str (optional) + name for the backbone N atom + ca_name: str (optional) + name for the alpha-carbon atom + check_protein: bool (optional) + whether to raise an error if the provided atomgroup is not a + subset of protein atoms Raises ------ @@ -282,61 +291,30 @@ def __init__(self, atomgroup, c_name='C', n_name='N', ca_name='CA', atomgroup.universe.trajectory, **kwargs) self.atomgroup = atomgroup residues = self.atomgroup.residues - protein = self.atomgroup.universe.select_atoms("protein").residues - if not residues.issubset(protein): - raise ValueError("Found atoms outside of protein. Only atoms " - "inside of a 'protein' selection can be used to " - "calculate dihedrals.") - elif not residues.isdisjoint(protein[[0, -1]]): - warnings.warn("Cannot determine phi and psi angles for the first " - "or last residues") - residues = residues.difference(protein[[0, -1]]) - - # find previous/next residues - u = residues[0].universe - nxt = u.residues[residues.ix[:-1]+1] # residues is ordered - if residues[-1].ix != len(u.residues): - nxt += u.residues[residues[-1].ix+1] - else: - residues = residues[:-1] - prev = u.residues[residues.ix-1] - psid = residues.segids - prid = residues.resids-1 - nrid = residues.resids+1 - sel = 'segid {} and resid {}' - - # delete wrong ones - pix = np.where((prev.segids != psid) | (prev.resids != prid))[0] - nix = np.where((nxt.segids != psid) | (nxt.resids != nrid))[0] - delete = [] # probably better way to do this - if len(pix): - prevls = list(prev) - for s, r, p in zip(psid[pix], prid[pix], pix): - try: - prevls[p] = u.select_atoms(sel.format(s, r)).residues[0] - except IndexError: - delete.append(p) - prev = sum(prevls) - - if len(nix): - nxtls = list(nxt) - for s, r, n in zip(psid[nix], nrid[nix], nix): - try: - nxtls[n] = u.select_atoms(sel.format(s, r)).residues[0] - except IndexError: - delete.append(n) - nxt = sum(nxtls) - - if len(delete): + if check_protein: + protein = self.atomgroup.universe.select_atoms("protein").residues + + if not residues.issubset(protein): + raise ValueError("Found atoms outside of protein. Only atoms " + "inside of a 'protein' selection can be used to " + "calculate dihedrals.") + elif not residues.isdisjoint(protein[[0, -1]]): + warnings.warn("Cannot determine phi and psi angles for the first " + "or last residues") + residues = residues.difference(protein[[0, -1]]) + + prev = residues._get_prev_residues_by_resid() + nxt = residues._get_next_residues_by_resid() + keep = np.array([r is not None for r in prev]) + keep = keep & np.array([r is not None for r in nxt]) + + if not np.all(keep): warnings.warn("Some residues in selection do not have " "phi or psi selections") - keep = np.ones_like(residues, dtype=bool) - keep[delete] = False - prev = prev[keep] - nxt = nxt[keep] - residues = residues[keep] - + prev = sum(prev[keep]) + nxt = sum(nxt[keep]) + residues = residues[keep] # find n, c, ca keep_prev = [sum(r.atoms.names==c_name)==1 for r in prev] diff --git a/package/MDAnalysis/core/topologyattrs.py b/package/MDAnalysis/core/topologyattrs.py index 90afc138aa1..7e66fd82f36 100644 --- a/package/MDAnalysis/core/topologyattrs.py +++ b/package/MDAnalysis/core/topologyattrs.py @@ -655,39 +655,72 @@ def _get_next_residues_by_resid(residues): Returns ------- List of Residues - The next residue in Universe. If not found, the corresponding - item in the list is ``None``. + List of the next residues in the Universe, by resid and segid. + If not found, the corresponding item in the list is ``None``. .. versionadded:: 1.0.0 """ u = residues[0].universe - nxres = rview = np.array([None]*len(residues), dtype=object) + nxres = np.array([None]*len(residues), dtype=object) + ix = np.arange(len(residues)) # no guarantee residues is ordered or unique last = max(residues.ix) if last == len(u.residues)-1: notlast = residues.ix != last - rview = nxres[notlast] + ix = ix[notlast] residues = residues[notlast] - rview[:] = nxt = u.residues[residues.ix+1] + nxres[ix] = nxt = u.residues[residues.ix+1] rsid = residues.segids nrid = residues.resids+1 sel = 'segid {} and resid {}' - invalid = [] # replace wrong residues wix = np.where((nxt.segids != rsid) | (nxt.resids != nrid))[0] if len(wix): for s, r, i in zip(rsid[wix], nrid[wix], wix): try: - rview[i] = u.select_atoms(sel.format(s, r)).residues[0] + nxres[ix[i]] = u.select_atoms(sel.format(s, r)).residues[0] except IndexError: - rview[i] = None + nxres[ix[i]] = None return nxres - + transplants[ResidueGroup].append(('_get_next_residues_by_resid', _get_next_residues_by_resid)) + def _get_prev_residues_by_resid(residues): + """Select list of Residues corresponding to the previous resid for each + residue in `residues`. + + Returns + ------- + List of Residues + List of the previous residues in the Universe, by resid and segid. + If not found, the corresponding item in the list is ``None``. + + .. versionadded:: 1.0.0 + """ + u = residues[0].universe + pvres = np.array([None]*len(residues)) + pvres[:] = prev = u.residues[residues.ix-1] + rsid = residues.segids + prid = residues.resids-1 + sel = 'segid {} and resid {}' + + # replace wrong residues + wix = np.where((prev.segids != rsid) | (prev.resids != prid))[0] + if len(wix): + for s, r, i in zip(rsid[wix], prid[wix], wix): + try: + pvres[i] = u.select_atoms(sel.format(s, r)).residues[0] + except IndexError: + pvres[i] = None + return pvres + + + transplants[ResidueGroup].append(('_get_prev_residues_by_resid', + _get_prev_residues_by_resid)) + def psi_selections(residues, c_name='C', n_name='N', ca_name='CA'): """Select list of AtomGroups corresponding to the psi protein backbone dihedral N-CA-C-N'. diff --git a/testsuite/MDAnalysisTests/analysis/test_dihedrals.py b/testsuite/MDAnalysisTests/analysis/test_dihedrals.py index 060082f808e..1719d81c935 100644 --- a/testsuite/MDAnalysisTests/analysis/test_dihedrals.py +++ b/testsuite/MDAnalysisTests/analysis/test_dihedrals.py @@ -106,11 +106,18 @@ def test_ramachandran_residue_selections(self, universe): def test_outside_protein_length(self, universe): with pytest.raises(ValueError): - rama = Ramachandran(universe.select_atoms("resid 220")).run() + rama = Ramachandran(universe.select_atoms("resid 220"), + check_protein=True).run() + + def test_outside_protein_unchecked(self, universe): + rama = Ramachandran(universe.select_atoms("resid 220"), + check_protein=False).run() def test_protein_ends(self, universe): - with pytest.warns(UserWarning): - rama = Ramachandran(universe.select_atoms("protein")).run() + with pytest.warns(UserWarning) as record: + rama = Ramachandran(universe.select_atoms("protein"), + check_protein=True).run() + assert len(record) == 1 def test_None_removal(self): with pytest.warns(UserWarning): diff --git a/testsuite/MDAnalysisTests/core/test_residuegroup.py b/testsuite/MDAnalysisTests/core/test_residuegroup.py index 81fbd5cf680..607fac8f1f0 100644 --- a/testsuite/MDAnalysisTests/core/test_residuegroup.py +++ b/testsuite/MDAnalysisTests/core/test_residuegroup.py @@ -267,8 +267,19 @@ def test_atom_order(self, universe): sorted(universe.residues.atoms.indices)) def test_get_next_residue(self, rg): - unsorted_rep_res = rg[[0, 1, 8, 3, 4, 0, 3, 1]] - next_res = sum(unsorted_rep_res._get_next_residues_by_resid()) - resids = unsorted_rep_res.resids+1 + unsorted_rep_res = rg[[0, 1, 8, 3, 4, 0, 3, 1, -1]] + next_res = unsorted_rep_res._get_next_residues_by_resid() + resids = list(unsorted_rep_res.resids+1) + resids[-1] = None + next_resids = [r.resid if r is not None else None for r in next_res] assert_equal(len(next_res), len(unsorted_rep_res)) - assert_equal(next_res.resids, resids) + assert_equal(next_resids, resids) + + def test_get_prev_residue(self, rg): + unsorted_rep_res = rg[[0, 1, 8, 3, 4, 0, 3, 1, -1]] + prev_res = unsorted_rep_res._get_prev_residues_by_resid() + resids = list(unsorted_rep_res.resids-1) + resids[0] = resids[5] = None + prev_resids = [r.resid if r is not None else None for r in prev_res] + assert_equal(len(prev_res), len(unsorted_rep_res)) + assert_equal(prev_resids, resids) From c1827e780a60de797bd63b9e281a0b0964abd7f7 Mon Sep 17 00:00:00 2001 From: Lily Wang Date: Tue, 2 Jun 2020 02:31:11 +1000 Subject: [PATCH 24/34] moved docstring to class, added example, updated CHANGELOG --- package/CHANGELOG | 2 + package/MDAnalysis/analysis/dihedrals.py | 67 ++++++++++++++++-------- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/package/CHANGELOG b/package/CHANGELOG index d33c2b4c92d..de51fc6c777 100644 --- a/package/CHANGELOG +++ b/package/CHANGELOG @@ -135,6 +135,8 @@ Enhancements the capability to allow intermittent behaviour (PR #2256) Changes + * Refactored dihedral selections and Ramachandran.__init__ to speed up + dihedral selections for faster tests (Issue #2671, PR #2706) * Removes the deprecated `t0`, `tf`, and `dtmax` from :class:Waterdynamics.SurvivalProbability. Instead the `start`, `stop` and `tau_max` keywords should be passed to diff --git a/package/MDAnalysis/analysis/dihedrals.py b/package/MDAnalysis/analysis/dihedrals.py index cfb49b042dd..cb19645e65e 100644 --- a/package/MDAnalysis/analysis/dihedrals.py +++ b/package/MDAnalysis/analysis/dihedrals.py @@ -254,39 +254,60 @@ class Ramachandran(AnalysisBase): :class:`~MDAnalysis.ResidueGroup` is generated from `atomgroup` which is compared to the protein to determine if it is a legitimate selection. + Parameters + ---------- + atomgroup : AtomGroup or ResidueGroup + atoms for residues for which :math:`\phi` and :math:`\psi` are + calculated + c_name: str (optional) + name for the backbone C atom + n_name: str (optional) + name for the backbone N atom + ca_name: str (optional) + name for the alpha-carbon atom + check_protein: bool (optional) + whether to raise an error if the provided atomgroup is not a + subset of protein atoms + + Example + ------- + For standard proteins, the default arguments will suffice to run a + Ramachandran analysis:: + + r = Ramachandran(u.select_atoms('protein')).run() + + For proteins with non-standard residues, or for calculating dihedral + angles for other linear polymers, you can switch off the protein checking + and provide your own atom names in place of the typical peptide backbone + atoms:: + + r = Ramachandran(u.atoms, c_name='CX', n_name='NT', ca_name='S', + check_protein=False).run() + + The above analysis will calculate angles from a "phi" selection of + CX'-NT-S-CX and "psi" selections of NT-S-CX-NT'. + + Raises + ------ + ValueError + If the selection of residues is not contained within the protein + and ``check_protein`` is ``True`` + Note ---- - If the residue selection is beyond the scope of the protein, then an error - will be raised. If the residue selection includes the first or last residue, + If ``check_protein`` is ``True`` and the residue selection is beyond + the scope of the protein and, then an error will be raised. + If the residue selection includes the first or last residue, then a warning will be raised and they will be removed from the list of residues, but the analysis will still run. If a :math:`\phi` or :math:`\psi` selection cannot be made, that residue will be removed from the analysis. + .. versionchanged:: 1.0.0 + added c_name, n_name, ca_name, and check_protein keyword arguments """ def __init__(self, atomgroup, c_name='C', n_name='N', ca_name='CA', check_protein=True, **kwargs): - """Parameters - ---------- - atomgroup : AtomGroup or ResidueGroup - atoms for residues for which :math:`\phi` and :math:`\psi` are - calculated - c_name: str (optional) - name for the backbone C atom - n_name: str (optional) - name for the backbone N atom - ca_name: str (optional) - name for the alpha-carbon atom - check_protein: bool (optional) - whether to raise an error if the provided atomgroup is not a - subset of protein atoms - - Raises - ------ - ValueError - If the selection of residues is not contained within the protein - - """ super(Ramachandran, self).__init__( atomgroup.universe.trajectory, **kwargs) self.atomgroup = atomgroup From ea2e8c43b8023f07a7583878051bef41a1514550 Mon Sep 17 00:00:00 2001 From: Rocco Meli Date: Fri, 5 Jun 2020 10:09:02 +0200 Subject: [PATCH 25/34] Fix #2679 (#2685) --- package/CHANGELOG | 2 + package/MDAnalysis/coordinates/PDB.py | 118 ++++++++++++++---- .../MDAnalysisTests/analysis/test_hole2.py | 62 ++++----- .../MDAnalysisTests/coordinates/test_pdb.py | 95 +++++++++++--- testsuite/MDAnalysisTests/data/6QYR.mmtf.gz | Bin 0 -> 6685 bytes testsuite/MDAnalysisTests/datafiles.py | 6 +- 6 files changed, 213 insertions(+), 70 deletions(-) create mode 100644 testsuite/MDAnalysisTests/data/6QYR.mmtf.gz diff --git a/package/CHANGELOG b/package/CHANGELOG index de51fc6c777..2476dbe6ac7 100644 --- a/package/CHANGELOG +++ b/package/CHANGELOG @@ -23,6 +23,8 @@ mm/dd/yy richardjgowers, kain88-de, lilyminium, p-j-smith, bdice, joaomcteixeira * 0.21.0 Fixes + * `PDBWriter` writes unitary `CRYST1` record (cubic box with sides of 1 Å) + when `u.dimensions` is `None` or `np.zeros(6)` (Issue #2679, PR #2685) * Ensures principal_axes() returns axes with the right hand convention (Issue #2637) * Fixed retrieval of auxiliary information after getting the last timestep of the trajectory (Issue #2674, PR #2683). diff --git a/package/MDAnalysis/coordinates/PDB.py b/package/MDAnalysis/coordinates/PDB.py index 3463d223e58..23f8914c79b 100644 --- a/package/MDAnalysis/coordinates/PDB.py +++ b/package/MDAnalysis/coordinates/PDB.py @@ -145,7 +145,6 @@ from six.moves import range, zip from six import raise_from, StringIO, BytesIO -import io import os import errno import itertools @@ -158,7 +157,6 @@ from ..lib import util from . import base from ..topology.core import guess_atom_element -from ..core.universe import Universe from ..exceptions import NoDataError @@ -167,6 +165,7 @@ # Pairs of residue name / atom name in use to deduce PDB formatted atom names Pair = collections.namedtuple('Atom', 'resname name') + class PDBReader(base.ReaderBase): """PDBReader that reads a `PDB-formatted`_ file, no frills. @@ -217,6 +216,18 @@ class PDBReader(base.ReaderBase): 79 - 80 LString(2) charge Charge on the atom. ============= ============ =========== ============================================= + Notes + ----- + If a system does not have unit cell parameters (such as in electron + microscopy structures), the PDB file format requires the CRYST1_ field to + be provided with unitary values (cubic box with sides of 1 Å) and an + appropriate REMARK. If unitary values are found within the CRYST1_ field, + :code:`PDBReader` will not set unit cell dimensions (which will take the + default value :code:`np.zeros(6)`, see Issue #2698) + and it will warn the user. + + .. _CRYST1: http://www.wwpdb.org/documentation/file-format-content/format33/sect8.html#CRYST1 + See Also -------- @@ -231,7 +242,8 @@ class PDBReader(base.ReaderBase): .. versionchanged:: 0.20.0 Strip trajectory header of trailing spaces and newlines .. versionchanged:: 1.0.0 - User warning for CRYST1 cryo-em structures + Raise user warning for CRYST1_ record with unitary valuse + (cubic box with sides of 1 Å) and do not set cell dimensions. """ format = ['PDB', 'ENT'] units = {'time': None, 'length': 'Angstrom'} @@ -364,6 +376,20 @@ def _read_next_timestep(self, ts=None): return self._read_frame(frame) def _read_frame(self, frame): + """ + Read frame from PDB file. + + Notes + ----- + When the CRYST1_ record has unitary values (cubic box with sides of + 1 Å), cell dimensions are considered fictitious. An user warning is + raised and cell dimensions are set to + :code:`np.zeros(6)` (see Issue #2698) + + .. versionchanged:: 1.0.0 + Raise user warning for CRYST1_ record with unitary valuse + (cubic box with sides of 1 Å) and do not set cell dimensions. + """ try: start = self._start_offsets[frame] stop = self._stop_offsets[frame] @@ -395,22 +421,23 @@ def _read_frame(self, frame): try: cell_dims = np.array([line[6:15], line[15:24], line[24:33], line[33:40], - line[40:47], line[47:54]], - dtype=np.float32) + line[40:47], line[47:54]], + dtype=np.float32) except ValueError: warnings.warn("Failed to read CRYST1 record, " "possibly invalid PDB file, got:\n{}" "".format(line)) else: - if (cell_dims == np.array([1, 1, 1, 90, 90, 90], - dtype=np.float32)).all(): - warnings.warn("1 A^3 CRYST1 record," - " this is usually a placeholder in" - " cryo-em structures. Unit cell" - " dimensions will not be set.") + if np.allclose(cell_dims, np.array([1.0, 1.0, 1.0, 90.0, 90.0, 90.0])): + # FIXME: Dimensions set to zeros. + # FIXME: This might change with Issue #2698 + warnings.warn("1 A^3 CRYST1 record," + " this is usually a placeholder." + " Unit cell dimensions will be set" + " to zeros.") else: self.ts._unitcell[:] = cell_dims - + # check if atom number changed if pos != self.n_atoms: raise ValueError("Inconsistency in file '{}': The number of atoms " @@ -465,6 +492,10 @@ class PDBWriter(base.WriterBase): The maximum frame number that can be stored in a PDB file is 9999 and it will wrap around (see :meth:`MODEL` for further details). + The CRYST1_ record specifies the unit cell. This record is set to + unitary values (cubic box with sides of 1 Å) if unit cell dimensions + are not set (:code:`None` or :code:`np.zeros(6)`, + see Issue #2698). See Also -------- @@ -491,10 +522,9 @@ class PDBWriter(base.WriterBase): Strip trajectory header of trailing spaces and newlines .. versionchanged:: 1.0.0 - ChainID now comes from the last character of segid, as stated in the documentation. + ChainID now comes from the last character of segid, as stated in the documentation. An indexing issue meant it previously used the first charater (Issue #2224) - """ fmt = { 'ATOM': ( @@ -582,7 +612,6 @@ def __init__(self, filename, bonds="conect", n_atoms=None, start=0, step=1, multi frame PDB file in which frames are written as MODEL_ ... ENDMDL_ records. If ``None``, then the class default is chosen. [``None``] - .. _CONECT: http://www.wwpdb.org/documentation/file-format-content/format32/sect10.html#CONECT .. _MODEL: http://www.wwpdb.org/documentation/file-format-content/format32/sect9.html#MODEL .. _ENDMDL: http://www.wwpdb.org/documentation/file-format-content/format32/sect9.html#ENDMDL @@ -603,7 +632,7 @@ def __init__(self, filename, bonds="conect", n_atoms=None, start=0, step=1, if start < 0: raise ValueError("'Start' must be a positive value") - self.start = self.frames_written = start + self.start = self.frames_written = start self.step = step self.remarks = remarks @@ -631,7 +660,28 @@ def _write_pdb_title(self): "".format(self.start, self.remarks)) def _write_pdb_header(self): - if self.first_frame_done == True: + """ + Write PDB header. + + The HEADER_ record is set to :code: `trajectory.header`. + The TITLE_ record explicitly mentions MDAnalysis and contains + information about trajectory frame(s). + The COMPND_ record is set to :code:`trajectory.compound`. + The REMARKS_ records are set to :code:`u.trajectory.remarks` + The CRYST1_ record specifies the unit cell. This record is set to + unitary values (cubic box with sides of 1 Å) if unit cell dimensions + are not set. + + .. _COMPND: http://www.wwpdb.org/documentation/file-format-content/format33/sect2.html#COMPND + .. _REMARKS: http://www.wwpdb.org/documentation/file-format-content/format33/remarks.html + + .. versionchanged: 1.0.0 + Fix writing of PDB file without unit cell dimensions (Issue #2679). + If cell dimensions are not found, unitary values (cubic box with + sides of 1 Å) are used (PDB standard for CRYST1_). + """ + + if self.first_frame_done is True: return self.first_frame_done = True @@ -651,7 +701,29 @@ def _write_pdb_header(self): self.REMARK(*remarks) except AttributeError: pass - self.CRYST1(self.convert_dimensions_to_unitcell(u.trajectory.ts)) + + # FIXME: Values for meaningless cell dimensions are not consistent. + # FIXME: See Issue #2698. Here we check for both None and zeros + if u.dimensions is None or np.allclose(u.dimensions, np.zeros(6)): + # Unitary unit cell by default. See PDB standard: + # http://www.wwpdb.org/documentation/file-format-content/format33/sect8.html#CRYST1 + self.CRYST1(np.array([1.0, 1.0, 1.0, 90.0, 90.0, 90.0])) + + # Add CRYST1 REMARK (285) + # The SCALE record is not included + # (We are only implementing a subset of the PDB standard) + self.REMARK("285 UNITARY VALUES FOR THE UNIT CELL AUTOMATICALLY SET") + self.REMARK("285 BY MDANALYSIS PDBWRITER BECAUSE UNIT CELL INFORMATION") + self.REMARK("285 WAS MISSING.") + self.REMARK("285 PROTEIN DATA BANK CONVENTIONS REQUIRE THAT") + self.REMARK("285 CRYST1 RECORD IS INCLUDED, BUT THE VALUES ON") + self.REMARK("285 THIS RECORD ARE MEANINGLESS.") + + warnings.warn("Unit cell dimensions not found. " + "CRYST1 record set to unitary values.") + + else: + self.CRYST1(self.convert_dimensions_to_unitcell(u.trajectory.ts)) def _check_pdb_coordinates(self): """Check if the coordinate values fall within the range allowed for PDB files. @@ -660,7 +732,10 @@ def _check_pdb_coordinates(self): already been written (in multi-frame mode) adds a REMARK instead of the coordinates and closes the file. - Raises :exc:`ValueError` if the coordinates fail the check. + Raises + ------ + ValueError + if the coordinates fail the check. .. versionchanged: 1.0.0 Check if :attr:`filename` is `StringIO` when attempting to remove @@ -920,7 +995,7 @@ def _write_timestep(self, ts, multiframe=False): have been written.) .. versionchanged:: 1.0.0 - ChainID now comes from the last character of segid, as stated in the documentation. + ChainID now comes from the last character of segid, as stated in the documentation. An indexing issue meant it previously used the first charater (Issue #2224) """ @@ -1025,9 +1100,6 @@ def COMPND(self, trajectory): def CRYST1(self, dimensions, spacegroup='P 1', zvalue=1): """Write CRYST1_ record. - - .. _CRYST1: http://www.wwpdb.org/documentation/file-format-content/format32/sect8.html#CRYST1 - """ self.pdbfile.write(self.fmt['CRYST1'].format( box=dimensions[:3], diff --git a/testsuite/MDAnalysisTests/analysis/test_hole2.py b/testsuite/MDAnalysisTests/analysis/test_hole2.py index fb8fff261a3..700827f027f 100644 --- a/testsuite/MDAnalysisTests/analysis/test_hole2.py +++ b/testsuite/MDAnalysisTests/analysis/test_hole2.py @@ -42,7 +42,7 @@ from MDAnalysis.analysis import hole2 from MDAnalysis.analysis.hole2.utils import check_and_fix_long_filename from MDAnalysis.exceptions import ApplicationError -from MDAnalysisTests.datafiles import PDB_HOLE, MULTIPDB_HOLE, PSF, DCD +from MDAnalysisTests.datafiles import PDB_HOLE, MULTIPDB_HOLE, DCD from MDAnalysisTests import executable_not_found @@ -58,6 +58,7 @@ def rlimits_missing(): return True return False + class TestCheckAndFixLongFilename(object): max_length = 70 @@ -72,7 +73,7 @@ def test_relative(self): if len(abspath) > self.max_length: fixed = check_and_fix_long_filename(abspath) assert fixed == self.filename - + @pytest.mark.skipif(os.name == 'nt' and sys.maxsize <= 2**32, reason="FileNotFoundError on Win 32-bit") def test_symlink_dir(self, tmpdir): @@ -87,7 +88,7 @@ def test_symlink_dir(self, tmpdir): fixed = check_and_fix_long_filename(path) assert os.path.islink(fixed) assert fixed.endswith(short_name) - + @pytest.mark.skipif(os.name == 'nt' and sys.maxsize <= 2**32, reason="OSError: symbolic link privilege not held") def test_symlink_file(self, tmpdir): @@ -203,7 +204,8 @@ def test_application_error(self, tmpdir): def test_output_level(self, tmpdir): with tmpdir.as_cwd(): with pytest.warns(UserWarning) as rec: - profiles = hole2.hole(self.filename, random_seed=self.random_seed, + profiles = hole2.hole(self.filename, + random_seed=self.random_seed, output_level=100) assert len(rec) == 1 assert 'needs to be < 3' in rec[0].message.args[0] @@ -307,10 +309,12 @@ def test_output_level(self, tmpdir, universe): output_level=100) h.run(start=self.start, stop=self.stop, random_seed=self.random_seed) - assert len(rec) == 3 - assert 'needs to be < 3' in rec[0].message.args[0] - assert 'has no dt information' in rec[1].message.args[0] - assert 'has no dt information' in rec[2].message.args[0] + assert len(rec) == 5 + + assert any('needs to be < 3' in r.message.args[0] for r in rec) + assert any('has no dt information' in r.message.args[0] for r in rec) # 2x + assert any('Unit cell dimensions not found.' in r.message.args[0] for r in rec) # 2x + # no profiles assert len(h.profiles) == 0 @@ -331,7 +335,7 @@ def test_cpoint_geometry(self, tmpdir, universe): line = f.read().split('CPOINT')[1].split('\n')[0] arr = np.array(list(map(float, line.split()))) assert_almost_equal(arr, cog) - + # plotting def test_plot(self, hole, frames, profiles): ax = hole.plot(label=True, frames=None, y_shift=1) @@ -344,7 +348,7 @@ def test_plot(self, hole, frames, profiles): assert_almost_equal(x, profile.rxn_coord) assert_almost_equal(y, profile.radius + i) assert line.get_label() == str(frame) - + def test_plot_mean_profile(self, hole, frames, profiles): binned, bins = hole.bin_radii(bins=100) mean = np.array(list(map(np.mean, binned))) @@ -354,7 +358,7 @@ def test_plot_mean_profile(self, hole, frames, profiles): yhigh = list(mean+(2*stds)) ax = hole.plot_mean_profile(bins=100, n_std=2) - + # test fillbetween standard deviation children = ax.get_children() poly = [] @@ -373,7 +377,7 @@ def test_plot_mean_profile(self, hole, frames, profiles): assert_almost_equal(xl, midpoints) assert_almost_equal(yl, mean) - @pytest.mark.skipif(sys.version_info < (3, 1), + @pytest.mark.skipif(sys.version_info < (3, 1), reason="get_data_3d requires 3.1 or higher") def test_plot3D(self, hole, frames, profiles): ax = hole.plot3D(frames=None, r_max=None) @@ -389,7 +393,7 @@ def test_plot3D(self, hole, frames, profiles): assert_almost_equal(z, profile.radius) assert line.get_label() == str(frame) - @pytest.mark.skipif(sys.version_info < (3, 1), + @pytest.mark.skipif(sys.version_info < (3, 1), reason="get_data_3d requires 3.1 or higher") def test_plot3D_rmax(self, hole, frames, profiles): ax = hole.plot3D(r_max=2.5) @@ -402,11 +406,11 @@ def test_plot3D_rmax(self, hole, frames, profiles): x, y, z = line.get_data_3d() assert_almost_equal(x, profile.rxn_coord) assert_almost_equal(np.unique(y), [frame]) - radius = np.where(profile.radius>2.5, np.nan, profile.radius) + radius = np.where(profile.radius > 2.5, np.nan, profile.radius) assert_almost_equal(z, radius) assert line.get_label() == str(frame) - - @pytest.mark.skipif(sys.version_info > (3, 1), + + @pytest.mark.skipif(sys.version_info > (3, 1), reason="get_data_3d requires 3.1 or higher") def test_plot3D(self, hole, frames, profiles): ax = hole.plot3D(frames=None, r_max=None) @@ -420,8 +424,8 @@ def test_plot3D(self, hole, frames, profiles): assert_almost_equal(x, profile.rxn_coord) assert_almost_equal(np.unique(y), [frame]) assert line.get_label() == str(frame) - - @pytest.mark.skipif(sys.version_info > (3, 1), + + @pytest.mark.skipif(sys.version_info > (3, 1), reason="get_data_3d requires 3.1 or higher") def test_plot3D_rmax(self, hole, frames, profiles): ax = hole.plot3D(r_max=2.5) @@ -445,7 +449,7 @@ class TestHoleAnalysisLong(BaseTestHole): rmsd = np.array([6.10501252e+00, 4.88398472e+00, 3.66303524e+00, 2.44202454e+00, 1.22100521e+00, 1.67285541e-07, 1.22100162e+00, 2.44202456e+00, 3.66303410e+00, 4.88398478e+00, 6.10502262e+00]) - + @pytest.fixture def order_parameter_keys_values(self, hole): op = hole.over_order_parameters(self.rmsd, frames=None) @@ -487,7 +491,7 @@ def test_over_order_parameters(self, hole): arr = np.array(list(hole.profiles.values())) for op_prof, arr_prof in zip(profiles.values(), arr[idx]): assert op_prof is arr_prof - + def test_over_order_parameters_file(self, hole, tmpdir): op = self.rmsd with tmpdir.as_cwd(): @@ -495,7 +499,7 @@ def test_over_order_parameters_file(self, hole, tmpdir): profiles = hole.over_order_parameters('rmsd.dat', frames=None) assert len(op) == len(profiles) - + for key, rmsd in zip(profiles.keys(), np.sort(op)): assert key == rmsd @@ -503,15 +507,15 @@ def test_over_order_parameters_file(self, hole, tmpdir): arr = np.array(list(hole.profiles.values())) for op_prof, arr_prof in zip(profiles.values(), arr[idx]): assert op_prof is arr_prof - + def test_over_order_parameters_missing_file(self, hole): with pytest.raises(ValueError) as exc: - prof = hole.over_order_parameters('missing.dat') + hole.over_order_parameters('missing.dat') assert 'not found' in str(exc.value) - + def test_over_order_parameters_invalid_file(self, hole): with pytest.raises(ValueError) as exc: - prof = hole.over_order_parameters(PDB_HOLE) + hole.over_order_parameters(PDB_HOLE) assert 'Could not parse' in str(exc.value) def test_over_order_parameters_frames(self, hole): @@ -549,7 +553,7 @@ def test_bin_radii(self, hole): break else: raise AssertionError('Radius not in binned radii') - + @pytest.mark.parametrize('midpoint', [1.5, 1.8, 2.0, 2.5]) def test_bin_radii_range(self, hole, midpoint): radii, bins = hole.bin_radii(bins=100, @@ -578,7 +582,7 @@ def test_bin_radii_range(self, hole, midpoint): raise AssertionError('Radius not in binned radii') else: assert not any([rad in x for x in radii]) - + def test_bin_radii_edges(self, hole): brange = list(np.linspace(1.0, 2.0, num=101, endpoint=True)) moved = brange[30:] + brange[10:30] + brange[:10] @@ -609,7 +613,7 @@ def test_plot_select_frames(self, hole, frames, profiles): assert_almost_equal(x, profile.rxn_coord) assert_almost_equal(y, profile.radius + i) assert line.get_label() == str(frame) - + @pytest.mark.parametrize('agg', [np.max, np.mean, np.std, np.min]) def test_plot_order_parameters(self, hole, order_parameter_keys_values, agg): @@ -643,7 +647,7 @@ def test_plot3D_order_parameters(self, hole, order_parameter_keys_values): assert_almost_equal(x, profile.rxn_coord) assert_almost_equal(np.unique(y), np.array([opx_])) assert_almost_equal(z, profile.radius) - + @pytest.mark.skipif(sys.version_info > (3, 1), reason="get_data_3d requires 3.1 or higher") def test_plot3D_order_parameters(self, hole, order_parameter_keys_values): diff --git a/testsuite/MDAnalysisTests/coordinates/test_pdb.py b/testsuite/MDAnalysisTests/coordinates/test_pdb.py index e1eef78d864..0c20fb59e23 100644 --- a/testsuite/MDAnalysisTests/coordinates/test_pdb.py +++ b/testsuite/MDAnalysisTests/coordinates/test_pdb.py @@ -38,8 +38,8 @@ XPDB_small, PSF, DCD, CONECT, CRD, INC_PDB, PDB_xlserial, ALIGN, ENT, PDB_cm, PDB_cm_gz, PDB_cm_bz2, - PDB_mc, PDB_mc_gz, PDB_mc_bz2, - PDB_CRYOEM_BOX) + PDB_mc, PDB_mc_gz, PDB_mc_bz2, + PDB_CRYOEM_BOX, MMTF_NOCRYST) from numpy.testing import (assert_equal, assert_array_almost_equal, assert_almost_equal) @@ -47,6 +47,7 @@ class TestPDBReader(_SingleFrameReader): __test__ = True + def setUp(self): # can lead to race conditions when testing in parallel self.universe = mda.Universe(RefAdKSmall.filename) @@ -190,6 +191,23 @@ def universe2(self): def universe3(self): return mda.Universe(PDB) + @pytest.fixture(params=[ + [PDB_CRYOEM_BOX, np.zeros(6)], + [MMTF_NOCRYST, None] + ]) + def universe_and_expected_dims(self, request): + """ + File with meaningless CRYST1 record and expected dimensions. + + Notes + ----- + This will need to be made consistent, see Issue #2698 + """ + filein = request.param[0] + expected_dims = request.param[1] + + return mda.Universe(filein), expected_dims + @pytest.fixture def outfile(self, tmpdir): return str(tmpdir.mkdir("PDBWriter").join('primitive-pdb-writer' + self.ext)) @@ -291,6 +309,51 @@ def test_write_single_frame_AtomGroup(self, universe2, outfile): "agree with original coordinates from frame %d" % u.trajectory.frame) + def test_write_nodims(self, universe_and_expected_dims, outfile): + """ + Test :code:`PDBWriter` for universe without cell dimensions. + + Notes + ----- + Test fix for Issue #2679. + """ + + u, expected_dims = universe_and_expected_dims + + # See Issue #2698 + if expected_dims is None: + assert u.dimensions is None + else: + assert np.allclose(u.dimensions, expected_dims) + + expected_msg = "Unit cell dimensions not found. CRYST1 record set to unitary values." + + with pytest.warns(UserWarning, match=expected_msg): + u.atoms.write(outfile) + + with pytest.warns(UserWarning, match="Unit cell dimensions will be set to zeros."): + uout = mda.Universe(outfile) + + assert_almost_equal( + uout.dimensions, np.zeros(6), + self.prec, + err_msg="Problem with default box." + ) + + assert_equal( + uout.trajectory.n_frames, 1, + err_msg="Output PDB should only contain a single frame" + ) + + assert_almost_equal( + u.atoms.positions, uout.atoms.positions, + self.prec, + err_msg="Written coordinates do not " + "agree with original coordinates from frame %d" % + u.trajectory.frame + ) + + def test_check_coordinate_limits_min(self, universe, outfile): """Test that illegal PDB coordinates (x <= -999.9995 A) are caught with ValueError (Issue 57)""" @@ -513,6 +576,7 @@ def helper(atoms, bonds): "the test reference; len(actual) is %d, len(desired) " "is %d" % (len(u._topology.bonds.values), len(desired))) + def test_conect_bonds_all(tmpdir): conect = mda.Universe(CONECT, guess_bonds=True) @@ -528,6 +592,7 @@ def test_conect_bonds_all(tmpdir): # assert_equal(len([b for b in conect.bonds if not b.is_guessed]), 1922) + def test_write_bonds_partial(tmpdir): u = mda.Universe(CONECT) # grab all atoms with bonds @@ -821,18 +886,19 @@ def writtenstuff(self, tmpdir_factory): return fh.readlines() def test_atomname_alignment(self, writtenstuff): - # Our PDBWriter adds some stuff up top, so line 1 happens at [4] + # Our PDBWriter adds some stuff up top, so line 1 happens at [9] refs = ("ATOM 1 H5T", "ATOM 2 CA ", "ATOM 3 CA ", "ATOM 4 H5''",) - for written, reference in zip(writtenstuff[3:], refs): + + for written, reference in zip(writtenstuff[9:], refs): assert_equal(written[:16], reference) def test_atomtype_alignment(self, writtenstuff): result_line = ("ATOM 1 H5T GUA A 1 7.974 6.430 9.561" " 1.00 0.00 RNAA H\n") - assert_equal(writtenstuff[3], result_line) + assert_equal(writtenstuff[9], result_line) @pytest.mark.parametrize('atom, refname', ((mda.coordinates.PDB.Pair('ASP', 'CA'), ' CA '), # Regular protein carbon alpha @@ -964,18 +1030,15 @@ def test_partially_missing_cryst(): assert_array_almost_equal(u.dimensions, 0.0) -def test_cryst_em_warning(): - #issue 2599 - with pytest.warns(UserWarning) as record: - u = mda.Universe(PDB_CRYOEM_BOX) - assert record[0].message.args[0] == "1 A^3 CRYST1 record," \ - " this is usually a placeholder in" \ - " cryo-em structures. Unit cell" \ - " dimensions will not be set." - +def test_cryst_meaningless_warning(): + # issue 2599 + # FIXME: This message might change with Issue #2698 + with pytest.warns(UserWarning, match="Unit cell dimensions will be set to zeros."): + mda.Universe(PDB_CRYOEM_BOX) + -def test_cryst_em_select(): - #issue 2599 +def test_cryst_meaningless_select(): + # issue 2599 u = mda.Universe(PDB_CRYOEM_BOX) cur_sele = u.select_atoms('around 0.1 (resid 4 and name CA and segid A)') assert cur_sele.n_atoms == 0 diff --git a/testsuite/MDAnalysisTests/data/6QYR.mmtf.gz b/testsuite/MDAnalysisTests/data/6QYR.mmtf.gz new file mode 100644 index 0000000000000000000000000000000000000000..70399c342d904858fe7e9b4725a30737ea0d821f GIT binary patch literal 6685 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 literal 0 HcmV?d00001 diff --git a/testsuite/MDAnalysisTests/datafiles.py b/testsuite/MDAnalysisTests/datafiles.py index 6611b566322..5ebe8c74bb6 100644 --- a/testsuite/MDAnalysisTests/datafiles.py +++ b/testsuite/MDAnalysisTests/datafiles.py @@ -186,8 +186,9 @@ "GMX_DIR", # GROMACS directory "GMX_TOP_BAD", # file with an #include that doesn't exist "ITP_no_endif", # file missing an #endif - "PDB_CRYOEM_BOX", #Issue 2599 - "PDB_CHECK_RIGHTHAND_PA" # for testing right handedness of principal_axes + "PDB_CRYOEM_BOX", # Issue 2599, Issue #2679, PR #2685 + "PDB_CHECK_RIGHTHAND_PA", # for testing right handedness of principal_axes + "MMTF_NOCRYST", # File with meaningless CRYST1 record (Issue #2679, PR #2685) ] from pkg_resources import resource_filename @@ -486,6 +487,7 @@ MMTF_gz = resource_filename(__name__, 'data/5KIH.mmtf.gz') MMTF_skinny = resource_filename(__name__, 'data/1ubq-less-optional.mmtf') MMTF_skinny2 = resource_filename(__name__, 'data/3NJW-onlyrequired.mmtf') +MMTF_NOCRYST = resource_filename(__name__, "data/6QYR.mmtf.gz") ALIGN_BOUND = resource_filename(__name__, 'data/analysis/align_bound.pdb.gz') ALIGN_UNBOUND = resource_filename(__name__, 'data/analysis/align_unbound.pdb.gz') From 2ef87ba2ebaa56ef49a48c56eb9a121c33e4d024 Mon Sep 17 00:00:00 2001 From: Marcello Sega Date: Sat, 30 May 2020 11:07:44 +0200 Subject: [PATCH 26/34] FHI-AIMS coordinate format and topology - Parser - read atoms - Reader - read atoms and fractional atom positions - read 0 or 3 lattice vectors - read velocities - Writer - writes atom positions and lattice - writes velocities - documentation - tests for all functionality (Reader, Writer, Parser) - CHANGELOG and AUTHORS updated Co-authored-by: Oliver Beckstein --- package/AUTHORS | 1 + package/CHANGELOG | 3 +- package/MDAnalysis/coordinates/FHIAIMS.py | 341 ++++++++++++++++++ package/MDAnalysis/coordinates/__init__.py | 4 + package/MDAnalysis/topology/FHIAIMSParser.py | 122 +++++++ package/MDAnalysis/topology/__init__.py | 6 + .../coordinates/FHIAIMS.rst | 2 + .../coordinates_modules.rst | 1 + .../topology/FHIAIMSParser.rst | 2 + .../documentation_pages/topology_modules.rst | 1 + .../MDAnalysisTests/coordinates/reference.py | 1 + .../coordinates/test_fhiaims.py | 245 +++++++++++++ testsuite/MDAnalysisTests/data/fhiaims.in | 10 + testsuite/MDAnalysisTests/datafiles.py | 2 + .../MDAnalysisTests/topology/test_fhiaims.py | 56 +++ 15 files changed, 796 insertions(+), 1 deletion(-) create mode 100644 package/MDAnalysis/coordinates/FHIAIMS.py create mode 100644 package/MDAnalysis/topology/FHIAIMSParser.py create mode 100644 package/doc/sphinx/source/documentation_pages/coordinates/FHIAIMS.rst create mode 100644 package/doc/sphinx/source/documentation_pages/topology/FHIAIMSParser.rst create mode 100644 testsuite/MDAnalysisTests/coordinates/test_fhiaims.py create mode 100644 testsuite/MDAnalysisTests/data/fhiaims.in create mode 100644 testsuite/MDAnalysisTests/topology/test_fhiaims.py diff --git a/package/AUTHORS b/package/AUTHORS index 53091b39756..0d729be658a 100644 --- a/package/AUTHORS +++ b/package/AUTHORS @@ -146,6 +146,7 @@ Chronological list of authors - Shakul Pathak - Andrea Rizzi - William Glass + - Marcello Sega External code ------------- diff --git a/package/CHANGELOG b/package/CHANGELOG index 2476dbe6ac7..2974e1cb19e 100644 --- a/package/CHANGELOG +++ b/package/CHANGELOG @@ -18,7 +18,7 @@ mm/dd/yy richardjgowers, kain88-de, lilyminium, p-j-smith, bdice, joaomcteixeira Yuan-Yu, xiki-tempula, HTian1997, Iv-Hristov, hmacdope, AnshulAngaria, ss62171, Luthaf, yuxuanzhuang, abhishandy, mlnance, shfrz, orbeckst, wvandertoorn, cbouy, AmeyaHarmalkar, Oscuro-Phoenix, andrrizzi, WG150, - tylerjereddy + tylerjereddy, Marcello-Sega * 0.21.0 @@ -90,6 +90,7 @@ Fixes * Contact Analysis class respects PBC (Issue #2368) Enhancements + * Added support for FHI-AIMS input files (PR #2705) * vastly improved support for 32-bit Windows (PR #2696) * Added methods to compute the root-mean-square-inner-product of subspaces and the cumulative overlap of a vector in a subspace for PCA (PR #2613) diff --git a/package/MDAnalysis/coordinates/FHIAIMS.py b/package/MDAnalysis/coordinates/FHIAIMS.py new file mode 100644 index 00000000000..11fb1ed03ce --- /dev/null +++ b/package/MDAnalysis/coordinates/FHIAIMS.py @@ -0,0 +1,341 @@ +# -*- Mode: python; tab-width: 4; indent-tabs-mode:nil; coding:utf-8 -*- +# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 +# +# MDAnalysis --- https://www.mdanalysis.org +# Copyright (c) 2006-2017 The MDAnalysis Development Team and contributors +# (see the file AUTHORS for the full list of names) +# +# Released under the GNU Public Licence, v2 or any higher version +# +# Please cite your use of MDAnalysis in published work: +# +# R. J. Gowers, M. Linke, J. Barnoud, T. J. E. Reddy, M. N. Melo, S. L. Seyler, +# D. L. Dotson, J. Domanski, S. Buchoux, I. M. Kenney, and O. Beckstein. +# MDAnalysis: A Python package for the rapid analysis of molecular dynamics +# simulations. In S. Benthall and S. Rostrup editors, Proceedings of the 15th +# Python in Science Conference, pages 102-109, Austin, TX, 2016. SciPy. +# doi: 10.25080/majora-629e541a-00e +# +# N. Michaud-Agrawal, E. J. Denning, T. B. Woolf, and O. Beckstein. +# MDAnalysis: A Toolkit for the Analysis of Molecular Dynamics Simulations. +# J. Comput. Chem. 32 (2011), 2319--2327, doi:10.1002/jcc.21787 +# + +""" +FHI-AIMS file format --- :mod:`MDAnalysis.coordinates.FHIAIMS` +============================================================== + +Classes to read and write `FHI-AIMS`_ coordinate files. + +The cell vectors are specified by the (optional) lines with the +``lattice_vector`` tag:: + + lattice_vector x y z + +where x, y, and z are expressed in ångström (Å). + +.. Note:: + + In the original `FHI-AIMS format`_, up to three lines with + ``lattice_vector`` are allowed (order matters) where the absent line implies + no periodicity in that direction. In MDAnalysis, only the case of no + ``lattice_vector`` or three ``lattice_vector`` lines are allowed. + +Atomic positions and names are specified either by the ``atom`` or by the +``atom_frac`` tags:: + + atom x y z name + atom_frac nx ny nz name + +where x, y, and z are expressed in ångström, and nx, ny and nz are real numbers +in [0, 1] and are used to compute the atomic positions in units of the basic +cell. + +Atomic velocities can be added on the line right after the corresponding +``atom`` in units of Å/ps using the ``velocity`` tag:: + + velocity vx vy vz + + +The field name is a string identifying the atomic species. See also the +specifications in the official `FHI-AIMS format`_. + +Classes +------- + +.. autoclass:: Timestep + :members: + :inherited-members: + +.. autoclass:: FHIAIMSReader + :members: + :inherited-members: + +.. autoclass:: FHIAIMSWriter + :members: + :inherited-members: + +Developer notes: ``FHIAIMSWriter`` format strings +------------------------------------------------- + +The :class:`FHIAIMSWriter` class has a :attr:`FHIAIMSWriter.fmt` +attribute, which is a dictionary of different strings for writing +lines in ``.in`` files. These are as follows: + +``xyz`` + An atom line without velocities. Requires that the `name` and + `pos` keys be supplied. E.g.:: + + fmt['xyz'].format(pos=(0.0, 1.0, 2.0), name='O') + +``vel`` + An line that specifies velocities:: + + fmt['xyz'].format(vel=(0.1, 0.2, 0.3)) + +``box_triclinic`` + The (optional) initial lines of the file which gives box dimensions. + Requires the `box` keyword, as a length 9 vector. This is a flattened + version of the (3, 3) triclinic vector representation of the unit + cell. + + +.. Links + +.. _FHI-AIMS: https://aimsclub.fhi-berlin.mpg.de/ +.. _FHI-AIMS format: https://doi.org/10.6084/m9.figshare.12413477.v1 + +""" +from __future__ import absolute_import + +import re +from six.moves import range, zip +from six import raise_from + +import itertools +import warnings + +import numpy as np + +from . import base +from .core import triclinic_box, triclinic_vectors +from ..exceptions import NoDataError +from ..lib import util +from ..lib import mdamath + + +class Timestep(base.Timestep): + + def _init_unitcell(self): + return np.zeros((3, 3), dtype=np.float32) + + @property + def dimensions(self): + """unitcell dimensions (A, B, C, alpha, beta, gamma)""" + return triclinic_box(self._unitcell[0], self._unitcell[1], self._unitcell[2]) + + @dimensions.setter + def dimensions(self, new): + self._unitcell[:] = triclinic_vectors(new) + + +class FHIAIMSReader(base.SingleFrameReaderBase): + """Reader for the FHIAIMS geometry format. + + Single frame reader for the `FHI-AIMS`_ input file format. Reads + geometry (3D and molecules only), positions (absolut or fractional), + velocities if given, all according to the `FHI-AIMS format`_ + specifications + + """ + format = ['IN', 'FHIAIMS'] + units = {'time': 'ps', 'length': 'Angstrom', 'velocity': 'Angstrom/ps'} + _Timestep = Timestep + + def _read_first_frame(self): + with util.openany(self.filename, 'rt') as fhiaimsfile: + relative, positions, velocities, lattice_vectors = [], [], [], [] + skip_tags = ["#", "initial_moment"] + oldline = '' + for line in fhiaimsfile: + line = line.strip() + if line.startswith("atom"): + positions.append(line.split()[1:-1]) + relative.append('atom_frac' in line) + oldline = line + continue + if line.startswith("velocity"): + if not 'atom' in oldline: + raise ValueError( + 'Non-conforming line (velocity must follow atom): ({0})in FHI-AIMS input file {0}'.format(line, self.filename)) + velocities.append(line.split()[1:]) + oldline = line + continue + if line.startswith("lattice"): + lattice_vectors.append(line.split()[1:]) + oldline = line + continue + if any([line.startswith(tag) for tag in skip_tags]): + oldline = line + continue + raise ValueError( + 'Non-conforming line: ({0})in FHI-AIMS input file {0}'.format(line, self.filename)) + + # positions and velocities are lists of lists of strings; they will be + # cast to np.arrays(..., dtype=float32) during assignment to ts.positions/ts.velocities + lattice_vectors = np.asarray(lattice_vectors, dtype=np.float32) + + if len(velocities) not in (0, len(positions)): + raise ValueError( + 'Found incorrect number of velocity tags ({0}) in the FHI-AIMS file, should be {1}.'.format( + len(velocities), len(positions))) + if len(lattice_vectors) not in (0, 3): + raise ValueError( + 'Found partial periodicity in FHI-AIMS file. This cannot be handled by MDAnalysis.') + if len(lattice_vectors) == 0 and any(relative): + raise ValueError( + 'Found relative coordinates in FHI-AIMS file without lattice info.') + + # create Timestep + + self.n_atoms = n_atoms = len(positions) + self.ts = ts = self._Timestep(n_atoms, **self._ts_kwargs) + ts.positions = positions + + if len(lattice_vectors) > 0: + ts._unitcell[:] = lattice_vectors + ts.positions[relative] = np.matmul( + ts.positions[relative], lattice_vectors) + + if len(velocities) > 0: + ts.velocities = velocities + + self.ts.frame = 0 # 0-based frame number + + def Writer(self, filename, n_atoms=None, **kwargs): + """Returns a FHIAIMSWriter for *filename*. + + Parameters + ---------- + filename: str + filename of the output FHI-AIMS file + + Returns + ------- + :class:`FHIAIMSWriter` + + """ + if n_atoms is None: + n_atoms = self.n_atoms + return FHIAIMSWriter(filename, n_atoms=n_atoms, **kwargs) + + +class FHIAIMSWriter(base.WriterBase): + """FHI-AIMS Writer. + + Single frame writer for the `FHI-AIMS`_ format. Writes geometry (3D and + molecules only), positions (absolut only), velocities if given, all + according to the `FHI-AIMS format`_ specifications. + + If no atom names are given, it will set each atom name to "X". + + """ + + format = ['IN', 'FHIAIMS'] + units = {'time': None, 'length': 'Angstrom', 'velocity': 'Angstrom/ps'} + + #: format strings for the FHI-AIMS file (all include newline) + fmt = { + # coordinates output format, see https://doi.org/10.6084/m9.figshare.12413477.v1 + 'xyz': "atom {pos[0]:12.8f} {pos[1]:12.8f} {pos[2]:12.8f} {name:<3s}\n", + 'vel': "velocity {vel[0]:12.8f} {vel[1]:12.8f} {vel[2]:12.8f}\n", + # unitcell + 'box_triclinic': "lattice_vector {box[0]:12.8f} {box[1]:12.8f} {box[2]:12.8f}\nlattice_vector {box[3]:12.8f} {box[4]:12.8f} {box[5]:12.8f}\nlattice_vector {box[6]:12.8f} {box[7]:12.8f} {box[8]:12.8f}\n" + } + + def __init__(self, filename, convert_units=True, n_atoms=None, **kwargs): + """Set up the FHI-AIMS Writer + + Parameters + ----------- + filename : str + output filename + n_atoms : int (optional) + number of atoms + + """ + self.filename = util.filename(filename, ext='.in', keep=True) + self.n_atoms = n_atoms + + def write(self, obj): + """Write selection at current trajectory frame to file. + + Parameters + ----------- + obj : AtomGroup or Universe or :class:`Timestep` + + """ + # write() method that complies with the Trajectory API + + try: + + # make sure to use atoms (Issue 46) + ag_or_ts = obj.atoms + # can write from selection == Universe (Issue 49) + + except AttributeError: + if isinstance(obj, base.Timestep): + ag_or_ts = obj.copy() + else: + raise_from( + TypeError("No Timestep found in obj argument"), None) + + # Check for topology information + missing_topology = [] + try: + names = ag_or_ts.names + except (AttributeError, NoDataError): + names = itertools.cycle(('X',)) + missing_topology.append('names') + + try: + atom_indices = ag_or_ts.ids + except (AttributeError, NoDataError): + atom_indices = range(1, ag_or_ts.n_atoms+1) + missing_topology.append('ids') + + if missing_topology: + warnings.warn( + "Supplied AtomGroup was missing the following attributes: " + "{miss}. These will be written with default values. " + "".format(miss=', '.join(missing_topology))) + + positions = ag_or_ts.positions + try: + velocities = ag_or_ts.velocities + has_velocities = True + except (AttributeError, NoDataError): + has_velocities = False + + with util.openany(self.filename, 'wt') as output_fhiaims: + # Lattice + try: # for AtomGroup/Universe + tri_dims = obj.universe.trajectory.ts.triclinic_dimensions + except AttributeError: # for Timestep + tri_dims = obj.triclinic_dimensions + # full output + if np.any(tri_dims != 0): + output_fhiaims.write( + self.fmt['box_triclinic'].format(box=tri_dims.flatten())) + + # Atom descriptions and coords + # Dont use enumerate here, + # all attributes could be infinite cycles! + for atom_index, name in zip( + range(ag_or_ts.n_atoms), names): + output_fhiaims.write(self.fmt['xyz'].format( + pos=positions[atom_index], + name=name)) + if has_velocities: + output_fhiaims.write(self.fmt['vel'].format( + vel=velocities[atom_index])) diff --git a/package/MDAnalysis/coordinates/__init__.py b/package/MDAnalysis/coordinates/__init__.py index 5bd23b82bf8..d8548f24648 100644 --- a/package/MDAnalysis/coordinates/__init__.py +++ b/package/MDAnalysis/coordinates/__init__.py @@ -254,6 +254,9 @@ class can choose an appropriate reader automatically. | NAMD | coor, | r/w | NAMD binary file format for coordinates | | | namdbin | | :mod:`MDAnalysis.coordinates.NAMDBIN` | +---------------+-----------+-------+------------------------------------------------------+ + | FHIAIMS | in | r/w | FHI-AIMS file format for coordinates | + | | | | :mod:`MDAnalysis.coordinates.FHIAIMS` | + +---------------+-----------+-------+------------------------------------------------------+ .. [#a] This format can also be used to provide basic *topology* information (i.e. the list of atoms); it is possible to create a @@ -741,3 +744,4 @@ class can choose an appropriate reader automatically. from . import GSD from . import null from . import NAMDBIN +from . import FHIAIMS diff --git a/package/MDAnalysis/topology/FHIAIMSParser.py b/package/MDAnalysis/topology/FHIAIMSParser.py new file mode 100644 index 00000000000..1965796b5b7 --- /dev/null +++ b/package/MDAnalysis/topology/FHIAIMSParser.py @@ -0,0 +1,122 @@ +# -*- Mode: python; tab-width: 4; indent-tabs-mode:nil; coding:utf-8 -*- +# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 +# +# MDAnalysis --- https://www.mdanalysis.org +# Copyright (c) 2006-2017 The MDAnalysis Development Team and contributors +# (see the file AUTHORS for the full list of names) +# +# Released under the GNU Public Licence, v2 or any higher version +# +# Please cite your use of MDAnalysis in published work: +# +# R. J. Gowers, M. Linke, J. Barnoud, T. J. E. Reddy, M. N. Melo, S. L. Seyler, +# D. L. Dotson, J. Domanski, S. Buchoux, I. M. Kenney, and O. Beckstein. +# MDAnalysis: A Python package for the rapid analysis of molecular dynamics +# simulations. In S. Benthall and S. Rostrup editors, Proceedings of the 15th +# Python in Science Conference, pages 102-109, Austin, TX, 2016. SciPy. +# doi: 10.25080/majora-629e541a-00e +# +# N. Michaud-Agrawal, E. J. Denning, T. B. Woolf, and O. Beckstein. +# MDAnalysis: A Toolkit for the Analysis of Molecular Dynamics Simulations. +# J. Comput. Chem. 32 (2011), 2319--2327, doi:10.1002/jcc.21787 +# + +""" +FHI-AIMS Topology Parser --- :mod:`MDAnalysis.topolgy.FHIAIMSParser` +==================================================================== + +Reads an `FHI-AIMS`_ ``.in`` file and pulls the atom information from it. +Because an FHI-AIMS input file only has atom name information, any +information about residues and segments will not be populated. + +.. _`FHI-AIMS`: https://aimsclub.fhi-berlin.mpg.de/ + + +See Also +-------- +:mod:`MDAnalysis.coordinates.FHIAIMS` + + +Classes +------- + +.. autoclass:: FHIAIMSParser + :members: + :inherited-members: + +""" +from __future__ import absolute_import + +from six.moves import range +import numpy as np + +from . import guessers +from ..lib.util import openany +from .base import TopologyReaderBase +from ..core.topology import Topology +from ..core.topologyattrs import ( + Atomnames, + Atomids, + Atomtypes, + Masses, + Resids, + Resnums, + Segids, + Elements, +) + + +class FHIAIMSParser(TopologyReaderBase): + """Parse a list of atoms from an FHI-AIMS file + + Creates the following attributes: + - Atomnames + + Guesses the following attributes: + - Atomtypes + - Masses + + """ + format = ['IN', 'FHIAIMS'] + + def parse(self, **kwargs): + """Read the file and return the structure. + + Returns + ------- + MDAnalysis Topology object + """ + # FHIAIMS geometry files are only single frames + names = [] + skip_tags = ["#", "lattice_vector", "initial_moment", "velocity"] + with openany(self.filename) as inf: + for line in inf: + line = line.strip() + if line.startswith("atom"): + names.append(line.split()[-1]) + continue + if any([line.startswith(tag) for tag in skip_tags]): + continue + # we are now seeing something that's neither atom nor lattice + raise ValueError( + 'Non-conforming line: ({0})in FHI-AIMS input file {0}'.format(line, self.filename)) + names = np.asarray(names) + natoms = len(names) + + # Guessing time + atomtypes = guessers.guess_types(names) + masses = guessers.guess_masses(names) + + attrs = [Atomnames(names), + Atomids(np.arange(natoms) + 1), + Atomtypes(atomtypes, guessed=True), + Masses(masses, guessed=True), + Resids(np.array([1])), + Resnums(np.array([1])), + Segids(np.array(['SYSTEM'], dtype=object)), + Elements(names)] + + top = Topology(natoms, 1, 1, + attrs=attrs) + + return top diff --git a/package/MDAnalysis/topology/__init__.py b/package/MDAnalysis/topology/__init__.py index a5a21069306..ba4501cb435 100644 --- a/package/MDAnalysis/topology/__init__.py +++ b/package/MDAnalysis/topology/__init__.py @@ -165,6 +165,11 @@ types, icodes, resnames, resids, segids, models + + + FHIAIMS[#a] in names FHIAIMS File Parser. Reads only the labels from atoms + and constructs minimal topology data. + :mod:`MDAnalysis.topology.FHIAIMSParser` ================= ========== ================= =================================================== .. [#a] This format can also be used to provide *coordinates* so that @@ -313,3 +318,4 @@ from . import MinimalParser from . import ITPParser from . import ParmEdParser +from . import FHIAIMSParser diff --git a/package/doc/sphinx/source/documentation_pages/coordinates/FHIAIMS.rst b/package/doc/sphinx/source/documentation_pages/coordinates/FHIAIMS.rst new file mode 100644 index 00000000000..f078e23b1bf --- /dev/null +++ b/package/doc/sphinx/source/documentation_pages/coordinates/FHIAIMS.rst @@ -0,0 +1,2 @@ +.. automodule:: MDAnalysis.coordinates.FHIAIMS + diff --git a/package/doc/sphinx/source/documentation_pages/coordinates_modules.rst b/package/doc/sphinx/source/documentation_pages/coordinates_modules.rst index e74387a4bd6..c0e7c3f3467 100644 --- a/package/doc/sphinx/source/documentation_pages/coordinates_modules.rst +++ b/package/doc/sphinx/source/documentation_pages/coordinates_modules.rst @@ -40,6 +40,7 @@ provide the format in the keyword argument *format* to coordinates/TXYZ coordinates/XTC coordinates/XYZ + coordinates/FHIAIMS coordinates/memory coordinates/chemfiles coordinates/null diff --git a/package/doc/sphinx/source/documentation_pages/topology/FHIAIMSParser.rst b/package/doc/sphinx/source/documentation_pages/topology/FHIAIMSParser.rst new file mode 100644 index 00000000000..2e059709845 --- /dev/null +++ b/package/doc/sphinx/source/documentation_pages/topology/FHIAIMSParser.rst @@ -0,0 +1,2 @@ +.. automodule:: MDAnalysis.topology.FHIAIMSParser + diff --git a/package/doc/sphinx/source/documentation_pages/topology_modules.rst b/package/doc/sphinx/source/documentation_pages/topology_modules.rst index 55740177ff5..f7656467958 100644 --- a/package/doc/sphinx/source/documentation_pages/topology_modules.rst +++ b/package/doc/sphinx/source/documentation_pages/topology_modules.rst @@ -45,6 +45,7 @@ topology file format in the *topology_format* keyword argument to topology/TPRParser topology/TXYZParser topology/XYZParser + topology/FHIAIMSParser .. rubric:: Topology core modules diff --git a/testsuite/MDAnalysisTests/coordinates/reference.py b/testsuite/MDAnalysisTests/coordinates/reference.py index c7439e77840..b195d0d5394 100644 --- a/testsuite/MDAnalysisTests/coordinates/reference.py +++ b/testsuite/MDAnalysisTests/coordinates/reference.py @@ -227,3 +227,4 @@ class RefLAMMPSDataMini(object): vel_atom1 = np.array([-0.005667593, 0.00791380978, -0.00300779533], dtype=np.float32) dimensions = np.array([60., 50., 30., 90., 90., 90.], dtype=np.float32) + diff --git a/testsuite/MDAnalysisTests/coordinates/test_fhiaims.py b/testsuite/MDAnalysisTests/coordinates/test_fhiaims.py new file mode 100644 index 00000000000..5295697d405 --- /dev/null +++ b/testsuite/MDAnalysisTests/coordinates/test_fhiaims.py @@ -0,0 +1,245 @@ +# -*- Mode: python; tab-width: 4; indent-tabs-mode:nil; coding:utf-8 -*- +# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 fileencoding=utf-8 +# +# MDAnalysis --- https://www.mdanalysis.org +# Copyright (c) 2006-2017 The MDAnalysis Development Team and contributors +# (see the file AUTHORS for the full list of names) +# +# Released under the GNU Public Licence, v2 or any higher version +# +# Please cite your use of MDAnalysis in published work: +# +# R. J. Gowers, M. Linke, J. Barnoud, T. J. E. Reddy, M. N. Melo, S. L. Seyler, +# D. L. Dotson, J. Domanski, S. Buchoux, I. M. Kenney, and O. Beckstein. +# MDAnalysis: A Python package for the rapid analysis of molecular dynamics +# simulations. In S. Benthall and S. Rostrup editors, Proceedings of the 15th +# Python in Science Conference, pages 102-109, Austin, TX, 2016. SciPy. +# doi: 10.25080/majora-629e541a-00e +# +# N. Michaud-Agrawal, E. J. Denning, T. B. Woolf, and O. Beckstein. +# MDAnalysis: A Toolkit for the Analysis of Molecular Dynamics Simulations. +# J. Comput. Chem. 32 (2011), 2319--2327, doi:10.1002/jcc.21787 +# +from __future__ import absolute_import + +import pytest +from six import StringIO +from six.moves import zip +import os + +import MDAnalysis as mda +import numpy as np +from MDAnalysisTests import make_Universe +from MDAnalysisTests.coordinates.base import ( + _SingleFrameReader, BaseWriterTest) +from MDAnalysisTests.datafiles import FHIAIMS +from numpy.testing import (assert_equal, + assert_array_almost_equal, + assert_almost_equal) + +from six import StringIO + + +@pytest.fixture(scope='module') +def universe(): + return mda.Universe(FHIAIMS, FHIAIMS) + + +@pytest.fixture(scope='module') +def universe_from_one_file(): + return mda.Universe(FHIAIMS) + + +@pytest.fixture(scope='module') +def ref(): + return RefFHIAIMS() + + +@pytest.fixture(scope='class') +def good_input_natural_units(): + buffer = 'atom 0.1 0.2 0.3 H\natom 0.2 0.4 0.6 H\natom 0.3 0.6 0.9 H' + return StringIO(buffer) + + +@pytest.fixture(scope='class') +def good_input_with_velocity(): + buffer = 'atom 0.1 0.1 0.1 H\nvelocity 0.1 0.1 0.1' + return StringIO(buffer) + + +class RefFHIAIMS(object): + from MDAnalysis.coordinates.FHIAIMS import (FHIAIMSReader, FHIAIMSWriter) + + filename, trajectory, topology = [FHIAIMS] * 3 + reader, writer = FHIAIMSReader, FHIAIMSWriter + pos_atom1 = np.asarray( + [6.861735, 2.103823, 37.753513], dtype=np.float32) + dimensions = np.asarray( + [18.6, 18.6, 55.8, 90., 90., 90.], dtype=np.float32) + n_atoms = 6 + n_frames = 1 + time = 0.0 + ext = '.in' + prec = 6 + container_format = True + changing_dimensions = False + + +class TestFHIAIMSReader(object): + + prec = 6 + + @pytest.fixture(scope='class') + def bad_input_missing_periodicity(self): + buffer = 'lattice_vector 1.0 0.0 0.0\nlattice_vector 0.0 1.0 0.0\natom 0.1 0.1 0.1 H' + return StringIO(buffer) + + @pytest.fixture(scope='class') + def bad_input_relative_positions_no_box(self): + buffer = 'atom_frac 0.1 0.1 0.1 H' + return StringIO(buffer) + + @pytest.fixture(scope='class') + def bad_input_missing_velocity(self): + buffer = 'atom 0.1 0.1 0.1 H\natom 0.2 0.2 0.2 H\nvelocity 0.1 0.1 0.1' + return StringIO(buffer) + + @pytest.fixture(scope='class') + def bad_input_velocity_wrong_position(self): + buffer = 'atom 0.1 0.1 0.1 H\natom 0.2 0.2 0.2 H\nvelocity 0.1 0.1 0.1\nvelocity 0.1 0.1 0.1' + return StringIO(buffer) + + @pytest.fixture(scope='class') + def bad_input_wrong_input_line(self): + buffer = 'garbage' + return StringIO(buffer) + + @pytest.fixture(scope='class') + def good_input_mixed_units(self): + buffer = 'lattice_vector 1.0 0.0 0.0\nlattice_vector 0.0 2.0 0.0\nlattice_vector 0.0 0.0 3.0\natom 0.1 0.2 0.3 H\natom_frac 0.2 0.2 0.2 H\natom_frac 0.3 0.3 0.3 H' + return StringIO(buffer) + + def test_single_file(self, universe, universe_from_one_file): + assert_almost_equal(universe.atoms.positions, universe_from_one_file.atoms.positions, + self.prec, "FHIAIMSReader failed to load universe from single file") + + def test_uses_FHIAIMSReader(self, universe): + from MDAnalysis.coordinates.FHIAIMS import FHIAIMSReader + + assert isinstance(universe.trajectory, + FHIAIMSReader), "failed to choose FHIAIMSReader" + + def test_dimensions(self, ref, universe): + assert_almost_equal( + ref.dimensions, universe.dimensions, + self.prec, "FHIAIMSReader failed to get unitcell dimensions") + + def test_n_atoms(self, ref, universe): + assert_equal( + ref.n_atoms, universe.trajectory.n_atoms, + "FHIAIMSReader failed to get the right number of atoms") + + def test_fhiaims_positions(self, ref, universe): + # first particle + assert_almost_equal(ref.pos_atom1, + universe.atoms.positions[0], + self.prec, + "FHIAIMSReader failed to read coordinates properly") + + def test_n_frames(self, ref, universe): + assert_equal(ref.n_frames, universe.trajectory.n_frames, + "wrong number of frames") + + def test_time(self, ref, universe): + assert_equal(ref.time, universe.trajectory.time, + "wrong time of the frame") + + def test_bad_input_missing_periodicity(self, bad_input_missing_periodicity): + with pytest.raises(ValueError, match="Found partial periodicity"): + u = mda.Universe(bad_input_missing_periodicity, format="FHIAIMS") + + def test_bad_input_relative_positions_no_box(self, bad_input_relative_positions_no_box): + with pytest.raises(ValueError, match="Found relative coordinates in FHI-AIMS file without lattice info"): + u = mda.Universe( + bad_input_relative_positions_no_box, format="FHIAIMS") + + def test_bad_input_missing_velocity(self, bad_input_missing_velocity): + with pytest.raises(ValueError, match="Found incorrect number of velocity tags"): + u = mda.Universe(bad_input_missing_velocity, format="FHIAIMS") + + def test_bad_input_velocity_wrong_position(self, bad_input_velocity_wrong_position): + with pytest.raises(ValueError, match="Non-conforming line .velocity must follow"): + u = mda.Universe( + bad_input_velocity_wrong_position, format="FHIAIMS") + + def test_bad_input_wrong_input_line(self, bad_input_wrong_input_line): + with pytest.raises(ValueError, match="Non-conforming line"): + u = mda.Universe(bad_input_wrong_input_line, format="FHIAIMS") + + def test_good_input_with_velocity(self, good_input_with_velocity): + u = mda.Universe(good_input_with_velocity, format="FHIAIMS") + assert_almost_equal(u.atoms.velocities[0], + np.asarray([0.1, 0.1, 0.1]), + self.prec, + "FHIAIMSReader failed to read velocities properly") + + def test_mixed_units(self, good_input_natural_units, good_input_mixed_units): + u_natural = mda.Universe(good_input_natural_units, format="FHIAIMS") + u_mixed = mda.Universe(good_input_mixed_units, format="FHIAIMS") + print(u_natural.atoms.positions) + print(u_mixed.atoms.positions) + assert_almost_equal(u_natural.atoms.positions, + u_mixed.atoms.positions, + self.prec, + "FHIAIMSReader failed to read positions in lattice units properly") + + +class TestFHIAIMSWriter(BaseWriterTest): + prec = 6 + ext = ".in" + + @pytest.fixture + def outfile(self, tmpdir): + return str(tmpdir.mkdir("FHIAIMSWriter").join('primitive-fhiaims-writer' + self.ext)) + + def test_writer(self, universe, outfile): + """Test writing from a single frame FHIAIMS file to a FHIAIMS file. + """ + universe.atoms.write(outfile) + u = mda.Universe(FHIAIMS, outfile) + assert_almost_equal(u.atoms.positions, + universe.atoms.positions, self.prec, + err_msg="Writing FHIAIMS file with FHIAIMSWriter " + "does not reproduce original coordinates") + + def test_writer_with_velocity(self, good_input_with_velocity, outfile): + """Test writing from a single frame FHIAIMS file to a FHIAIMS file. + """ + universe_in = mda.Universe(good_input_with_velocity, format="FHIAIMS") + universe_in.atoms.write(outfile) + u = mda.Universe(outfile) + assert_almost_equal(u.atoms.velocities, + universe_in.atoms.velocities, self.prec, + err_msg="Writing FHIAIMS file with FHIAIMSWriter " + "does not reproduce original velocities") + + def test_writer_no_atom_names(self, u_no_names, outfile): + u_no_names.atoms.write(outfile) + u = mda.Universe(outfile) + expected = np.array(['X'] * u_no_names.atoms.n_atoms) + assert_equal(u.atoms.names, expected) + + def test_writer_with_n_atoms_none(self, good_input_natural_units, outfile): + u = mda.Universe(good_input_natural_units, format="FHIAIMS") + with mda.Writer(outfile, natoms=None) as w: + w.write(u.atoms) + with open(outfile, 'r') as fhiaimsfile: + line = fhiaimsfile.readline().strip() + assert line.startswith( + 'atom'), "Line written incorrectly with FHIAIMSWriter" + assert line.endswith( + 'H'), "Line written incorrectly with FHIAIMSWriter" + line = np.asarray(line.split()[1:-1], dtype=np.float32) + assert_almost_equal(line, [0.1, 0.2, 0.3], self.prec, + err_msg="Writing FHIAIMS file with FHIAIMSWriter " + "does not reproduce original positions") diff --git a/testsuite/MDAnalysisTests/data/fhiaims.in b/testsuite/MDAnalysisTests/data/fhiaims.in new file mode 100644 index 00000000000..5cafb215fe1 --- /dev/null +++ b/testsuite/MDAnalysisTests/data/fhiaims.in @@ -0,0 +1,10 @@ +# a comment line +lattice_vector 18.600000000000 0.000000000000 0.000000000000 +lattice_vector 0.000000000000 18.600000000000 0.000000000000 +lattice_vector 0.000000000000 0.000000000000 55.800000000000 +atom 6.861735000000 2.103823000000 37.753513000000 O +atom 7.119867000000 2.218342000000 36.808137000000 H +atom 7.394193000000 1.415300000000 38.335713000000 H +atom 11.150187000000 3.314877000000 38.741698000000 O +atom 10.441989000000 3.154266000000 39.417404000000 H +atom 12.040210000000 3.004673000000 39.173454000000 H diff --git a/testsuite/MDAnalysisTests/datafiles.py b/testsuite/MDAnalysisTests/datafiles.py index 5ebe8c74bb6..ae0389b55f8 100644 --- a/testsuite/MDAnalysisTests/datafiles.py +++ b/testsuite/MDAnalysisTests/datafiles.py @@ -189,6 +189,7 @@ "PDB_CRYOEM_BOX", # Issue 2599, Issue #2679, PR #2685 "PDB_CHECK_RIGHTHAND_PA", # for testing right handedness of principal_axes "MMTF_NOCRYST", # File with meaningless CRYST1 record (Issue #2679, PR #2685) + "FHIAIMS", # to test FHIAIMS coordinate files ] from pkg_resources import resource_filename @@ -276,6 +277,7 @@ PDB_icodes = resource_filename(__name__, 'data/1osm.pdb.gz') PDB_CRYOEM_BOX = resource_filename(__name__, 'data/5a7u.pdb') PDB_CHECK_RIGHTHAND_PA = resource_filename(__name__, 'data/6msm.pdb.bz2') +FHIAIMS = resource_filename(__name__, 'data/fhiaims.in') GRO = resource_filename(__name__, 'data/adk_oplsaa.gro') GRO_velocity = resource_filename(__name__, 'data/sample_velocity_file.gro') diff --git a/testsuite/MDAnalysisTests/topology/test_fhiaims.py b/testsuite/MDAnalysisTests/topology/test_fhiaims.py new file mode 100644 index 00000000000..0c47a07d454 --- /dev/null +++ b/testsuite/MDAnalysisTests/topology/test_fhiaims.py @@ -0,0 +1,56 @@ +# -*- Mode: python; tab-width: 4; indent-tabs-mode:nil; coding:utf-8 -*- +# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 fileencoding=utf-8 +# +# MDAnalysis --- https://www.mdanalysis.org +# Copyright (c) 2006-2017 The MDAnalysis Development Team and contributors +# (see the file AUTHORS for the full list of names) +# +# Released under the GNU Public Licence, v2 or any higher version +# +# Please cite your use of MDAnalysis in published work: +# +# R. J. Gowers, M. Linke, J. Barnoud, T. J. E. Reddy, M. N. Melo, S. L. Seyler, +# D. L. Dotson, J. Domanski, S. Buchoux, I. M. Kenney, and O. Beckstein. +# MDAnalysis: A Python package for the rapid analysis of molecular dynamics +# simulations. In S. Benthall and S. Rostrup editors, Proceedings of the 15th +# Python in Science Conference, pages 102-109, Austin, TX, 2016. SciPy. +# doi: 10.25080/majora-629e541a-00e +# +# N. Michaud-Agrawal, E. J. Denning, T. B. Woolf, and O. Beckstein. +# MDAnalysis: A Toolkit for the Analysis of Molecular Dynamics Simulations. +# J. Comput. Chem. 32 (2011), 2319--2327, doi:10.1002/jcc.21787 +# +from __future__ import absolute_import + +from numpy.testing import assert_equal, assert_almost_equal + +import MDAnalysis as mda + +from MDAnalysisTests.topology.base import ParserBase +from MDAnalysisTests.datafiles import FHIAIMS + +class TestFHIAIMS(ParserBase): + parser = mda.topology.FHIAIMSParser.FHIAIMSParser + expected_attrs = ['names', 'elements'] + guessed_attrs = ['masses', 'types'] + expected_n_residues = 1 + expected_n_segments = 1 + expected_n_atoms = 6 + ref_filename = FHIAIMS + + def test_names(self, top): + assert_equal(top.names.values, + ['O', 'H', 'H', 'O', 'H', 'H']) + + def test_types(self, top): + assert_equal(top.types.values, + ['O', 'H', 'H', 'O', 'H', 'H']) + + def test_elements(self, top): + assert_equal(top.elements.values, + ['O', 'H', 'H', 'O', 'H', 'H']) + + def test_masses(self, top): + assert_almost_equal(top.masses.values, + [15.999, 1.008, 1.008, 15.999, + 1.008, 1.008]) From 7b7e1d32239983078d926c14195debc5a1ab61c4 Mon Sep 17 00:00:00 2001 From: Oliver Beckstein Date: Fri, 5 Jun 2020 01:00:01 -0700 Subject: [PATCH 27/34] topology format table and docs update - add links to codes in table - fixed MMTF formatting (can only have one line in first cell in the simple reST layout) - FHI-AIMS formatted - added page for GSD format (had been previously forgotten) --- package/MDAnalysis/topology/__init__.py | 68 +++++++++++-------- .../topology/GSDParser.rst | 1 + .../documentation_pages/topology_modules.rst | 3 +- 3 files changed, 44 insertions(+), 28 deletions(-) create mode 100644 package/doc/sphinx/source/documentation_pages/topology/GSDParser.rst diff --git a/package/MDAnalysis/topology/__init__.py b/package/MDAnalysis/topology/__init__.py index ba4501cb435..b12bddaf9a0 100644 --- a/package/MDAnalysis/topology/__init__.py +++ b/package/MDAnalysis/topology/__init__.py @@ -47,14 +47,14 @@ ================= ========== ================= =================================================== Name extension attributes remarks ================= ========== ================= =================================================== - CHARMM/XPLOR PSF psf resnames, :mod:`MDAnalysis.topology.PSFParser` - names, types, + CHARMM/XPLOR PSF psf resnames, CHARMM_/XPLOR/NAMD_ topology format; + names, types, :mod:`MDAnalysis.topology.PSFParser` charges, bonds, angles, dihedrals, impropers - CHARMM CARD [#a]_ crd names, "CARD" coordinate output from CHARMM; deals with + CHARMM CARD [#a]_ crd names, "CARD" coordinate output from CHARMM_; deals with tempfactors, either standard or EXTended format; resnames, :mod:`MDAnalysis.topology.CRDParser` @@ -68,19 +68,19 @@ resnames, segids, - XPDB [#a]_ pdb As PDB except Extended PDB format (can use 5-digit residue - icodes numbers). To use, specify the format "XPBD" - explicitly: + XPDB [#a]_ pdb As PDB except Extended PDB format as used by e.g., NAMD_ + icodes (can use 5-digit residue numbers). To use, specify + the format "XPBD" explicitly: ``Universe(..., topology_format="XPDB")``. Module :mod:`MDAnalysis.coordinates.PDB` PQR [#a]_ pqr names, charges, PDB-like but whitespace-separated files with charge - types, and radius information; + types, and radius information as used by, e.g., APBS_. radii, resids, :mod:`MDAnalysis.topology.PQRParser` resnames, icodes, segids - PDBQT [#a]_ pdbqt names, types, file format used by AutoDock with atom types and + PDBQT [#a]_ pdbqt names, types, file format used by AutoDock_ with atom types and altLocs, charges, partial charges. Module: resnames, :mod:`MDAnalysis.topology.PDBQTParser` resids, @@ -89,27 +89,27 @@ tempfactors, segids, - GROMOS96 [#a]_ gro names, resids, GROMOS96 coordinate file; + GROMOS96 [#a]_ gro names, resids, GROMOS96 coordinate file (used e.g., by Gromacs_) resnames, :mod:`MDAnalysis.topology.GROParser` - AMBER top, names, charges simple AMBER format reader (only supports a subset + Amber top, names, charges simple Amber_ format reader (only supports a subset prmtop, type_indices, of flags); parm7 types, :mod:`MDAnalysis.topology.TOPParser` resnames, - DESRES [#a]_ dms names, numbers, DESRES molecular sturcture reader (only supports - masses, charges, the atom and bond records); + DESRES [#a]_ dms names, numbers, DESRES molecular structure reader (only supports + masses, charges, the atom and bond records) as used by Desmond_ and Anton; chainids, resids, :mod:`MDAnalysis.topology.DMSParser` resnames, segids, radii, - TPR [#b]_ tpr names, types, Gromacs portable run input reader (limited + TPR [#b]_ tpr names, types, Gromacs_ portable run input reader (limited resids, resnames, experimental support for some of the more recent charges, bonds, versions of the file format); masses, moltypes, :mod:`MDAnalysis.topology.TPRParser` molnums - - ITP itp names, types, Gromacs include topology file; + + ITP itp names, types, Gromacs_ include topology file; resids, resnames, :mod:`MDAnalysis.topology.ITPParser` charges, bonds, masses, segids, @@ -121,28 +121,28 @@ charges, bonds, resnames, - LAMMPS [#a]_ data ids, types, LAMMPS Data file parser + LAMMPS [#a]_ data ids, types, LAMMPS_ Data file parser masses, charges, :mod:`MDAnalysis.topology.LAMMPSParser` resids, bonds, angles, dihedrals - LAMMPS [#a]_ lammpsdump id, masses LAMMPS ascii dump file reader + LAMMPS [#a]_ lammpsdump id, masses LAMMPS_ ascii dump file reader :mod:`MDAnalysis.topology.LAMMPSParser` XYZ [#a]_ xyz names XYZ File Parser. Reads only the labels from atoms and constructs minimal topology data. :mod:`MDAnalysis.topology.XYZParser` - TXYZ [#a]_ txyz, names, atomids, Tinker XYZ File Parser. Reads atom labels, numbers + TXYZ [#a]_ txyz, names, atomids, Tinker_ XYZ File Parser. Reads atom labels, numbers arc masses, types, and connectivity; masses are guessed from atoms names. bonds :mod:`MDAnalysis.topology.TXYZParser` - GAMESS [#a]_ gms, names, GAMESS output parser. Read only atoms of assembly + GAMESS [#a]_ gms, names, GAMESS_ output parser. Read only atoms of assembly log atomic charges, section (atom, elems and coords) and construct topology. :mod:`MDAnalysis.topology.GMSParser` - DL_Poly [#a]_ config, ids, names DL_Poly CONFIG or HISTORY file. Reads only the + DL_POLY [#a]_ config, ids, names `DL_POLY`_ CONFIG or HISTORY file. Reads only the history atom names. If atoms are written out of order, will correct the order. :mod:`MDAnalysis.topology.DLPolyParser` @@ -152,23 +152,22 @@ bonds, angles, angles, and dihedrals. dihedrals :mod:`MDAnalysis.topology.HoomdXMLParser` - GSD [#a]_ gsd types, charges, GSD topology file. Reads atom types, + GSD [#a]_ gsd types, charges, HOOMD_ GSD topology file. Reads atom types, radii, masses masses, and charges if possible. Also reads bonds, bonds, angles, angles, and dihedrals. dihedrals :mod:`MDAnalysis.topology.GSDParser` - Macromolecular mmtf altLocs, `Macromolecular Transmission Format (MMTF)`_. - transmission bfactors, bonds, An efficient compact format for biomolecular - format charges, masses, structures. + MMTF [#a]_ mmtf altLocs, `Macromolecular Transmission Format (MMTF)`_. An + bfactors, bonds, efficient compact format for biomolecular + charges, masses, structures. names, occupancies, types, icodes, resnames, resids, segids, models - - FHIAIMS[#a] in names FHIAIMS File Parser. Reads only the labels from atoms - and constructs minimal topology data. + FHIAIMS [#a]_ in names `FHI-AIMS`_ File Parser. Reads only the labels from + atoms and constructs minimal topology data. :mod:`MDAnalysis.topology.FHIAIMSParser` ================= ========== ================= =================================================== @@ -188,8 +187,23 @@ :ref:`Coordinates` with the :ref:`Supported coordinate formats` +.. _CHARMM: https://www.charmm.org/charmm/ .. _HOOMD XML: http://codeblue.umich.edu/hoomd-blue/doc/page_xml_file_format.html +.. _HOOMD: http://glotzerlab.engin.umich.edu/hoomd-blue/ +.. _NAMD: http://www.ks.uiuc.edu/Research/namd/ +.. _LAMMPS: https://lammps.sandia.gov/ +.. _Gromacs: http://www.gromacs.org/ +.. _Amber: http://ambermd.org/ +.. _Desmond: https://www.deshawresearch.com/resources_desmond.html +.. _Tinker: https://dasher.wustl.edu/tinker/ +.. _DL_POLY: https://www.scd.stfc.ac.uk/Pages/DL_POLY.aspx +.. _AutoDock: http://autodock.scripps.edu/ +.. _APBS: https://apbs-pdb2pqr.readthedocs.io/en/latest/apbs/ .. _Macromolecular Transmission Format (MMTF): https://mmtf.rcsb.org/ +.. _FHI-AIMS: https://aimsclub.fhi-berlin.mpg.de/ +.. _GAMESS: https://www.msg.chem.iastate.edu/gamess/ + + .. _topology-parsers-developer-notes: diff --git a/package/doc/sphinx/source/documentation_pages/topology/GSDParser.rst b/package/doc/sphinx/source/documentation_pages/topology/GSDParser.rst new file mode 100644 index 00000000000..50f4d2f4a92 --- /dev/null +++ b/package/doc/sphinx/source/documentation_pages/topology/GSDParser.rst @@ -0,0 +1 @@ +.. automodule:: MDAnalysis.topology.GSDParser diff --git a/package/doc/sphinx/source/documentation_pages/topology_modules.rst b/package/doc/sphinx/source/documentation_pages/topology_modules.rst index f7656467958..ed8caba8ce6 100644 --- a/package/doc/sphinx/source/documentation_pages/topology_modules.rst +++ b/package/doc/sphinx/source/documentation_pages/topology_modules.rst @@ -28,8 +28,10 @@ topology file format in the *topology_format* keyword argument to topology/CRDParser topology/DLPolyParser topology/DMSParser + topology/FHIAIMSParser topology/GMSParser topology/GROParser + topology/GSDParser topology/HoomdXMLParser topology/ITPParser topology/LAMMPSParser @@ -45,7 +47,6 @@ topology file format in the *topology_format* keyword argument to topology/TPRParser topology/TXYZParser topology/XYZParser - topology/FHIAIMSParser .. rubric:: Topology core modules From 8d7d77f45e3de293b27217f75cf5990c1582d9ce Mon Sep 17 00:00:00 2001 From: Irfan Alibay Date: Fri, 5 Jun 2020 11:33:13 +0100 Subject: [PATCH 28/34] Remove lib.mdamath._angle (#2712) Removes unused lib.mdamath._angle() (Issue #2650) --- package/CHANGELOG | 1 + package/MDAnalysis/lib/mdamath.py | 16 ++-------------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/package/CHANGELOG b/package/CHANGELOG index 2974e1cb19e..ff8e564c60e 100644 --- a/package/CHANGELOG +++ b/package/CHANGELOG @@ -138,6 +138,7 @@ Enhancements the capability to allow intermittent behaviour (PR #2256) Changes + * Unused `MDAnalysis.lib.mdamath._angle` has been removed (Issue #2650) * Refactored dihedral selections and Ramachandran.__init__ to speed up dihedral selections for faster tests (Issue #2671, PR #2706) * Removes the deprecated `t0`, `tf`, and `dtmax` from diff --git a/package/MDAnalysis/lib/mdamath.py b/package/MDAnalysis/lib/mdamath.py index 334e5e2b240..b2e731ac9da 100644 --- a/package/MDAnalysis/lib/mdamath.py +++ b/package/MDAnalysis/lib/mdamath.py @@ -40,6 +40,8 @@ .. autofunction:: find_fragments .. versionadded:: 0.11.0 +.. versionchanged: 1.0.0 + Unused function :func:`_angle()` has now been removed. """ from __future__ import division, absolute_import from six.moves import zip @@ -146,20 +148,6 @@ def dihedral(ab, bc, cd): return (x if stp(ab, bc, cd) <= 0.0 else -x) -def _angle(a, b): - """Angle between two vectors *a* and *b* in degrees. - - If one of the lengths is 0 then the angle is returned as 0 - (instead of `nan`). - """ - # This function has different limits than angle? - - angle = np.arccos(np.dot(a, b) / (norm(a) * norm(b))) - if np.isnan(angle): - return 0.0 - return np.rad2deg(angle) - - def sarrus_det(matrix): """Computes the determinant of a 3x3 matrix according to the `rule of Sarrus`_. From abfd74814ceb7b98ef66f9351e861463b74cf845 Mon Sep 17 00:00:00 2001 From: Richard Gowers Date: Sat, 6 Jun 2020 13:48:31 +0100 Subject: [PATCH 29/34] Issue 2656 nsgrid segfault (#2665) segfaulting on zero box size Co-authored-by: Lily Wang <31115101+lilyminium@users.noreply.github.com> --- package/CHANGELOG | 3 ++ package/MDAnalysis/lib/nsgrid.pyx | 23 +++++----- testsuite/MDAnalysisTests/lib/test_nsgrid.py | 45 +++++++++++++++++++- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/package/CHANGELOG b/package/CHANGELOG index ff8e564c60e..94a9e86e2d4 100644 --- a/package/CHANGELOG +++ b/package/CHANGELOG @@ -23,6 +23,9 @@ mm/dd/yy richardjgowers, kain88-de, lilyminium, p-j-smith, bdice, joaomcteixeira * 0.21.0 Fixes + * Fixed select_atoms("around 0.0 ...") selections and capped_distance + causing a segfault + (Issue #2656 PR #2665) * `PDBWriter` writes unitary `CRYST1` record (cubic box with sides of 1 Å) when `u.dimensions` is `None` or `np.zeros(6)` (Issue #2679, PR #2685) * Ensures principal_axes() returns axes with the right hand convention (Issue #2637) diff --git a/package/MDAnalysis/lib/nsgrid.pyx b/package/MDAnalysis/lib/nsgrid.pyx index 373c48c538a..eadd8edf6ef 100644 --- a/package/MDAnalysis/lib/nsgrid.pyx +++ b/package/MDAnalysis/lib/nsgrid.pyx @@ -535,7 +535,7 @@ cdef class _NSGrid(object): """ - cdef readonly dreal cutoff # cutoff + cdef readonly dreal used_cutoff # cutoff used for cell creation cdef ns_int size # total cells cdef ns_int ncoords # number of coordinates cdef ns_int[DIM] ncells # individual cells in every dimension @@ -570,27 +570,30 @@ cdef class _NSGrid(object): cdef ns_int xi, yi, zi cdef real bbox_vol cdef dreal relative_cutoff_margin + cdef dreal original_cutoff self.ncoords = ncoords - # Calculate best cutoff - self.cutoff = cutoff if not force: + # Calculate best cutoff, with 0.01A minimum + cutoff = max(cutoff, 0.01) + original_cutoff = cutoff # First, we add a small margin to the cell size so that we can safely # use the condition d <= cutoff (instead of d < cutoff) for neighbor # search. relative_cutoff_margin = 1.0e-8 - while self.cutoff == cutoff: - self.cutoff = cutoff * (1.0 + relative_cutoff_margin) + while cutoff == original_cutoff: + cutoff = cutoff * (1.0 + relative_cutoff_margin) relative_cutoff_margin *= 10.0 bbox_vol = box.c_pbcbox.box[XX][XX] * box.c_pbcbox.box[YY][YY] * box.c_pbcbox.box[YY][YY] - while bbox_vol/self.cutoff**3 > max_size: - self.cutoff *= 1.2 + while bbox_vol / cutoff**3 > max_size: + cutoff *= 1.2 for i in range(DIM): - self.ncells[i] = (box.c_pbcbox.box[i][i] / self.cutoff) + self.ncells[i] = (box.c_pbcbox.box[i][i] / cutoff) self.cellsize[i] = box.c_pbcbox.box[i][i] / self.ncells[i] self.size = self.ncells[XX] * self.ncells[YY] * self.ncells[ZZ] + self.used_cutoff = cutoff self.cell_offsets[XX] = 0 self.cell_offsets[YY] = self.ncells[XX] @@ -706,6 +709,7 @@ cdef class FastNS(object): cdef real[:, ::1] coords cdef real[:, ::1] coords_bbox cdef readonly dreal cutoff + cdef _NSGrid grid cdef ns_int max_gridsize cdef bint periodic @@ -863,8 +867,7 @@ cdef class FastNS(object): # Generate another grid to search searchcoords = search_coords.astype(np.float32, order='C', copy=False) searchcoords_bbox = self.box.fast_put_atoms_in_bbox(searchcoords) - searchgrid = _NSGrid(searchcoords_bbox.shape[0], self.grid.cutoff, self.box, self.max_gridsize, force=True) - searchgrid.fill_grid(searchcoords_bbox) + searchgrid = _NSGrid(searchcoords_bbox.shape[0], self.grid.used_cutoff, self.box, self.max_gridsize, force=True) size_search = searchcoords.shape[0] diff --git a/testsuite/MDAnalysisTests/lib/test_nsgrid.py b/testsuite/MDAnalysisTests/lib/test_nsgrid.py index d3bcc9d82bd..fb0035a5442 100644 --- a/testsuite/MDAnalysisTests/lib/test_nsgrid.py +++ b/testsuite/MDAnalysisTests/lib/test_nsgrid.py @@ -29,7 +29,7 @@ import numpy as np import MDAnalysis as mda -from MDAnalysisTests.datafiles import GRO, Martini_membrane_gro +from MDAnalysisTests.datafiles import GRO, Martini_membrane_gro, PDB from MDAnalysis.lib import nsgrid @@ -231,3 +231,46 @@ def test_nsgrid_probe_close_to_box_boundary(): expected_dists = np.array([2.3689647], dtype=np.float64) assert_equal(results.get_pairs(), expected_pairs) assert_allclose(results.get_pair_distances(), expected_dists, rtol=1.e-6) + + +def test_zero_max_dist(): + # see issue #2656 + # searching with max_dist = 0.0 shouldn't cause segfault (and infinite subboxes) + ref = np.array([1.0, 1.0, 1.0], dtype=np.float32) + conf = np.array([2.0, 1.0, 1.0], dtype=np.float32) + + box = np.array([10., 10., 10., 90., 90., 90.], dtype=np.float32) + + res = mda.lib.distances._nsgrid_capped(ref, conf, box=box, max_cutoff=0.0) + + +@pytest.fixture() +def u_pbc_triclinic(): + u = mda.Universe(PDB) + u.dimensions = [10, 10, 10, 60, 60, 60] + return u + + +def test_around_res(u_pbc_triclinic): + # sanity check for issue 2656, shouldn't segfault (obviously) + ag = u_pbc_triclinic.select_atoms('around 0.0 resid 3') + assert len(ag) == 0 + + +def test_around_overlapping(): + # check that around 0.0 catches when atoms *are* superimposed + u = mda.Universe.empty(60, trajectory=True) + xyz = np.zeros((60, 3)) + x = np.tile(np.arange(12), (5,))+np.repeat(np.arange(5)*100, 12) + # x is 5 images of 12 atoms + + xyz[:, 0] = x # y and z are 0 + u.load_new(xyz) + + u.dimensions = [100, 100, 100, 60, 60, 60] + # Technically true but not what we're testing: + # dist = mda.lib.distances.distance_array(u.atoms[:12].positions, + # u.atoms[12:].positions, + # box=u.dimensions) + # assert np.count_nonzero(np.any(dist <= 0.0, axis=0)) == 48 + assert u.select_atoms('around 0.0 index 0:11').n_atoms == 48 From 736a12c35e9688a5b113d5ca0c54be8d1617d36b Mon Sep 17 00:00:00 2001 From: Tyler Reddy Date: Sat, 6 Jun 2020 06:53:48 -0600 Subject: [PATCH 30/34] CI: enable more win32 Azure tests (#2713) * add `pip` install of `scikit-learn` for Azure 32-bit Windows testing, so that a few more tests are probed there (+11 more tests pass instead of skip; approx. 45 seconds added to test suite in testing on my fork) * clean up the `pip install` line in Azure CI to span several alphabetically-sorted lines for readability (achieved using yaml multiline folding: https://yaml-multiline.info/ ); this is similar to the approach suggested for i.e., `Dockerfile`s that have long `apt-get` dependency install lines * use `rsx` pytest flag to report some details on the test skips (etc.) in Azure CI Windows 32-bit --- azure-pipelines.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 2f5c803ae0c..bab483e5326 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -34,7 +34,15 @@ jobs: architecture: $(PYTHON_ARCH) - script: python -m pip install --upgrade pip setuptools wheel displayName: 'Install tools' - - script: python -m pip install numpy scipy cython pytest pytest-xdist matplotlib + - script: >- + python -m pip install + cython + matplotlib + numpy + pytest + pytest-xdist + scikit-learn + scipy displayName: 'Install dependencies' - powershell: | cd package @@ -45,5 +53,5 @@ jobs: displayName: 'Build MDAnalysis' - powershell: | cd testsuite - pytest .\MDAnalysisTests --disable-pytest-warnings -n 2 + pytest .\MDAnalysisTests --disable-pytest-warnings -n 2 -rsx displayName: 'Run MDAnalysis Test Suite' From 8c06037ec8298e729eb7e93c82c514c54c00bcd0 Mon Sep 17 00:00:00 2001 From: Lily Wang <31115101+lilyminium@users.noreply.github.com> Date: Sun, 7 Jun 2020 00:56:58 +1000 Subject: [PATCH 31/34] Make NoDataError subclass of AttributeError and add informative error messages (#2636) * made NoDataError subclass of AttributeError * getattr checks for topologyattr-related info in raising errors * special-cased timestep properties --- package/CHANGELOG | 7 +- package/MDAnalysis/__init__.py | 5 +- package/MDAnalysis/coordinates/base.py | 8 + package/MDAnalysis/core/groups.py | 238 +++++++++++------- package/MDAnalysis/core/topologyattrs.py | 27 +- package/MDAnalysis/exceptions.py | 11 +- .../core/test_group_traj_access.py | 2 +- testsuite/MDAnalysisTests/core/test_groups.py | 88 +++++++ 8 files changed, 281 insertions(+), 105 deletions(-) diff --git a/package/CHANGELOG b/package/CHANGELOG index 94a9e86e2d4..5161c00ea4a 100644 --- a/package/CHANGELOG +++ b/package/CHANGELOG @@ -23,9 +23,12 @@ mm/dd/yy richardjgowers, kain88-de, lilyminium, p-j-smith, bdice, joaomcteixeira * 0.21.0 Fixes + * Added more informative error messages about topology attributes + (Issue #2565) + * Made NoDataError a subclass of ValueError *and* AttributeError + (Issue #2635) * Fixed select_atoms("around 0.0 ...") selections and capped_distance - causing a segfault - (Issue #2656 PR #2665) + causing a segfault (Issue #2656 PR #2665) * `PDBWriter` writes unitary `CRYST1` record (cubic box with sides of 1 Å) when `u.dimensions` is `None` or `np.zeros(6)` (Issue #2679, PR #2685) * Ensures principal_axes() returns axes with the right hand convention (Issue #2637) diff --git a/package/MDAnalysis/__init__.py b/package/MDAnalysis/__init__.py index ec5ce33cb7e..ad5fb648c42 100644 --- a/package/MDAnalysis/__init__.py +++ b/package/MDAnalysis/__init__.py @@ -177,7 +177,10 @@ _SELECTION_WRITERS = {} _CONVERTERS = {} # Registry of TopologyAttributes -_TOPOLOGY_ATTRS = {} +_TOPOLOGY_ATTRS = {} # {attrname: cls} +_TOPOLOGY_TRANSPLANTS = {} # {name: [attrname, method, transplant class]} +_TOPOLOGY_ATTRNAMES = {} # {lower case name w/o _ : name} + # Storing anchor universes for unpickling groups import weakref diff --git a/package/MDAnalysis/coordinates/base.py b/package/MDAnalysis/coordinates/base.py index 90afb37f417..e51101cfc7c 100644 --- a/package/MDAnalysis/coordinates/base.py +++ b/package/MDAnalysis/coordinates/base.py @@ -446,6 +446,14 @@ def __getitem__(self, atoms): return self._pos[atoms] else: raise TypeError + + def __getattr__(self, attr): + # special-case timestep info + if attr in ('velocities', 'forces', 'positions'): + raise NoDataError('This Timestep has no ' + attr) + err = "{selfcls} object has no attribute '{attr}'" + raise AttributeError(err.format(selfcls=type(self).__name__, + attr=attr)) def __len__(self): return self.n_atoms diff --git a/package/MDAnalysis/core/groups.py b/package/MDAnalysis/core/groups.py index 0d6ad9d07ba..f3fb36dfba6 100644 --- a/package/MDAnalysis/core/groups.py +++ b/package/MDAnalysis/core/groups.py @@ -100,7 +100,8 @@ import os import warnings -from .. import _ANCHOR_UNIVERSES, _CONVERTERS +from .. import (_ANCHOR_UNIVERSES, _CONVERTERS, + _TOPOLOGY_ATTRS, _TOPOLOGY_TRANSPLANTS, _TOPOLOGY_ATTRNAMES) from ..lib import util from ..lib.util import cached, warn_if_not_unique, unique_int_1d from ..lib import distances @@ -126,11 +127,12 @@ def _unpickle(uhash, ix): "".format( uhash, ', '.join([str(k) for k in _ANCHOR_UNIVERSES.keys()]) - ) - ), + ) + ), None) return u.atoms[ix] + def _unpickle_uag(basepickle, selections, selstrs): bfunc, bargs = basepickle[0], basepickle[1:][0] basegroup = bfunc(*bargs) @@ -257,6 +259,7 @@ def _add_prop(cls, attr): ---------- attr : A :class:`TopologyAttr` object """ + def getter(self): return attr.__getitem__(self) @@ -337,14 +340,54 @@ def __new__(cls, *args, **kwargs): if parent in u._class_bases) except StopIteration: raise_from(TypeError("Attempted to instantiate class '{}' but " - "none of its parents are known to the " - "universe. Currently possible parent " - "classes are: {}".format(cls.__name__, - str(sorted(u._class_bases.keys())))), + "none of its parents are known to the " + "universe. Currently possible parent " + "classes are: {}".format(cls.__name__, + str(sorted(u._class_bases.keys())))), None) newcls = u._classes[cls] = parent_cls._mix(cls) return object.__new__(newcls) + def __getattr__(self, attr): + selfcls = type(self).__name__ + + if attr in _TOPOLOGY_TRANSPLANTS: + topattr, meth, clstype = _TOPOLOGY_TRANSPLANTS[attr] + if isinstance(meth, property): + attrname = attr + attrtype = 'property' + else: + attrname = attr + '()' + attrtype = 'method' + + # property of wrong group/component + if not isinstance(self, clstype): + mname = 'property' if isinstance(meth, property) else 'method' + err = '{attr} is a {method} of {clstype}, not {selfcls}' + clsname = clstype.__name__ + if clsname == 'GroupBase': + clsname = selfcls + 'Group' + raise AttributeError(err.format(attr=attrname, + method=attrtype, + clstype=clsname, + selfcls=selfcls)) + # missing required topologyattr + else: + err = ('{selfcls}.{attrname} not available; ' + 'this requires {topattr}') + raise NoDataError(err.format(selfcls=selfcls, + attrname=attrname, + topattr=topattr)) + + else: + clean = attr.lower().replace('_', '') + err = '{selfcls} has no attribute {attr}. '.format(selfcls=selfcls, + attr=attr) + if clean in _TOPOLOGY_ATTRNAMES: + match = _TOPOLOGY_ATTRNAMES[clean] + err += 'Did you mean {match}?'.format(match=match) + raise AttributeError(err) + class _ImmutableBase(object): """Class used to shortcut :meth:`__new__` to :meth:`object.__new__`. @@ -355,6 +398,7 @@ class _ImmutableBase(object): # cache lookup if the class is reused (as in ag._derived_class(...)). __new__ = object.__new__ + def check_pbc_and_unwrap(function): """Decorator to raise ValueError when both 'pbc' and 'unwrap' are set to True. """ @@ -362,10 +406,12 @@ def check_pbc_and_unwrap(function): def wrapped(group, *args, **kwargs): if kwargs.get('compound') == 'group': if kwargs.get('pbc') and kwargs.get('unwrap'): - raise ValueError("both 'pbc' and 'unwrap' can not be set to true") + raise ValueError( + "both 'pbc' and 'unwrap' can not be set to true") return function(group, *args, **kwargs) return wrapped + def _only_same_level(function): @functools.wraps(function) def wrapped(self, other): @@ -452,6 +498,7 @@ class GroupBase(_MutableBase): | | | ``t`` but not both | +-------------------------------+------------+----------------------------+ """ + def __init__(self, *args): try: if len(args) == 1: @@ -468,7 +515,7 @@ def __init__(self, *args): "Segment objects eg: AtomGroup([Atom1, Atom2, Atom3]) " "or an iterable of indices and a Universe reference " "eg: AtomGroup([0, 5, 7, 8], u)."), - None) + None) # indices for the objects I hold self._ix = np.asarray(ix, dtype=np.intp) @@ -500,12 +547,27 @@ def __getitem__(self, item): # subclasses, such as UpdatingAtomGroup, to control the class # resulting from slicing. return self._derived_class(self.ix[item], self.universe) + + def __getattr__(self, attr): + selfcls = type(self).__name__ + if attr in _TOPOLOGY_ATTRS: + cls = _TOPOLOGY_ATTRS[attr] + if attr == cls.singular and attr != cls.attrname: + err = ('{selfcls} has no attribute {attr}. ' + 'Do you mean {plural}?') + raise AttributeError(err.format(selfcls=selfcls, attr=attr, + plural=cls.attrname)) + else: + err = 'This Universe does not contain {singular} information' + raise NoDataError(err.format(singular=cls.singular)) + else: + return super(GroupBase, self).__getattr__(attr) def __repr__(self): name = self.level.name return ("<{}Group with {} {}{}>" "".format(name.capitalize(), len(self), name, - "s"[len(self)==1:])) # Shorthand for a conditional plural 's'. + "s"[len(self) == 1:])) # Shorthand for a conditional plural 's'. def __str__(self): name = self.level.name @@ -554,6 +616,7 @@ def __radd__(self, other): raise TypeError("unsupported operand type(s) for +:" " '{}' and '{}'".format(type(self).__name__, type(other).__name__)) + def __sub__(self, other): return self.difference(other) @@ -664,7 +727,6 @@ def isunique(self): # return ``not np.any(mask)`` here but using the following is faster: return not np.count_nonzero(mask) - @warn_if_not_unique @check_pbc_and_unwrap def center(self, weights, pbc=False, compound='group', unwrap=False): @@ -752,7 +814,8 @@ def center(self, weights, pbc=False, compound='group', unwrap=False): if pbc: coords = atoms.pack_into_box(inplace=False) elif unwrap: - coords = atoms.unwrap(compound=comp, reference=None, inplace=False) + coords = atoms.unwrap( + compound=comp, reference=None, inplace=False) else: coords = atoms.positions # If there's no atom, return its (empty) coordinates unchanged. @@ -774,13 +837,13 @@ def center(self, weights, pbc=False, compound='group', unwrap=False): compound_indices = atoms.molnums except AttributeError: raise_from(NoDataError("Cannot use compound='molecules': " - "No molecule information in topology."), None) + "No molecule information in topology."), None) elif comp == 'fragments': try: compound_indices = atoms.fragindices except NoDataError: raise_from(NoDataError("Cannot use compound='fragments': " - "No bond information in topology."), None) + "No bond information in topology."), None) else: raise ValueError("Unrecognized compound definition: {}\nPlease use" " one of 'group', 'residues', 'segments', " @@ -1421,7 +1484,7 @@ def wrap(self, compound="atoms", center="com", box=None, inplace=True): atoms = _atoms comp = compound.lower() - if comp not in ('atoms', 'group', 'segments', 'residues', 'molecules', \ + if comp not in ('atoms', 'group', 'segments', 'residues', 'molecules', 'fragments'): raise ValueError("Unrecognized compound definition '{}'. " "Please use one of 'atoms', 'group', 'segments', " @@ -1435,7 +1498,7 @@ def wrap(self, compound="atoms", center="com", box=None, inplace=True): positions = distances.apply_PBC(atoms.positions, box) else: ctr = center.lower() - if ctr == 'com': + if ctr == 'com': # Don't use hasattr(self, 'masses') because that's incredibly # slow for ResidueGroups or SegmentGroups if not hasattr(self._u._topology, 'masses'): @@ -1468,7 +1531,7 @@ def wrap(self, compound="atoms", center="com", box=None, inplace=True): compound_indices = atoms.molnums except AttributeError: raise_from(NoDataError("Cannot use compound='molecules', " - "this requires molnums."), None) + "this requires molnums."), None) else: # comp == 'fragments' try: compound_indices = atoms.fragindices @@ -1585,7 +1648,7 @@ def unwrap(self, compound='fragments', reference='com', inplace=True): unique_atoms = atoms.unique if reference is not None: ref = reference.lower() - if ref == 'com': + if ref == 'com': # Don't use hasattr(self, 'masses') because that's incredibly # slow for ResidueGroups or SegmentGroups if not hasattr(unique_atoms, 'masses'): @@ -1632,7 +1695,7 @@ def unwrap(self, compound='fragments', reference='com', inplace=True): compound_indices = unique_atoms.molnums except AttributeError: raise_from(NoDataError("Cannot use compound='molecules', this " - "requires molnums."), None) + "requires molnums."), None) # Now process every compound: unique_compound_indices = unique_int_1d(compound_indices) positions = unique_atoms.positions @@ -1726,7 +1789,7 @@ def groupby(self, topattrs): res = dict() if isinstance(topattrs, (string_types, bytes)): - attr=topattrs + attr = topattrs if isinstance(topattrs, bytes): attr = topattrs.decode('utf-8') ta = getattr(self, attr) @@ -2213,21 +2276,18 @@ class AtomGroup(GroupBase): Removed instant selectors, use select_atoms('name ...') to select atoms by name. """ - def __getattr__(self, attr): - # is this a known attribute failure? - # TODO: Generalise this to cover many attributes - if attr in ('fragments', 'fragindices', 'n_fragments', 'unwrap'): - # eg: - # if attr in _ATTR_ERRORS: - # raise NDE(_ATTR_ERRORS[attr]) - raise NoDataError("AtomGroup.{} not available; this requires Bonds" - "".format(attr)) - raise AttributeError("{cls} has no attribute {attr}".format( - cls=self.__class__.__name__, attr=attr)) def __reduce__(self): return (_unpickle, (self.universe.anchor_name, self.ix)) + def __getattr__(self, attr): + # special-case timestep info + if attr in ('velocities', 'forces'): + raise NoDataError('This Timestep has no ' + attr) + elif attr == 'positions': + raise NoDataError('This Universe has no coordinates') + return super(AtomGroup, self).__getattr__(attr) + @property def atoms(self): """The :class:`AtomGroup` itself. @@ -2275,10 +2335,10 @@ def residues(self, new): r_ix = [r.resindex for r in new] except AttributeError: raise_from(TypeError("Can only set AtomGroup residues to Residue " - "or ResidueGroup not {}".format( - ', '.join(type(r) for r in new - if not isinstance(r, Residue)) - )), None) + "or ResidueGroup not {}".format( + ', '.join(type(r) for r in new + if not isinstance(r, Residue)) + )), None) if not isinstance(r_ix, itertools.cycle) and len(r_ix) != len(self): raise ValueError("Incorrect size: {} for AtomGroup of size: {}" "".format(len(new), len(self))) @@ -2448,18 +2508,12 @@ def velocities(self): :attr:`~MDAnalysis.coordinates.base.Timestep.velocities`. """ ts = self.universe.trajectory.ts - try: - return np.array(ts.velocities[self.ix]) - except (AttributeError, NoDataError): - raise_from(NoDataError("Timestep does not contain velocities"), None) + return np.array(ts.velocities[self.ix]) @velocities.setter def velocities(self, values): ts = self.universe.trajectory.ts - try: - ts.velocities[self.ix, :] = values - except (AttributeError, NoDataError): - raise_from(NoDataError("Timestep does not contain velocities"), None) + ts.velocities[self.ix, :] = values @property def forces(self): @@ -2482,18 +2536,12 @@ def forces(self): contain :attr:`~MDAnalysis.coordinates.base.Timestep.forces`. """ ts = self.universe.trajectory.ts - try: - return ts.forces[self.ix] - except (AttributeError, NoDataError): - raise_from(NoDataError("Timestep does not contain forces"), None) + return ts.forces[self.ix] @forces.setter def forces(self, values): ts = self.universe.trajectory.ts - try: - ts.forces[self.ix, :] = values - except (AttributeError, NoDataError): - raise_from(NoDataError("Timestep does not contain forces"), None) + ts.forces[self.ix, :] = values @property def ts(self): @@ -2851,8 +2899,8 @@ def split(self, level): levelindices = getattr(self, accessors[level]) except AttributeError: raise_from(AttributeError('This universe does not have {} ' - 'information. Maybe it is not provided in the ' - 'topology format in use.'.format(level)), + 'information. Maybe it is not provided in the ' + 'topology format in use.'.format(level)), None) except KeyError: raise_from( @@ -2860,7 +2908,7 @@ def split(self, level): ( "level = '{0}' not supported, " "must be one of {1}").format(level, accessors.keys()) - ), + ), None) return [self[levelindices == index] for index in @@ -2899,7 +2947,8 @@ def get_TopAttr(u, name, cls): # indices of bonds box = self.dimensions if self.dimensions.all() else None - b = guess_bonds(self.atoms, self.atoms.positions, vdwradii=vdwradii, box=box) + 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) @@ -3019,7 +3068,6 @@ def cmap(self): "cmap only makes sense for a group with exactly 5 atoms") return topologyobjects.CMap(self.ix, self.universe) - def convert_to(self, package): """ Convert :class:`AtomGroup` to a structure from another Python package. @@ -3038,7 +3086,7 @@ def convert_to(self, package): >>> parmed_structure - + Parameters ---------- package: str @@ -3049,7 +3097,7 @@ def convert_to(self, package): ------- output: An instance of the structure type from another package. - + Raises ------ TypeError: @@ -3167,7 +3215,8 @@ def write(self, filename=None, file_format=None, # Try and select a Class using get_ methods (becomes `writer`) # Once (and if!) class is selected, use it in with block try: - writer = get_writer_for(filename, format=file_format, multiframe=multiframe) + writer = get_writer_for( + filename, format=file_format, multiframe=multiframe) except (ValueError, TypeError): pass else: @@ -3298,9 +3347,9 @@ def segments(self, new): "Can only set ResidueGroup segments to Segment " "or SegmentGroup, not {}".format( ', '.join(type(r) for r in new - if not isinstance(r, Segment)) - ) - ), + if not isinstance(r, Segment)) + ) + ), None) if not isinstance(s_ix, itertools.cycle) and len(s_ix) != len(self): raise ValueError("Incorrect size: {} for ResidueGroup of size: {}" @@ -3514,11 +3563,27 @@ class ComponentBase(_MutableBase): Components are the individual objects that are found in Groups. """ + def __init__(self, ix, u): # index of component self._ix = ix self._u = u + def __getattr__(self, attr): + selfcls = type(self).__name__ + if attr in _TOPOLOGY_ATTRS: + cls = _TOPOLOGY_ATTRS[attr] + if attr == cls.attrname and attr != cls.singular: + err = ('{selfcls} has no attribute {attr}. ' + 'Do you mean {singular}?') + raise AttributeError(err.format(selfcls=selfcls, attr=attr, + singular=cls.singular)) + else: + err = 'This Universe does not contain {singular} information' + raise NoDataError(err.format(singular=cls.singular)) + else: + return super(ComponentBase, self).__getattr__(attr) + def __lt__(self, other): if self.level != other.level: raise TypeError("Can't compare different level objects") @@ -3553,7 +3618,7 @@ def __add__(self, other): o_ix = other.ix_array return self.level.plural( - np.concatenate([self.ix_array, o_ix]), self.universe) + np.concatenate([self.ix_array, o_ix]), self.universe) def __radd__(self, other): """Using built-in sum requires supporting 0 + self. If other is @@ -3613,14 +3678,6 @@ class Atom(ComponentBase): from :class:`ComponentBase`, so this class only includes ad-hoc methods specific to :class:`Atoms`. """ - def __getattr__(self, attr): - """Try and catch known attributes and give better error message""" - if attr in ('fragment', 'fragindex'): - raise NoDataError("Atom has no {} data, this requires Bonds" - "".format(attr)) - else: - raise AttributeError("{cls} has no attribute {attr}".format( - cls=self.__class__.__name__, attr=attr)) def __repr__(self): me = '' + def __getattr__(self, attr): + # special-case timestep info + ts = {'velocity': 'velocities', 'force': 'forces'} + if attr in ts: + raise NoDataError('This Timestep has no ' + ts[attr]) + elif attr == 'position': + raise NoDataError('This Universe has no coordinates') + return super(Atom, self).__getattr__(attr) + @property def residue(self): return self.universe.residues[self.universe._topology.resindices[self]] @@ -3699,18 +3765,12 @@ def velocity(self): :attr:`~MDAnalysis.coordinates.base.Timestep.velocities`. """ ts = self.universe.trajectory.ts - try: - return ts.velocities[self.ix].copy() - except (AttributeError, NoDataError): - raise_from(NoDataError("Timestep does not contain velocities"), None) + return ts.velocities[self.ix].copy() @velocity.setter def velocity(self, values): ts = self.universe.trajectory.ts - try: - ts.velocities[self.ix, :] = values - except (AttributeError, NoDataError): - raise_from(NoDataError("Timestep does not contain velocities"), None) + ts.velocities[self.ix, :] = values @property def force(self): @@ -3730,18 +3790,12 @@ def force(self): :attr:`~MDAnalysis.coordinates.base.Timestep.forces`. """ ts = self.universe.trajectory.ts - try: - return ts.forces[self.ix].copy() - except (AttributeError, NoDataError): - raise_from(NoDataError("Timestep does not contain forces"), None) + return ts.forces[self.ix].copy() @force.setter def force(self, values): ts = self.universe.trajectory.ts - try: - ts.forces[self.ix, :] = values - except (AttributeError, NoDataError): - raise_from(NoDataError("Timestep does not contain forces"), None) + ts.forces[self.ix, :] = values class Residue(ComponentBase): @@ -3753,6 +3807,7 @@ class Residue(ComponentBase): from :class:`ComponentBase`, so this class only includes ad-hoc methods specific to :class:`Residues`. """ + def __repr__(self): me = '".format(basestr[:-1], - "s"[len(self._selection_strings)==1:], sels, basegrp) + "s"[len(self._selection_strings) == 1:], sels, basegrp) @property def atoms(self): @@ -4062,6 +4119,7 @@ def copy(self): Segment.level = SEGMENTLEVEL SegmentGroup.level = SEGMENTLEVEL + def requires(*attrs): """Decorator to check if all :class:`AtomGroup` arguments have certain attributes diff --git a/package/MDAnalysis/core/topologyattrs.py b/package/MDAnalysis/core/topologyattrs.py index 7e66fd82f36..786643316ae 100644 --- a/package/MDAnalysis/core/topologyattrs.py +++ b/package/MDAnalysis/core/topologyattrs.py @@ -56,7 +56,7 @@ Atom, Residue, Segment, AtomGroup, ResidueGroup, SegmentGroup, check_pbc_and_unwrap) -from .. import _TOPOLOGY_ATTRS +from .. import _TOPOLOGY_ATTRS, _TOPOLOGY_TRANSPLANTS, _TOPOLOGY_ATTRNAMES def _check_length(func): @@ -170,13 +170,24 @@ 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 + attrname = classdict.get('attrname') + singular = classdict.get('singular', attrname) + + if attrname is None: + attrname = singular + + if singular: + _TOPOLOGY_ATTRS[singular] = _TOPOLOGY_ATTRS[attrname] = cls + _singular = singular.lower().replace('_', '') + _attrname = attrname.lower().replace('_', '') + _TOPOLOGY_ATTRNAMES[_singular] = singular + _TOPOLOGY_ATTRNAMES[_attrname] = attrname + + for clstype, transplants in cls.transplants.items(): + for name, method in transplants: + _TOPOLOGY_TRANSPLANTS[name] = [attrname, method, clstype] + clean = name.lower().replace('_', '') + _TOPOLOGY_ATTRNAMES[clean] = name class TopologyAttr(six.with_metaclass(_TopologyAttrMeta, object)): diff --git a/package/MDAnalysis/exceptions.py b/package/MDAnalysis/exceptions.py index ad11f1c86e5..2cf46636e3e 100644 --- a/package/MDAnalysis/exceptions.py +++ b/package/MDAnalysis/exceptions.py @@ -1,5 +1,5 @@ # -*- Mode: python; tab-width: 4; indent-tabs-mode:nil; coding:utf-8 -*- -# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 +# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 # # MDAnalysis --- https://www.mdanalysis.org # Copyright (c) 2006-2017 The MDAnalysis Development Team and contributors @@ -27,12 +27,17 @@ """ + class SelectionError(Exception): """Raised when a atom selection failed.""" -class NoDataError(ValueError): - """Raised when empty input is not allowed or required data are missing.""" +class NoDataError(ValueError, AttributeError): + """Raised when empty input is not allowed or required data are missing. + + .. versionchanged:: 1.0.0 + Now a subclass of AttributeError as well as ValueError + """ class ApplicationError(OSError): diff --git a/testsuite/MDAnalysisTests/core/test_group_traj_access.py b/testsuite/MDAnalysisTests/core/test_group_traj_access.py index 467b6bb0369..bbb834e96d6 100644 --- a/testsuite/MDAnalysisTests/core/test_group_traj_access.py +++ b/testsuite/MDAnalysisTests/core/test_group_traj_access.py @@ -46,7 +46,7 @@ def assert_not_view(arr): def assert_correct_errormessage(func, var): - errmsg = "Timestep does not contain {}".format(var) + errmsg = "Timestep has no {}".format(var) try: func[0](*func[1:]) except NoDataError as e: diff --git a/testsuite/MDAnalysisTests/core/test_groups.py b/testsuite/MDAnalysisTests/core/test_groups.py index c3cd9e17393..d0113187f10 100644 --- a/testsuite/MDAnalysisTests/core/test_groups.py +++ b/testsuite/MDAnalysisTests/core/test_groups.py @@ -35,6 +35,7 @@ import six import MDAnalysis as mda +from MDAnalysis.exceptions import NoDataError from MDAnalysisTests import make_Universe, no_deprecated_call from MDAnalysisTests.datafiles import PSF, DCD from MDAnalysis.core import groups @@ -1312,6 +1313,93 @@ def test_component_set_plural(self, attr, groupname): with pytest.raises(AttributeError): setattr(comp, attr, 24) +class TestAttributeGetting(object): + + @staticmethod + @pytest.fixture() + def universe(): + return make_Universe(extras=('masses', 'altLocs')) + + @pytest.mark.parametrize('attr', ['masses', 'altLocs']) + def test_get_present_topattr_group(self, universe, attr): + values = getattr(universe.atoms, attr) + assert values is not None + + @pytest.mark.parametrize('attr', ['mass', 'altLoc']) + def test_get_present_topattr_component(self, universe, attr): + value = getattr(universe.atoms[0], attr) + assert value is not None + + @pytest.mark.parametrize('attr,singular', [ + ('masses', 'mass'), + ('altLocs', 'altLoc')]) + def test_get_plural_topattr_from_component(self, universe, attr, singular): + with pytest.raises(AttributeError) as exc: + getattr(universe.atoms[0], attr) + assert ('Do you mean ' + singular) in str(exc.value) + + @pytest.mark.parametrize('attr,singular', [ + ('masses', 'mass'), + ('altLocs', 'altLoc')]) + def test_get_sing_topattr_from_group(self, universe, attr, singular): + with pytest.raises(AttributeError) as exc: + getattr(universe.atoms, singular) + assert ('Do you mean '+attr) in str(exc.value) + + @pytest.mark.parametrize('attr,singular', [ + ('elements', 'element'), + ('tempfactors', 'tempfactor'), + ('bonds', 'bonds')]) + def test_get_absent_topattr_group(self, universe, attr, singular): + with pytest.raises(NoDataError) as exc: + getattr(universe.atoms, attr) + assert 'does not contain '+singular in str(exc.value) + + def test_get_non_topattr(self, universe): + with pytest.raises(AttributeError) as exc: + universe.atoms.jabberwocky + assert 'has no attribute' in str(exc.value) + + def test_unwrap_without_bonds(self, universe): + with pytest.raises(NoDataError) as exc: + universe.atoms.unwrap() + err = ('AtomGroup.unwrap() not available; ' + 'this requires Bonds') + assert str(exc.value) == err + + def test_get_absent_attr_method(self, universe): + with pytest.raises(NoDataError) as exc: + universe.atoms.total_charge() + err = ('AtomGroup.total_charge() not available; ' + 'this requires charges') + assert str(exc.value) == err + + def test_get_absent_attrprop(self, universe): + with pytest.raises(NoDataError) as exc: + universe.atoms.fragindices + err = ('AtomGroup.fragindices not available; ' + 'this requires bonds') + assert str(exc.value) == err + + def test_attrprop_wrong_group(self, universe): + with pytest.raises(AttributeError) as exc: + universe.atoms[0].fragindices + err = ('fragindices is a property of AtomGroup, not Atom') + assert str(exc.value) == err + + def test_attrmethod_wrong_group(self, universe): + with pytest.raises(AttributeError) as exc: + universe.atoms[0].center_of_mass() + err = ('center_of_mass() is a method of AtomGroup, not Atom') + assert str(exc.value) == err + + @pytest.mark.parametrize('attr', ['altlocs', 'alt_Locs']) + def test_wrong_name(self, universe, attr): + with pytest.raises(AttributeError) as exc: + getattr(universe.atoms, attr) + err = ('AtomGroup has no attribute {}. ' + 'Did you mean altLocs?').format(attr) + assert str(exc.value) == err class TestInitGroup(object): From e835b5beff00bb6a5870e6b71435290408f0bc57 Mon Sep 17 00:00:00 2001 From: Rocco Meli Date: Sun, 7 Jun 2020 04:04:30 +0200 Subject: [PATCH 32/34] Fix #2692 (#2694) - Use user-provided remark in XYZWriter and add MDAnalysis version if not --- package/CHANGELOG | 1 + package/MDAnalysis/coordinates/XYZ.py | 37 ++++++++++++++----- .../MDAnalysisTests/coordinates/test_xyz.py | 20 ++++++++++ 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/package/CHANGELOG b/package/CHANGELOG index 5161c00ea4a..cbd69f7e13f 100644 --- a/package/CHANGELOG +++ b/package/CHANGELOG @@ -23,6 +23,7 @@ mm/dd/yy richardjgowers, kain88-de, lilyminium, p-j-smith, bdice, joaomcteixeira * 0.21.0 Fixes + * Use user-provided `remark` in `XYZWriter` (Issue #2692) * Added more informative error messages about topology attributes (Issue #2565) * Made NoDataError a subclass of ValueError *and* AttributeError diff --git a/package/MDAnalysis/coordinates/XYZ.py b/package/MDAnalysis/coordinates/XYZ.py index abd72e14fce..4eb6af44502 100644 --- a/package/MDAnalysis/coordinates/XYZ.py +++ b/package/MDAnalysis/coordinates/XYZ.py @@ -54,7 +54,7 @@ XYZ File format --------------- -Definiton used by the :class:`XYZReader` and :class:`XYZWriter` (and +Definition used by the :class:`XYZReader` and :class:`XYZWriter` (and the `VMD xyzplugin`_ from whence the definition was taken):: [ comment line ] !! NOT IMPLEMENTED !! DO NOT INCLUDE @@ -146,16 +146,18 @@ def __init__(self, filename, n_atoms=None, atoms=None, convert_units=True, writing [``True``] remark: str (optional) single line of text ("molecule name"). By default writes MDAnalysis - version + version and frame + + .. versionchanged:: 1.0.0 + Removed :code:`default_remark` variable (Issue #2692). """ self.filename = filename + self.remark = remark self.n_atoms = n_atoms self.convert_units = convert_units self.atomnames = self._get_atoms_elements_or_names(atoms) - default_remark = "Written by {0} (release {1})".format( - self.__class__.__name__, __version__) - self.remark = default_remark if remark is None else remark + # can also be gz, bz2 self._xyz = util.anyopen(self.filename, 'wt') @@ -190,8 +192,8 @@ def close(self): def write(self, obj): """Write object `obj` at current trajectory frame to file. - Atom elements (or names) in the output are taken from the `obj` or default - to the value of the `atoms` keyword supplied to the + Atom elements (or names) in the output are taken from the `obj` or + default to the value of the `atoms` keyword supplied to the :class:`XYZWriter` constructor. Parameters @@ -229,7 +231,13 @@ def write(self, obj): self.write_next_timestep(ts) def write_next_timestep(self, ts=None): - """Write coordinate information in *ts* to the trajectory""" + """ + Write coordinate information in *ts* to the trajectory + + .. versionchanged:: 1.0.0 + Print out :code:`remark` if present, otherwise use generic one + (Issue #2692). + """ if ts is None: if not hasattr(self, 'ts'): raise NoDataError('XYZWriter: no coordinate data to write to ' @@ -259,8 +267,19 @@ def write_next_timestep(self, ts=None): else: coordinates = ts.positions + # Write number of atoms self._xyz.write("{0:d}\n".format(ts.n_atoms)) - self._xyz.write("frame {0}\n".format(ts.frame)) + + # Write remark + if self.remark is None: + remark = "frame {} | Written by MDAnalysis {} (release {})\n".format( + ts.frame, self.__class__.__name__, __version__) + + self._xyz.write(remark) + else: + self._xyz.write(self.remark.strip() + "\n") + + # Write content for atom, (x, y, z) in zip(self.atomnames, coordinates): self._xyz.write("{0!s:>8} {1:10.5f} {2:10.5f} {3:10.5f}\n" "".format(atom, x, y, z)) diff --git a/testsuite/MDAnalysisTests/coordinates/test_xyz.py b/testsuite/MDAnalysisTests/coordinates/test_xyz.py index 7f39509be0c..d4f9819ec4b 100644 --- a/testsuite/MDAnalysisTests/coordinates/test_xyz.py +++ b/testsuite/MDAnalysisTests/coordinates/test_xyz.py @@ -38,6 +38,7 @@ BaseWriterTest) from MDAnalysisTests import make_Universe +from MDAnalysis import __version__ class XYZReference(BaseReference): def __init__(self): @@ -106,6 +107,25 @@ def test_no_conversion(self, ref, reader, tmpdir): w.write(ts) self._check_copy(outfile, ref, reader) + @pytest.mark.parametrize("remarkout, remarkin", + [ + 2 * ["Curstom Remark"], + 2 * [""], + [None, "frame 0 | Written by MDAnalysis XYZWriter (release {0})".format(__version__)], + ] + ) + def test_remark(self, remarkout, remarkin, ref, tmpdir): + u = mda.Universe(ref.topology, ref.trajectory) + outfile = "write-remark.xyz" + + with tmpdir.as_cwd(): + u.atoms.write(outfile, remark=remarkout) + + with open(outfile, "r") as xyzout: + lines = xyzout.readlines() + + assert lines[1].strip() == remarkin + class XYZ_BZ_Reference(XYZReference): def __init__(self): From ae6193e3e93093500a2253d1a82e9b286d2852a6 Mon Sep 17 00:00:00 2001 From: Irfan Alibay Date: Mon, 8 Jun 2020 11:48:51 +0100 Subject: [PATCH 33/34] Mol2 universe (#2718) * Fixes issue 2717 * Adds tests * Switched ag safeguard to encode_block --- package/CHANGELOG | 1 + package/MDAnalysis/coordinates/MOL2.py | 2 ++ .../MDAnalysisTests/coordinates/test_mol2.py | 17 +++++++++++++++++ 3 files changed, 20 insertions(+) diff --git a/package/CHANGELOG b/package/CHANGELOG index cbd69f7e13f..da37b8957d5 100644 --- a/package/CHANGELOG +++ b/package/CHANGELOG @@ -23,6 +23,7 @@ mm/dd/yy richardjgowers, kain88-de, lilyminium, p-j-smith, bdice, joaomcteixeira * 0.21.0 Fixes + * MOL2Writer now accepts both Universes and AtomgGroups (Issue #2717) * Use user-provided `remark` in `XYZWriter` (Issue #2692) * Added more informative error messages about topology attributes (Issue #2565) diff --git a/package/MDAnalysis/coordinates/MOL2.py b/package/MDAnalysis/coordinates/MOL2.py index c21a64db3b9..696128913ef 100644 --- a/package/MDAnalysis/coordinates/MOL2.py +++ b/package/MDAnalysis/coordinates/MOL2.py @@ -306,6 +306,8 @@ def encode_block(self, obj): ---------- obj : AtomGroup or Universe """ + # Issue 2717 + obj = obj.atoms traj = obj.universe.trajectory ts = traj.ts diff --git a/testsuite/MDAnalysisTests/coordinates/test_mol2.py b/testsuite/MDAnalysisTests/coordinates/test_mol2.py index 0fe25e79eea..b42f2d2b068 100644 --- a/testsuite/MDAnalysisTests/coordinates/test_mol2.py +++ b/testsuite/MDAnalysisTests/coordinates/test_mol2.py @@ -29,6 +29,7 @@ from numpy.testing import ( assert_equal, assert_array_equal, assert_array_almost_equal, TestCase, + assert_almost_equal ) from MDAnalysisTests.datafiles import ( @@ -190,3 +191,19 @@ def test_mol2_multi_write(tmpdir): u = mda.Universe(mol2_molecules) u.atoms[:4].write('group1.mol2') u.atoms[:4].write('group1.mol2') + + +def test_mol2_universe_write(tmpdir): + # see Issue 2717 + with tmpdir.as_cwd(): + outfile = 'test.mol2' + + u = mda.Universe(mol2_comments_header) + + with mda.Writer(outfile) as W: + W.write(u) + + u2 = mda.Universe(outfile) + + assert_almost_equal(u.atoms.positions, u2.atoms.positions) + assert_almost_equal(u.dimensions, u2.dimensions) From 3f7f5d345eb0f650bdb38d46db1091637061cf0c Mon Sep 17 00:00:00 2001 From: Richard Gowers Date: Mon, 8 Jun 2020 21:20:31 +0100 Subject: [PATCH 34/34] WIP Issue 2043 deprecate ts write (#2110) * deprecate Timestep argument to Writers (issue #2043) Co-authored-by: Irfan Alibay Co-authored-by: Lily Wang <31115101+lilyminium@users.noreply.github.com> --- package/CHANGELOG | 3 +- package/MDAnalysis/coordinates/DCD.py | 24 ++++- package/MDAnalysis/coordinates/FHIAIMS.py | 9 +- package/MDAnalysis/coordinates/GRO.py | 10 +- package/MDAnalysis/coordinates/MOL2.py | 11 +- package/MDAnalysis/coordinates/NAMDBIN.py | 18 +++- package/MDAnalysis/coordinates/PDB.py | 12 ++- package/MDAnalysis/coordinates/TRJ.py | 44 ++++---- package/MDAnalysis/coordinates/TRR.py | 26 ++++- package/MDAnalysis/coordinates/TRZ.py | 26 ++++- package/MDAnalysis/coordinates/XDR.py | 2 +- package/MDAnalysis/coordinates/XTC.py | 28 ++++- package/MDAnalysis/coordinates/XYZ.py | 22 +++- package/MDAnalysis/coordinates/__init__.py | 10 +- package/MDAnalysis/coordinates/base.py | 51 ++++++--- package/MDAnalysis/coordinates/chemfiles.py | 16 +-- package/MDAnalysis/coordinates/null.py | 2 +- .../coordinates/test_chemfiles.py | 19 ++-- .../MDAnalysisTests/coordinates/test_dcd.py | 2 +- .../coordinates/test_lammps.py | 14 +++ .../coordinates/test_netcdf.py | 14 +-- .../MDAnalysisTests/coordinates/test_trz.py | 2 +- .../coordinates/test_writer_api.py | 102 ++++++++++++++++++ .../MDAnalysisTests/coordinates/test_xdr.py | 10 +- 24 files changed, 351 insertions(+), 126 deletions(-) create mode 100644 testsuite/MDAnalysisTests/coordinates/test_writer_api.py diff --git a/package/CHANGELOG b/package/CHANGELOG index da37b8957d5..a7f2b212c03 100644 --- a/package/CHANGELOG +++ b/package/CHANGELOG @@ -215,7 +215,8 @@ Deprecations * analysis.density.density_from_Universe() (remove in 2.0) * analysis.density.notwithin_coordinates_factory() (remove in 2.0) * analysis.density.density_from_PDB and BfactorDensityCreator (remove in 2.0) - + * Writer.write_next_timestep is deprecated, use write() instead (remove in 2.0) + * Writer.write(Timestep) is deprecated, use either a Universe or AtomGroup 09/05/19 IAlibay, richardjgowers diff --git a/package/MDAnalysis/coordinates/DCD.py b/package/MDAnalysis/coordinates/DCD.py index e6216a266b9..d16fd404350 100644 --- a/package/MDAnalysis/coordinates/DCD.py +++ b/package/MDAnalysis/coordinates/DCD.py @@ -390,17 +390,35 @@ def __init__(self, is_periodic=1, istart=istart) - def write_next_timestep(self, ts): - """Write timestep object into trajectory. + def _write_next_frame(self, ag): + """Write information associated with ``obj`` at current frame into trajectory Parameters ---------- - ts: TimeStep + ag : AtomGroup or Universe See Also -------- :meth:`DCDWriter.write` takes a more general input + + + .. deprecated:: 1.0.0 + Deprecated use of Timestep as argument. To be removed in version 2.0 + .. versionchanged:: 1.0.0 + Added ability to pass AtomGroup or Universe. + Renamed from `write_next_timestep` to `_write_next_frame`. """ + if isinstance(ag, base.Timestep): + ts = ag + else: + try: + ts = ag.ts + except AttributeError: + try: + # Universe? + ts = ag.trajectory.ts + except AttributeError: + raise TypeError("No Timestep found in ag argument") xyz = ts.positions.copy() dimensions = ts.dimensions.copy() diff --git a/package/MDAnalysis/coordinates/FHIAIMS.py b/package/MDAnalysis/coordinates/FHIAIMS.py index 11fb1ed03ce..f475b2c7620 100644 --- a/package/MDAnalysis/coordinates/FHIAIMS.py +++ b/package/MDAnalysis/coordinates/FHIAIMS.py @@ -267,22 +267,19 @@ def __init__(self, filename, convert_units=True, n_atoms=None, **kwargs): self.filename = util.filename(filename, ext='.in', keep=True) self.n_atoms = n_atoms - def write(self, obj): + def _write_next_frame(self, obj): """Write selection at current trajectory frame to file. Parameters ----------- - obj : AtomGroup or Universe or :class:`Timestep` - + obj : AtomGroup or Universe """ # write() method that complies with the Trajectory API - + # TODO 2.0: Remove timestep logic try: - # make sure to use atoms (Issue 46) ag_or_ts = obj.atoms # can write from selection == Universe (Issue 49) - except AttributeError: if isinstance(obj, base.Timestep): ag_or_ts = obj.copy() diff --git a/package/MDAnalysis/coordinates/GRO.py b/package/MDAnalysis/coordinates/GRO.py index 981ea1f7c00..3e091816860 100644 --- a/package/MDAnalysis/coordinates/GRO.py +++ b/package/MDAnalysis/coordinates/GRO.py @@ -344,7 +344,7 @@ def write(self, obj): Parameters ----------- - obj : AtomGroup or Universe or :class:`Timestep` + obj : AtomGroup or Universe Note ---- @@ -357,6 +357,9 @@ def write(self, obj): *resName* and *atomName* are truncated to a maximum of 5 characters .. versionchanged:: 0.16.0 `frame` kwarg has been removed + .. deprecated:: 1.0.0 + Deprecated calling with Timestep, use AtomGroup or Universe. + To be removed in version 2.0. """ # write() method that complies with the Trajectory API @@ -368,6 +371,11 @@ def write(self, obj): except AttributeError: if isinstance(obj, base.Timestep): + warnings.warn( + 'Passing a Timestep to write is deprecated, ' + 'and will be removed in 2.0; ' + 'use either an AtomGroup or Universe', + DeprecationWarning) ag_or_ts = obj.copy() else: raise_from(TypeError("No Timestep found in obj argument"), None) diff --git a/package/MDAnalysis/coordinates/MOL2.py b/package/MDAnalysis/coordinates/MOL2.py index 696128913ef..9b4d0b4662a 100644 --- a/package/MDAnalysis/coordinates/MOL2.py +++ b/package/MDAnalysis/coordinates/MOL2.py @@ -375,21 +375,16 @@ def encode_block(self, obj): molecule[1] = molecule_1_store return return_val - def write(self, obj): + def _write_next_frame(self, obj): """Write a new frame to the MOL2 file. Parameters ---------- obj : AtomGroup or Universe - """ - self.write_next_timestep(obj) - def write_next_timestep(self, obj): - """Write a new frame to the MOL2 file. - Parameters - ---------- - obj : AtomGroup or Universe + .. versionchanged:: 1.0.0 + Renamed from `write_next_timestep` to `_write_next_frame`. """ block = self.encode_block(obj) self.file.writelines(block) diff --git a/package/MDAnalysis/coordinates/NAMDBIN.py b/package/MDAnalysis/coordinates/NAMDBIN.py index ef4a5bb68ea..7b70c6e91c2 100755 --- a/package/MDAnalysis/coordinates/NAMDBIN.py +++ b/package/MDAnalysis/coordinates/NAMDBIN.py @@ -113,15 +113,23 @@ def __init__(self, filename, n_atoms=None, **kwargs): """ self.filename = util.filename(filename) - def write(self, obj): - """Write obj at current trajectory frame to file. + def _write_next_frame(self, obj): + """Write information associated with ``obj`` at current frame into trajectory + Parameters ---------- - obj : :class:`~MDAnalysis.core.groups.AtomGroup` or :class:`~MDAnalysis.core.universe.Universe` or a :class:`Timestep` - write coordinate information associate with `obj` - """ + obj : :class:`~MDAnalysis.core.groups.AtomGroup` or :class:`~MDAnalysis.core.universe.Universe` + write coordinate information associated with `obj` + + + .. versionchanged:: 1.0.0 + Renamed from `write` to `_write_next_frame`. + .. deprecated:: 1.0.0 + Passing a Timestep is deprecated for removal in version 2.0 + """ + # TODO 2.0: Remove Timestep logic if isinstance(obj, base.Timestep): n_atoms = obj.n_atoms coor = obj.positions.reshape(n_atoms*3) diff --git a/package/MDAnalysis/coordinates/PDB.py b/package/MDAnalysis/coordinates/PDB.py index 23f8914c79b..649bde13808 100644 --- a/package/MDAnalysis/coordinates/PDB.py +++ b/package/MDAnalysis/coordinates/PDB.py @@ -825,7 +825,7 @@ def _update_frame(self, obj): * :attr:`PDBWriter.timestep` (the underlying trajectory :class:`~MDAnalysis.coordinates.base.Timestep`) - Before calling :meth:`write_next_timestep` this method **must** be + Before calling :meth:`_write_next_frame` this method **must** be called at least once to enable extracting topology information from the current frame. """ @@ -864,7 +864,7 @@ def write(self, obj): # Issue 105: with write() ONLY write a single frame; use # write_all_timesteps() to dump everything in one go, or do the # traditional loop over frames - self.write_next_timestep(self.ts, multiframe=self._multiframe) + self._write_next_frame(self.ts, multiframe=self._multiframe) self._write_pdb_bonds() # END record is written when file is being close()d @@ -907,7 +907,7 @@ def write_all_timesteps(self, obj): for framenumber in range(start, len(traj), step): traj[framenumber] - self.write_next_timestep(self.ts, multiframe=True) + self._write_next_frame(self.ts, multiframe=True) self._write_pdb_bonds() self.close() @@ -915,7 +915,7 @@ def write_all_timesteps(self, obj): # Set the trajectory to the starting position traj[start] - def write_next_timestep(self, ts=None, **kwargs): + def _write_next_frame(self, ts=None, **kwargs): '''write a new timestep to the PDB file :Keywords: @@ -931,6 +931,10 @@ def write_next_timestep(self, ts=None, **kwargs): argument, :meth:`PDBWriter._update_frame` *must* be called with the :class:`~MDAnalysis.core.groups.AtomGroup.Universe` as its argument so that topology information can be gathered. + + + .. versionchanged:: 1.0.0 + Renamed from `write_next_timestep` to `_write_next_frame`. ''' if ts is None: try: diff --git a/package/MDAnalysis/coordinates/TRJ.py b/package/MDAnalysis/coordinates/TRJ.py index e35d606e5ad..d87a4ca1824 100644 --- a/package/MDAnalysis/coordinates/TRJ.py +++ b/package/MDAnalysis/coordinates/TRJ.py @@ -869,7 +869,6 @@ def __init__(self, self.dt = dt self.remarks = remarks or "AMBER NetCDF format (MDAnalysis.coordinates.trj.NCDFWriter)" - self.ts = None # when/why would this be assigned?? self._first_frame = True # signals to open trajectory self.trjfile = None # open on first write with _init_netcdf() self.periodic = None # detect on first write @@ -972,39 +971,48 @@ def _init_netcdf(self, periodic=True): self._first_frame = False self.trjfile = ncfile - def is_periodic(self, ts=None): - """Test if `Timestep` contains a periodic trajectory. + def is_periodic(self, ts): + """Test if timestep ``ts`` contains a periodic box. Parameters ---------- ts : :class:`Timestep` :class:`Timestep` instance containing coordinates to - be written to trajectory file; default is the current - timestep + be written to trajectory file Returns ------- bool Return ``True`` if `ts` contains a valid simulation box """ - ts = ts if ts is not None else self.ts return np.all(ts.dimensions > 0) - def write_next_timestep(self, ts=None): - """write a new timestep to the trj file + def _write_next_frame(self, ag): + """Write information associated with ``ag`` at current frame into trajectory Parameters ---------- - ts : :class:`Timestep` - :class:`Timestep` instance containing coordinates to - be written to trajectory file; default is the current - timestep + ag : AtomGroup or Universe + + + .. deprecated:: 1.0.0 + Deprecated using Timestep. To be removed in version 2.0. + .. versionchanged:: 1.0.0 + Added ability to use either AtomGroup or Universe. + Renamed from `write_next_timestep` to `_write_next_frame`. """ - if ts is None: - ts = self.ts - if ts is None: - raise IOError( - "NCDFWriter: no coordinate data to write to trajectory file") + if isinstance(ag, base.Timestep): + ts = ag + else: + try: + # Atomgroup? + ts = ag.ts + except AttributeError: + try: + # Universe? + ts = ag.trajectory.ts + except AttributeError: + raise TypeError("No Timestep found in ag argument") if ts.n_atoms != self.n_atoms: raise IOError( @@ -1020,7 +1028,7 @@ def _write_next_timestep(self, ts): """Write coordinates and unitcell information to NCDF file. Do not call this method directly; instead use - :meth:`write_next_timestep` because some essential setup is done + :meth:`write` because some essential setup is done there before writing the first frame. Based on Joshua Adelman's `netcdf4storage.py`_ in `Issue 109`_. diff --git a/package/MDAnalysis/coordinates/TRR.py b/package/MDAnalysis/coordinates/TRR.py index e92871f77b0..bd77ffea1f0 100644 --- a/package/MDAnalysis/coordinates/TRR.py +++ b/package/MDAnalysis/coordinates/TRR.py @@ -33,6 +33,7 @@ """ from __future__ import absolute_import +from . import base from .XDR import XDRBaseReader, XDRBaseWriter from ..lib.formats.libmdaxdr import TRRFile from ..lib.mdamath import triclinic_vectors, triclinic_box @@ -58,18 +59,37 @@ class TRRWriter(XDRBaseWriter): 'force': 'kJ/(mol*nm)'} _file = TRRFile - def write_next_timestep(self, ts): - """Write timestep object into trajectory. + def _write_next_frame(self, ag): + """Write information associated with ``ag`` at current frame into trajectory Parameters ---------- - ts : :class:`~base.Timestep` + ag : AtomGroup or Universe See Also -------- .write(AtomGroup/Universe/TimeStep) The normal write() method takes a more general input + + + .. versionchanged:: 1.0.0 + Renamed from `write_next_timestep` to `_write_next_frame`. + .. deprecated:: 1.0.0 + Deprecated the use of Timestep as arguments to write. Use either + an AtomGroup or Universe. To be removed in version 2.0. """ + if isinstance(ag, base.Timestep): + ts = ag + else: + try: + ts = ag.ts + except AttributeError: + try: + # special case: can supply a Universe, too... + ts = ag.trajectory.ts + except AttributeError: + raise TypeError("No Timestep found in ag argument") + xyz = None if ts.has_positions: xyz = ts.positions.copy() diff --git a/package/MDAnalysis/coordinates/TRZ.py b/package/MDAnalysis/coordinates/TRZ.py index 8f2d1d5fff0..6598f077b1a 100644 --- a/package/MDAnalysis/coordinates/TRZ.py +++ b/package/MDAnalysis/coordinates/TRZ.py @@ -530,10 +530,30 @@ def _writeheader(self, title): out['nrec'] = 10 out.tofile(self.trzfile) - def write_next_timestep(self, ts): + def _write_next_frame(self, obj): + """Write information associated with ``obj`` at current frame into trajectory + + Parameters + ---------- + ag : AtomGroup or Universe + + + .. versionchanged:: 1.0.0 + Renamed from `write_next_timestep` to `_write_next_frame`. + """ # Check size of ts is same as initial - if not ts.n_atoms == self.n_atoms: - raise ValueError("Number of atoms in ts different to initialisation") + # TODO: Remove Timestep logic in 2.0 + if isinstance(obj, base.Timestep): + ts = obj + if not ts.n_atoms == self.n_atoms: + raise ValueError("Number of atoms in ts different to initialisation") + else: + try: # atomgroup? + ts = obj.ts + except AttributeError: # universe? + ts = obj.trajectory.ts + if not obj.atoms.n_atoms == self.n_atoms: + raise ValueError("Number of atoms in ts different to initialisation") # Gather data, faking it when unavailable data = {} diff --git a/package/MDAnalysis/coordinates/XDR.py b/package/MDAnalysis/coordinates/XDR.py index bb7bd010433..8095323ade9 100644 --- a/package/MDAnalysis/coordinates/XDR.py +++ b/package/MDAnalysis/coordinates/XDR.py @@ -92,7 +92,7 @@ class XDRBaseReader(base.ReaderBase): """Base class for libmdaxdr file formats xtc and trr This class handles integration of XDR based formats into MDAnalysis. The - XTC and TRR classes only implement `write_next_timestep` and + XTC and TRR classes only implement `_write_next_frame` and `_frame_to_ts`. .. _offsets-label: diff --git a/package/MDAnalysis/coordinates/XTC.py b/package/MDAnalysis/coordinates/XTC.py index a581748ed01..125db00eacd 100644 --- a/package/MDAnalysis/coordinates/XTC.py +++ b/package/MDAnalysis/coordinates/XTC.py @@ -33,6 +33,7 @@ """ from __future__ import absolute_import +from . import base from .XDR import XDRBaseReader, XDRBaseWriter from ..lib.formats.libmdaxdr import XTCFile from ..lib.mdamath import triclinic_vectors, triclinic_box @@ -70,18 +71,37 @@ def __init__(self, filename, n_atoms, convert_units=True, **kwargs) self.precision = precision - def write_next_timestep(self, ts): - """Write timestep object into trajectory. + def _write_next_frame(self, ag): + """Write information associated with ``ag`` at current frame into trajectory Parameters ---------- - ts : :class:`~base.Timestep` + ag : AtomGroup or Universe See Also -------- - .write(AtomGroup/Universe/TimeStep) + .write(AtomGroup/Universe) The normal write() method takes a more general input + + + .. deprecated:: 1.0.0 + Deprecated using Timestep. To be removed in version 2.0. + .. versionchanged:: 1.0.0 + Added ability to use either AtomGroup or Universe. """ + if isinstance(ag, base.Timestep): + ts = ag + else: + try: + # Atomgroup? + ts = ag.ts + except AttributeError: + try: + # Universe? + ts = ag.trajectory.ts + except AttributeError: + raise TypeError("No Timestep found in ag argument") + xyz = ts.positions.copy() time = ts.time step = ts.frame diff --git a/package/MDAnalysis/coordinates/XYZ.py b/package/MDAnalysis/coordinates/XYZ.py index 4eb6af44502..88678e6e64b 100644 --- a/package/MDAnalysis/coordinates/XYZ.py +++ b/package/MDAnalysis/coordinates/XYZ.py @@ -89,6 +89,7 @@ import os import errno import numpy as np +import warnings import logging logger = logging.getLogger('MDAnalysis.coordinates.XYZ') @@ -201,6 +202,11 @@ def write(self, obj): obj : Universe or AtomGroup The :class:`~MDAnalysis.core.groups.AtomGroup` or :class:`~MDAnalysis.core.universe.Universe` to write. + + + .. deprecated:: 1.0.0 + Deprecated the use of Timestep as arguments to write. Use either an + AtomGroup or Universe. To be removed in version 2.0. """ # prepare the Timestep and extract atom names if possible # (The way it is written it should be possible to write @@ -210,6 +216,11 @@ def write(self, obj): atoms = obj.atoms except AttributeError: if isinstance(obj, base.Timestep): + warnings.warn( + 'Passing a Timestep to write is deprecated, ' + 'and will be removed in 2.0; ' + 'use either an AtomGroup or Universe', + DeprecationWarning) ts = obj else: six.raise_from(TypeError("No Timestep found in obj argument"), None) @@ -228,15 +239,16 @@ def write(self, obj): # update atom names self.atomnames = self._get_atoms_elements_or_names(atoms) - self.write_next_timestep(ts) + self._write_next_frame(ts) - def write_next_timestep(self, ts=None): + def _write_next_frame(self, ts=None): """ Write coordinate information in *ts* to the trajectory .. versionchanged:: 1.0.0 Print out :code:`remark` if present, otherwise use generic one (Issue #2692). + Renamed from `write_next_timestep` to `_write_next_frame`. """ if ts is None: if not hasattr(self, 'ts'): @@ -255,9 +267,9 @@ def write_next_timestep(self, ts=None): else: if (not isinstance(self.atomnames, itertools.cycle) and len(self.atomnames) != ts.n_atoms): - logger.info('Trying to write a TimeStep with unkown atoms. ' - 'Expected {}, got {}. Try using "write" if you are ' - 'using "write_next_timestep" directly'.format( + logger.info('Trying to write a TimeStep with unknown atoms. ' + 'Expected {} atoms, got {}. Try using "write" if you are ' + 'using "_write_next_frame" directly'.format( len(self.atomnames), ts.n_atoms)) self.atomnames = np.array([self.atomnames[0]] * ts.n_atoms) diff --git a/package/MDAnalysis/coordinates/__init__.py b/package/MDAnalysis/coordinates/__init__.py index d8548f24648..361489a9ab0 100644 --- a/package/MDAnalysis/coordinates/__init__.py +++ b/package/MDAnalysis/coordinates/__init__.py @@ -622,14 +622,12 @@ class can choose an appropriate reader automatically. Signature:: - W = TrajectoryWriter(filename,n_atoms,**kwargs) - W.write_next_timestep(Timestep) + with TrajectoryWriter(filename, n_atoms, **kwargs) as w: + w.write(Universe) # write a whole universe or:: - W.write(AtomGroup) # write a selection - W.write(Universe) # write a whole universe - W.write(Timestep) # same as write_next_timestep() + w.write(AtomGroup) # write a selection of Atoms from Universe Methods @@ -639,8 +637,6 @@ class can choose an appropriate reader automatically. opens *filename* and writes header if required by format ``write(obj)`` write Timestep data in *obj* - ``write_next_timestep([timestep])`` - write data in *timestep* to trajectory file ``convert_dimensions_to_unitcell(timestep)`` take the dimensions from the timestep and convert to the native unitcell representation of the format diff --git a/package/MDAnalysis/coordinates/base.py b/package/MDAnalysis/coordinates/base.py index e51101cfc7c..640d9af78f9 100644 --- a/package/MDAnalysis/coordinates/base.py +++ b/package/MDAnalysis/coordinates/base.py @@ -2183,6 +2183,11 @@ class WriterBase(six.with_metaclass(_Writermeta, IOBase)): See Trajectory API definition in :mod:`MDAnalysis.coordinates.__init__` for the required attributes and methods. + + + .. deprecated:: 1.0.0 + :func:`write_next_timestep` has been deprecated, please use + :func:`write` instead. """ def convert_dimensions_to_unitcell(self, ts, inplace=True): @@ -2204,26 +2209,46 @@ def write(self, obj): Parameters ---------- - obj : :class:`~MDAnalysis.core.groups.AtomGroup` or :class:`~MDAnalysis.core.universe.Universe` or a :class:`Timestep` + obj : :class:`~MDAnalysis.core.groups.AtomGroup` or :class:`~MDAnalysis.core.universe.Universe` write coordinate information associate with `obj` Note ---- The size of the `obj` must be the same as the number of atoms provided when setting up the trajectory. + + + .. deprecated:: 1.0.0 + Deprecated the use of Timestep as arguments to write. Use either + an AtomGroup or Universe. To be removed in version 2.0. """ if isinstance(obj, Timestep): - ts = obj - else: - try: - ts = obj.ts - except AttributeError: - try: - # special case: can supply a Universe, too... - ts = obj.trajectory.ts - except AttributeError: - six.raise_from(TypeError("No Timestep found in obj argument"), None) - return self.write_next_timestep(ts) + warnings.warn( + 'Passing a Timestep to write is deprecated, ' + 'and will be removed in 2.0; ' + 'use either an AtomGroup or Universe', + DeprecationWarning) + + return self._write_next_frame(obj) + + def write_next_timestep(self, obj): + """Write current timestep, using the supplied ``obj``. + + Parameters + ---------- + obj : :class:`~MDAnalysis.core.groups.AtomGroup` or :class:`~MDAnalysis.core.universe.Universe` + write coordinate information associated with ``obj`` + + + .. deprecated:: 1.0.0 + Deprecated, use write() instead + """ + warnings.warn( + 'Writer.write_next_timestep is deprecated, ' + 'and will be removed in 2.0; ' + 'use Writer.write()', + DeprecationWarning) + return self.write(obj) def __del__(self): self.close() @@ -2257,8 +2282,6 @@ def has_valid_coordinates(self, criteria, x): x = np.ravel(x) return np.all(criteria["min"] < x) and np.all(x <= criteria["max"]) - # def write_next_timestep(self, ts=None) - class SingleFrameReaderBase(ProtoReader): """Base class for Readers that only have one frame. diff --git a/package/MDAnalysis/coordinates/chemfiles.py b/package/MDAnalysis/coordinates/chemfiles.py index c7fbe842c2c..673c62e2826 100644 --- a/package/MDAnalysis/coordinates/chemfiles.py +++ b/package/MDAnalysis/coordinates/chemfiles.py @@ -273,8 +273,8 @@ def close(self): self._file.close() self._closed = True - def write(self, obj): - """Write object `obj` at current trajectory frame to file. + def _write_next_frame(self, obj): + """Write information associated with ``obj`` at current frame into trajectory. Topology for the output is taken from the `obj` or default to the value of the `topology` keyword supplied to the :class:`ChemfilesWriter` @@ -286,10 +286,11 @@ def write(self, obj): Parameters ---------- - obj : Universe or AtomGroup + obj : AtomGroup or Universe The :class:`~MDAnalysis.core.groups.AtomGroup` or :class:`~MDAnalysis.core.universe.Universe` to write. """ + # TODO 2.0: Remove timestep logic if hasattr(obj, "atoms"): if hasattr(obj, 'universe'): # For AtomGroup and children (Residue, ResidueGroup, Segment) @@ -311,16 +312,7 @@ def write(self, obj): frame = self._timestep_to_chemfiles(ts) frame.topology = self._topology_to_chemfiles(obj, len(frame.atoms)) - self._file.write(frame) - - def write_next_timestep(self, ts): - """Write timestep object into trajectory. - Parameters - ---------- - ts: TimeStep - """ - frame = self._timestep_to_chemfiles(ts) self._file.write(frame) def _timestep_to_chemfiles(self, ts): diff --git a/package/MDAnalysis/coordinates/null.py b/package/MDAnalysis/coordinates/null.py index c3a4e5118f8..3ba8cda6087 100644 --- a/package/MDAnalysis/coordinates/null.py +++ b/package/MDAnalysis/coordinates/null.py @@ -56,5 +56,5 @@ class NullWriter(base.WriterBase): def __init__(self, filename, **kwargs): pass - def write_next_timestep(self, ts=None): + def _write_next_frame(self, obj): pass diff --git a/testsuite/MDAnalysisTests/coordinates/test_chemfiles.py b/testsuite/MDAnalysisTests/coordinates/test_chemfiles.py index d21506f3fe5..21dd97759c9 100644 --- a/testsuite/MDAnalysisTests/coordinates/test_chemfiles.py +++ b/testsuite/MDAnalysisTests/coordinates/test_chemfiles.py @@ -162,12 +162,12 @@ def test_write_topology(self, tmpdir): # Manually setting the topology when creating the ChemfilesWriter # (1) from an object with ChemfilesWriter(outfile, topology=u) as writer: - writer.write_next_timestep(u.trajectory.ts) + writer.write(u) self.check_topology(datafiles.CONECT, outfile) # (2) from a file with ChemfilesWriter(outfile, topology=datafiles.CONECT) as writer: - writer.write_next_timestep(u.trajectory.ts) + writer.write(u) # FIXME: this does not work, since chemfiles also insert the bonds # which are implicit in PDB format (between standard residues), while # MDAnalysis only read the explicit CONNECT records. @@ -175,9 +175,11 @@ def test_write_topology(self, tmpdir): # self.check_topology(datafiles.CONECT, outfile) def test_write_velocities(self, tmpdir): - ts = mda.coordinates.base.Timestep(4, velocities=True) - ts.dimensions = [20, 30, 41, 90, 90, 90] + u = mda.Universe.empty(4, trajectory=True) + u.add_TopologyAttr('type', values=['H', 'H', 'H', 'H']) + ts = u.trajectory.ts + ts.dimensions = [20, 30, 41, 90, 90, 90] ts.positions = [ [1, 1, 1], [2, 2, 2], @@ -191,17 +193,10 @@ def test_write_velocities(self, tmpdir): [40, 40, 40], ] - u = mda.Universe.empty(4) - u.add_TopologyAttr('type') - u.atoms[0].type = "H" - u.atoms[1].type = "H" - u.atoms[2].type = "H" - u.atoms[3].type = "H" - outfile = "chemfiles-write-velocities.lmp" with tmpdir.as_cwd(): with ChemfilesWriter(outfile, topology=u, chemfiles_format='LAMMPS Data') as writer: - writer.write_next_timestep(ts) + writer.write(u) with open(outfile) as file: content = file.read() diff --git a/testsuite/MDAnalysisTests/coordinates/test_dcd.py b/testsuite/MDAnalysisTests/coordinates/test_dcd.py index dccfca070b9..e21e6e6e420 100644 --- a/testsuite/MDAnalysisTests/coordinates/test_dcd.py +++ b/testsuite/MDAnalysisTests/coordinates/test_dcd.py @@ -280,7 +280,7 @@ def test_other_writer(universe_dcd, tmpdir, ext, decimal): outfile = str(tmpdir.join("test.{}".format(ext))) with t.OtherWriter(outfile) as W: for ts in universe_dcd.trajectory: - W.write_next_timestep(ts) + W.write(universe_dcd) uw = mda.Universe(PSF, outfile) # check that the coordinates are identical for each time step diff --git a/testsuite/MDAnalysisTests/coordinates/test_lammps.py b/testsuite/MDAnalysisTests/coordinates/test_lammps.py index 1e775b94980..e3c88539ab3 100644 --- a/testsuite/MDAnalysisTests/coordinates/test_lammps.py +++ b/testsuite/MDAnalysisTests/coordinates/test_lammps.py @@ -154,6 +154,20 @@ def test_Writer_numerical_attrs(self, attr, LAMMPSDATAWriter): decimal=6) +def test_datawriter_universe(tmpdir): + fn = str(tmpdir.join('out.data')) + + u = mda.Universe(LAMMPSdata_mini) + + with mda.Writer(fn, n_atoms=len(u.atoms)) as w: + w.write(u) + + u2 = mda.Universe(fn) + + assert_almost_equal(u.atoms.positions, u2.atoms.positions) + assert_almost_equal(u.dimensions, u2.dimensions) + + class TestLAMMPSDATAWriter_data_partial(TestLAMMPSDATAWriter): N_kept = 5 diff --git a/testsuite/MDAnalysisTests/coordinates/test_netcdf.py b/testsuite/MDAnalysisTests/coordinates/test_netcdf.py index de040e9a092..81409b82d00 100644 --- a/testsuite/MDAnalysisTests/coordinates/test_netcdf.py +++ b/testsuite/MDAnalysisTests/coordinates/test_netcdf.py @@ -664,7 +664,7 @@ def test_OtherWriter(self, universe, outfile): def _copy_traj(self, writer, universe): for ts in universe.trajectory: - writer.write_next_timestep(ts) + writer.write(universe) def _check_new_traj(self, universe, outfile): uw = mda.Universe(self.topology, outfile) @@ -724,7 +724,7 @@ def test_TRR2NCDF(self, outfile): with mda.Writer(outfile, trr.trajectory.n_atoms, velocities=True, format="ncdf") as W: for ts in trr.trajectory: - W.write_next_timestep(ts) + W.write(trr) uw = mda.Universe(GRO, outfile) @@ -877,7 +877,7 @@ def test_writer_units(self, outfile, var, expected): with mda.Writer(outfile, trr.trajectory.n_atoms, velocities=True, forces=True, format='ncdf') as W: for ts in trr.trajectory: - W.write_next_timestep(ts) + W.write(trr) with netcdf.netcdf_file(outfile, mode='r') as ncdf: unit = ncdf.variables[var].units.decode('utf-8') @@ -902,11 +902,3 @@ def test_wrong_n_atoms(self, outfile): u = make_Universe(trajectory=True) with pytest.raises(IOError): w.write(u.trajectory.ts) - - def test_no_ts(self, outfile): - # no ts supplied at any point - from MDAnalysis.coordinates.TRJ import NCDFWriter - - with NCDFWriter(outfile, 100) as w: - with pytest.raises(IOError): - w.write_next_timestep() diff --git a/testsuite/MDAnalysisTests/coordinates/test_trz.py b/testsuite/MDAnalysisTests/coordinates/test_trz.py index 5e814a0402e..7f1c542241b 100644 --- a/testsuite/MDAnalysisTests/coordinates/test_trz.py +++ b/testsuite/MDAnalysisTests/coordinates/test_trz.py @@ -153,7 +153,7 @@ def test_write_trajectory(self, universe, outfile): def _copy_traj(self, writer, universe, outfile): for ts in universe.trajectory: - writer.write_next_timestep(ts) + writer.write(universe) writer.close() uw = mda.Universe(TRZ_psf, outfile) diff --git a/testsuite/MDAnalysisTests/coordinates/test_writer_api.py b/testsuite/MDAnalysisTests/coordinates/test_writer_api.py new file mode 100644 index 00000000000..ad578732733 --- /dev/null +++ b/testsuite/MDAnalysisTests/coordinates/test_writer_api.py @@ -0,0 +1,102 @@ +# -*- Mode: python; tab-width: 4; indent-tabs-mode:nil; coding:utf-8 -*- +# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 fileencoding=utf-8 +# +# MDAnalysis --- https://www.mdanalysis.org +# Copyright (c) 2006-2017 The MDAnalysis Development Team and contributors +# (see the file AUTHORS for the full list of names) +# +# Released under the GNU Public Licence, v2 or any higher version +# +# Please cite your use of MDAnalysis in published work: +# +# R. J. Gowers, M. Linke, J. Barnoud, T. J. E. Reddy, M. N. Melo, S. L. Seyler, +# D. L. Dotson, J. Domanski, S. Buchoux, I. M. Kenney, and O. Beckstein. +# MDAnalysis: A Python package for the rapid analysis of molecular dynamics +# simulations. In S. Benthall and S. Rostrup editors, Proceedings of the 15th +# Python in Science Conference, pages 102-109, Austin, TX, 2016. SciPy. +# +# N. Michaud-Agrawal, E. J. Denning, T. B. Woolf, and O. Beckstein. +# MDAnalysis: A Toolkit for the Analysis of Molecular Dynamics Simulations. +# J. Comput. Chem. 32 (2011), 2319--2327, doi:10.1002/jcc.21787 +# +from __future__ import absolute_import + +import itertools +import pytest + +import MDAnalysis as mda + + +# grab all known writers +# sort so test order is predictable for parallel tests +writers = sorted(set(mda._MULTIFRAME_WRITERS.values()) | + set(mda._SINGLEFRAME_WRITERS.values()), + key=lambda x: x.__name__) +known_ts_haters = [ + mda.coordinates.MOL2.MOL2Writer, + mda.coordinates.PDB.PDBWriter, + mda.coordinates.PDB.MultiPDBWriter, + mda.coordinates.PQR.PQRWriter, + mda.coordinates.PDBQT.PDBQTWriter, + mda.coordinates.LAMMPS.DATAWriter, + mda.coordinates.CRD.CRDWriter, +] + + +@pytest.mark.parametrize('writer', [w for w in writers + if not w in known_ts_haters]) +def test_ts_deprecated(writer, tmpdir): + u = mda.Universe.empty(10, trajectory=True) + + if writer == mda.coordinates.chemfiles.ChemfilesWriter: + # chemfiles Writer exists but doesn't work without chemfiles + if not mda.coordinates.chemfiles.check_chemfiles_version(): + pytest.skip("Chemfiles not available") + fn = str(tmpdir.join('out.xtc')) + else: + fn = str(tmpdir.join('out.traj')) + + with writer(fn, n_atoms=u.atoms.n_atoms) as w: + with pytest.warns(DeprecationWarning): + w.write(u.trajectory.ts) + + +@pytest.mark.parametrize('writer', writers) +def test_write_with_atomgroup(writer, tmpdir): + u = mda.Universe.empty(10, trajectory=True) + + if writer == mda.coordinates.chemfiles.ChemfilesWriter: + # chemfiles Writer exists but doesn't work without chemfiles + if not mda.coordinates.chemfiles.check_chemfiles_version(): + pytest.skip("Chemfiles not available") + fn = str(tmpdir.join('out.xtc')) + elif writer == mda.coordinates.MOL2.MOL2Writer: + pytest.skip("MOL2 only writes MOL2 back out") + elif writer == mda.coordinates.LAMMPS.DATAWriter: + pytest.skip("DATAWriter requires integer atom types") + else: + fn = str(tmpdir.join('out.traj')) + + with writer(fn, n_atoms=u.atoms.n_atoms) as w: + w.write(u.atoms) + + +@pytest.mark.parametrize('writer', writers) +def test_write_with_universe(writer, tmpdir): + u = mda.Universe.empty(10, trajectory=True) + + if writer == mda.coordinates.chemfiles.ChemfilesWriter: + # chemfiles Writer exists but doesn't work without chemfiles + if not mda.coordinates.chemfiles.check_chemfiles_version(): + pytest.skip("Chemfiles not available") + fn = str(tmpdir.join('out.xtc')) + elif writer == mda.coordinates.MOL2.MOL2Writer: + pytest.skip("MOL2 only writes MOL2 back out") + elif writer == mda.coordinates.LAMMPS.DATAWriter: + pytest.skip("DATAWriter requires integer atom types") + else: + fn = str(tmpdir.join('out.traj')) + + with writer(fn, n_atoms=10) as w: + w.write(u) + diff --git a/testsuite/MDAnalysisTests/coordinates/test_xdr.py b/testsuite/MDAnalysisTests/coordinates/test_xdr.py index c34f307f9ab..47d65cee913 100644 --- a/testsuite/MDAnalysisTests/coordinates/test_xdr.py +++ b/testsuite/MDAnalysisTests/coordinates/test_xdr.py @@ -340,7 +340,7 @@ def test_write_trajectory(self, universe, Writer, outfile): """Test writing Gromacs trajectories (Issue 38)""" with Writer(outfile, universe.atoms.n_atoms, dt=universe.trajectory.dt) as W: for ts in universe.trajectory: - W.write_next_timestep(ts) + W.write(universe) uw = mda.Universe(GRO, outfile) @@ -366,7 +366,7 @@ def test_timestep_not_modified_by_writer(self, universe, Writer, outfile): with Writer(outfile, trj.n_atoms, dt=trj.dt) as W: # last timestep (so that time != 0) (say it again, just in case...) trj[-1] - W.write_next_timestep(ts) + W.write(universe) assert_equal( ts._pos, @@ -388,7 +388,7 @@ class TestTRRWriter(_GromacsWriter): def test_velocities(self, universe, Writer, outfile): with Writer(outfile, universe.atoms.n_atoms, dt=universe.trajectory.dt) as W: for ts in universe.trajectory: - W.write_next_timestep(ts) + W.write(universe) uw = mda.Universe(GRO, outfile) @@ -414,7 +414,7 @@ def test_gaps(self, universe, Writer, outfile): ts.has_positions = False if ts.frame % 2 == 0: ts.has_velocities = False - W.write_next_timestep(ts) + W.write(universe) uw = mda.Universe(GRO, outfile) # check that the velocities are identical for each time step, except @@ -509,7 +509,7 @@ def test_write_trajectory(self, universe, tmpdir): outfile = str(tmpdir.join('xdr-writer-issue117' + self.ext)) with mda.Writer(outfile, n_atoms=universe.atoms.n_atoms) as W: for ts in universe.trajectory: - W.write_next_timestep(ts) + W.write(universe) uw = mda.Universe(PRMncdf, outfile)