Repository: sunpy/sunpy

Issue:
Typo / missing functionality in TimeSeries concatenate method
### Description
The current method signature is `def concatenate(self, otherts, same_source=False, **kwargs):` so there is a small typo 'otherts' -> 'others' but currently the method can only take a single timeseries so should probably be 'other'. Simplest fix would be to just change 'otherts' to 'other' but I think it might be more beneficial to change to 'others' and add the missing code to handle a list/iterable of timeseries and is reference in the code comments.

### Expected vs Actual behavior
Works fine to concatenate a time series with a single other time series but doesn't work combing a list of time series.

Currently doing this type of thing quite, often where the TimeSeries are all of the same type

```python
my_timeseries = TimeSeries(listofiles)
combined_ts = my_timeseries[0]
for ts in my_timeseries[1:]:
     combined_ts = combined_ts.concatenate(bk)
```

### Steps to Reproduce
```python

```

### System Details
- SunPy Version: 2.0.1
- Astropy Version: 3.2.3
- Python Version: 3.7.0
- OS information: macOS 10.15.6



Discussion:
I *think* the intention might have been "Other TimeSeries" > "otherts", but I can see where the confusion has come from! Not sure what our policy is on renaming arguments (ie. does it need a deprecation?), but definitely agree that we should change it.

Adding the ability to concatenate multiple TimeSeries' is a feature request, PRs welcome 😉 
I'd like to work on this - will send in a PR soon 😃

To clarify: For now, I won't rename otherts. Based upon the type of otherts however - i.e. TimeSeries or iterable, I'll handle it differently, either concatenating directly or looping through the list and concatenating.
I've been trying to look for ways to do this and I'm unable to choose between two different approaches -
1. Simply check the type of otherts. If it is a TimeSeries object, pack it as a list and handle it as I would any other iterable (for which I'll be writing code anyway).
2. Overload the `concatenate` method using `@seconddispatch` and handle it differently depending upon the type of otherts at the expense of some code duplication.

Not sure which might be more robust. Any tips? 😅
Pick the one you prefer.

Resolving patch:
diff --git a/sunpy/timeseries/metadata.py b/sunpy/timeseries/metadata.py
--- a/sunpy/timeseries/metadata.py
+++ b/sunpy/timeseries/metadata.py
@@ -4,6 +4,7 @@
 import copy
 import warnings
 import itertools
+from collections.abc import Iterable
 
 from sunpy.time import TimeRange, parse_time
 from sunpy.time.time import _variables_for_parse_time_docstring
@@ -336,24 +337,41 @@ def get(self, keys, time=None, colname=None):
         # Return a TimeSeriesMetaData object
         return TimeSeriesMetaData(meta=metadata)
 
-    def concatenate(self, tsmetadata2):
+    def concatenate(self, others):
         """
-        Combine the metadata from a `~sunpy.timeseries.TimeSeriesMetaData` with
-        the current `~sunpy.timeseries.TimeSeriesMetaData` and return as a new
-        `~sunpy.timeseries.TimeSeriesMetaData`.
+        Combine the metadata from a `~sunpy.timeseries.TimeSeriesMetaData` or an
+        iterable containing multiple `~sunpy.timeseries.TimeSeriesMetaData`
+        with the current `~sunpy.timeseries.TimeSeriesMetaData` and return it as
+        a new `~sunpy.timeseries.TimeSeriesMetaData`.
 
         Parameters
         ----------
-        tsmetadata2 : `~sunpy.timeseries.TimeSeriesMetaData`
-            The second TimeSeriesMetaData object.
-        """
+        others : `~sunpy.timeseries.TimeSeriesMetaData` or `collections.abc.Iterable`
+            The second `~sunpy.timeseries.metadata.TimeSeriesMetaData` object or an iterable
+            containing multiple `~sunpy.timeseries.metadata.TimeSeriesMetaData` objects.
+        """
+        # If an individual TimeSeriesMetaData object is to be concatenated, wrap it in a list
+        # Else if it is an iterable, check if all items within it are valid
+        # Else, data provided is invalid
+        if isinstance(others, self.__class__):
+            others = [others]
+        elif isinstance(others, Iterable):
+            if not all(isinstance(series, self.__class__) for series in others):
+                raise TypeError("Invalid type within iterable. Iterable must only contain "
+                                "TimeSeriesMetaData objects.")
+        else:
+            raise TypeError(f"Invalid type provided: {type(others)}. "
+                            "Please provide a TimeSeriesMetaData object or "
+                            "an iterable containing TimeSeriesMetaData objects.")
+
         # Create a copy of the metadata
         meta = TimeSeriesMetaData(copy.copy(self.metadata))
 
-        # Append each metadata entry from the second TimeSeriesMetaData object
+        # Append each metadata entry of each TimeSeriesMetaData object from the iterable
         # to the original TimeSeriesMetaData object.
-        for entry in tsmetadata2.metadata:
-            meta.append(entry[0], entry[1], entry[2])
+        for series in others:
+            for entry in series.metadata:
+                meta.append(entry[0], entry[1], entry[2])
 
         return meta
 
diff --git a/sunpy/timeseries/timeseriesbase.py b/sunpy/timeseries/timeseriesbase.py
--- a/sunpy/timeseries/timeseriesbase.py
+++ b/sunpy/timeseries/timeseriesbase.py
@@ -5,6 +5,7 @@
 import copy
 import warnings
 from collections import OrderedDict
+from collections.abc import Iterable
 
 import pandas as pd
 
@@ -356,17 +357,18 @@ def extract(self, column_name):
         object._sanitize_units()
         return object
 
-    def concatenate(self, otherts, same_source=False, **kwargs):
+    def concatenate(self, others, same_source=False, **kwargs):
         """
-        Concatenate with another `~sunpy.timeseries.TimeSeries`. This function
-        will check and remove any duplicate times. It will keep the column
-        values from the original timeseries to which the new time series is
-        being added.
+        Concatenate with another `~sunpy.timeseries.TimeSeries` or an iterable containing multiple
+        `~sunpy.timeseries.TimeSeries`. This function will check and remove any duplicate times.
+        It will keep the column values from the original timeseries to which the new time
+        series is being added.
 
         Parameters
         ----------
-        otherts : `~sunpy.timeseries.TimeSeries`
-            Another `~sunpy.timeseries.TimeSeries`.
+        others : `~sunpy.timeseries.TimeSeries` or `collections.abc.Iterable`
+            Another `~sunpy.timeseries.TimeSeries` or an iterable containing multiple
+            `~sunpy.timeseries.TimeSeries`.
         same_source : `bool`, optional
             Set to `True` to check if the sources of the time series match. Defaults to `False`.
 
@@ -378,28 +380,60 @@ def concatenate(self, otherts, same_source=False, **kwargs):
         Notes
         -----
         Extra keywords are passed to `pandas.concat`.
+
+        Examples
+        --------
+        A single `~sunpy.timeseries.TimeSeries` or an `collections.abc.Iterable` containing multiple
+        `~sunpy.timeseries.TimeSeries` can be passed to concatenate.
+
+        >>> timeseries_1.concatenate(timeseries_2) # doctest: +SKIP
+        >>> timeseries_1.concatenate([timeseries_2, timeseries_3]) # doctest: +SKIP
+
+        Set `same_source` to `True` if the sources of the time series are the same.
+
+        >>> timeseries_1.concatenate([timeseries_2, timeseries_3], same_source=True) # doctest: +SKIP
         """
-        # TODO: decide if we want to be able to concatenate multiple time series at once.
-        # check to see if nothing needs to be done
-        if self == otherts:
+        # Check to see if nothing needs to be done in case the same TimeSeries is provided.
+        if self == others:
             return self
+        elif isinstance(others, Iterable):
+            if len(others) == 1 and self == next(iter(others)):
+                return self
 
         # Check the sources match if specified.
-        if same_source and not (isinstance(otherts, self.__class__)):
-            raise TypeError("TimeSeries classes must match if specified.")
-
-        # Concatenate the metadata and data
-        kwargs['sort'] = kwargs.pop('sort', False)
-        meta = self.meta.concatenate(otherts.meta)
-        data = pd.concat([self._data.copy(), otherts.to_dataframe()], **kwargs)
+        if (
+            same_source
+            and isinstance(others, Iterable)
+            and not all(isinstance(series, self.__class__) for series in others)
+        ):
+            raise TypeError("TimeSeries classes must match if 'same_source' is specified.")
+        elif (
+            same_source
+            and not isinstance(others, Iterable)
+            and not isinstance(others, self.__class__)
+        ):
+            raise TypeError("TimeSeries classes must match if 'same_source' is specified.")
+
+        # If an iterable is not provided, it must be a TimeSeries object, so wrap it in a list.
+        if not isinstance(others, Iterable):
+            others = [others]
+
+        # Concatenate the metadata and data.
+        kwargs["sort"] = kwargs.pop("sort", False)
+        meta = self.meta.concatenate([series.meta for series in others])
+        data = pd.concat(
+            [self._data.copy(), *list(series.to_dataframe() for series in others)], **kwargs
+        )
 
         # Add all the new units to the dictionary.
         units = OrderedDict()
         units.update(self.units)
-        units.update(otherts.units)
+        units.update(
+            {k: v for unit in list(series.units for series in others) for k, v in unit.items()}
+        )
 
         # If sources match then build similar TimeSeries.
-        if self.__class__ == otherts.__class__:
+        if all(self.__class__ == series.__class__ for series in others):
             object = self.__class__(data.sort_index(), meta, units)
         else:
             # Build generic time series if the sources don't match.


Test patch:
diff --git a/sunpy/timeseries/tests/test_timeseriesbase.py b/sunpy/timeseries/tests/test_timeseriesbase.py
--- a/sunpy/timeseries/tests/test_timeseriesbase.py
+++ b/sunpy/timeseries/tests/test_timeseriesbase.py
@@ -119,7 +119,7 @@ def noaa_pre_txt_test_ts():
 
 @pytest.fixture
 def generic_ts():
-    # Generate the data and the corrisponding dates
+    # Generate the data and the corresponding dates
     base = parse_time("2016/10/01T05:00:00")
     dates = base - TimeDelta(np.arange(24 * 60)*u.minute)
     intensity = np.sin(np.arange(0, 12 * np.pi, ((12 * np.pi) / (24 * 60))))
@@ -232,40 +232,70 @@ def test_data_type(eve_test_ts, esp_test_ts, fermi_gbm_test_ts, norh_test_ts, go
 
 @pytest.fixture
 def truncation_slice_test_ts_1(eve_test_ts):
-    # Truncate by slicing the second half off.
-    return eve_test_ts.truncate(0, int(len(eve_test_ts.to_dataframe()) / 2), None)
+    # Truncate by keeping only the first quarter.
+    return eve_test_ts.truncate(0, int(len(eve_test_ts.to_dataframe()) / 4), None)
 
 
 @pytest.fixture
 def truncation_slice_test_ts_2(eve_test_ts):
-    # Truncate by slicing the first half off.
+    # Truncate by keeping only the second quarter.
+    return eve_test_ts.truncate(
+        int(len(eve_test_ts.to_dataframe()) / 4), int(len(eve_test_ts.to_dataframe()) / 2), None
+    )
+
+
+@pytest.fixture
+def truncation_slice_test_ts_3(eve_test_ts):
+    # Truncate by keeping only the third quarter.
     return eve_test_ts.truncate(
-        int(len(eve_test_ts.to_dataframe()) / 2), len(eve_test_ts.to_dataframe()), None)
+        int(len(eve_test_ts.to_dataframe()) / 2), int(3 * len(eve_test_ts.to_dataframe()) / 4), None
+    )
 
 
-def test_truncation_slices(eve_test_ts, truncation_slice_test_ts_1,
-                           truncation_slice_test_ts_2):
+@pytest.fixture
+def truncation_slice_test_ts_4(eve_test_ts):
+    # Truncate by keeping only the fourth quarter.
+    return eve_test_ts.truncate(
+        int(3 * len(eve_test_ts.to_dataframe()) / 4), len(eve_test_ts.to_dataframe()), None
+    )
+
+
+def test_truncation_slices(eve_test_ts,
+                           truncation_slice_test_ts_1, truncation_slice_test_ts_2,
+                           truncation_slice_test_ts_3, truncation_slice_test_ts_4):
     # Test resulting DataFrame are similar
     assert len(eve_test_ts.to_dataframe()) == (len(truncation_slice_test_ts_1.to_dataframe()) +
-                                               len(truncation_slice_test_ts_2.to_dataframe()))
+                                               len(truncation_slice_test_ts_2.to_dataframe()) +
+                                               len(truncation_slice_test_ts_3.to_dataframe()) +
+                                               len(truncation_slice_test_ts_4.to_dataframe()))
     # Test column lists and unit dictionaries match
     assert (eve_test_ts.columns ==
             truncation_slice_test_ts_1.columns ==
-            truncation_slice_test_ts_2.columns)
+            truncation_slice_test_ts_2.columns ==
+            truncation_slice_test_ts_3.columns ==
+            truncation_slice_test_ts_4.columns)
     assert (eve_test_ts.meta.columns ==
             truncation_slice_test_ts_1.meta.columns ==
-            truncation_slice_test_ts_2.meta.columns)
-    assert eve_test_ts.units == truncation_slice_test_ts_1.units == truncation_slice_test_ts_2.units
+            truncation_slice_test_ts_2.meta.columns ==
+            truncation_slice_test_ts_3.meta.columns ==
+            truncation_slice_test_ts_4.meta.columns)
+    assert (eve_test_ts.units ==
+            truncation_slice_test_ts_1.units ==
+            truncation_slice_test_ts_2.units ==
+            truncation_slice_test_ts_3.units ==
+            truncation_slice_test_ts_4.units)
     # Test MetaDict match
     assert (eve_test_ts.meta.metadata[0][2] ==
             truncation_slice_test_ts_1.meta.metadata[0][2] ==
-            truncation_slice_test_ts_2.meta.metadata[0][2])
+            truncation_slice_test_ts_2.meta.metadata[0][2] ==
+            truncation_slice_test_ts_3.meta.metadata[0][2] ==
+            truncation_slice_test_ts_4.meta.metadata[0][2])
     # For TS and meta, Test time ranges match for the start and end of the TS.
     assert (truncation_slice_test_ts_1.time_range.start ==
             truncation_slice_test_ts_1.meta.time_range.start ==
             eve_test_ts.time_range.start)
-    assert (truncation_slice_test_ts_2.time_range.end ==
-            truncation_slice_test_ts_2.meta.time_range.end ==
+    assert (truncation_slice_test_ts_4.time_range.end ==
+            truncation_slice_test_ts_4.meta.time_range.end ==
             eve_test_ts.time_range.end)
 
 
@@ -461,13 +491,24 @@ def test_extraction(eve_test_ts, extraction_test_ts):
 
 
 @pytest.fixture
-def concatenated_slices_test_ts(truncation_slice_test_ts_1,
-                                truncation_slice_test_ts_2):
-    # Concatenate the slices to make a TS similar to the original
-    return truncation_slice_test_ts_1.concatenate(truncation_slice_test_ts_2)
+def concatenated_slices_test_ts(truncation_slice_test_ts_1, truncation_slice_test_ts_2,
+                                truncation_slice_test_ts_3, truncation_slice_test_ts_4):
+    # Concatenate the slices individually to make a TS similar to the original
+    truncation_slice_test_ts_1 = truncation_slice_test_ts_1.concatenate(truncation_slice_test_ts_2)
+    truncation_slice_test_ts_1 = truncation_slice_test_ts_1.concatenate(truncation_slice_test_ts_3)
+    return truncation_slice_test_ts_1.concatenate(truncation_slice_test_ts_4)
+
+
+@pytest.fixture
+def concatenated_slices_test_list(truncation_slice_test_ts_1, truncation_slice_test_ts_2,
+                                  truncation_slice_test_ts_3, truncation_slice_test_ts_4):
+    # Concatenate the slices in a list to make a TS similar to the original
+    return truncation_slice_test_ts_1.concatenate(
+        [truncation_slice_test_ts_2, truncation_slice_test_ts_3, truncation_slice_test_ts_4]
+    )
 
 
-def test_concatenation_of_slices(eve_test_ts, concatenated_slices_test_ts):
+def test_concatenation_of_slices_ts(eve_test_ts, concatenated_slices_test_ts):
     # Test resulting DataFrame is similar to the original
     assert_frame_equal(concatenated_slices_test_ts.to_dataframe(), eve_test_ts.to_dataframe())
     # Otherwise: concatenated_ts.data.equals(eve_test_ts)
@@ -481,14 +522,34 @@ def test_concatenation_of_slices(eve_test_ts, concatenated_slices_test_ts):
     # ToDo: Will TSMD.concatenate() want to re-merge the metadata entries back into one?
 
 
+def test_concatenation_of_slices_list(eve_test_ts, concatenated_slices_test_list):
+    # Test resulting DataFrame is similar to the original
+    assert_frame_equal(concatenated_slices_test_list.to_dataframe(), eve_test_ts.to_dataframe())
+    # Otherwise: concatenated_ts.data.equals(eve_test_ts)
+    # Compare timeranges from before and after match for both metadata and TS
+    assert eve_test_ts.meta.time_range == concatenated_slices_test_list.meta.time_range
+    assert eve_test_ts.time_range == concatenated_slices_test_list.time_range
+    # Test metadata MetaDict matches
+    eve_test_ts.meta.metadata[0][
+        2] == concatenated_slices_test_list.meta.metadata[0][
+            2] == concatenated_slices_test_list.meta.metadata[1][2]
+    # ToDo: Will TSMD.concatenate() want to re-merge the metadata entries back into one?
+
+
 @pytest.fixture
 def concatenation_different_data_test_ts(eve_test_ts, fermi_gbm_test_ts):
     # Take two different data sources and concatenate
     return eve_test_ts.concatenate(fermi_gbm_test_ts)
 
 
-def test_concatenation_of_different_data(eve_test_ts, fermi_gbm_test_ts,
-                                         concatenation_different_data_test_ts):
+@pytest.fixture
+def concatenation_different_data_test_list(eve_test_ts, fermi_gbm_test_ts):
+    # Take two different data sources, pass one as an iterable and concatenate
+    return eve_test_ts.concatenate([fermi_gbm_test_ts])
+
+
+def test_concatenation_of_different_data_ts(eve_test_ts, fermi_gbm_test_ts,
+                                            concatenation_different_data_test_ts):
     # TODO: test the metadata is as expected using the below. (note ATM this fails if the order is changed)
     # assert concatenation_different_data_test_ts.meta.metadata[0] == fermi_gbm_test_ts.meta.metadata[0]
     # assert concatenation_different_data_test_ts.meta.metadata[1] == eve_test_ts.meta.metadata[0]
@@ -518,18 +579,61 @@ def test_concatenation_of_different_data(eve_test_ts, fermi_gbm_test_ts,
     assert_frame_equal(concatenation_different_data_test_ts.to_dataframe(), comined_df)
 
 
-def test_concatenation_of_self(eve_test_ts):
+def test_concatenation_of_different_data_list(eve_test_ts, fermi_gbm_test_ts,
+                                              concatenation_different_data_test_list):
+    # Same test_concatenation_of_different_data_ts except an iterable is passed to concatenate
+    value = True
+    for key in list(concatenation_different_data_test_list.meta.metadata[0][2]
+                    .keys()):
+        if concatenation_different_data_test_list.meta.metadata[0][2][
+                key] != fermi_gbm_test_ts.meta.metadata[0][2][key]:
+            value = False
+    for key in list(concatenation_different_data_test_list.meta.metadata[1][2]
+                    .keys()):
+        if concatenation_different_data_test_list.meta.metadata[1][2][
+                key] != eve_test_ts.meta.metadata[0][2][key]:
+            value = False
+    assert value
+
+    # Test units concatenation
+    comined_units = copy.deepcopy(eve_test_ts.units)
+    comined_units.update(fermi_gbm_test_ts.units)
+    assert dict(concatenation_different_data_test_list.units) == dict(
+        comined_units)
+
+    # Test data is the concatenation
+    comined_df = pd.concat([eve_test_ts.to_dataframe(), fermi_gbm_test_ts.to_dataframe()],
+                           sort=False)
+    comined_df = comined_df.sort_index()
+    assert_frame_equal(concatenation_different_data_test_list.to_dataframe(), comined_df)
+
+
+def test_concatenation_of_self_ts(eve_test_ts):
     # Check that a self concatenation returns the original timeseries
     assert eve_test_ts.concatenate(eve_test_ts) == eve_test_ts
 
 
-def test_concatenation_different_data_error(eve_test_ts, fermi_gbm_test_ts):
+def test_concatenation_of_self_list(eve_test_ts):
+    # Check that a self concatenation as an iterable returns the original timeseries
+    assert eve_test_ts.concatenate([eve_test_ts]) == eve_test_ts
+
+
+def test_concatenation_different_data_ts_error(eve_test_ts, fermi_gbm_test_ts):
     # Take two different data sources and concatenate but set with the same_source
-    # kwarg as true, this should not concatenate.
-    with pytest.raises(TypeError):
+    # kwarg as true. This should not concatenate.
+    with pytest.raises(TypeError, match="TimeSeries classes must match if "
+                                        "'same_source' is specified."):
         eve_test_ts.concatenate(fermi_gbm_test_ts, same_source=True)
 
 
+def test_concatenation_different_data_list_error(eve_test_ts, fermi_gbm_test_ts):
+    # Take two different data sources, pass one as an iterable and concatenate
+    # but set with the same_source kwarg as true. This should not concatenate.
+    with pytest.raises(TypeError, match="TimeSeries classes must match if "
+                                        "'same_source' is specified."):
+        eve_test_ts.concatenate([fermi_gbm_test_ts], same_source=True)
+
+
 def test_generic_construction_concatenation():
     # Generate the data and the corrisponding dates
     base = parse_time(datetime.datetime.today())
diff --git a/sunpy/timeseries/tests/test_timeseriesmetadata.py b/sunpy/timeseries/tests/test_timeseriesmetadata.py
--- a/sunpy/timeseries/tests/test_timeseriesmetadata.py
+++ b/sunpy/timeseries/tests/test_timeseriesmetadata.py
@@ -158,12 +158,33 @@ def test_append_invalid_timerange(basic_1_md):
 # Test TimeSeriesMetaData Concatenation
 # =============================================================================
 
-def test_concatenate(basic_ascending_append_md, basic_4_md, complex_append_md):
+def test_concatenate_ts(basic_ascending_append_md, basic_4_md, complex_append_md):
     concatenated = copy.deepcopy(basic_ascending_append_md)
     concatenated = concatenated.concatenate(basic_4_md)
     assert concatenated == complex_append_md
 
 
+def test_concatenate_list(basic_ascending_append_md, basic_3_md, basic_4_md, complex_append_md):
+    concatenated = copy.deepcopy(basic_ascending_append_md)
+    concatenated = concatenated.concatenate([basic_3_md, basic_4_md])
+    assert concatenated == complex_append_md
+
+
+def test_concatenate_invalid_type(basic_ascending_append_md):
+    concatenated = copy.deepcopy(basic_ascending_append_md)
+    with pytest.raises(TypeError, match="Invalid type provided: <class 'int'>. "
+                                        "Please provide a TimeSeriesMetaData object or "
+                                        "an iterable containing TimeSeriesMetaData objects."):
+        concatenated = concatenated.concatenate(100)
+
+
+def test_concatenate_invalid_type_within_list(basic_ascending_append_md):
+    concatenated = copy.deepcopy(basic_ascending_append_md)
+    with pytest.raises(TypeError, match="Invalid type within iterable. Iterable must only contain "
+                                        "TimeSeriesMetaData objects."):
+        concatenated = concatenated.concatenate([100])
+
+
 # =============================================================================
 # Test TimeSeriesMetaData Truncation
 # =============================================================================