Repository: ross-rotordynamics/ross
Pull request #711 (2020-12-01 19:42:23)

Add save() and load() methods to manage ROSS results
This PR is a remake from PR #703. One can check the discussion history by accessing the previous link. 

Adds abstract classes "Results" and "ST_Results" that holds a generic save() and load() methods.
The other classes will inherit these methods from "Results" and "ST_Results". It avoids redundancies by repetition of functions with the same functionality.
The user will be able to save the results of a given analysis and load it later, avoiding the need to re-run the entire analysis, which could be costly.

Example of how to use it:

```python
        >>> # Example running a stochastic unbalance response
        >>> from tempfile import tempdir
        >>> from pathlib import Path
        >>> import ross.stochastic as srs

        >>> # Running an example
        >>> rotors = srs.st_rotor_example()
        >>> freq_range = np.linspace(0, 500, 31)
        >>> n = 3
        >>> m = np.random.uniform(0.001, 0.002, 10)
        >>> p = 0.0
        >>> results = rotors.run_unbalance_response(n, m, p, freq_range)

        >>> # create path for a temporary file
        >>> file = Path(tempdir) / 'results.toml'
        >>> results.save(file)

        >>> # Loading file
        >>> results2 = srs.ST_ForcedResponseResults.load(file)
        >>> results2.magnitude.all() == results.magnitude.all()
        True
```



codecov[bot]: # [Codecov](https://codecov.io/gh/ross-rotordynamics/ross/pull/711?src=pr&el=h1) Report
> Merging [#711](https://codecov.io/gh/ross-rotordynamics/ross/pull/711?src=pr&el=desc) (8cce742) into [master](https://codecov.io/gh/ross-rotordynamics/ross/commit/384a98d540f5f2b684bcf9235eff5d3e2db736a2?el=desc) (384a98d) will **increase** coverage by `0.11%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/ross-rotordynamics/ross/pull/711/graphs/tree.svg?width=650&height=150&src=pr&token=SZjY7TrJnS)](https://codecov.io/gh/ross-rotordynamics/ross/pull/711?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #711      +/-   ##
==========================================
+ Coverage   91.17%   91.28%   +0.11%     
==========================================
  Files          30       30              
  Lines        5403     5474      +71     
==========================================
+ Hits         4926     4997      +71     
  Misses        477      477              
```


| [Impacted Files](https://codecov.io/gh/ross-rotordynamics/ross/pull/711?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [ross/\_\_init\_\_.py](https://codecov.io/gh/ross-rotordynamics/ross/pull/711/diff?src=pr&el=tree#diff-cm9zcy9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |
| [ross/results.py](https://codecov.io/gh/ross-rotordynamics/ross/pull/711/diff?src=pr&el=tree#diff-cm9zcy9yZXN1bHRzLnB5) | `85.77% <100.00%> (+0.78%)` | :arrow_up: |
| [ross/stochastic/\_\_init\_\_.py](https://codecov.io/gh/ross-rotordynamics/ross/pull/711/diff?src=pr&el=tree#diff-cm9zcy9zdG9jaGFzdGljL19faW5pdF9fLnB5) | `100.00% <100.00%> (ø)` | |
| [ross/stochastic/st\_results.py](https://codecov.io/gh/ross-rotordynamics/ross/pull/711/diff?src=pr&el=tree#diff-cm9zcy9zdG9jaGFzdGljL3N0X3Jlc3VsdHMucHk=) | `94.55% <100.00%> (+0.45%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/ross-rotordynamics/ross/pull/711?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/ross-rotordynamics/ross/pull/711?src=pr&el=footer). Last update [384a98d...8cce742](https://codecov.io/gh/ross-rotordynamics/ross/pull/711?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

rodrigomoliveira1: I'll work on a test file to these classes. I need a guarantee that all analyzes are saved and loaded correctly. Please, don't merge it yet.

Patch:
diff --git a/docs/api.rst b/docs/api.rst
index 4fad0615..72aae9b7 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -21,6 +21,11 @@ Elements
     DiskElement
     BearingElement
     SealElement
+    BallBearingElement
+    RollerBearingElement
+    BearingFluidFlow
+    BearingElement6DoF
+    MagneticBearingElement
 
 Rotor
 -------
diff --git a/ross/__init__.py b/ross/__init__.py
index 1d8372e0..4aa5e388 100644
--- a/ross/__init__.py
+++ b/ross/__init__.py
@@ -8,6 +8,7 @@ from .defects import *
 from .disk_element import *
 from .materials import *
 from .point_mass import *
+from .results import *
 from .rotor_assembly import *
 from .shaft_element import *
 from .utils import visualize_matrix
diff --git a/ross/new_units.txt b/ross/new_units.txt
index 83bd3cf4..2879927c 100644
--- a/ross/new_units.txt
+++ b/ross/new_units.txt
@@ -1,3 +1,6 @@
 RPM = rpm * 1
 Hz = rps
 hour = 60 * minute = h = hr
+
+# prefixes
+pkpk_- = 0.5
diff --git a/ross/results.py b/ross/results.py
index 7928eec9..82efa446 100644
--- a/ross/results.py
+++ b/ross/results.py
@@ -3,8 +3,13 @@
 This module returns graphs for each type of analyses in rotor_assembly.py.
 """
 import copy
+import inspect
+from abc import ABC
+from collections.abc import Iterable
+from pathlib import Path
 
 import numpy as np
+import toml
 from plotly import graph_objects as go
 from plotly.subplots import make_subplots
 from scipy import linalg as la
@@ -13,8 +18,149 @@ from ross.plotly_theme import tableau_colors
 from ross.units import Q_
 from ross.utils import intersection
 
+__all__ = [
+    "CriticalSpeedResults",
+    "ModalResults",
+    "CampbellResults",
+    "FrequencyResponseResults",
+    "ForcedResponseResults",
+    "StaticResults",
+    "SummaryResults",
+    "ConvergenceResults",
+    "TimeResponseResults",
+]
 
-class CriticalSpeedResults:
+
+class Results(ABC):
+    """Results class.
+
+    This class is a general abstract class to be implemented in other classes
+    for post-processing results, in order to add saving and loading data options.
+    """
+
+    def save(self, file):
+        """Save results in a .toml file.
+
+        This function will save the simulation results to a .toml file.
+        The file will have all the argument's names and values that are needed to
+        reinstantiate the class.
+
+        Parameters
+        ----------
+        file : str, pathlib.Path
+            The name of the file the results will be saved in.
+
+        Examples
+        --------
+        >>> # Example running a unbalance response
+        >>> from tempfile import tempdir
+        >>> from pathlib import Path
+        >>> import ross as rs
+
+        >>> # Running an example
+        >>> rotor = rs.rotor_example()
+        >>> speed = np.linspace(0, 1000, 101)
+        >>> response = rotor.run_unbalance_response(node=3,
+        ...                                         unbalance_magnitude=0.001,
+        ...                                         unbalance_phase=0.0,
+        ...                                         frequency=speed)
+
+        >>> # create path for a temporary file
+        >>> file = Path(tempdir) / 'unb_resp.toml'
+        >>> response.save(file)
+        """
+        # get __init__ arguments
+        signature = inspect.signature(self.__init__)
+        args_list = list(signature.parameters)
+        args = {arg: getattr(self, arg) for arg in args_list}
+        try:
+            data = toml.load(file)
+        except FileNotFoundError:
+            data = {}
+
+        data[f"{self.__class__.__name__}"] = args
+        with open(file, "w") as f:
+            toml.dump(data, f, encoder=toml.TomlNumpyEncoder())
+
+        if "rotor" in args.keys():
+            aux_file = str(file)[:-5] + "_rotor" + str(file)[-5:]
+            args["rotor"].save(aux_file)
+
+    @classmethod
+    def read_toml_data(cls, data):
+        """Read and parse data stored in a .toml file.
+
+        The data passed to this method needs to be according to the
+        format saved in the .toml file by the .save() method.
+
+        Parameters
+        ----------
+        data : dict
+            Dictionary obtained from toml.load().
+
+        Returns
+        -------
+        The result object.
+        """
+        return cls(**data)
+
+    @classmethod
+    def load(cls, file):
+        """Load results from a .toml file.
+
+        This function will load the simulation results from a .toml file.
+        The file must have all the argument's names and values that are needed to
+        reinstantiate the class.
+
+        Parameters
+        ----------
+        file : str, pathlib.Path
+            The name of the file the results will be loaded from.
+
+        Examples
+        --------
+        >>> # Example running a stochastic unbalance response
+        >>> from tempfile import tempdir
+        >>> from pathlib import Path
+        >>> import ross as rs
+
+        >>> # Running an example
+        >>> rotor = rs.rotor_example()
+        >>> freq_range = np.linspace(0, 500, 31)
+        >>> n = 3
+        >>> m = 0.01
+        >>> p = 0.0
+        >>> results = rotor.run_unbalance_response(n, m, p, freq_range)
+
+        >>> # create path for a temporary file
+        >>> file = Path(tempdir) / 'unb_resp.toml'
+        >>> results.save(file)
+
+        >>> # Loading file
+        >>> results2 = rs.ForcedResponseResults.load(file)
+        >>> results2.magnitude.all() == results.magnitude.all()
+        True
+        """
+        str_type = [np.dtype(f"<U4{i}") for i in range(10)]
+
+        data = toml.load(file)
+        data = list(data.values())[0]
+        for key, value in data.items():
+            if key == "rotor":
+                aux_file = str(file)[:-5] + "_rotor" + str(file)[-5:]
+                from ross.rotor_assembly import Rotor
+
+                data[key] = Rotor.load(aux_file)
+
+            elif isinstance(value, Iterable):
+                data[key] = np.array(value)
+                if data[key].dtype in str_type:
+                    data[key] = np.array(value).astype(np.complex128)
+
+        return cls.read_toml_data(data)
+
+
+class CriticalSpeedResults(Results):
     """Class used to store results from run_critical_speed() method.
 
     Parameters
@@ -36,7 +182,7 @@ class CriticalSpeedResults:
         self.damping_ratio = damping_ratio
 
 
-class ModalResults:
+class ModalResults(Results):
     """Class used to store results and provide plots for Modal Analysis.
 
     Two options for plottting are available: plot_mode3D (mode shape 3D view)
@@ -59,8 +205,6 @@ class ModalResults:
         Logarithmic decrement for each mode.
     damping_ratio : array
         Damping ratio for each mode.
-    lti : StateSpaceContinuous
-        Space State Continuos with A, B, C and D matrices.
     ndof : int
         Number of degrees of freedom.
     nodes : list
@@ -80,7 +224,6 @@ class ModalResults:
         wd,
         damping_ratio,
         log_dec,
-        lti,
         ndof,
         nodes,
         nodes_pos,
@@ -93,7 +236,6 @@ class ModalResults:
         self.wd = wd
         self.damping_ratio = damping_ratio
         self.log_dec = log_dec
-        self.lti = lti
         self.ndof = ndof
         self.nodes = nodes
         self.nodes_pos = nodes_pos
@@ -702,7 +844,7 @@ class ModalResults:
         return fig
 
 
-class CampbellResults:
+class CampbellResults(Results):
     """Class used to store results and provide plots for Campbell Diagram.
 
     It's possible to visualize multiples harmonics in a single plot to check
@@ -875,7 +1017,7 @@ class CampbellResults:
         return fig
 
 
-class FrequencyResponseResults:
+class FrequencyResponseResults(Results):
     """Class used to store results and provide plots for Frequency Response.
 
     Parameters
@@ -928,7 +1070,8 @@ class FrequencyResponseResults:
             Default is "rad/s"
         amplitude_units : str, optional
             Units for the y axis.
-            Default is "m/N"
+            Default is "m/N" 0 to peak.
+            To use peak to peak use the prefix 'pkpk_' (e.g. pkpk_m)
         fig : Plotly graph_objects.Figure()
             The figure object with the plot.
         mag_kwargs : optional
@@ -1072,7 +1215,8 @@ class FrequencyResponseResults:
             Default is "rad/s"
         amplitude_units : str, optional
             Units for the y axis.
-            Default is "m/N"
+            Default is "m/N" 0 to peak.
+            To use peak to peak use the prefix 'pkpk_' (e.g. pkpk_m)
         phase_units : str, optional
             Units for the x axis.
             Default is "rad"
@@ -1168,7 +1312,8 @@ class FrequencyResponseResults:
             Default is "rad/s"
         amplitude_units : str, optional
             Units for the y axis.
-            Default is "m/N"
+            Default is "m/N" 0 to peak.
+            To use peak to peak use the prefix 'pkpk_' (e.g. pkpk_m)
         phase_units : str, optional
             Units for the x axis.
             Default is "rad"
@@ -1234,7 +1379,7 @@ class FrequencyResponseResults:
         return subplots
 
 
-class ForcedResponseResults:
+class ForcedResponseResults(Results):
     """Store results and provide plots for Unbalance and Forced Response analysis.
 
     Parameters
@@ -1298,7 +1443,8 @@ class ForcedResponseResults:
             Default is "rad/s"
         amplitude_units : str, optional
             Units for the y axis.
-            Default is "m"
+            Default is "m" 0 to peak.
+            To use peak to peak use the prefix 'pkpk_' (e.g. pkpk_m)
         fig : Plotly graph_objects.Figure()
             The figure object with the plot.
         kwargs : optional
@@ -1469,7 +1615,8 @@ class ForcedResponseResults:
             Default is "rad/s"
         amplitude_units : str, optional
             Units for the y axis.
-            Default is "m"
+            Default is "m" 0 to peak.
+            To use peak to peak use the prefix 'pkpk_' (e.g. pkpk_m)
         phase_units : str, optional
             Units for the x axis.
             Default is "rad"
@@ -1586,7 +1733,8 @@ class ForcedResponseResults:
             Default is "rad/s"
         amplitude_units : str, optional
             Amplitude units.
-            Default is "m/N"
+            Default is "m/N" 0 to peak.
+            To use peak to peak use the prefix 'pkpk_' (e.g. pkpk_m)
         phase_units : str, optional
             Phase units.
             Default is "rad"
@@ -2119,7 +2267,7 @@ class ForcedResponseResults:
                 x=nodes_pos,
                 y=Mr,
                 mode="lines",
-                name="Bending Moment (abs)",
+                name=f"Bending Moment (abs) ({moment_units})",
                 legendgroup="Mr",
                 showlegend=True,
                 hovertemplate=f"Nodal Position: %{{x:.2f}}<br>Mr ({moment_units}): %{{y:.2e}}",
@@ -2140,7 +2288,8 @@ class ForcedResponseResults:
 
         fig.update_xaxes(title_text=f"Rotor Length ({rotor_length_units})")
         fig.update_yaxes(
-            title_text=f"Bending Moment ({moment_units})", title_font=dict(size=12)
+            title_text=f"Bending Moment ({moment_units})",
+            title_font=dict(size=12),
         )
         fig.update_layout(**kwargs)
 
@@ -2254,10 +2403,14 @@ class ForcedResponseResults:
                 domain=dict(x=[0.47, 1]),
             ),
             title=dict(
-                text=f"Deflected Shape<br>Speed = {speed_str} {frequency_units}"
+                text=f"Deflected Shape<br>Speed = {speed_str} {frequency_units}",
             ),
             legend=dict(
-                orientation="h", xanchor="center", yanchor="bottom", x=0.5, y=-0.3
+                orientation="h",
+                xanchor="center",
+                yanchor="bottom",
+                x=0.5,
+                y=-0.3,
             ),
             width=800,
             height=600,
@@ -2267,7 +2420,7 @@ class ForcedResponseResults:
         return subplots
 
 
-class StaticResults:
+class StaticResults(Results):
     """Class used to store results and provide plots for Static Analysis.
 
     This class plots free-body diagram, deformed shaft, shearing
@@ -2712,7 +2865,7 @@ class StaticResults:
         return fig
 
 
-class SummaryResults:
+class SummaryResults(Results):
     """Class used to store results and provide plots rotor summary.
 
     This class aims to present a summary of the main parameters and attributes
@@ -2921,7 +3074,7 @@ class SummaryResults:
         return fig
 
 
-class ConvergenceResults:
+class ConvergenceResults(Results):
     """Class used to store results and provide plots for Convergence Analysis.
 
     This class plots:
@@ -3016,7 +3169,7 @@ class ConvergenceResults:
         return fig
 
 
-class TimeResponseResults:
+class TimeResponseResults(Results):
     """Class used to store results and provide plots for Time Response Analysis.
 
     This class takes the results from time response analysis and creates a
diff --git a/ross/rotor_assembly.py b/ross/rotor_assembly.py
index b025db50..d5240fd0 100644
--- a/ross/rotor_assembly.py
+++ b/ross/rotor_assembly.py
@@ -616,7 +616,7 @@ class Rotor(object):
         with warnings.catch_warnings():
             warnings.simplefilter("ignore")
             log_dec = 2 * np.pi * damping_ratio / np.sqrt(1 - damping_ratio ** 2)
-        lti = self._lti(speed)
+
         modal_results = ModalResults(
             speed,
             evalues,
@@ -625,7 +625,6 @@ class Rotor(object):
             wd,
             damping_ratio,
             log_dec,
-            lti,
             self.ndof,
             self.nodes,
             self.nodes_pos,
@@ -1749,8 +1748,8 @@ class Rotor(object):
         >>> rotor.time_response(speed, F, t) # doctest: +ELLIPSIS
         (array([0.        , 0.18518519, 0.37037037, ...
         """
-        modal = self.run_modal(speed=speed)
-        return signal.lsim(modal.lti, F, t, X0=ic)
+        lti = self._lti(speed)
+        return signal.lsim(lti, F, t, X0=ic)
 
     def plot_rotor(self, nodes=1, check_sld=False, length_units="m", **kwargs):
         """Plot a rotor object.
@@ -2469,7 +2468,7 @@ class Rotor(object):
         Parameters
         ----------
         **kwargs: dictionary
-        
+
             **kwargs receives:
                 dt : float
                     Time step.
diff --git a/ross/stochastic/__init__.py b/ross/stochastic/__init__.py
index 5c472a71..0a6bd975 100644
--- a/ross/stochastic/__init__.py
+++ b/ross/stochastic/__init__.py
@@ -2,5 +2,6 @@ from .st_bearing_seal_element import *
 from .st_disk_element import *
 from .st_materials import *
 from .st_point_mass import *
+from .st_results import *
 from .st_rotor_assembly import *
 from .st_shaft_element import *
diff --git a/ross/stochastic/st_results.py b/ross/stochastic/st_results.py
index a59a391c..4c16540d 100644
--- a/ross/stochastic/st_results.py
+++ b/ross/stochastic/st_results.py
@@ -2,7 +2,13 @@
 
 This module returns graphs for each type of analyses in st_rotor_assembly.py.
 """
+import inspect
+from abc import ABC
+from collections.abc import Iterable
+from pathlib import Path
+
 import numpy as np
+import toml
 from plotly import express as px
 from plotly import graph_objects as go
 from plotly import io as pio
@@ -16,8 +22,133 @@ pio.renderers.default = "browser"
 colors1 = px.colors.qualitative.Dark24
 colors2 = px.colors.qualitative.Light24
 
+__all__ = [
+    "ST_CampbellResults",
+    "ST_FrequencyResponseResults",
+    "ST_TimeResponseResults",
+    "ST_ForcedResponseResults",
+    "ST_Results",
+]
+
+
+class ST_Results(ABC):
+    """Results class.
+
+    This class is a general abstract class to be implemented in other classes
+    for post-processing results, in order to add saving and loading data options.
+    """
+
+    def save(self, file):
+        """Save results in a .toml file.
+
+        This function will save the simulation results to a .toml file.
+        The file will have all the argument's names and values that are needed to
+        reinstantiate the class.
+
+        Parameters
+        ----------
+        file : str, pathlib.Path
+            The name of the file the results will be saved in.
+
+        Examples
+        --------
+        >>> # Example running a stochastic unbalance response
+        >>> from tempfile import tempdir
+        >>> from pathlib import Path
+        >>> import ross.stochastic as srs
+
+        >>> # Running an example
+        >>> rotors = srs.st_rotor_example()
+        >>> freq_range = np.linspace(0, 500, 31)
+        >>> n = 3
+        >>> m = np.random.uniform(0.001, 0.002, 10)
+        >>> p = 0.0
+        >>> results = rotors.run_unbalance_response(n, m, p, freq_range)
+
+        >>> # create path for a temporary file
+        >>> file = Path(tempdir) / 'results.toml'
+        >>> results.save(file)
+        """
+        # get __init__ arguments
+        signature = inspect.signature(self.__init__)
+        args_list = list(signature.parameters)
+        args = {arg: getattr(self, arg) for arg in args_list}
+        try:
+            data = toml.load(file)
+        except FileNotFoundError:
+            data = {}
+
+        data[f"{self.__class__.__name__}"] = args
+        with open(file, "w") as f:
+            toml.dump(data, f, encoder=toml.TomlNumpyEncoder())
+
+    @classmethod
+    def read_toml_data(cls, data):
+        """Read and parse data stored in a .toml file.
+
+        The data passed to this method needs to be according to the
+        format saved in the .toml file by the .save() method.
+
+        Parameters
+        ----------
+        data : dict
+            Dictionary obtained from toml.load().
+
+        Returns
+        -------
+        The result object.
+        """
+        return cls(**data)
+
+    @classmethod
+    def load(cls, file):
+        """Load results from a .toml file.
+
+        This function will load the simulation results from a .toml file.
+        The file must have all the argument's names and values that are needed to
+        reinstantiate the class.
 
-class ST_CampbellResults:
+        Parameters
+        ----------
+        file : str, pathlib.Path
+            The name of the file the results will be loaded from.
+
+        Examples
+        --------
+        >>> # Example running a stochastic unbalance response
+        >>> from tempfile import tempdir
+        >>> from pathlib import Path
+        >>> import ross.stochastic as srs
+
+        >>> # Running an example
+        >>> rotors = srs.st_rotor_example()
+        >>> freq_range = np.linspace(0, 500, 31)
+        >>> n = 3
+        >>> m = np.random.uniform(0.001, 0.002, 10)
+        >>> p = 0.0
+        >>> results = rotors.run_unbalance_response(n, m, p, freq_range)
+
+        >>> # create path for a temporary file
+        >>> file = Path(tempdir) / 'results.toml'
+        >>> results.save(file)
+
+        >>> # Loading file
+        >>> results2 = srs.ST_ForcedResponseResults.load(file)
+        >>> results2.forced_resp.all() == results.forced_resp.all()
+        True
+        """
+        data = toml.load(file)
+        # extract single dictionary in the data
+        data = list(data.values())[0]
+        for key, value in data.items():
+            if isinstance(value, Iterable):
+                data[key] = np.array(value)
+                if data[key].dtype == np.dtype("<U49"):
+                    data[key] = np.array(value).astype(np.complex128)
+        return cls.read_toml_data(data)
+
+
+class ST_CampbellResults(ST_Results):
     """Store stochastic results and provide plots for Campbell Diagram.
 
     It's possible to visualize multiples harmonics in a single plot to check
@@ -349,7 +480,7 @@ class ST_CampbellResults:
         return subplots
 
 
-class ST_FrequencyResponseResults:
+class ST_FrequencyResponseResults(ST_Results):
     """Store stochastic results and provide plots for Frequency Response.
 
     Parameters
@@ -375,7 +506,11 @@ class ST_FrequencyResponseResults:
         self.phase = phase
 
     def plot_magnitude(
-        self, percentile=[], conf_interval=[], units="mic-pk-pk", **kwargs
+        self,
+        percentile=[],
+        conf_interval=[],
+        units="mic-pk-pk",
+        **kwargs,
     ):
         """Plot amplitude vs frequency.
 
@@ -611,7 +746,11 @@ class ST_FrequencyResponseResults:
         return fig
 
     def plot_polar_bode(
-        self, percentile=[], conf_interval=[], units="mic-pk-pk", **kwargs
+        self,
+        percentile=[],
+        conf_interval=[],
+        units="mic-pk-pk",
+        **kwargs,
     ):
         """Plot polar forced response using Plotly.
 
@@ -718,7 +857,7 @@ class ST_FrequencyResponseResults:
                     linecolor="black",
                     linewidth=2.5,
                 ),
-            )
+            ),
         )
 
         return fig
@@ -793,7 +932,7 @@ class ST_FrequencyResponseResults:
         return subplots
 
 
-class ST_TimeResponseResults:
+class ST_TimeResponseResults(ST_Results):
     """Store stochastic results and provide plots for Time Response and Orbit Response.
 
     Parameters
@@ -1167,12 +1306,12 @@ class ST_TimeResponseResults:
                 xaxis=dict(title=dict(text="<b>Rotor Length</b>"), showspikes=False),
                 yaxis=dict(title=dict(text="<b>Amplitude - X</b>"), showspikes=False),
                 zaxis=dict(title=dict(text="<b>Amplitude - Y</b>"), showspikes=False),
-            )
+            ),
         )
         return fig
 
 
-class ST_ForcedResponseResults:
+class ST_ForcedResponseResults(ST_Results):
     """Store stochastic results and provide plots for Forced Response.
 
     Parameters
@@ -1275,7 +1414,6 @@ class ST_ForcedResponseResults:
                 probe_resp[j] = np.sqrt((_probe_resp[0] * np.cos(angle)) ** 2 +
                                         (_probe_resp[1] * np.sin(angle)) ** 2)
             # fmt: on
-
             fig.add_trace(
                 go.Scatter(
                     x=self.frequency_range,
@@ -1644,7 +1782,7 @@ class ST_ForcedResponseResults:
             polar=dict(
                 radialaxis=fig2.layout.polar.radialaxis,
                 angularaxis=fig2.layout.polar.angularaxis,
-            )
+            ),
         )
 
         return fig


Test patch:
diff --git a/ross/tests/test_results.py b/ross/tests/test_results.py
new file mode 100644
index 00000000..c8a5f785
--- /dev/null
+++ b/ross/tests/test_results.py
@@ -0,0 +1,143 @@
+from pathlib import Path
+from tempfile import tempdir
+
+import numpy as np
+import pytest
+from numpy.testing import assert_allclose, assert_almost_equal, assert_equal
+
+from ross.rotor_assembly import *
+from ross.results import *
+
+
+@pytest.fixture
+def rotor1():
+    return rotor_example()
+
+
+def test_save_load_campbell(rotor1):
+    speed = np.linspace(0, 1000, 51)
+    response = rotor1.run_campbell(speed)
+
+    file = Path(tempdir) / "campbell.toml"
+    response.save(file)
+    response2 = CampbellResults.load(file)
+
+    assert response2.speed_range.all() == response.speed_range.all()
+    assert response2.wd.all() == response.wd.all()
+    assert response2.log_dec.all() == response.log_dec.all()
+    assert response2.whirl_values.all() == response.whirl_values.all()
+
+
+def test_save_load_criticalspeed(rotor1):
+    response = rotor1.run_critical_speed()
+
+    file = Path(tempdir) / "critical_speed.toml"
+    response.save(file)
+    response2 = CriticalSpeedResults.load(file)
+
+    assert response2.wn.all() == response.wn.all()
+    assert response2.wd.all() == response.wd.all()
+    assert response2.log_dec.all() == response.log_dec.all()
+    assert response2.damping_ratio.all() == response.damping_ratio.all()
+
+
+def test_save_load_modal(rotor1):
+    response = rotor1.run_modal(0)
+
+    file = Path(tempdir) / "modal.toml"
+    response.save(file)
+    response2 = ModalResults.load(file)
+
+    assert response2.speed == response.speed
+    assert response2.evalues.all() == response.evalues.all()
+    assert response2.evectors.all() == response.evectors.all()
+    assert response2.wn.all() == response.wn.all()
+    assert response2.wd.all() == response.wd.all()
+    assert response2.damping_ratio.all() == response.damping_ratio.all()
+    assert response2.log_dec.all() == response.log_dec.all()
+    assert response2.ndof == response.ndof
+    assert np.array(response2.nodes).all() == np.array(response.nodes).all()
+    assert np.array(response2.nodes_pos).all() == np.array(response.nodes_pos).all()
+    assert np.array(response2.shaft_elements_length).all() == np.array(response.shaft_elements_length).all()
+
+
+def test_save_load_freqresponse(rotor1):
+    speed = np.linspace(0, 1000, 51)
+    response = rotor1.run_freq_response(speed_range=speed)
+
+    file = Path(tempdir) / "frf.toml"
+    response.save(file)
+    response2 = FrequencyResponseResults.load(file)
+
+    assert response2.freq_resp.all() == response.freq_resp.all()
+    assert response2.speed_range.all() == response.speed_range.all()
+    assert response2.magnitude.all() == response.magnitude.all()
+    assert response2.phase.all() == response.phase.all()
+
+
+def test_save_load_unbalance_response(rotor1):
+    speed = np.linspace(0, 1000, 51)
+    response = rotor1.run_unbalance_response(3, 0.01, 0.0, speed)
+
+    file = Path(tempdir) / "unbalance.toml"
+    response.save(file)
+    response2 = ForcedResponseResults.load(file)
+
+    assert response2.rotor == response.rotor
+    assert response2.forced_resp.all() == response.forced_resp.all()
+    assert response2.speed_range.all() == response.speed_range.all()
+    assert response2.magnitude.all() == response.magnitude.all()
+    assert response2.phase.all() == response.phase.all()
+    assert response2.unbalance.all() == response.unbalance.all()
+
+
+def test_save_load_static(rotor1):
+    response = rotor1.run_static()
+
+    file = Path(tempdir) / "static.toml"
+    response.save(file)
+    response2 = StaticResults.load(file)
+
+    assert np.array(response2.deformation).all() == np.array(response.deformation).all()
+    assert np.array(response2.Vx).all() == np.array(response.Vx).all()
+    assert np.array(response2.Bm).all() == np.array(response.Bm).all()
+    assert np.array(response2.w_shaft).all() == np.array(response.w_shaft).all()
+    assert response2.disk_forces == response.disk_forces
+    assert response2.bearing_forces == response.bearing_forces
+    assert np.array(response2.nodes).all() == np.array(response.nodes).all()
+    assert np.array(response2.nodes_pos).all() == np.array(response.nodes_pos).all()
+    assert np.array(response2.Vx_axis).all() == np.array(response.Vx_axis).all()
+
+
+def test_save_load_convergence(rotor1):
+    response = rotor1.convergence()
+
+    file = Path(tempdir) / "convergence.toml"
+    response.save(file)
+    response2 = ConvergenceResults.load(file)
+
+    assert response2.el_num.all() == response.el_num.all()
+    assert response2.eigv_arr.all() == response.eigv_arr.all()
+    assert response2.error_arr.all() == response.error_arr.all()
+
+
+def test_save_load_timeresponse(rotor1):
+    speed = 500.0
+    size = 1000
+    node = 3
+    t = np.linspace(0, 10, size)
+    F = np.zeros((size, rotor1.ndof))
+    F[:, 4 * node] = 10 * np.cos(2 * t)
+    F[:, 4 * node + 1] = 10 * np.sin(2 * t)
+    response = rotor1.run_time_response(speed, F, t)
+
+    file = Path(tempdir) / "time.toml"
+    response.save(file)
+    response2 = TimeResponseResults.load(file)
+
+    assert response2.t.all() == response.t.all()
+    assert response2.yout.all() == response.yout.all()
+    assert response2.xout.all() == response.xout.all()
+    assert np.array(response2.nodes_list).all() == np.array(response.nodes_list).all()
+    assert np.array(response2.nodes_pos).all() == np.array(response.nodes_pos).all()
+    assert response2.number_dof == response.number_dof