Repository: MDAnalysis/mdanalysis (Python)

Issue:
update analysis.bat.BAT to use .results
Follow the new AnalysisBase API and store results in analysis.bat.BAT in `.results` instead of class attributes.

See omnibus issue #3261 for background and details. See PR #3263 as an example (DensityAnalysis) but note that no deprecations are needed (new in 2.0.0)
- [ ] put all generated data into `self.results` instead of `self`
- [ ] keep managed attributes in place with deprecation message
- [ ] add an entry in CHANGELOG under Changes (for completeness)


Discussion:

Patch:
diff --git a/package/CHANGELOG b/package/CHANGELOG
index a94b9050120..80a6feeb528 100644
--- a/package/CHANGELOG
+++ b/package/CHANGELOG
@@ -174,6 +174,8 @@ Enhancements
     checking if it can be used in parallel analysis. (Issue #2996, PR #2950)
 
 Changes
+  * `bat.BAT` now uses the `analysis.base.Results` class to store the `bat`
+    results attribute (Issue #3261, #3272)
   * `contacts.Contacts` now stores data using the `Contacts.results.timeseries`
     attribute. `Contacts.timeseries` is now deprecated (Issue #3261)
   * `DensityAnalysis` now uses the `results.density` attribute for storing
diff --git a/package/MDAnalysis/analysis/bat.py b/package/MDAnalysis/analysis/bat.py
index df23d7235bf..0cb360fc8a4 100644
--- a/package/MDAnalysis/analysis/bat.py
+++ b/package/MDAnalysis/analysis/bat.py
@@ -93,21 +93,21 @@ class to calculate dihedral angles for a given set of atoms or residues
    R.run()
 
 After :meth:`R.run()<BAT.run>`, the coordinates can be accessed with
-:attr:`R.bat<BAT.bat>`. The following code snippets assume that the previous
-snippet has been executed.
+:attr:`R.results.bat<BAT.bat>`. The following code snippets assume that the
+previous snippet has been executed.
 
 Reconstruct Cartesian coordinates for the first frame::
 
    # Reconstruct Cartesian coordinates from BAT coordinates
    # of the first frame
-   XYZ = R.Cartesian(R.bat[0,:])
+   XYZ = R.Cartesian(R.results.bat[0,:])
 
    # The original and reconstructed Cartesian coordinates should all be close
    print(np.allclose(XYZ, selected_residues.positions, atol=1e-6))
 
 Change a single torsion angle by :math:`\pi`::
 
-   bat = R.bat[0,:]
+   bat = R.results.bat[0,:]
    bat[bat.shape[0]-12] += np.pi
    XYZ = R.Cartesian(bat)
 
@@ -124,7 +124,7 @@ class to calculate dihedral angles for a given set of atoms or residues
    Rnew = BAT(selected_residues, filename='test.npy')
 
    # The BAT coordinates before and after disk I/O should be close
-   print(np.allclose(Rnew.bat, R.bat))
+   print(np.allclose(Rnew.results.bat, R.results.bat))
 
 
 Analysis classes
@@ -133,7 +133,7 @@ class to calculate dihedral angles for a given set of atoms or residues
     :members:
     :inherited-members:
 
-    .. attribute:: bat
+    .. attribute:: results.bat
 
         Contains the time series of the Bond-Angle-Torsion coordinates as a
         (nframes, 3N) :class:`numpy.ndarray` array. Each row corresponds to
@@ -364,8 +364,8 @@ def __init__(self, ag, initial_atom=None, filename=None, **kwargs):
             self.load(filename)
 
     def _prepare(self):
-        self.bat = np.zeros((self.n_frames, 3*self._ag.n_atoms), \
-            dtype=np.float64)
+        self.results.bat = np.zeros(
+                (self.n_frames, 3*self._ag.n_atoms), dtype=np.float64)
 
     def _single_frame(self):
         # Calculate coordinates based on the root atoms
@@ -422,8 +422,8 @@ def _single_frame(self):
         # Wrap torsions to between -np.pi and np.pi
         torsions = ((torsions + np.pi) % (2 * np.pi)) - np.pi
 
-        self.bat[self._frame_index, :] = \
-            np.concatenate((root_based, bonds, angles, torsions))
+        self.results.bat[self._frame_index, :] = np.concatenate(
+                (root_based, bonds, angles, torsions))
 
     def load(self, filename, start=None, stop=None, step=None):
         """Loads the bat trajectory from a file in numpy binary format
@@ -447,15 +447,17 @@ def load(self, filename, start=None, stop=None, step=None):
         self._setup_frames(self._trajectory, start, stop, step)
 
         logger.info("Loading file")
-        self.bat = np.load(filename)
+        self.results.bat = np.load(filename)
 
         # Check array dimensions
-        if self.bat.shape!=(self.n_frames, 3*self._ag.n_atoms):
-          raise ValueError('Dimensions of array in loaded file, ' + \
-              f'({self.bat.shape[0]},{self.bat.shape[1]}), differ from ' + \
-              f'required dimensions of ({self.n_frames, 3*self._ag.n_atoms})')
+        if self.results.bat.shape != (self.n_frames, 3*self._ag.n_atoms):
+            errmsg = ('Dimensions of array in loaded file, '
+                      f'({self.results.bat.shape[0]},'
+                      f'{self.results.bat.shape[1]}), differ from required '
+                      f'dimensions of ({self.n_frames, 3*self._ag.n_atoms})')
+            raise ValueError(errmsg)
         # Check position of initial atom
-        if (self.bat[0,:3] != self._root[0].position).any():
+        if (self.results.bat[0, :3] != self._root[0].position).any():
             raise ValueError('Position of initial atom in file ' + \
                 'inconsistent with current trajectory in starting frame.')
         return self
@@ -467,7 +469,7 @@ def save(self, filename):
         --------
         load: Loads the bat trajectory from a file in numpy binary format
         """
-        np.save(filename, self.bat)
+        np.save(filename, self.results.bat)
 
     def Cartesian(self, bat_frame):
         """Conversion of a single frame from BAT to Cartesian coordinates


Test patch:
diff --git a/testsuite/MDAnalysisTests/analysis/test_bat.py b/testsuite/MDAnalysisTests/analysis/test_bat.py
index 9fb0e93b977..49ccdacbcd2 100644
--- a/testsuite/MDAnalysisTests/analysis/test_bat.py
+++ b/testsuite/MDAnalysisTests/analysis/test_bat.py
@@ -28,7 +28,7 @@
 
 import MDAnalysis as mda
 from MDAnalysisTests.datafiles import (PSF, DCD, mol2_comments_header, XYZ_mini,
-                                       BATArray)
+                                       BATArray, TPR, XTC)
 from MDAnalysis.analysis.bat import BAT
 
 
@@ -43,7 +43,7 @@ def selected_residues(self):
     def bat(self, selected_residues):
         R = BAT(selected_residues)
         R.run()
-        return R.bat
+        return R.results.bat
 
     @pytest.fixture
     def bat_npz(self, tmpdir, selected_residues):
@@ -72,7 +72,7 @@ def test_bat_coordinates(self, bat):
             err_msg="error: BAT coordinates should match test values")
 
     def test_bat_coordinates_single_frame(self, selected_residues):
-        bat = BAT(selected_residues).run(start=1, stop=2).bat
+        bat = BAT(selected_residues).run(start=1, stop=2).results.bat
         test_bat = [np.load(BATArray)[1]]
         assert_almost_equal(
             bat,
@@ -89,7 +89,7 @@ def test_bat_reconstruction(self, selected_residues, bat):
 
     def test_bat_IO(self, bat_npz, selected_residues, bat):
         R2 = BAT(selected_residues, filename=bat_npz)
-        test_bat = R2.bat
+        test_bat = R2.results.bat
         assert_almost_equal(
             bat,
             test_bat,
@@ -98,22 +98,32 @@ def test_bat_IO(self, bat_npz, selected_residues, bat):
 
     def test_bat_nobonds(self):
         u = mda.Universe(XYZ_mini)
-        with pytest.raises(AttributeError):
+        errmsg = "AtomGroup has no attribute bonds"
+        with pytest.raises(AttributeError, match=errmsg):
             Z = BAT(u.atoms)
 
     def test_bat_bad_initial_atom(self, selected_residues):
-        with pytest.raises(ValueError):
+        errmsg = 'Initial atom is not a terminal atom'
+        with pytest.raises(ValueError, match=errmsg):
             R = BAT(selected_residues, initial_atom = selected_residues[0])
 
     def test_bat_disconnected_atom_group(self):
         u = mda.Universe(PSF, DCD)
         selected_residues = u.select_atoms("resid 1-3") + \
             u.select_atoms("resid 5-7")
-        with pytest.raises(ValueError):
+        errmsg = 'Additional torsions not found.'
+        with pytest.raises(ValueError, match=errmsg):
             R = BAT(selected_residues)
 
+    def test_bat_multifragments_atomgroup(self):
+        u = mda.Universe(TPR, XTC)
+        errmsg = 'AtomGroup has more than one molecule'
+        with pytest.raises(ValueError, match=errmsg):
+            BAT(u.select_atoms('resname SOL'))
+
     def test_bat_incorrect_dims(self, bat_npz):
         u = mda.Universe(PSF, DCD)
         selected_residues = u.select_atoms("resid 1-3")
-        with pytest.raises(ValueError):
+        errmsg = 'Dimensions of array in loaded file'
+        with pytest.raises(ValueError, match=errmsg):
             R = BAT(selected_residues, filename=bat_npz)