Repository: MDAnalysis/mdanalysis (Python)

Issue:
Update AlignTraj and AverageStructure to use new .results
Follow the new AnalysisBase API and store results in align.AlignTraj and align.AverageStructure in `.results` instead of class attributes.

See omnibus issue #3261 for background and details. See PR #3263 as an example (DensityAnalysis)
- [x] put all generated data into `self.results` instead of `self`
- [x] keep managed attributes in place with deprecation message
- [x] test deprecation messages/old attribute access
- [x] update CHANGELOG: one entry in Changes (use .results) and one entry in Deprecations (attribute access to be removed in 3.0.0)

Note: needs to be done in 2.0.0 if #3277 gets merged.


Discussion:
PR #3277 was merged so this one needs to happen.



Patch:
diff --git a/package/CHANGELOG b/package/CHANGELOG
index f70078f975d..abaf73f9f2a 100644
--- a/package/CHANGELOG
+++ b/package/CHANGELOG
@@ -175,6 +175,9 @@ Enhancements
     checking if it can be used in parallel analysis. (Issue #2996, PR #2950)
 
 Changes
+  * `analysis.align.AlignTraj` and `analysis.align.AverageStructure` now store
+    their result attributes using the `analysis.base.Results` class
+    (Issues #3278 and #3261)
   * `analysis.hydrogenbonds.hbond_analysis.HydrogenBondAnalyis` now stores
     `hbonds` data using the `analysis.base.Results class (Issues #3271, #3261)
   * `analysis.rms.RMSD` and `analysis.rms.RMSF` classes now store `rmsd` and
@@ -238,6 +241,12 @@ Changes
   * Added OpenMM coordinate and topology converters (Issue #2863, PR #2917)
 
 Deprecations
+  * The `analysis.align.AlignTraj.rmsd` attribute is now deprecated in
+    favour of `analysis.align.AlignTraj.results.rmsd` (Issue #3278, #3261)
+  * The `universe`, `positions`, and `rmsd` attributes of
+    `analysis.align.AverageStructure` are now deprecated in favour of
+    `results.universe`, `results.positions`, and `results.rmsd`
+    (Issue #3278, #3261)
   * The `hbonds` attribute of
     `hydrogenbonds.hbond_analysis.HydrogenBondAnalysis.hbonds` is now
     deprecated in favour of `results.hbonds` (Issues #3271, #3261)
diff --git a/package/MDAnalysis/analysis/align.py b/package/MDAnalysis/analysis/align.py
index 1f08bea2d5c..acfcbb37f2c 100644
--- a/package/MDAnalysis/analysis/align.py
+++ b/package/MDAnalysis/analysis/align.py
@@ -535,7 +535,7 @@ class AlignTraj(AnalysisBase):
 
     .. versionchanged:: 1.0.0
        ``save()`` has now been removed, as an alternative use ``np.savetxt()``
-       on :attr:`rmsd`.
+       on :attr:`results.rmsd`.
 
     """
 
@@ -589,9 +589,19 @@ def __init__(self, mobile, reference, select='all', filename=None,
             Atoms of the reference structure to be aligned against
         mobile_atoms : AtomGroup
             Atoms inside each trajectory frame to be rmsd_aligned
-        rmsd : Array
+        results.rmsd : :class:`numpy.ndarray`
             Array of the rmsd values of the least rmsd between the mobile_atoms
             and reference_atoms after superposition and minimimization of rmsd
+
+            .. versionadded:: 2.0.0
+
+        rmsd : :class:`numpy.ndarray`
+            Alias to the :attr:`results.rmsd` attribute.
+
+            .. deprecated:: 2.0.0
+               Will be removed in MDAnalysis 3.0.0. Please use
+               :attr:`results.rmsd` instead.
+
         filename : str
             String reflecting the filename of the file where the aligned
             positions will be written to upon running RMSD alignment
@@ -627,6 +637,10 @@ def __init__(self, mobile, reference, select='all', filename=None,
            Support for the ``start``, ``stop``, and ``step`` keywords has been
            removed. These should instead be passed to :meth:`AlignTraj.run`.
 
+        .. versionchanged:: 2.0.0
+           :attr:`rmsd` results are now stored in a
+           :class:`MDAnalysis.analysis.base.Results` instance.
+
         """
         select = rms.process_selection(select)
         self.ref_atoms = reference.select_atoms(*select['reference'])
@@ -677,17 +691,18 @@ def _prepare(self):
         self._ref_com = self.ref_atoms.center(self._weights)
         self._ref_coordinates = self.ref_atoms.positions - self._ref_com
         # allocate the array for selection atom coords
-        self.rmsd = np.zeros((self.n_frames,))
+        self.results.rmsd = np.zeros((self.n_frames,))
 
     def _single_frame(self):
         index = self._frame_index
         mobile_com = self.mobile_atoms.center(self._weights)
         mobile_coordinates = self.mobile_atoms.positions - mobile_com
-        mobile_atoms, self.rmsd[index] = _fit_to(mobile_coordinates,
-                                                 self._ref_coordinates,
-                                                 self.mobile,
-                                                 mobile_com,
-                                                 self._ref_com, self._weights)
+        mobile_atoms, self.results.rmsd[index] = _fit_to(
+                mobile_coordinates,
+                self._ref_coordinates,
+                self.mobile,
+                mobile_com,
+                self._ref_com, self._weights)
         # write whole aligned input trajectory system
         self._writer.write(mobile_atoms)
 
@@ -696,6 +711,14 @@ def _conclude(self):
         if not self._verbose:
             logging.disable(logging.NOTSET)
 
+    @property
+    def rmsd(self):
+        wmsg = ("The `rmsd` attribute was deprecated in MDAnalysis 2.0.0 and "
+                "will be removed in MDAnalysis 3.0.0. Please use "
+                "`results.rmsd` instead.")
+        warnings.warn(wmsg, DeprecationWarning)
+        return self.results.rmsd
+
 
 class AverageStructure(AnalysisBase):
     """RMS-align trajectory to a reference structure using a selection,
@@ -722,7 +745,7 @@ class AverageStructure(AnalysisBase):
 
         # align to the third frame and average structure
         av = align.AverageStructure(u, ref_frame=3).run()
-        averaged_universe = av.universe
+        averaged_universe = av.results.universe
 
     """
 
@@ -775,12 +798,42 @@ def __init__(self, mobile, reference=None, select='all', filename=None,
             Atoms of the reference structure to be aligned against
         mobile_atoms : AtomGroup
             Atoms inside each trajectory frame to be rmsd_aligned
-        universe : mda.Universe
+        results.universe : :class:`MDAnalysis.Universe`
             New Universe with average positions
-        positions : np.ndarray(dtype=float)
+
+            .. versionadded:: 2.0.0
+
+        universe : :class:`MDAnalysis.Universe`
+            Alias to the :attr:`results.universe` attribute.
+
+            .. deprecated:: 2.0.0
+               Will be removed in MDAnalysis 3.0.0. Please use
+               :attr:`results.universe` instead.
+
+        results.positions : np.ndarray(dtype=float)
             Average positions
-        rmsd : float
+
+            .. versionadded:: 2.0.0
+
+        positions : np.ndarray(dtype=float)
+            Alias to the :attr:`results.positions` attribute.
+
+            .. deprecated:: 2.0.0
+               Will be removed in MDAnalysis 3.0.0. Please use
+               :attr:`results.positions` instead.
+
+        results.rmsd : float
             Average RMSD per frame
+
+            .. versionadded:: 2.0.0
+
+        rmsd : float
+            Alias to the :attr:`results.rmsd` attribute.
+
+            .. deprecated:: 2.0.0
+               Will be removed in MDAnalysis 3.0.0. Please use
+               :attr:`results.rmsd` instead.
+
         filename : str
             String reflecting the filename of the file where the average
             structure is written
@@ -799,6 +852,9 @@ def __init__(self, mobile, reference=None, select='all', filename=None,
 
 
         .. versionadded:: 1.0.0
+        .. versionchanged:: 2.0.0
+           :attr:`universe`, :attr:`positions`, and :attr:`rmsd` are now
+           stored in a :class:`MDAnalysis.analysis.base.Results` instance.
         """
         if in_memory or isinstance(mobile.trajectory, MemoryReader):
             mobile.transfer_to_memory()
@@ -832,9 +888,9 @@ def __init__(self, mobile, reference=None, select='all', filename=None,
         self.ref_frame = ref_frame
 
         self.filename = filename
-        self.universe = mda.Merge(self.mobile_atoms)
+        self.results.universe = mda.Merge(self.mobile_atoms)
 
-        natoms = len(self.universe.atoms)
+        natoms = len(self.results.universe.atoms)
         self.ref_atoms, self.mobile_atoms = get_matching_atoms(
             self.ref_atoms, self.mobile_atoms, tol_mass=tol_mass,
             strict=strict, match_atoms=match_atoms)
@@ -863,28 +919,53 @@ def _prepare(self):
             self.reference.universe.trajectory[current_frame]
 
         # allocate the array for selection atom coords
-        self.positions = np.zeros((len(self.mobile_atoms), 3))
-        self.rmsd = 0
+        self.results.positions = np.zeros((len(self.mobile_atoms), 3))
+        self.results.rmsd = 0
 
     def _single_frame(self):
         mobile_com = self.mobile_atoms.center(self._weights)
         mobile_coordinates = self.mobile_atoms.positions - mobile_com
-        self.rmsd += _fit_to(mobile_coordinates,
-                             self._ref_coordinates,
-                             self.mobile,
-                             mobile_com,
-                             self._ref_com, self._weights)[1]
-        self.positions += self.mobile_atoms.positions
+        self.results.rmsd += _fit_to(mobile_coordinates,
+                                     self._ref_coordinates,
+                                     self.mobile,
+                                     mobile_com,
+                                     self._ref_com, self._weights)[1]
+        self.results.positions += self.mobile_atoms.positions
 
     def _conclude(self):
-        self.positions /= self.n_frames
-        self.rmsd /= self.n_frames
-        self.universe.load_new(self.positions.reshape((1, -1, 3)))
-        self._writer.write(self.universe.atoms)
+        self.results.positions /= self.n_frames
+        self.results.rmsd /= self.n_frames
+        self.results.universe.load_new(
+                self.results.positions.reshape((1, -1, 3)))
+        self._writer.write(self.results.universe.atoms)
         self._writer.close()
         if not self._verbose:
             logging.disable(logging.NOTSET)
 
+    @property
+    def universe(self):
+        wmsg = ("The `universe` attribute was deprecated in MDAnalysis 2.0.0 "
+                "and will be removed in MDAnalysis 3.0.0. Please use "
+                "`results.universe` instead.")
+        warnings.warn(wmsg, DeprecationWarning)
+        return self.results.universe
+
+    @property
+    def positions(self):
+        wmsg = ("The `positions` attribute was deprecated in MDAnalysis 2.0.0 "
+                "and will be removed in MDAnalyssi 3.0.0. Please use "
+                "`results.positions` instead.")
+        warnings.warn(wmsg, DeprecationWarning)
+        return self.results.positions
+
+    @property
+    def rmsd(self):
+        wmsg = ("The `rmsd` attribute was deprecated in MDAnalysis 2.0.0 "
+                "and will be removed in MDAnalysis 3.0.0. Please use "
+                "`results.rmsd` instead.")
+        warnings.warn(wmsg, DeprecationWarning)
+        return self.results.rmsd
+
 
 def sequence_alignment(mobile, reference, match_score=2, mismatch_penalty=-1,
                        gap_penalty=-2, gapextension_penalty=-0.1):


Test patch:
diff --git a/testsuite/MDAnalysisTests/analysis/test_align.py b/testsuite/MDAnalysisTests/analysis/test_align.py
index 38ad541d848..6edc0ac8fa9 100644
--- a/testsuite/MDAnalysisTests/analysis/test_align.py
+++ b/testsuite/MDAnalysisTests/analysis/test_align.py
@@ -268,14 +268,23 @@ def test_AlignTraj_step_works(self, universe, reference, tmpdir):
         # this shouldn't throw an exception
         align.AlignTraj(universe, reference, filename=outfile).run(step=10)
 
+    def test_AlignTraj_deprecated_attribute(self, universe, reference, tmpdir):
+        reference.trajectory[-1]
+        outfile = str(tmpdir.join('align_test.dcd'))
+        x = align.AlignTraj(universe, reference, filename=outfile).run(stop=2)
+
+        wmsg = "The `rmsd` attribute was deprecated in MDAnalysis 2.0.0"
+        with pytest.warns(DeprecationWarning, match=wmsg):
+            assert_equal(x.rmsd, x.results.rmsd)
+
     def test_AlignTraj(self, universe, reference, tmpdir):
         reference.trajectory[-1]
         outfile = str(tmpdir.join('align_test.dcd'))
         x = align.AlignTraj(universe, reference, filename=outfile).run()
         fitted = mda.Universe(PSF, outfile)
 
-        assert_almost_equal(x.rmsd[0], 6.9290, decimal=3)
-        assert_almost_equal(x.rmsd[-1], 5.2797e-07, decimal=3)
+        assert_almost_equal(x.results.rmsd[0], 6.9290, decimal=3)
+        assert_almost_equal(x.results.rmsd[-1], 5.2797e-07, decimal=3)
 
         # RMSD against the reference frame
         # calculated on Mac OS X x86 with MDA 0.7.2 r689
@@ -288,8 +297,8 @@ def test_AlignTraj_weighted(self, universe, reference, tmpdir):
         x = align.AlignTraj(universe, reference,
                             filename=outfile, weights='mass').run()
         fitted = mda.Universe(PSF, outfile)
-        assert_almost_equal(x.rmsd[0], 0, decimal=3)
-        assert_almost_equal(x.rmsd[-1], 6.9033, decimal=3)
+        assert_almost_equal(x.results.rmsd[0], 0, decimal=3)
+        assert_almost_equal(x.results.rmsd[-1], 6.9033, decimal=3)
 
         self._assert_rmsd(reference, fitted, 0, 0.0,
                           weights=universe.atoms.masses)
@@ -308,7 +317,7 @@ def test_AlignTraj_custom_weights(self, universe, reference, tmpdir):
         x_weights = align.AlignTraj(universe, reference,
                                     filename=outfile, weights=weights).run()
 
-        assert_array_almost_equal(x.rmsd, x_weights.rmsd)
+        assert_array_almost_equal(x.results.rmsd, x_weights.results.rmsd)
 
     def test_AlignTraj_custom_mass_weights(self, universe, reference, tmpdir):
         outfile = str(tmpdir.join('align_test.dcd'))
@@ -316,8 +325,8 @@ def test_AlignTraj_custom_mass_weights(self, universe, reference, tmpdir):
                             filename=outfile,
                             weights=reference.atoms.masses).run()
         fitted = mda.Universe(PSF, outfile)
-        assert_almost_equal(x.rmsd[0], 0, decimal=3)
-        assert_almost_equal(x.rmsd[-1], 6.9033, decimal=3)
+        assert_almost_equal(x.results.rmsd[0], 0, decimal=3)
+        assert_almost_equal(x.results.rmsd[-1], 6.9033, decimal=3)
 
         self._assert_rmsd(reference, fitted, 0, 0.0,
                           weights=universe.atoms.masses)
@@ -337,8 +346,8 @@ def test_AlignTraj_in_memory(self, universe, reference, tmpdir):
         x = align.AlignTraj(universe, reference, filename=outfile,
                             in_memory=True).run()
         assert x.filename is None
-        assert_almost_equal(x.rmsd[0], 6.9290, decimal=3)
-        assert_almost_equal(x.rmsd[-1], 5.2797e-07, decimal=3)
+        assert_almost_equal(x.results.rmsd[0], 6.9290, decimal=3)
+        assert_almost_equal(x.results.rmsd[-1], 5.2797e-07, decimal=3)
 
         # check in memory trajectory
         self._assert_rmsd(reference, universe, 0, 6.929083044751061)
@@ -392,7 +401,7 @@ def _get_aligned_average_positions(ref_files, ref, select="all", **kwargs):
     prealigner = align.AlignTraj(u, ref, select=select, **kwargs).run()
     ag = u.select_atoms(select)
     reference_coordinates = u.trajectory.timeseries(asel=ag).mean(axis=1)
-    rmsd = sum(prealigner.rmsd/len(u.trajectory))
+    rmsd = sum(prealigner.results.rmsd/len(u.trajectory))
     return reference_coordinates, rmsd
 
 class TestAverageStructure(object):
@@ -407,30 +416,51 @@ def universe(self):
     def reference(self):
         return mda.Universe(PSF, CRD)
 
+    def test_average_structure_deprecated_attrs(self, universe, reference):
+        # Issue #3278 - remove in MDAnalysis 3.0.0
+        avg = align.AverageStructure(universe, reference).run(stop=2)
+
+        wmsg = "The `universe` attribute was deprecated in MDAnalysis 2.0.0"
+        with pytest.warns(DeprecationWarning, match=wmsg):
+            assert_equal(avg.universe.atoms.positions,
+                         avg.results.universe.atoms.positions)
+
+        wmsg = "The `positions` attribute was deprecated in MDAnalysis 2.0.0"
+        with pytest.warns(DeprecationWarning, match=wmsg):
+            assert_equal(avg.positions, avg.results.positions)
+
+        wmsg = "The `rmsd` attribute was deprecated in MDAnalysis 2.0.0"
+        with pytest.warns(DeprecationWarning, match=wmsg):
+            assert avg.rmsd == avg.results.rmsd
+
     def test_average_structure(self, universe, reference):
         ref, rmsd = _get_aligned_average_positions(self.ref_files, reference)
         avg = align.AverageStructure(universe, reference).run()
-        assert_almost_equal(avg.universe.atoms.positions, ref, decimal=4)
-        assert_almost_equal(avg.rmsd, rmsd)
+        assert_almost_equal(avg.results.universe.atoms.positions, ref,
+                            decimal=4)
+        assert_almost_equal(avg.results.rmsd, rmsd)
 
     def test_average_structure_mass_weighted(self, universe, reference):
         ref, rmsd = _get_aligned_average_positions(self.ref_files, reference, weights='mass')
         avg = align.AverageStructure(universe, reference, weights='mass').run()
-        assert_almost_equal(avg.universe.atoms.positions, ref, decimal=4)
-        assert_almost_equal(avg.rmsd, rmsd)
+        assert_almost_equal(avg.results.universe.atoms.positions, ref,
+                            decimal=4)
+        assert_almost_equal(avg.results.rmsd, rmsd)
 
     def test_average_structure_select(self, universe, reference):
         select = 'protein and name CA and resid 3-5'
         ref, rmsd = _get_aligned_average_positions(self.ref_files, reference, select=select)
         avg = align.AverageStructure(universe, reference, select=select).run()
-        assert_almost_equal(avg.universe.atoms.positions, ref, decimal=4)
-        assert_almost_equal(avg.rmsd, rmsd)
+        assert_almost_equal(avg.results.universe.atoms.positions, ref,
+                            decimal=4)
+        assert_almost_equal(avg.results.rmsd, rmsd)
 
     def test_average_structure_no_ref(self, universe):
         ref, rmsd = _get_aligned_average_positions(self.ref_files, universe)
         avg = align.AverageStructure(universe).run()
-        assert_almost_equal(avg.universe.atoms.positions, ref, decimal=4)
-        assert_almost_equal(avg.rmsd, rmsd)
+        assert_almost_equal(avg.results.universe.atoms.positions, ref,
+                            decimal=4)
+        assert_almost_equal(avg.results.rmsd, rmsd)
 
     def test_average_structure_no_msf(self, universe):
         avg = align.AverageStructure(universe).run()
@@ -453,13 +483,15 @@ def test_average_structure_ref_frame(self, universe):
         universe.trajectory[0]
         ref, rmsd = _get_aligned_average_positions(self.ref_files, u)
         avg = align.AverageStructure(universe, ref_frame=ref_frame).run()
-        assert_almost_equal(avg.universe.atoms.positions, ref, decimal=4)
-        assert_almost_equal(avg.rmsd, rmsd)
+        assert_almost_equal(avg.results.universe.atoms.positions, ref,
+                            decimal=4)
+        assert_almost_equal(avg.results.rmsd, rmsd)
 
     def test_average_structure_in_memory(self, universe):
         avg = align.AverageStructure(universe, in_memory=True).run()
         reference_coordinates = universe.trajectory.timeseries().mean(axis=1)
-        assert_almost_equal(avg.universe.atoms.positions, reference_coordinates, decimal=4)
+        assert_almost_equal(avg.results.universe.atoms.positions,
+                            reference_coordinates, decimal=4)
         assert avg.filename is None