Repository: pastas/pastas

Issue:
[Development] export all time series from Pastas model
**Describe the proposed feature**
Add a method to export all the time series from a Pastas Model to a single pandas DataFrame. This could then be used to export the results to a csv or excel, using regular pandas methods. Helps to share modeling results with others (and applications).

**Code to show the expected the behavior**

`df = ml.get_all_series(tmin, tmax)   # df is a pandas DataFrame`



Discussion:
raoulcollenteur: Fixed the issue with tmin!

I thought of this method as the method to obtain all the time series to reconstruct a plot like plot_decomposition. Do you have a suggestion for a better name?

To get all the stresses we should probably add a new method ml.get_stresses next to ml.get_stress. similar to get_contribution and get_contributions.
dbrakenhoff: Perhaps `get_input_series` and `get_output_series`, and we'll just accept that the observations series is one of the output timeseries (or we add it in both methods)?

p.s. the code isn't formatted according to autopep8 yet ;)

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 62b08f70..f60a47bf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,7 +17,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.7, 3.8, 3.9]
+        python-version: ['3.7', '3.8', '3.9', '3.10']
     steps:
       - uses: actions/checkout@v2
 
diff --git a/pastas/model.py b/pastas/model.py
index 23b0a19d..db5cacda 100644
--- a/pastas/model.py
+++ b/pastas/model.py
@@ -366,7 +366,7 @@ class Model:
             istart += 1
         if self.transform:
             sim = self.transform.simulate(sim, p[istart:istart +
-                                                 self.transform.nparam])
+                                                        self.transform.nparam])
 
         # Respect provided tmin/tmax at this point, since warmup matters for
         # simulation but should not be returned, unless return_warmup=True.
@@ -1164,7 +1164,7 @@ class Model:
         # use warmup
         if tmin:
             tmin_warm = (Timestamp(tmin) - warmup).floor(freq) + \
-                self.settings["time_offset"]
+                        self.settings["time_offset"]
         else:
             tmin_warm = None
 
@@ -1239,6 +1239,57 @@ class Model:
         sim_org = ml.simulate(tmin=tmin, tmax=tmax)
         return sim - sim_org
 
+    def get_output_series(self, tmin=None, tmax=None, add_contributions=True,
+                          split=True):
+        """Method to get all the modeled output time series from the Model.
+
+        Parameters
+        ----------
+        tmin: str, optional
+            String with a start date for the simulation period (E.g. '1980').
+            If none is provided, the tmin from the oseries is used.
+        tmax: str, optional
+            String with an end date for the simulation period (E.g. '2010').
+            If none is provided, the tmax from the oseries is used.
+        add_contributions: bool, optional
+            Add the contributions from the different stresses or not.¬
+        split: bool, optional
+            Passed on to ml.get_contributions. Split the contribution from
+            recharge into evaporation and precipitation. See also
+            ml.get_contributions.
+
+        Returns
+        -------
+        df: pandas.DataFrame
+            Pandas DataFrame with the time series as columns and DatetimeIndex.
+
+        Notes
+        -----
+        Export the observed, simulated time series, the noise and residuals
+        series, and the contributions from the different stressmodels.
+
+        Examples
+        --------
+        >>> df = ml.get_output_series(tmin="2000", tmax="2010")
+        >>> df.to_csv("fname.csv")
+        """
+        obs = self.observations(tmin=tmin, tmax=tmax)
+        obs.name = "Head_Calibration"
+
+        sim = self.simulate(tmin=tmin, tmax=tmax)
+        res = self.residuals(tmin=tmin, tmax=tmax)
+        noise = self.noise(tmin=tmin, tmax=tmax)
+
+        df = [obs, sim, res, noise]
+
+        if add_contributions:
+            contribs = self.get_contributions(tmin=tmin, tmax=tmax, split=split)
+            for contrib in contribs:
+                df.append(contrib)
+
+        df = concat(df, axis=1)
+        return df
+
     def _get_response(self, block_or_step, name, p=None, dt=None, add_0=False,
                       **kwargs):
         """Internal method to compute the block and step response.
@@ -1441,7 +1492,7 @@ class Model:
         # use warmup
         if tmin:
             tmin_warm = (Timestamp(tmin) - warmup).floor(freq) + \
-                self.settings["time_offset"]
+                        self.settings["time_offset"]
         else:
             tmin_warm = None
 
@@ -1563,7 +1614,7 @@ class Model:
         for (val1, val2), (val3, val4) in zip(model.items(), fit.items()):
             basic += (
                 f"{val1:<8}{val2:<23}{val3:<9}"
-                f"{val4:>{wspace+len_val4}}\n"
+                f"{val4:>{wspace + len_val4}}\n"
             )
 
         # Create the parameters block
@@ -1616,9 +1667,9 @@ class Model:
             # create message
             if len(msg) > 0:
                 msg = [
-                    f"\n\nWarnings! ({len(msg)})\n"
-                    f"{string.format('', fill='=', align='>', width=width)}"
-                ] + msg
+                          f"\n\nWarnings! ({len(msg)})\n"
+                          f"{string.format('', fill='=', align='>', width=width)}"
+                      ] + msg
                 warnings = "\n".join(msg)
             else:
                 warnings = ""


Test patch:
diff --git a/tests/test_model.py b/tests/test_model.py
index 3e779e40..97a74e2d 100644
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -102,7 +102,7 @@ def test_load_model():
     for icol in ["initial", "optimal", "pmin", "pmax", "stderr"]:
         ml.parameters[icol] = ml.parameters[icol].astype(float)
     ml.parameters["vary"] = ml.parameters["vary"].astype(bool)
-    
+
     # check if parameters and pcov dataframes are equal
     assert ml.parameters.equals(ml2.parameters)
     assert ml.fit.pcov.equals(ml2.fit.pcov)
@@ -161,3 +161,15 @@ def test_observations():
     ml = test_add_stressmodel()
     ml.observations()
     return
+
+
+def test_get_output_series():
+    ml = test_add_stressmodel()
+    ml.get_output_series()
+    return
+
+
+def test_get_output_series_arguments():
+    ml = test_add_stressmodel()
+    ml.get_output_series(split=False, add_contributions=False)
+    return