Repository: nasa/prog_models (language: python)

Issue:
Updates to serialization
Move class and function used in JSON serialization to separate util file. Added functionality to parameters directly as well. 

This change helps enable some features in prog_server


Discussion / hints:

Fix patch:
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
index ab0b8a660..6312f7637 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -18,7 +18,7 @@ Optional, Solution that is proposed. Requirement can be met with other solutions
 
 **DoD**
 edit this to be a list of tasks that need to be completed
- - [ ] Implement feture
+ - [ ] Implement feature
  - [ ] Add to example 
  - [ ] Add to tutorial
  - [ ] Add tests 
diff --git a/.github/workflows/print-benchmarking.yml b/.github/workflows/print-benchmarking.yml
new file mode 100644
index 000000000..83595d42d
--- /dev/null
+++ b/.github/workflows/print-benchmarking.yml
@@ -0,0 +1,79 @@
+name: Print Benchmarking
+
+on: pull_request
+
+jobs:
+  benchmark_branch: 
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: ['3.9']
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install -r requirements.txt
+          python -m pip install -e .
+      - name: benchmark
+        id: bench
+        shell: bash
+        run: |
+          bench=$(python -m tests.benchmarking)
+          # V To support multiline strings V
+          bench="${bench//'%'/'%25'}"
+          bench="${bench//$'\n'/'%0A'}"
+          bench="${bench//$'\r'/'%0D'}"
+          echo "::set-output name=bench::$bench"
+      - name: Clone prog_models
+        uses: actions/checkout@v2
+        with:
+          repository: nasa/prog_models
+          ref: ${{ github.event.pull_request.base.ref }}
+          path: prog_models_target
+      - name: Downgrade
+        run: |
+          python -m pip uninstall -y prog_models
+          python -m pip install -e ./prog_models_target
+          cd prog_models_target
+      - name: benchmark-pre
+        id: bench-pre
+        shell: bash
+        run: |
+          bench=$(python -m tests.benchmarking)
+          # V To support multiline strings V
+          bench="${bench//'%'/'%25'}"
+          bench="${bench//$'\n'/'%0A'}"
+          bench="${bench//$'\r'/'%0D'}"
+          echo "::set-output name=bench::$bench"
+      - name: Auto Comment
+        uses: wow-actions/auto-comment@v1
+        with:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          pullRequestOpened: >
+            Benchmarking Results
+
+            From:
+
+            ${{steps.bench-pre.outputs.bench}}
+            
+            To:
+
+            ${{steps.bench.outputs.bench}}
+
+          pullRequestSynchronize: > 
+            Benchmarking Results [Update]
+
+            From:
+
+            ${{steps.bench-pre.outputs.bench}}
+
+            
+            To:
+
+
+            ${{steps.bench.outputs.bench}}
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index 529fac435..b82652a54 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -30,37 +30,37 @@ jobs:
         python -m pip install requests
     - name: Run tests
       run: python -m tests
-  test_prog_algs_released:
-    runs-on: ubuntu-latest
-    strategy:
-      matrix:
-        python-version: ['3.9']
-    steps:
-    - uses: actions/checkout@v3
-    - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v4
-      with:
-        python-version: ${{ matrix.python-version }}
-    - name: Install dependencies
-      run: |
-        python -m pip install --upgrade pip
-        python -m pip install -e .
-        python -m pip install notebook
-        python -m pip install testbook
-        python -m pip install requests
-    - name: Clone prog_algs
-      uses: actions/checkout@v3
-      with:
-        repository: nasa/prog_algs 
-        ref: refs/heads/master
-        path: prog_algs
-    - name: Install prog_algs
-      run: |
-        python -m pip install -e ./prog_algs
-    - name: Run tests
-      run: |
-        cd prog_algs 
-        python -m tests
+  # test_prog_algs_released:
+  #   runs-on: ubuntu-latest
+  #   strategy:
+  #     matrix:
+  #       python-version: ['3.9']
+  #   steps:
+  #   - uses: actions/checkout@v2
+  #   - name: Set up Python ${{ matrix.python-version }}
+  #     uses: actions/setup-python@v2
+  #     with:
+  #       python-version: ${{ matrix.python-version }}
+  #   - name: Install dependencies
+  #     run: |
+  #       python -m pip install --upgrade pip
+  #       python -m pip install -e .
+  #       python -m pip install notebook
+  #       python -m pip install testbook
+  #       python -m pip install requests
+  #   - name: Clone prog_algs
+  #     uses: actions/checkout@v2
+  #     with:
+  #       repository: nasa/prog_algs 
+  #       ref: refs/heads/master
+  #       path: prog_algs
+  #   - name: Install prog_algs
+  #     run: |
+  #       python -m pip install -e ./prog_algs
+  #   - name: Run tests
+  #     run: |
+  #       cd prog_algs 
+  #       python -m tests
   test_prog_algs_dev:
     runs-on: ubuntu-latest
     strategy:
@@ -106,30 +106,30 @@ jobs:
     - name: Run copyright check
       run: |
         python scripts/test_copyright.py
-  coverage:
-    runs-on: ubuntu-latest
-    strategy:
-      matrix:
-        python-version: ['3.9']
-    steps:
-    - uses: actions/checkout@v3
-    - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v4
-      with:
-        python-version: ${{ matrix.python-version }}
-    - name: Install dependencies
-      run: |
-        python -m pip install --upgrade pip
-        python -m pip install -e .
-        pip install coverage
-        pip install notebook
-        pip install testbook
-        pip install requests
-    - name: Run coverage
-      run: |
-        coverage run -m tests
-        coverage xml
-    - name: "Upload coverage to Codecov"
-      uses: codecov/codecov-action@v3
-      with:
-        fail_ci_if_error: true
+  # coverage:
+  #   runs-on: ubuntu-latest
+  #   strategy:
+  #     matrix:
+  #       python-version: ['3.9']
+  #   steps:
+  #   - uses: actions/checkout@v3
+  #   - name: Set up Python ${{ matrix.python-version }}
+  #     uses: actions/setup-python@v4
+  #     with:
+  #       python-version: ${{ matrix.python-version }}
+  #   - name: Install dependencies
+  #     run: |
+  #       python -m pip install --upgrade pip
+  #       python -m pip install -e .
+  #       pip install coverage
+  #       pip install notebook
+  #       pip install testbook
+  #       pip install requests
+  #   - name: Run coverage
+  #     run: |
+  #       coverage run -m tests
+  #       coverage xml
+  #   - name: "Upload coverage to Codecov"
+  #     uses: codecov/codecov-action@v3
+  #     with:
+  #       fail_ci_if_error: true
diff --git a/.gitignore b/.gitignore
index e5ba107e1..05eea8fa5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -106,3 +106,7 @@ model_test.pkl
 battery123.cfg
 
 dataset_1.pkl
+
+*.keras
+
+.VSCodeCounter/
diff --git a/README.md b/README.md
index f26a592f7..cf2058388 100644
--- a/README.md
+++ b/README.md
@@ -18,14 +18,7 @@ See documentation [here](https://nasa.github.io/prog_models/)
 Here is the directory structure for the github repository 
  
 `src/prog_models/` - The prognostics model python package<br />
-&nbsp;&nbsp; |-`datasets/` - Tools for accessing prognostics datasets<br />
-&nbsp;&nbsp; |-`models/` - Example models<br /> 
-&nbsp;&nbsp; |-`linear_model.py` - A simpler subclass of PrognosticsModel for linear models<br />
-&nbsp;&nbsp; |-`prognostics_model.py` - Physics-based model superclass of degraded system behavior<br />
-&nbsp;&nbsp; |-`sim_result.py` - Class for storing the result of a simulation (used by `prognostics_model`)<br />
-&nbsp;&nbsp; |-`visualize.py` - Visualization tools<br />
 `docs/` - Project documentation<br />
-`sphinx_config/` - Configuration for automatic documentation generation<br />
 `examples/` - Example Python scripts using prog_models<br />
 `tests/` - Tests for prog_models<br />
 `README.md` - The readme (this file)<br />
@@ -40,31 +33,36 @@ Use the following to cite this repository:
 @misc{2022_nasa_prog_models,
     author    = {Christopher Teubert and Matteo Corbetta and Chetan Kulkarni and Katelyn Jarvis and Matthew Daigle},
     title     = {Prognostics Models Python Package},
-    month     = May,
+    month     = October,
     year      = 2022,
-    version   = {1.3},
+    version   = {1.4},
     url       = {https://github.com/nasa/prog\_models}
     }
 ```
 
 The corresponding reference should look like this:
 
-C. Teubert, C. Kulkarni, M. Corbetta, K. Jarvis, M. Daigle, Prognostics Model Python Package, v1.3, May 2022. URL https://github.com/nasa/prog_models.
+C. Teubert, C. Kulkarni, M. Corbetta, K. Jarvis, M. Daigle, Prognostics Model Python Package, v1.4, October 2022. URL https://github.com/nasa/prog_models.
 
 Alternatively, if using both prog_models and prog_algs, you can cite the combined package as
 
-C. Teubert, C. Kulkarni, M. Corbetta, K. Jarvis, M. Daigle, ProgPy Prognostics Python Packages, v1.3, May 2022. URL https://github.com/nasa/prog_models.
+C. Teubert, C. Kulkarni, M. Corbetta, K. Jarvis, M. Daigle, ProgPy Prognostics Python Packages, v1.4, October 2022. URL https://nasa.github.io/progpy.
 
+## Contributing Organizations
+ProgPy was created by a partnership of multiple organizations, working together to build a set of high-quality prognostic tools for the wider PHM Community. We would like to give a big thank you for the ProgPy community, especially the following contributing organizations:
+
+* [NASA's Diagnostics and Prognostics Group](https://www.nasa.gov/content/diagnostics-prognostics)
+* German Aerospace Center (DLR)
+* Northrop Grumman Corporation (NGC)
+* Vanderbilt University
 
 ## Acknowledgements
 The structure and algorithms of this package are strongly inspired by the [MATLAB Prognostics Model Library](https://github.com/nasa/PrognosticsModelLibrary). We would like to recognize Matthew Daigle and the rest of the team that contributed to the Prognostics Model Library for the contributions their work on the MATLAB library made to the design of prog_models.
 
 ## Notices
-
 Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
 
 ## Disclaimers
-
 No Warranty: THE SUBJECT SOFTWARE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY OF ANY KIND, EITHER EXPRESSED, IMPLIED, OR STATUTORY, INCLUDING, BUT NOT LIMITED TO, ANY WARRANTY THAT THE SUBJECT SOFTWARE WILL CONFORM TO SPECIFICATIONS, ANY IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR FREEDOM FROM INFRINGEMENT, ANY WARRANTY THAT THE SUBJECT SOFTWARE WILL BE ERROR FREE, OR ANY WARRANTY THAT DOCUMENTATION, IF PROVIDED, WILL CONFORM TO THE SUBJECT SOFTWARE. THIS AGREEMENT DOES NOT, IN ANY MANNER, CONSTITUTE AN ENDORSEMENT BY GOVERNMENT AGENCY OR ANY PRIOR RECIPIENT OF ANY RESULTS, RESULTING DESIGNS, HARDWARE, SOFTWARE PRODUCTS OR ANY OTHER APPLICATIONS RESULTING FROM USE OF THE SUBJECT SOFTWARE.  FURTHER, GOVERNMENT AGENCY DISCLAIMS ALL WARRANTIES AND LIABILITIES REGARDING THIRD-PARTY SOFTWARE, IF PRESENT IN THE ORIGINAL SOFTWARE, AND DISTRIBUTES IT "AS IS."
 
 Waiver and Indemnity:  RECIPIENT AGREES TO WAIVE ANY AND ALL CLAIMS AGAINST THE UNITED STATES GOVERNMENT, ITS CONTRACTORS AND SUBCONTRACTORS, AS WELL AS ANY PRIOR RECIPIENT.  IF RECIPIENT'S USE OF THE SUBJECT SOFTWARE RESULTS IN ANY LIABILITIES, DEMANDS, DAMAGES, EXPENSES OR LOSSES ARISING FROM SUCH USE, INCLUDING ANY DAMAGES FROM PRODUCTS BASED ON, OR RESULTING FROM, RECIPIENT'S USE OF THE SUBJECT SOFTWARE, RECIPIENT SHALL INDEMNIFY AND HOLD HARMLESS THE UNITED STATES GOVERNMENT, ITS CONTRACTORS AND SUBCONTRACTORS, AS WELL AS ANY PRIOR RECIPIENT, TO THE EXTENT PERMITTED BY LAW.  RECIPIENT'S SOLE REMEDY FOR ANY SUCH MATTER SHALL BE THE IMMEDIATE, UNILATERAL TERMINATION OF THIS AGREEMENT.
diff --git a/docs/_downloads/1b9c2fb843346c9087fbd3fb7ca4c46b/dataset.py b/docs/_downloads/1b9c2fb843346c9087fbd3fb7ca4c46b/dataset.py
index e7e2a64b8..36ab69766 100644
--- a/docs/_downloads/1b9c2fb843346c9087fbd3fb7ca4c46b/dataset.py
+++ b/docs/_downloads/1b9c2fb843346c9087fbd3fb7ca4c46b/dataset.py
@@ -18,7 +18,7 @@ def run_example():
     print('done')
 
     # We recommend saving the dataset to disk for future use
-    # This way you dont have to download it each time
+    # This way you don't have to download it each time
     import pickle
     pickle.dump((desc, data), open(f'dataset_{DATASET_ID}.pkl', 'wb'))
 
diff --git a/docs/_downloads/322a9249992e6c1d385f3a49efd66300/prog_model_template.py b/docs/_downloads/322a9249992e6c1d385f3a49efd66300/prog_model_template.py
index 539534731..92ce856dd 100644
--- a/docs/_downloads/322a9249992e6c1d385f3a49efd66300/prog_model_template.py
+++ b/docs/_downloads/322a9249992e6c1d385f3a49efd66300/prog_model_template.py
@@ -239,7 +239,7 @@ def output(self, t, x):
 
     def event_state(self, t, x):
         """
-        Calculate event states (i.e., measures of progress towards event (0-1, where 0 means event has occured))
+        Calculate event states (i.e., measures of progress towards event (0-1, where 0 means event has occurred))
 
         Parameters
         ----------
diff --git a/docs/_downloads/7bdd7d73bab579719a189b79acfa685c/events.py b/docs/_downloads/7bdd7d73bab579719a189b79acfa685c/events.py
index 552c7dbe9..c0e9b5351 100644
--- a/docs/_downloads/7bdd7d73bab579719a189b79acfa685c/events.py
+++ b/docs/_downloads/7bdd7d73bab579719a189b79acfa685c/events.py
@@ -21,7 +21,7 @@ def run_example():
     # We of course are interested in end of discharge, but for this example we
     # have a requirement that says the battery must not fall below 5% State of Charge (SOC)
     # Note: SOC is the event state for the End of Discharge (EOD) event
-    # Event states, like SOC go between 0 and 1, where 1 is healthy and at 0 the event has occured. 
+    # Event states, like SOC go between 0 and 1, where 1 is healthy and at 0 the event has occurred. 
     # So, 5% SOC corresponds to an 'EOD' event state of 0.05
     # Additionally, we have two warning thresholds (yellow and red)
 
diff --git a/docs/_downloads/c720c5a5756e78cb7420a8f5ee2ae18d/future_loading.py b/docs/_downloads/c720c5a5756e78cb7420a8f5ee2ae18d/future_loading.py
index 406a5c03a..07257a6df 100644
--- a/docs/_downloads/c720c5a5756e78cb7420a8f5ee2ae18d/future_loading.py
+++ b/docs/_downloads/c720c5a5756e78cb7420a8f5ee2ae18d/future_loading.py
@@ -40,7 +40,7 @@ def future_loading(t, x=None):
 
     ## Example 2: Moving Average loading 
     # This is useful in cases where you are running reoccuring simulations, and are measuring the actual load on the system, 
-    # but dont have a good way of predicting it, and you expect loading to be steady
+    # but don't have a good way of predicting it, and you expect loading to be steady
 
     def future_loading(t, x=None):
         return future_loading.load
diff --git a/docs/_downloads/e4947713746e05efe1e55cf84e8ac3f8/new_model.py b/docs/_downloads/e4947713746e05efe1e55cf84e8ac3f8/new_model.py
index d076707a8..f6878c3d9 100644
--- a/docs/_downloads/e4947713746e05efe1e55cf84e8ac3f8/new_model.py
+++ b/docs/_downloads/e4947713746e05efe1e55cf84e8ac3f8/new_model.py
@@ -107,8 +107,8 @@ def future_load(t, x=None):
     threshs_met = m.threshold_met(simulated_results.states[-1])
     for (key, met) in threshs_met.items():
         if met:
-            event_occured = key
-    print('\nThis event that occured first: ', event_occured)
+            event_occurred = key
+    print('\nThis event that occurred first: ', event_occurred)
     # It falls before it hits the gorund, obviously
 
     # Metrics can be analyzed from the simulation results. For example: monotonicity
diff --git a/docs/deriv_prog_model.html b/docs/deriv_prog_model.html
index 765bc971b..af3038475 100644
--- a/docs/deriv_prog_model.html
+++ b/docs/deriv_prog_model.html
@@ -107,7 +107,7 @@ <h1>Derivative Prognostics Model<a class="headerlink" href="#derivative-prognost
 <dl class="py method">
 <dt id="prog_models.deriv_prog_model.DerivProgModel.event_state">
 <code class="sig-name descname">event_state</code><span class="sig-paren">(</span><em class="sig-param"><span class="n">t</span></em>, <em class="sig-param"><span class="n">x</span></em><span class="sig-paren">)</span> &#x2192; dict<a class="headerlink" href="#prog_models.deriv_prog_model.DerivProgModel.event_state" title="Permalink to this definition">¶</a></dt>
-<dd><p>Calculate event states (i.e., measures of progress towards event (0-1, where 0 means event has occured))</p>
+<dd><p>Calculate event states (i.e., measures of progress towards event (0-1, where 0 means event has occurred))</p>
 <dl class="field-list simple">
 <dt class="field-odd">Parameters</dt>
 <dd class="field-odd"><ul class="simple">
@@ -296,7 +296,7 @@ <h1>Derivative Prognostics Model<a class="headerlink" href="#derivative-prognost
 <li><p><strong>inputs</strong> (<em>[dict]</em>) – Future input (from future_loading_eqn) for each time in times</p></li>
 <li><p><strong>states</strong> (<em>[dict]</em>) – Estimated states for each time in times</p></li>
 <li><p><strong>outputs</strong> (<em>[dict]</em>) – Estimated outputs for each time in times</p></li>
-<li><p><strong>event_states</strong> (<em>[dict]</em>) – Estimated event state (e.g., SOH), between 1-0 where 0 is event occurance, for each time in times</p></li>
+<li><p><strong>event_states</strong> (<em>[dict]</em>) – Estimated event state (e.g., SOH), between 1-0 where 0 is event occurrence, for each time in times</p></li>
 </ul>
 </p>
 </dd>
@@ -347,7 +347,7 @@ <h1>Derivative Prognostics Model<a class="headerlink" href="#derivative-prognost
 <li><p><strong>inputs</strong> (<em>[dict]</em>) – Future input (from future_loading_eqn) for each time in times</p></li>
 <li><p><strong>states</strong> (<em>[dict]</em>) – Estimated states for each time in times</p></li>
 <li><p><strong>outputs</strong> (<em>[dict]</em>) – Estimated outputs for each time in times</p></li>
-<li><p><strong>event_states</strong> (<em>[dict]</em>) – Estimated event state (e.g., SOH), between 1-0 where 0 is event occurance, for each time in times</p></li>
+<li><p><strong>event_states</strong> (<em>[dict]</em>) – Estimated event state (e.g., SOH), between 1-0 where 0 is event occurrence, for each time in times</p></li>
 </ul>
 </p>
 </dd>
diff --git a/examples/__init__.py b/examples/__init__.py
index bab27e336..bfc402350 100644
--- a/examples/__init__.py
+++ b/examples/__init__.py
@@ -1,4 +1,4 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the
 # National Aeronautics and Space Administration.  All Rights Reserved.
 
-__all__ = ['sim', 'sim_valve', 'sim_pump', 'sim_battery_eol', 'model_gen', 'benchmarking', 'new_model', 'sensitivity', 'noise', 'future_loading', 'param_est', 'derived_params', 'state_limits', 'dynamic_step_size', 'events', 'vectorized', 'linear_model', 'dataset', 'generate_surrogate']
+__all__ = ['sim', 'sim_valve', 'sim_pump', 'sim_battery_eol', 'benchmarking', 'new_model', 'sensitivity', 'noise', 'future_loading', 'param_est', 'derived_params', 'state_limits', 'dynamic_step_size', 'events', 'vectorized', 'linear_model', 'dataset', 'generate_surrogate', 'lstm_model']
diff --git a/examples/benchmarking.py b/examples/benchmarking.py
index 8a320d2f7..10b4a1b6d 100644
--- a/examples/benchmarking.py
+++ b/examples/benchmarking.py
@@ -2,29 +2,30 @@
 # National Aeronautics and Space Administration.  All Rights Reserved.
 
 """
-Example benchmarking the computational efficiency of models. 
+Simple example benchmarking the computational efficiency of models.
 """
 
-from timeit import timeit
 from prog_models.models import BatteryCircuit
+from timeit import timeit
 
 def run_example():
     # Step 1: Create a model object
     batt = BatteryCircuit()
     
     # Step 2: Define future loading function 
+    loading = batt.InputContainer({'i': 2})  # Constant loading
     def future_loading(t, x=None):
         # Constant Loading
-        return batt.InputContainer({'i': 2})
+        return loading
 
     # Step 3: Benchmark simulation of 600 seconds
     print('Benchmarking...')
     def sim():  
-        results = batt.simulate_to(600, future_loading)
+        batt.simulate_to(600, future_loading)
     time = timeit(sim, number=500)
 
     # Print results
-    print('Simulation Time: {} ms/sim'.format(time*2))
+    print('Simulation Time: {} ms/sim'.format(time))
 
 # This allows the module to be executed directly 
 if __name__=='__main__':
diff --git a/examples/custom_model.py b/examples/custom_model.py
new file mode 100644
index 000000000..a6f57d0e5
--- /dev/null
+++ b/examples/custom_model.py
@@ -0,0 +1,124 @@
+# Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
+# This ensures that the directory containing examples is in the python search directories
+
+"""
+Example building a custom model with LSTMStateTransitionModel.
+
+.. dropdown:: More details
+
+    For most cases, you will be able to use the standard LSTMStateTransitionModel.from_data class with configuration (see the LSTMStateTransitionModel class for more details). However, sometimes you might want to add custom layers, or other complex components. In that case, you will build a custom model and pass it into LSTMStateTransitionModel.
+
+    In this example, we generate fake data using the BatteryElectroChemEOD model. This is a case where we're generating a surrogate model from the physics-based model. For cases where you're generating a model from data (e.g., collected from a testbed or a real-world environment), you'll replace that generated data with your own. 
+
+    We build and fit a custom model using keras.layers. Finally, we compare performance to the standard format and the original model.
+"""
+
+import matplotlib.pyplot as plt
+import numpy as np
+from prog_models.data_models import LSTMStateTransitionModel
+from prog_models.models import BatteryElectroChemEOD
+from tensorflow import keras
+from tensorflow.keras import layers
+
+def run_example():
+    print('Generating data...')
+    batt = BatteryElectroChemEOD()
+    future_loading_eqns = [lambda t, x=None: batt.InputContainer({'i': 1+1.5*load}) for load in range(6)]
+    # Generate data with different loading and step sizes
+    # Adding the step size as an element of the output
+    input_data = []
+    output_data = []
+    for i in range(9):
+        dt = i/3+0.25
+        for loading_eqn in future_loading_eqns:
+            d = batt.simulate_to_threshold(loading_eqn, save_freq=dt, dt=dt) 
+            u = np.array([np.hstack((u_i.matrix[:][0].T, [dt])) for u_i in d.inputs], dtype=float)
+            z = d.outputs
+
+            input_data.append(u)
+            output_data.append(z)
+
+    # Step 2: Build standard model
+    print("Building standard model...")
+    m_batt = LSTMStateTransitionModel.from_data(
+        inputs = input_data,
+        outputs = output_data,  
+        window=12, 
+        epochs=30, 
+        units=64,  # Additional units given the increased complexity of the system
+        input_keys = ['i', 'dt'],
+        output_keys = ['t', 'v'])
+    m_batt.plot_history() 
+
+    # Step 3: Build custom model
+    print('Building custom model...')
+    (u_all, z_all, _, _) = LSTMStateTransitionModel.pre_process_data(input_data, output_data, window=12)
+    
+    # Normalize
+    n_inputs = len(input_data[0][0])
+    u_mean = np.mean(u_all[:,0,:n_inputs], axis=0)
+    u_std = np.std(u_all[:,0,:n_inputs], axis=0)
+    # If there's no variation- don't normalize 
+    u_std[u_std == 0] = 1
+    z_mean = np.mean(z_all, axis=0)
+    z_std = np.std(z_all, axis=0)
+    # If there's no variation- don't normalize 
+    z_std[z_std == 0] = 1
+
+    # Add output (since z_t-1 is last input)
+    u_mean = np.hstack((u_mean, z_mean))
+    u_std = np.hstack((u_std, z_std))
+
+    u_all = (u_all - u_mean)/u_std
+    z_all = (z_all - z_mean)/z_std
+
+    # u_mean and u_std act on the column vector form (from inputcontainer)
+    # so we need to transpose them to a column vector
+    normalization = (u_mean[np.newaxis].T, u_std[np.newaxis].T, z_mean, z_std)
+
+    callbacks = [
+        keras.callbacks.ModelCheckpoint("jena_sense.keras", save_best_only=True)
+    ]
+    inputs = keras.Input(shape=u_all.shape[1:])
+    x = layers.Bidirectional(layers.LSTM(128))(inputs)
+    x = layers.Dropout(0.1)(x)
+    x = layers.Dense(z_all.shape[1] if z_all.ndim == 2 else 1)(x)
+    model = keras.Model(inputs, x)
+    model.compile(optimizer="rmsprop", loss="mse", metrics=["mae"])
+    history = model.fit(u_all, z_all, epochs=30, callbacks = callbacks, validation_split = 0.1)
+
+    # Step 4: Build LSTMStateTransitionModel
+    m_custom = LSTMStateTransitionModel(model, 
+        normalization=normalization, 
+        input_keys = ['i', 'dt'],
+        output_keys = ['t', 'v'], history=history  # Provide history so plot_history will work
+    )
+    m_custom.plot_history()
+
+    # Step 5: Simulate
+    print('Simulating...')
+    t_counter = 0
+    x_counter = batt.initialize()
+    def future_loading(t, x=None):
+        return batt.InputContainer({'i': 3})
+
+    def future_loading2(t, x = None):
+        nonlocal t_counter, x_counter
+        z = batt.output(x_counter)
+        z = m_batt.InputContainer({'i': 3, 't_t-1': z['t'], 'v_t-1': z['v'], 'dt': t - t_counter})
+        x_counter = batt.next_state(x_counter, future_loading(t), t - t_counter)
+        t_counter = t
+        return z
+    data = batt.simulate_to_threshold(future_loading, dt=1, save_freq=1)
+    results = m_batt.simulate_to(data.times[-1], future_loading2, dt=1, save_freq=1)
+    results_custom = m_custom.simulate_to(data.times[-1], future_loading2, dt=1, save_freq=1)
+
+    # Step 6: Compare performance
+    print('Comparing performance...')
+    data.outputs.plot(title='original model', compact=False)
+    results.outputs.plot(title='generated model', compact=False)
+    results_custom.outputs.plot(title='custom model', compact=False)
+    plt.show()
+
+if __name__ == '__main__':
+    run_example()
diff --git a/examples/dataset.py b/examples/dataset.py
index e7e2a64b8..0457b16ac 100644
--- a/examples/dataset.py
+++ b/examples/dataset.py
@@ -4,22 +4,25 @@
 """
 Example downloading and using a NASA prognostics dataset.
 
-In this example, a battery dataset is downloaded from the NASA PCoE data repository. This dataset is then accessed and plotted. 
+.. dropdown:: More details
+
+    In this example, a battery dataset is downloaded from the NASA PCoE data repository. This dataset is then accessed and plotted. 
 """
 
+import matplotlib.pyplot as plt
+import pickle
+from prog_models.datasets import nasa_battery
 DATASET_ID = 1
 
 def run_example():
     # Step 1: Download and import the dataset for a single battery
     # Note: This may take some time
-    from prog_models.datasets import nasa_battery
     print('Downloading... ', end='')
     (desc, data) = nasa_battery.load_data(DATASET_ID)
     print('done')
 
     # We recommend saving the dataset to disk for future use
-    # This way you dont have to download it each time
-    import pickle
+    # This way you don't have to download it each time
     pickle.dump((desc, data), open(f'dataset_{DATASET_ID}.pkl', 'wb'))
 
     # Step 2: Access the dataset description
@@ -41,14 +44,13 @@ def run_example():
     print(f"Details of run 4: {desc['runs'][4]}")
 
     # Plot the run
-    import matplotlib.pyplot as plt
     plt.figure()
     plt.subplot(2, 1, 1)
-    plt.plot(data[4][:, 0], data[4][:, 1])
+    plt.plot(data[4]['relativeTime'], data[4]['current'])
     plt.ylabel('Current (A)')
 
     plt.subplot(2, 1, 2)
-    plt.plot(data[4][:, 0], data[4][:, 2])
+    plt.plot(data[4]['relativeTime'], data[4]['voltage'])
     plt.ylabel('Voltage (V)')
     plt.xlabel('Time (s)')
     plt.title('Run 4')
@@ -57,7 +59,7 @@ def run_example():
     indices = [i for i, x in enumerate(desc['runs']) if 'reference discharge' in x['desc'] and 'rest' not in x['desc']]
     plt.figure()
     for i in indices:
-        plt.plot(data[i][:, 0], data[i][:, 2], label=f"Run {i}")
+        plt.plot(data[i]['relativeTime'], data[i]['voltage'], label=f"Run {i}")
     plt.title('Reference discharge profiles')
     plt.xlabel('Time (s)')
     plt.ylabel('Voltage (V)')
diff --git a/examples/derived_params.py b/examples/derived_params.py
index 13870ad55..e36de9dcd 100644
--- a/examples/derived_params.py
+++ b/examples/derived_params.py
@@ -3,6 +3,10 @@
 
 """
 Example demonstrating ways to use the derived parameters feature for model building. 
+
+.. dropdown:: More details
+    
+    In this example, a derived parameter (i.e., a parameter that is a function of another parameter) are defined for the simple ThrownObject model. These parameters are then calculated whenever their dependency parameters are updated, eliminating the need to calculate each timestep in simulation. The functionality of this feature is then demonstrated.
 """
 
 from prog_models.models.thrown_object import ThrownObject
diff --git a/examples/dynamic_step_size.py b/examples/dynamic_step_size.py
index a566b22d2..88c679230 100644
--- a/examples/dynamic_step_size.py
+++ b/examples/dynamic_step_size.py
@@ -5,7 +5,6 @@
 Example demonstrating ways to use the dynamic step size feature. This feature allows users to define a time-step that changes with time or state. 
 """
 
-import prog_models
 from prog_models.models.thrown_object import ThrownObject
 
 def run_example():
diff --git a/examples/events.py b/examples/events.py
index 552c7dbe9..5de620a19 100644
--- a/examples/events.py
+++ b/examples/events.py
@@ -4,15 +4,15 @@
 """
 Example further illustrating the concept of 'events' which generalizes EOL. 
 
-'Events' is the term used to describe something to be predicted. 
-Generally in the PHM community these are referred to as End of Life (EOL). 
-However, they can be much more.
+.. dropdown:: More details
 
-In the prog_models package, events can be anything that needs to be predicted. 
-Events can represent End of Life (EOL), End of Mission (EOM), warning thresholds, or any Event of Interest (EOI). 
+    :term:`Events<event>` is the term used to describe something to be predicted. Generally in the PHM community these are referred to as End of Life (EOL). However, they can be much more.
 
-This example demonstrates how events can be used in your applications. 
+    In prog_models, events can be anything that needs to be predicted. Events can represent End of Life (EOL), End of Mission (EOM), warning thresholds, or any Event of Interest (EOI). 
+
+    This example demonstrates how events can be used in your applications. 
 """
+import matplotlib.pyplot as plt
 from prog_models.models import BatteryElectroChemEOD
 
 def run_example():
@@ -21,7 +21,7 @@ def run_example():
     # We of course are interested in end of discharge, but for this example we
     # have a requirement that says the battery must not fall below 5% State of Charge (SOC)
     # Note: SOC is the event state for the End of Discharge (EOD) event
-    # Event states, like SOC go between 0 and 1, where 1 is healthy and at 0 the event has occured. 
+    # Event states, like SOC go between 0 and 1, where 1 is healthy and at 0 the event has occurred. 
     # So, 5% SOC corresponds to an 'EOD' event state of 0.05
     # Additionally, we have two warning thresholds (yellow and red)
 
@@ -83,7 +83,6 @@ def future_loading(t, x=None):
 
     # 2c: Plot results
     simulated_results.event_states.plot()
-    import matplotlib.pyplot as plt
     plt.show()
 
 # This allows the module to be executed directly 
diff --git a/examples/full_lstm_model.py b/examples/full_lstm_model.py
new file mode 100644
index 000000000..979cc9fda
--- /dev/null
+++ b/examples/full_lstm_model.py
@@ -0,0 +1,125 @@
+# Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
+# This ensures that the directory containing examples is in the python search directories
+
+"""
+Example building a full model with events and thresholds using LSTMStateTransitionModel. 
+
+.. dropdown:: More details
+
+    In this example, we generate fake data using the ThrownObject model. This is a case where we're generating a surrogate model from the physics-based model. For cases where you're generating a model from data (e.g., collected from a testbed or a real-world environment), you'll replace that generated data with your own. 
+
+    We then create a subclass of the LSTMStateTransitionModel, defining the event_state and threshold equations as a function of output. We use the generated model and compare to the original model.
+"""
+
+import matplotlib.pyplot as plt
+import numpy as np
+from prog_models.data_models import LSTMStateTransitionModel
+from prog_models.models import ThrownObject
+
+def run_example():
+    # -----------------------------------------------------
+    # Method 1 - manual definition
+    # In this example we complete the models by manually defining event_state 
+    # and thresholds_met as function of output.
+    # -----------------------------------------------------
+    TIMESTEP = 0.01
+    m = ThrownObject()
+    def future_loading(t, x=None):
+        return m.InputContainer({})  # No input for thrown object 
+
+    # Step 1: Generate additional data
+    # We will use data generated above, but we also want data at additional timesteps 
+    print('Generating data...')
+    data = m.simulate_to_threshold(future_loading, threshold_keys='impact', save_freq=TIMESTEP, dt=TIMESTEP)
+    data_half = m.simulate_to_threshold(future_loading, threshold_keys='impact', save_freq=TIMESTEP/2, dt=TIMESTEP/2)
+    data_quarter = m.simulate_to_threshold(future_loading, threshold_keys='impact', save_freq=TIMESTEP/4, dt=TIMESTEP/4)
+    data_twice = m.simulate_to_threshold(future_loading, threshold_keys='impact', save_freq=TIMESTEP*2, dt=TIMESTEP*2)
+    data_four = m.simulate_to_threshold(future_loading, threshold_keys='impact', save_freq=TIMESTEP*4, dt=TIMESTEP*4)
+
+    # Step 2: Data Prep
+    # We need to add the timestep as a input
+    u = np.array([[TIMESTEP] for _ in data.inputs])
+    u_half = np.array([[TIMESTEP/2] for _ in data_half.inputs])
+    u_quarter = np.array([[TIMESTEP/4] for _ in data_quarter.inputs])
+    u_twice = np.array([[TIMESTEP*2] for _ in data_twice.inputs])
+    u_four = np.array([[TIMESTEP*4] for _ in data_four.inputs])
+
+    # In this case we are saying that velocity is directly measurable, 
+    # unlike the original model. This is necessary to calculate the events.
+    # Since the outputs will then match the states, we pass in the states below
+
+    u_data = [u, u_half, u_quarter, u_twice, u_four]
+    z_data = [data.states, data_half.states, data_quarter.states, data_twice.states, data_four.states]
+
+    # Step 3: Create model
+    print('Creating model...')
+
+    # Create a subclass of LSTMStateTransitionModel, 
+    # overridding event-related methods and members
+    class LSTMThrownObject(LSTMStateTransitionModel):
+        events = [
+            'falling', # Event- object is falling
+            'impact' # Event- object has impacted ground
+        ]
+
+        def initialize(self, u=None, z=None):
+            # Add logic required for thrown object
+            self.max_x = 0.0
+            return super().initialize(u, z)
+
+        def event_state(self, x):
+            # Using class name instead of self allows the class to be subclassed
+            z = LSTMThrownObject.output(self, x)
+            # Logic from ThrownObject.event_state, using output instead of state
+            self.max_x = max(self.max_x, z['x'])  # Maximum altitude
+            return {
+                'falling': max(z['v']/self.parameters['throwing_speed'],0),  # Throwing speed is max speed
+                'impact': max(z['x']/self.max_x,0)  # 1 until falling begins, then it's fraction of height
+            }
+
+        def threshold_met(self, x):
+            z = LSTMThrownObject.output(self, x)
+            # Logic from ThrownObject.threshold_met, using output instead of state
+            return {
+                'falling': z['v'] < 0,
+                'impact': z['x'] <= 0
+            }
+    
+    # Step 4: Generate Model
+    print('Building model...')
+    m2 = LSTMThrownObject.from_data(
+        inputs=u_data,  
+        outputs=z_data,
+        window=4, 
+        epochs=30, 
+        input_keys = ['dt'],
+        output_keys = m.states)
+    m2.plot_history()
+
+    # Step 5: Simulate with model
+    t_counter = 0
+    x_counter = m.initialize()
+    def future_loading3(t, x = None):
+        nonlocal t_counter, x_counter
+        z = m2.InputContainer({'x_t-1': x_counter['x'], 'v_t-1': x_counter['v'], 'dt': t - t_counter})
+        x_counter = m.next_state(x_counter, future_loading(t), t - t_counter)
+        t_counter = t
+        return z
+
+    # Use new dt, not used in training
+    # Using a dt not used in training will demonstrate the model's 
+    # ability to handle different timesteps not part of training set
+    data = m.simulate_to_threshold(future_loading, threshold_keys='impact', dt=TIMESTEP*3, save_freq=TIMESTEP*3)
+    results3 = m2.simulate_to_threshold(future_loading3, threshold_keys='impact', dt=TIMESTEP*3, save_freq=TIMESTEP*3)
+
+    # Step 6: Compare Results
+    print('Comparing results...')
+    print('Predicted impact time:')
+    print('\tOriginal: ', data.times[-1])
+    print('\tLSTM: ', results3.times[-1])
+    data.outputs.plot(title='original model')
+    results3.outputs.plot(title='generated model')
+    plt.show()
+
+if __name__ == '__main__':
+    run_example()
diff --git a/examples/future_loading.py b/examples/future_loading.py
index 406a5c03a..04f999c1c 100644
--- a/examples/future_loading.py
+++ b/examples/future_loading.py
@@ -5,9 +5,10 @@
 Example demonstrating ways to use future loading. 
 """
 
+import matplotlib.pyplot as plt
+from numpy.random import normal
 from prog_models.models import BatteryCircuit
 from statistics import mean
-from numpy.random import normal
 
 def run_example(): 
     m = BatteryCircuit()
@@ -40,7 +41,7 @@ def future_loading(t, x=None):
 
     ## Example 2: Moving Average loading 
     # This is useful in cases where you are running reoccuring simulations, and are measuring the actual load on the system, 
-    # but dont have a good way of predicting it, and you expect loading to be steady
+    # but don't have a good way of predicting it, and you expect loading to be steady
 
     def future_loading(t, x=None):
         return future_loading.load
@@ -168,7 +169,6 @@ def future_loading(t, x=None):
     # In this example future_loading.t has to be updated with current time before each prediction.
 
     # Show plots
-    import matplotlib.pyplot as plt
     plt.show()
 
 # This allows the module to be executed directly 
diff --git a/examples/generate_surrogate.py b/examples/generate_surrogate.py
index aba4faf48..b9b9e23f0 100644
--- a/examples/generate_surrogate.py
+++ b/examples/generate_surrogate.py
@@ -2,12 +2,16 @@
 # National Aeronautics and Space Administration.  All Rights Reserved.
 
 """
-Example of generating a Dynamic Mode Decomposition surrogate model using the battery model 
-"""
+Example of generating a Dynamic Mode Decomposition surrogate model from a battery model.
 
-from prog_models.models import BatteryElectroChemEOD as Battery
+.. dropdown:: More details
+
+    In this example, an instance of a battery model is created. The DMD DataModel is used to generate a surrogate of this battery model for specific loading schemes. This surrogate can be used in place of the original model, approximating it's behavior. Frequently, surrogate models run faster than the original, at the cost of some accuracy. The performance of the two models are then compared. 
+
+"""
 
 import matplotlib.pyplot as plt
+from prog_models.models import BatteryElectroChemEOD as Battery
 
 def run_example(): 
     ### Example 1: Standard DMD Application 
@@ -130,8 +134,8 @@ def future_loading(t, x=None):
         'save_freq': 1, # For DMD, this value is the time step for which the surrogate model is generated
         'dt': 0.1, # For DMD, this value is the time step of the training data
         'trim_data': 1, # Value between 0 and 1 that determines the fraction of data resulting from simulate_to_threshold that is used to train DMD surrogate model
-        'states': ['Vsn','Vsp','tb'], # Define internal states to be included in surrogate model
-        'outputs': ['v'] # Define outputs to be included in surrogate model 
+        'state_keys': ['Vsn','Vsp','tb'], # Define internal states to be included in surrogate model
+        'output_keys': ['v'] # Define outputs to be included in surrogate model 
     }
 
     # Set noise in Prognostics Model, default for surrogate model is also this value
diff --git a/examples/growth.csv b/examples/growth.csv
new file mode 100644
index 000000000..ae8ad94c7
--- /dev/null
+++ b/examples/growth.csv
@@ -0,0 +1,148 @@
+0,12,24,0
+10,12,24,8.64E-08
+20,12,24,1.73E-07
+30,12,24,2.59E-07
+40,12,24,3.46E-07
+50,12,24,4.32E-07
+60,12,24,5.18E-07
+70,12,24,6.05E-07
+80,12,24,6.91E-07
+90,12,24,7.78E-07
+100,12,24,8.64E-07
+110,12,24,9.50E-07
+120,12,24,1.04E-06
+130,12,24,1.12E-06
+140,12,24,1.21E-06
+150,12,24,1.30E-06
+160,12,24,1.38E-06
+170,12,24,1.47E-06
+180,12,24,1.56E-06
+190,12,24,1.64E-06
+200,12,24,1.73E-06
+210,12,24,1.81E-06
+220,12,24,1.90E-06
+230,12,24,1.99E-06
+240,12,24,2.07E-06
+250,12,24,2.16E-06
+260,12,24,2.25E-06
+270,12,24,2.33E-06
+280,12,24,2.42E-06
+290,12,24,2.51E-06
+300,12,24,2.59E-06
+310,12,24,2.68E-06
+320,12,24,2.76E-06
+330,12,24,2.85E-06
+340,12,24,2.94E-06
+350,12,24,3.02E-06
+360,12,24,3.11E-06
+370,12,24,3.20E-06
+380,12,24,3.28E-06
+390,12,24,3.37E-06
+400,12,24,3.46E-06
+410,12,24,3.54E-06
+420,12,24,3.63E-06
+430,12,24,3.72E-06
+440,12,24,3.80E-06
+450,12,24,3.89E-06
+460,12,24,3.97E-06
+470,12,24,4.06E-06
+480,12,24,4.15E-06
+490,12,24,4.23E-06
+500,12,24,4.32E-06
+510,8,32,4.41E-06
+520,8,32,5.10E-06
+530,8,32,5.79E-06
+540,8,32,6.48E-06
+550,8,32,7.17E-06
+560,8,32,7.86E-06
+570,8,32,8.55E-06
+580,8,32,9.24E-06
+590,8,32,9.94E-06
+600,8,32,1.06E-05
+610,8,32,1.13E-05
+620,8,32,1.20E-05
+630,8,32,1.27E-05
+640,8,32,1.34E-05
+650,8,32,1.41E-05
+660,8,32,1.48E-05
+670,8,32,1.55E-05
+680,8,32,1.62E-05
+690,8,32,1.68E-05
+700,8,32,1.75E-05
+710,8,32,1.82E-05
+720,8,32,1.89E-05
+730,8,32,1.96E-05
+740,8,32,2.03E-05
+750,0,28,2.10E-05
+760,0,28,2.21E-05
+770,0,28,2.32E-05
+780,0,28,2.43E-05
+790,0,28,2.54E-05
+800,0,28,2.65E-05
+810,0,28,2.76E-05
+820,0,28,2.87E-05
+830,0,28,2.98E-05
+840,0,28,3.09E-05
+850,0,28,3.20E-05
+860,0,28,3.31E-05
+870,0,28,3.42E-05
+880,0,28,3.53E-05
+890,0,28,3.64E-05
+900,0,28,3.75E-05
+910,0,28,3.86E-05
+920,0,28,3.97E-05
+930,0,28,4.08E-05
+940,0,28,4.18E-05
+950,0,28,4.29E-05
+960,0,28,4.40E-05
+970,0,28,4.51E-05
+980,0,28,4.62E-05
+990,0,28,4.73E-05
+1000,0,28,4.84E-05
+1010,0,28,4.95E-05
+1020,0,28,5.06E-05
+1030,0,28,5.17E-05
+1040,0,28,5.28E-05
+1050,0,28,5.39E-05
+1060,0,28,5.50E-05
+1070,0,28,5.61E-05
+1080,0,28,5.72E-05
+1090,0,28,5.83E-05
+1100,0,28,5.94E-05
+1110,0,28,6.05E-05
+1120,0,28,6.16E-05
+1130,0,28,6.27E-05
+1140,0,28,6.38E-05
+1150,0,28,6.49E-05
+1160,0,28,6.60E-05
+1170,0,28,6.71E-05
+1180,0,28,6.82E-05
+1190,0,28,6.93E-05
+1200,0,28,7.04E-05
+1210,0,28,7.15E-05
+1220,0,28,7.26E-05
+1230,0,28,7.37E-05
+1240,0,28,7.48E-05
+1250,0,28,7.59E-05
+1260,0,28,7.70E-05
+1270,0,28,7.81E-05
+1280,0,28,7.92E-05
+1290,0,28,8.03E-05
+1300,0,28,8.14E-05
+1310,0,28,8.25E-05
+1320,0,28,8.36E-05
+1330,0,28,8.47E-05
+1340,0,28,8.58E-05
+1350,0,28,8.69E-05
+1360,0,28,8.79E-05
+1370,0,28,8.90E-05
+1380,0,28,9.01E-05
+1390,0,28,9.12E-05
+1400,0,28,9.23E-05
+1410,0,28,9.34E-05
+1420,0,28,9.45E-05
+1430,0,28,9.56E-05
+1440,0,28,9.67E-05
+1450,0,28,9.78E-05
+1460,0,28,9.89E-05
+1470,0,28,1.00E-04
\ No newline at end of file
diff --git a/examples/growth.py b/examples/growth.py
new file mode 100644
index 000000000..28405b6a8
--- /dev/null
+++ b/examples/growth.py
@@ -0,0 +1,92 @@
+# Copyright © 2021 United States Government as represented by the Administrator of the
+# National Aeronautics and Space Administration.  All Rights Reserved.
+
+"""
+Example demonstrating the Paris Law Crack Growth Equation
+"""
+
+import csv
+import matplotlib.pyplot as plt
+import os
+from prog_models.models.paris_law import ParisLawCrackGrowth 
+
+def run_example(): 
+    # Step 1: Create a model object
+    m = ParisLawCrackGrowth(process_noise = 0)
+    
+    # Step 2: Define future loading function 
+    def future_loading(t, x=None):
+        #variable (piece-wise) future loading scheme 
+        #inputs are ['k_min', 'k_max']
+        if (t < 500):
+            k_min = 12
+            k_max = 24
+        elif (t < 750):
+            k_min = 8
+            k_max = 32
+        else:
+            k_min = 0
+            k_max = 28
+        return m.InputContainer({'k_min': k_min, 'k_max': k_max})
+
+    # Step 3: Estimate parameters
+    # We do not know the model parameters for this system, 
+    # so we need to estimate it using data collected from the system
+    # First we have to import some data from the real system
+    # This is what we use to estimate parameters
+    times = []
+    inputs = []
+    outputs = []
+
+    #Finds file path
+    csv_dir = os.path.join(os.path.dirname(__file__), 'growth.csv')
+
+    #Reads csv file
+    try:
+        with open(csv_dir, newline='') as csvfile:
+            data = csv.reader(csvfile, delimiter=',', quotechar='|' , quoting=csv.QUOTE_NONNUMERIC)
+            for row in data:
+                times.append(row[0])
+                inputs.append({'k_min': row[1], 'k_max': row[2]})
+                outputs.append({'c_l': row[3]})
+    except FileNotFoundError:
+        print("No data file found")
+
+    # Estimates the model parameters
+    keys = ['c', 'm']
+
+    print('Model configuration before')
+    for key in keys:
+        print("-", key, m.parameters[key])
+    print(' Error: ', m.calc_error(times, inputs, outputs, dt=10))
+
+    m.estimate_params([(times, inputs, outputs)], keys, dt=10)
+
+    print('\nOptimized configuration')
+    for key in keys:
+        print("-", key, m.parameters[key])
+    print(' Error: ', m.calc_error(times, inputs, outputs, dt=10))
+
+    # Step 4: Simulate to threshold
+    print('\n\n------------------------------------------------')
+    print('Simulating to threshold\n\n')
+    options = {
+        'save_freq': 10, # Frequency at which results are saved
+        'dt': 10, # Timestep
+        'print': True,
+        'horizon': 1e5, # Horizon
+    }
+
+    (times, inputs, _, outputs, event_states) = m.simulate_to_threshold(future_loading, **options)
+
+    # Step 5: Plot Results
+    # crack length
+    # plot event state
+
+    inputs.plot(ylabel='Stress Intensity')
+    event_states.plot(ylabel= 'CGF')
+    outputs.plot(ylabel= {'c_l': "Crack Length"}, compact= False)
+    plt.show()
+
+if __name__ == '__main__':
+    run_example()
diff --git a/examples/linear_model.py b/examples/linear_model.py
index d798fd658..307c08fde 100644
--- a/examples/linear_model.py
+++ b/examples/linear_model.py
@@ -7,8 +7,8 @@
 The model is used in a simulation, and the state is printed every second
 """
 
-from prog_models import LinearModel
 import numpy as np
+from prog_models import LinearModel
 
 class ThrownObject(LinearModel):
     """
diff --git a/examples/lstm_model.py b/examples/lstm_model.py
new file mode 100644
index 000000000..cf4e1c814
--- /dev/null
+++ b/examples/lstm_model.py
@@ -0,0 +1,227 @@
+# Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
+# This ensures that the directory containing examples is in the python search directories 
+
+"""
+Example building a LSTMStateTransitionModel from data. This is a simple example of how to use the LSTMStateTransitionModel class.
+
+.. dropdown:: More details
+
+    In this example, we generate fake data using the ThrownObject model. This is a case where we're generating a surrogate model from the physics-based model. For cases where you're generating a model from data (e.g., collected from a testbed or a real-world environment), you'll replace that generated data with your own. We then use the generated model and compare to the original model.
+
+    Finally, we repeat the exercise with data from the more complex BatteryElectroChemEOD model.
+"""
+
+import matplotlib.pyplot as plt
+import numpy as np
+from prog_models.data_models import LSTMStateTransitionModel
+from prog_models.models import ThrownObject, BatteryElectroChemEOD
+
+def run_example():
+    # -----------------------------------------------------
+    # Example 1- set timestep 
+    # Here we will create a model for a specific timestep.
+    # The model will only work with that timestep 
+    # This is useful if you know the timestep you would like to use
+    # -----------------------------------------------------
+    TIMESTEP = 0.01
+
+    # Step 1: Generate data
+    # We'll use the ThrownObject model to generate data.
+    # For cases where you're generating a model from data (e.g., collected from a testbed or a real-world environment), 
+    # you'll replace that generated data with your own.
+    print('Generating data')
+    m = ThrownObject()
+
+    def future_loading(t, x=None):
+        return m.InputContainer({})  # No input for thrown object 
+
+    data = m.simulate_to_threshold(future_loading, threshold_keys='impact', save_freq=TIMESTEP, dt=TIMESTEP)
+
+    # Step 2: Generate model
+    # We'll use the LSTMStateTransitionModel class to generate a model from the data.
+    print('Building model...')
+    m2 = LSTMStateTransitionModel.from_data(
+        inputs = [data.inputs],
+        outputs = [data.outputs],  
+        window=4, 
+        epochs=30,  # Maximum number of epochs, may stop earlier if early stopping enabled
+        output_keys = ['x'])
+
+    # We can see the training history
+    # Should show the model progressively getting better (i.e., the loss going down).
+    # If val_loss starts going up again, then we may be overtraining
+    m2.plot_history()
+    plt.show()
+    
+    # Step 3: Use model to simulate_to time of threshold
+    print('Simulating with generated model...')
+
+    t_counter = 0
+    x_counter = m.initialize()
+    def future_loading2(t, x = None):
+        # Future Loading is a bit complicated here 
+        # Loading for the resulting model includes the data inputs, 
+        # and the output from the last timestep
+        nonlocal t_counter, x_counter
+        z = m.output(x_counter)
+        z = m2.InputContainer(z.matrix)
+        x_counter = m.next_state(x_counter, future_loading(t), t - t_counter)
+        t_counter = t
+        return z
+    
+    results2 = m2.simulate_to(data.times[-1], future_loading2, dt=TIMESTEP, save_freq=TIMESTEP)
+
+    # Step 4: Compare model to original model
+    print('Comparing results...')
+    data.outputs.plot(title='original model')
+    results2.outputs.plot(title='generated model')
+    plt.show()
+
+    # -----------------------------------------------------
+    # Example 2- variable timestep 
+    # Here we will create a model to work with any timestep
+    # We do this by adding timestep as a variable in the model
+    # -----------------------------------------------------
+
+    # Step 1: Generate additional data
+    # We will use data generated above, but we also want data at additional timesteps 
+    print('\n------------------------------------------\nExample 2...')
+    print('Generating additional data...')
+    data_half = m.simulate_to_threshold(future_loading, threshold_keys='impact', save_freq=TIMESTEP/2, dt=TIMESTEP/2)
+    data_quarter = m.simulate_to_threshold(future_loading, threshold_keys='impact', save_freq=TIMESTEP/4, dt=TIMESTEP/4)
+    data_twice = m.simulate_to_threshold(future_loading, threshold_keys='impact', save_freq=TIMESTEP*2, dt=TIMESTEP*2)
+    data_four = m.simulate_to_threshold(future_loading, threshold_keys='impact', save_freq=TIMESTEP*4, dt=TIMESTEP*4)
+
+    # Step 2: Data Prep
+    # We need to add the timestep as a input
+    u = np.array([[TIMESTEP] for _ in data.inputs])
+    u_half = np.array([[TIMESTEP/2] for _ in data_half.inputs])
+    u_quarter = np.array([[TIMESTEP/4] for _ in data_quarter.inputs])
+    u_twice = np.array([[TIMESTEP*2] for _ in data_twice.inputs])
+    u_four = np.array([[TIMESTEP*4] for _ in data_four.inputs])
+
+    input_data = [u, u_half, u_quarter, u_twice, u_four]
+    output_data = [data.outputs, data_half.outputs, data_quarter.outputs, data_twice.outputs, data_four.outputs]
+
+    # Step 3: Generate Model
+    print('Building model...')
+    m3 = LSTMStateTransitionModel.from_data(
+        inputs = input_data,  
+        outputs = output_data,
+        window=4, 
+        epochs=30, 
+        input_keys = ['dt'],
+        output_keys = ['x']) 
+    # Note, since we're generating from a model, we could also have done this:
+    # m3 = LSTMStateTransitionModel.from_model(
+    #     m,
+    #     [future_loading for _ in range(5)],
+    #     dt = [TIMESTEP, TIMESTEP/2, TIMESTEP/4, TIMESTEP*2, TIMESTEP*4],
+    #     window=4, 
+    #     epochs=30)  
+
+    # Take a look at the training history
+    m3.plot_history()
+    plt.show() 
+
+    # Step 4: Simulate with model
+    t_counter = 0
+    x_counter = m.initialize()
+    def future_loading3(t, x = None):
+        nonlocal t_counter, x_counter
+        z = m3.InputContainer({'x_t-1': x_counter['x'], 'dt': t - t_counter})
+        x_counter = m.next_state(x_counter, future_loading(t), t - t_counter)
+        t_counter = t
+        return z
+
+    # Use new dt, not used in training
+    # Using a dt not used in training will demonstrate the model's 
+    # ability to handle different timesteps not part of training set
+    data = m.simulate_to(data.times[-1], future_loading, dt=TIMESTEP*3, save_freq=TIMESTEP*3)
+    results3 = m3.simulate_to(data.times[-1], future_loading3, dt=TIMESTEP*3, save_freq=TIMESTEP*3)
+
+    # Step 5: Compare Results
+    print('Comparing results...')
+    data.outputs.plot(title='original model')
+    results3.outputs.plot(title='generated model')
+    plt.show()
+
+    # -----------------------------------------------------
+    # Example 3- More complicated system
+    # Here we will create a model for a more complicated system
+    # For this example we will use the BatteryElectroChemEOD model
+    # We also include the event state (SOC)
+    # -----------------------------------------------------
+    print('\n------------------------------------------\nExample 3...')
+    print('Generating data...')
+    batt = BatteryElectroChemEOD(process_noise = 0, measurement_noise=0)
+    future_loading_eqns = [lambda t, x=None, load=load: batt.InputContainer({'i': 1+1.5*load}) for load in range(6)]
+    # Generate data with different loading and step sizes
+    # Adding the step size as an element of the output
+    input_data = []
+    output_data = []
+    es_data = []
+    t_met_data = []
+    for i in range(9):
+        dt = i/3+0.25
+        for loading_eqn in future_loading_eqns:
+            d = batt.simulate_to_threshold(loading_eqn, save_freq=dt, dt=dt) 
+            input_data.append(np.array([np.hstack((u_i.matrix[:][0].T, [dt])) for u_i in d.inputs], dtype=float))
+            output_data.append(d.outputs)
+            es_data.append(d.event_states)
+            t_met = [[False]for _ in d.times]
+            t_met[-1][0] = True  # Threshold has been met at the last timestep
+            t_met_data.append(t_met)
+  
+    # Step 2: Generate Model
+    print('Building model...') 
+    m_batt = LSTMStateTransitionModel.from_data(
+        inputs = input_data,
+        outputs = output_data,
+        event_states = es_data,
+        t_met = t_met_data,
+        window=12, 
+        epochs=10, 
+        units=64,  # Additional units given the increased complexity of the system
+        input_keys = ['i', 'dt'],
+        output_keys = ['t', 'v'],
+        event_keys=['EOD']) 
+
+    # Take a look at the training history.
+    m_batt.plot_history()
+    plt.show()
+
+    # Step 3: Simulate with model
+    t_counter = 0
+    x_counter = batt.initialize()
+
+    def future_loading(t, x=None):
+        return batt.InputContainer({'i': 3})
+
+    def future_loading2(t, x = None):
+        nonlocal t_counter, x_counter
+        z = batt.output(x_counter)
+        z = m_batt.InputContainer({'i': 3, 't_t-1': z['t'], 'v_t-1': z['v'], 'dt': t - t_counter})
+        x_counter = batt.next_state(x_counter, future_loading(t), t - t_counter)
+        t_counter = t
+        return z
+
+    # Use a new dt, not used in training. 
+    # Using a dt not used in training will demonstrate the model's 
+    # ability to handle different timesteps not part of training set
+    data = batt.simulate_to_threshold(future_loading, dt=1, save_freq=1)
+    results = m_batt.simulate_to_threshold(future_loading2, dt=1, save_freq=1)
+
+    # Step 5: Compare Results
+    print('Comparing results...')
+    data.outputs.plot(title='original model', compact=False)
+    results.outputs.plot(title='generated model', compact=False)
+    data.event_states.plot(title='original model', compact=False)
+    results.event_states.plot(title='generated model', compact=False)
+    plt.show()
+
+    # This last example isn't a perfect fit, but it matches the behavior pretty well
+    # Especially the voltage curve
+
+if __name__ == '__main__':
+    run_example()
diff --git a/examples/matrix_model.py b/examples/matrix_model.py
index 5ec088b4b..9ce508f11 100644
--- a/examples/matrix_model.py
+++ b/examples/matrix_model.py
@@ -2,9 +2,13 @@
 # National Aeronautics and Space Administration.  All Rights Reserved.
 
 """
-This example shows the use of the advanced feature - matrix models. Matrix models represent the state of the system using matricies instead of dictionaries. The provided model.StateContainer, InputContainer, and OutputContainer can be treated as dictionaries but use an underly matrix. This is important for some applications like surrogate and machine-learned models where the state is represented by a tensor, and operations by matrix operations. Simulation functions propogate the state using the matrix form, preventing the inefficiency of having to convert to and from dictionaries.
+Example demonstrating using matrix input, states, and outputs in models
 
-In this example, a model is designed to simulate a thrown object using matrix notation (instead of dictionary notation as in the standard model). The implementation of the model is comparable to a standard model, except that it uses the x.matrix, u.matrix, and z.matirx to compute matrix operations within each function.
+.. dropdown:: More details
+
+    This example shows the use of the advanced feature - matrix models. Matrix models represent the state of the system using matricies instead of dictionaries. The provided model.StateContainer, InputContainer, and OutputContainer can be treated as dictionaries but use an underly matrix. This is important for some applications like surrogate and machine-learned models where the state is represented by a tensor, and operations by matrix operations. Simulation functions propogate the state using the matrix form, preventing the inefficiency of having to convert to and from dictionaries.
+
+    In this example, a model is designed to simulate a thrown object using matrix notation (instead of dictionary notation as in the standard model). The implementation of the model is comparable to a standard model, except that it uses the x.matrix, u.matrix, and z.matirx to compute matrix operations within each function.
 """
 
 def run_example():
diff --git a/examples/model_gen.py b/examples/model_gen.py
deleted file mode 100644
index 0924760d6..000000000
--- a/examples/model_gen.py
+++ /dev/null
@@ -1,84 +0,0 @@
-# Copyright © 2021 United States Government as represented by the Administrator of the
-# National Aeronautics and Space Administration.  All Rights Reserved.
-
-"""
-Example generating models from constituent parts. 
-
-The model used for this example is that of an object thrown into the air, predicting the impact event
-"""
-
-# Deriv prog model was selected because the model can be described as x' = x + dx*dt
-from prog_models import PrognosticsModel
-
-def run_example():
-    # Step 1: Define keys
-    keys = {
-        'inputs': [], # no inputs, no way to control
-        'states': [
-            'x', # Position (m) 
-            'v'  # Velocity (m/s)
-            ],
-        'outputs': [ # Anything we can measure
-            'x' # Position (m)
-        ],
-        'events': [
-            'falling', # Event- object is falling
-            'impact' # Event- object has impacted ground
-        ]
-    }
-
-    thrower_height = 1.83 # m
-    throwing_speed = 40 # m/s
-    # Step 2: Define initial state
-    def initialize(u, z):
-        return {
-            'x': thrower_height, # Thrown, so initial altitude is height of thrower
-            'v': throwing_speed # Velocity at which the ball is thrown - this guy is an professional baseball pitcher
-            }
-    
-    # Step 3: Define dx equation
-    def dx(x, u):
-        return {
-            'x': x['v'],
-            'v': -9.81 # Acceleration of gravity
-        }
-
-    # Step 3: Define equation for calculating output/measuremetn
-    def output(x):
-        return {
-            'x': x['x']
-        }
-
-    # Step 4: Define threshold equation
-    def threshold_met(x):
-        return {
-            'falling': x['v'] < 0,
-            'impact': x['x'] <= 0
-        }
-
-    # Step 5 (optional): Define event state equation- measurement of how close you are to threshold (0-1)
-    def event_state(x): 
-        event_state.max_x = max(event_state.max_x, x['x']) # Maximum altitude
-        return {
-            'falling': max(x['v']/throwing_speed,0), # Throwing speed is max speed
-            'impact': max(x['x']/event_state.max_x,0) # 1 until falling begins, then it's fraction of height
-        }
-    event_state.max_x = 0
-    
-    # Step 6: Generate model
-    m = PrognosticsModel.generate_model(keys, initialize, output, event_state_eqn = event_state, threshold_eqn=threshold_met, dx_eqn=dx)
-
-    # Step 7: Setup for simulation 
-    def future_load(t, x=None):
-        return {}
-
-    # Step 8: Simulate to impact
-    event = 'impact'
-    simulated_results = m.simulate_to_threshold(future_load, threshold_keys=[event], dt = 0.005, save_freq=1, print = True)
-
-    # Print flight time
-    print('The object hit the ground in {} seconds'.format(round(simulated_results.times[-1],2)))
-
-# This allows the module to be executed directly 
-if __name__=='__main__':
-    run_example()
diff --git a/examples/new_model.py b/examples/new_model.py
index d076707a8..1c2f46e7d 100644
--- a/examples/new_model.py
+++ b/examples/new_model.py
@@ -2,7 +2,9 @@
 # National Aeronautics and Space Administration.  All Rights Reserved.
 
 """
-Example defining and testing a new model. 
+Example defining and using a new prognostics model.  
+
+In this example a simple model of an object thrown upward into the air is defined. That model is then used in simulation under different conditions and the results are displayed in different formats.
 """
 
 from prog_models import PrognosticsModel
@@ -28,18 +30,17 @@ class ThrownObject(PrognosticsModel):
 
     # The Default parameters. Overwritten by passing parameters dictionary into constructor
     default_parameters = {
-        'thrower_height': 1.83,  # m
-        'throwing_speed': 40,  # m/s
+        'x0': {  # Initial State
+            'x': 1.83,  # Height of thrower (m)
+            'v': 40  # Velocity at which the ball is thrown (m/s)
+        },
         'g': -9.81,  # Acceleration due to gravity in m/s^2
         'process_noise': 0.0  # amount of noise in each step
     }
 
-    def initialize(self, u, z):
-        self.max_x = 0.0
-        return self.StateContainer({
-            'x': self.parameters['thrower_height'],  # Thrown, so initial altitude is height of thrower
-            'v': self.parameters['throwing_speed']  # Velocity at which the ball is thrown - this guy is a professional baseball pitcher
-            })
+    def initialize(self, *args, **kwargs):
+        self.max_x = 0  # Set maximum height
+        return super().initialize(*args, **kwargs)
     
     def dx(self, x, u):
         return self.StateContainer({'x': x['v'],
@@ -59,7 +60,7 @@ def threshold_met(self, x):
     def event_state(self, x): 
         self.max_x = max(self.max_x, x['x'])  # Maximum altitude
         return {
-            'falling': max(x['v']/self.parameters['throwing_speed'],0),  # Throwing speed is max speed
+            'falling': max(x['v']/self.parameters['x0']['v'],0),  # Throwing speed is max speed
             'impact': max(x['x']/self.max_x,0)  # 1 until falling begins, then it's fraction of height
         }
 
@@ -107,8 +108,8 @@ def future_load(t, x=None):
     threshs_met = m.threshold_met(simulated_results.states[-1])
     for (key, met) in threshs_met.items():
         if met:
-            event_occured = key
-    print('\nThis event that occured first: ', event_occured)
+            event_occurred = key
+    print('\nThis event that occurred first: ', event_occurred)
     # It falls before it hits the gorund, obviously
 
     # Metrics can be analyzed from the simulation results. For example: monotonicity
diff --git a/examples/noise.py b/examples/noise.py
index 25df71eca..103c70f05 100644
--- a/examples/noise.py
+++ b/examples/noise.py
@@ -6,7 +6,6 @@
 """
 
 import matplotlib.pyplot as plt
-
 from prog_models.models.thrown_object import ThrownObject
 
 def run_example():
diff --git a/examples/sensitivity.py b/examples/sensitivity.py
index 04882ad19..780ceb555 100644
--- a/examples/sensitivity.py
+++ b/examples/sensitivity.py
@@ -5,9 +5,9 @@
 Example performing a sensitivity analysis on a new model. 
 """
 
+import numpy as np
 # Deriv prog model was selected because the model can be described as x' = x + dx*dt
 from prog_models.models.thrown_object import ThrownObject
-import numpy as np
 
 def run_example():
     # Demo model
diff --git a/examples/serialization.py b/examples/serialization.py
new file mode 100644
index 000000000..2bbab189c
--- /dev/null
+++ b/examples/serialization.py
@@ -0,0 +1,81 @@
+# Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
+
+"""
+Example of serializing and de-serializing a battery model using JSON and pickling methods
+"""
+
+import matplotlib.pyplot as plt
+import pickle
+from prog_models.models import BatteryElectroChemEOD as Battery
+
+def run_example():  
+    ## Step 1: Create a model object
+    batt = Battery()
+
+    # Set process nosie to 0 to illustrate match between original and serialized versions
+    batt.parameters['process_noise'] = 0 
+
+    ### Step 2: serialize model for future use
+    # Note: Model serialization has a lot of purposes, like saving a specific model to a file to be loaded later or sending a model to another machine over a network connection.
+    
+    # METHOD 1: Serialize with JSON 
+    save_json = batt.to_json()
+
+    # Model can be called directly with serialized result
+    serial_1 = Battery.from_json(save_json)
+
+    # Serialized result can also be saved to a text file and uploaded later using the following code:
+    txtFile = open("model_save_json.txt", "w")
+    txtFile.write(save_json)
+    txtFile.close() 
+
+    with open('model_save_json.txt') as infile: 
+        load_json = infile.read()
+
+    serial_2 = Battery.from_json(load_json)
+
+    # METHOD 2: Serialize by pickling
+    pickle.dump(batt, open('model_save_pkl.pkl','wb'))
+    load_pkl = pickle.load(open('model_save_pkl.pkl','rb'))
+
+    ## Step 3: Simulate to threshold and compare results
+    options_sim = {
+        'save_freq': 1 # Frequency at which results are saved, or equivalently time step in results
+    }
+
+    # Define loading profile 
+    def future_loading(t, x=None):
+        if (t < 600):
+            i = 3
+        elif (t < 1000):
+            i = 2
+        elif (t < 1500):
+            i = 1.5
+        else:
+            i = 4
+        return batt.InputContainer({'i': i})
+
+    # Simulate to threshold 
+    results_orig = batt.simulate_to_threshold(future_loading,**options_sim)
+    results_serial_1 = serial_1.simulate_to_threshold(future_loading, **options_sim)
+    results_serial_2 = serial_2.simulate_to_threshold(future_loading, **options_sim)
+    results_serial_3 = load_pkl.simulate_to_threshold(future_loading, **options_sim)
+
+    # Plot results for comparison
+    voltage_orig = [results_orig.outputs[iter]['v'] for iter in range(len(results_orig.times))]
+    voltage_serial_1 = [results_serial_1.outputs[iter]['v'] for iter in range(len(results_serial_1.times))]
+    voltage_serial_2 = [results_serial_2.outputs[iter]['v'] for iter in range(len(results_serial_2.times))]
+    voltage_serial_3 = [results_serial_3.outputs[iter]['v'] for iter in range(len(results_serial_3.times))]
+
+    plt.plot(results_orig.times,voltage_orig,'-b',label='Original surrogate') 
+    plt.plot(results_serial_1.times,voltage_serial_1,'--r',label='First JSON serialized surrogate') 
+    plt.plot(results_serial_2.times,voltage_serial_2,'-.g',label='Second JSON serialized surrogate') 
+    plt.plot(results_serial_3.times, voltage_serial_3, '--y', label='Pickled serialized surrogate')
+    plt.legend()
+    plt.xlabel('Time (sec)')
+    plt.ylabel('Voltage (volts)')
+    plt.show()
+
+# This allows the module to be executed directly 
+if __name__ == '__main__':
+    run_example()
diff --git a/examples/sim.py b/examples/sim.py
index 5104be480..97e8ef94a 100644
--- a/examples/sim.py
+++ b/examples/sim.py
@@ -5,9 +5,12 @@
 Example of a battery being simulated for a set period of time and then till threshold is met.
 """
 
+import matplotlib.pyplot as plt
+from prog_models.models import BatteryElectroChem
 from prog_models.models import BatteryCircuit as Battery
+
 # VVV Uncomment this to use Electro Chemistry Model VVV
-# from prog_models.models import BatteryElectroChem as Battery
+# Battery = BatteryElectroChem
 
 def run_example(): 
     # Step 1: Create a model object
@@ -31,6 +34,16 @@ def future_loading(t, x=None):
     print('\n\n------------------------------------------------')
     print('Simulating for 200 seconds\n\n')
     simulated_results = batt.simulate_to(200, future_loading, print = True, progress = True)
+    # The result of the simulation is now stored in simulated_results.
+    # You can access the results by accessing the individual variables:
+    #   times, inputs, states, outputs, event_states
+    # e.g., states = simulated_results.states
+
+    # You can also plot the results, for example
+    simulated_results.outputs.plot()
+
+    # or, with configuration
+    simulated_results.outputs.plot(compact = False, suptitle = 'Outputs', title = 'example title', xlabel = 'time', ylabel = 'output')
 
     # Simulate to threshold
     print('\n\n------------------------------------------------')
@@ -43,6 +56,34 @@ def future_loading(t, x=None):
     }
     simulated_results = batt.simulate_to_threshold(future_loading, **options)
 
+    # Alternately, you can set a max step size and allow step size to be adjusted automatically
+    options['dt'] = ('auto', 2)  # set step size automatically, with a max of 2 seconds
+    options['save_freq'] = 201  # Save every 201 seconds
+    options['save_pts'] = [250, 772, 1023]  # Special points we sould like to see reported
+    simulated_results = batt.simulate_to_threshold(future_loading, **options)
+    # Note that even though the step size is 2, the odd points in the save frequency are met perfectly, dt is adjusted automatically to capture the save points
+
+    simulated_results.outputs.plot()
+
+    if isinstance(batt, BatteryElectroChem):
+        # Plotting max current with time
+        # This is the maximum sustainable current that can be drawn
+        # from the battery at steady-state. It decreases with discharge
+        # This information can be used to inform planning
+        pm = [batt.performance_metrics(x)['max_i'][0] for x in simulated_results.states]
+        plt.figure()
+        plt.plot(simulated_results.times, pm)
+        plt.xlabel('Time (s)')
+        plt.ylabel('Maximum Sustainable Current Draw (amps)')
+
+    # You can also change the integration method. For example:
+    options['integration_method'] = 'rk4'  # Using Runge-Kutta 4th order
+    simulated_results_rk4 = batt.simulate_to_threshold(future_loading, **options)
+
+    simulated_results_rk4.outputs.plot(compact=False)
+
+    plt.show()
+
 # This allows the module to be executed directly 
 if __name__ == '__main__':
     run_example()
diff --git a/examples/sim_battery_eol.py b/examples/sim_battery_eol.py
index d08588e81..a6e2ca7f2 100644
--- a/examples/sim_battery_eol.py
+++ b/examples/sim_battery_eol.py
@@ -6,7 +6,6 @@
 """
 
 import matplotlib.pyplot as plt
-
 from prog_models.models import BatteryElectroChem as Battery
 
 def run_example(): 
diff --git a/examples/sim_dcmotor_singlephase.py b/examples/sim_dcmotor_singlephase.py
new file mode 100644
index 000000000..5d240b12b
--- /dev/null
+++ b/examples/sim_dcmotor_singlephase.py
@@ -0,0 +1,35 @@
+# Copyright © 2021 United States Government as represented by the Administrator of the
+# National Aeronautics and Space Administration.  All Rights Reserved.
+
+"""
+Example of a DC motor being simulated for a set amount of time, using the single-phase dcmotor model. 
+"""
+
+import math
+from prog_models.models import dcmotor_singlephase
+
+def run_example():
+    motor = dcmotor_singlephase.DCMotorSP()
+    
+    def future_loading(t, x=None):
+        f = 0.5
+        
+        # Simple load proportional to rotor speed. 
+        # This is a typical, hyper-simplified model of a fixed-pitch propeller directly attached to the motor shaft such that the resistant torque
+        # becomes: Cq * omega^2, where Cq is a (assumed to be) constant depending on the propeller profile and omega is the rotor speed.
+        # Since there's no transmission, omega is exactly the speed of the motor shaft.
+        if x is None:  # First load (before state is initialized)
+            t_l = 0.0
+        else:
+            t_l = 1e-5 * x['v_rot']**2.0
+        return motor.InputContainer({
+            'v': 10.0 + 2.0 * math.sin(math.tau * f * t),   # voltage input assumed sinusoidal just to show variations in the input. No physical meaning.
+            't_l': t_l  # assuming constant load (simple)
+            })
+
+    simulated_results = motor.simulate_to(2.0, future_loading, dt=1e-3, save_freq=0.1, print=True)
+    simulated_results.states.plot(compact=False)
+
+if __name__ == '__main__':
+    print("Simulation of DC single-phase motor")
+    run_example()
diff --git a/examples/sim_pump.py b/examples/sim_pump.py
index 0642fc314..acf06fbad 100644
--- a/examples/sim_pump.py
+++ b/examples/sim_pump.py
@@ -6,7 +6,8 @@
 """
 
 from prog_models.models import CentrifugalPump
-
+import matplotlib.pyplot as plt  
+from prog_models.sim_result import SimResult
 def run_example(): 
     # Step 1: Setup Pump
     pump = CentrifugalPump(process_noise= 0)
@@ -43,16 +44,17 @@ def future_loading(t, x=None):
     simulated_results = pump.simulate_to_threshold(future_loading, first_output, **config)
 
     # Step 4: Plot Results
-    from prog_models.visualize import plot_timeseries
-    plot_timeseries(simulated_results.times, simulated_results.inputs, options={'compact': False, 'title': 'Inputs',
-                                                    'xlabel': 'time', 'ylabel':{lbl: lbl for lbl in pump.inputs}})
-    plot_timeseries(simulated_results.times, simulated_results.states, options={'compact': False, 'title': 'States', 'xlabel': 'time', 'ylabel': ''})
-    plot_timeseries(simulated_results.times, simulated_results.outputs, options={'compact': False, 'title': 'Outputs', 'xlabel': 'time', 'ylabel': ''})
-    plot_timeseries(simulated_results.times, simulated_results.event_states, options={'compact': False, 'title': 'Events', 'xlabel': 'time', 'ylabel': ''})
+    simulated_results.inputs.plot(compact = False, title = 'Inputs', xlabel = 'time', ylabel = {lbl: lbl for lbl in pump.inputs})
+    simulated_results.outputs.plot(compact = False, title = 'Outputs', xlabel = 'time', ylabel = '')
+    simulated_results.states.plot(compact = False, title = 'States', xlabel = 'time', ylabel = '')
+    simulated_results.event_states.plot(compact = False, title = 'Events', xlabel = 'time', ylabel = '')
+
     thresholds_met = [pump.threshold_met(x) for x in simulated_results.states]
-    plot_timeseries(simulated_results.times, thresholds_met, options={'compact': True, 'title': 'Events', 'xlabel': 'time', 'ylabel': ''}, legend = {'display': True})
+    thresholds_met = SimResult(simulated_results.times, thresholds_met)
+    thresholds_met.plot(compact = False, title = 'Threshold Met', xlabel = 'time', ylabel = '')
+    
+
 
-    import matplotlib.pyplot as plt    
     plt.show()
 
 # This allows the module to be executed directly 
diff --git a/examples/state_limits.py b/examples/state_limits.py
index 9fb174527..af686c288 100644
--- a/examples/state_limits.py
+++ b/examples/state_limits.py
@@ -3,10 +3,12 @@
 
 """
 Example demonstrating when and how to identify model state limits. 
+
+In this example, state limits are defined for the ThrownObject Model. These are limits on the range of each state. The use of this feature is then demonstrated.
 """
 
-from prog_models.models.thrown_object import ThrownObject
 from math import inf
+from prog_models.models.thrown_object import ThrownObject
 
 def run_example():
     # Demo model
diff --git a/examples/vectorized.py b/examples/vectorized.py
index f38438023..5c2e82dfd 100644
--- a/examples/vectorized.py
+++ b/examples/vectorized.py
@@ -5,8 +5,8 @@
 Example using simulate_to_threshold with vectorized states. In this example we are using the thrown_object model to simulate multiple thrown objects
 """
 
-from prog_models.models.thrown_object import ThrownObject
 from numpy import array, all
+from prog_models.models.thrown_object import ThrownObject
 
 def run_example():
     # Step 1: Setup object
diff --git a/examples/visualize.py b/examples/visualize.py
deleted file mode 100644
index 13bb40824..000000000
--- a/examples/visualize.py
+++ /dev/null
@@ -1,39 +0,0 @@
-# Copyright © 2021 United States Government as represented by the Administrator of the
-# National Aeronautics and Space Administration.  All Rights Reserved.
-
-"""
-Example demonstrating the Visualization Module. 
-"""
-
-import matplotlib.pyplot as plt
-from prog_models.visualize import plot_timeseries
-from prog_models.models.thrown_object import ThrownObject
-
-def run_example():
-    print('Visualize Module Example')
-    m = ThrownObject()
-
-    # Step 2: Setup for simulation 
-    def future_load(t, x=None):
-        return {}
-
-    # Step 3: Simulate to impact
-    event = 'impact'
-    options={'dt':0.005, 'save_freq':1}
-    simulated_results = m.simulate_to_threshold(future_load,
-                                                                             threshold_keys=[event], 
-                                                                             **options)
-    
-
-    # Display states
-    # ==============
-    plot_timeseries(simulated_results.times, simulated_results.states, 
-                          options = {'compact': False, 'suptitle': 'state evolution', 'title': True,
-                                     'xlabel': 'time', 'ylabel': {'x': 'position', 'v': 'velocity'}, 'display_labels': 'minimal'},
-                          legend  = {'display': True, 'display_at_subplot': 'all'} )
-    plot_timeseries(simulated_results.times, simulated_results.states, options = {'compact': True, 'suptitle': 'state evolution', 'title': 'example title',
-                                                    'xlabel': 'time', 'ylabel':'position'})
-    plt.show()
-
-if __name__ == '__main__':
-    run_example()
diff --git a/prog_model_template.py b/prog_model_template.py
index 539534731..2534fd72e 100644
--- a/prog_model_template.py
+++ b/prog_model_template.py
@@ -10,9 +10,10 @@
 
 # Note: To preserve vectorization use numpy math function (e.g., maximum, minimum, sign, sqrt, etc.) instead of non-vectorized functions (max, min, etc.)
 
-from prog_models import PrognosticsModel
 from numpy import inf
 
+from prog_models import PrognosticsModel
+
 # REPLACE THIS WITH DERIVED PARAMETER CALLBACKS (IF ANY)
 # See examples.derived_params
 # 
@@ -58,10 +59,18 @@ class ProgModelTemplate(PrognosticsModel):
     ]
 
     # REPLACE THE FOLLOWING LIST WITH CONFIGURED PARAMETERS
-    default_parameters = { # Set default parameters
+    # Note- everything required to configure the model 
+    # should be in parameters- this is to enable the serialization features 
+    default_parameters = {  # Set default parameters
         'Example Parameter 1': 0,
         'Example Parameter 2': 3,
-        'process_noise': 0.1, # Process noise
+        'process_noise': 0.1,  # Process noise
+        'x0': {  # Initial state  
+            'Examples State 1': 1.5,
+            'Examples State 2': -935,
+            'Examples State 3': 42.1,
+            'Examples State 4': 0
+        }
     }
 
     # REPLACE THE FOLLOWING WITH STATE BOUNDS IF NEEDED
@@ -83,62 +92,69 @@ class ProgModelTemplate(PrognosticsModel):
         "Example Parameter 2": [example_callback]
     }
 
+    # UNCOMMENT THIS FUNCTION IF YOU NEED CONSTRUCTION LOGIC (E.G., INPUT VALIDATION)
+    # def __init__(self, **kwargs):
+    #     """
+    #     Constructor for model
 
-    def __init__(self, **kwargs):
-        """
-        Constructor for model
-        """
-        # ADD OPTIONS CHECKS HERE
-
-        # e.g., Checking for required parameters
-        # if not 'required_param' in kwargs: 
-        #   throw Exception;
+    #     Note 
+    #     ----
+    #     To use the JSON serialization capabilities in to_json and from_json, model.parameters must include everything necessary for initialize, including any keyword arguments.
+    #     """
+    #     # ADD OPTIONS CHECKS HERE
 
-        # e.g. 2, Modify parameters
-        # kwargs['some_param'] = some_function(kwargs['some_param'])
+    #     # e.g., Checking for required parameters
+    #     # if not 'required_param' in kwargs: 
+    #     #   throw Exception;
 
-        super().__init__(**kwargs) # Run Parent constructor
+    #     super().__init__(**kwargs) # Run Parent constructor
 
+    # Model state initialization - there are two ways to provide the logic to initialize model state. 
+    # 1. Provide the initial state in parameters['x0'], or
+    # 2. Provide an Initialization function
+    #
+    # If following method 2, uncomment the initialize function, below.
     # Sometimes initial input (u) and initial output (z) are needed to initialize the model
     # In that case remove the '= None' for the appropriate argument
     # Note: If they are needed, that requirement propogated through to the simulate_to* functions
-    def initialize(self, u=None, z=None):
-        """
-        Calculate initial state given inputs and outputs
-
-        Parameters
-        ----------
-        u : dict
-            Inputs, with keys defined by model.inputs.
-            e.g., u = {'i':3.2} given inputs = ['i']
-        z : dict
-            Outputs, with keys defined by model.outputs.
-            e.g., z = {'t':12.4, 'v':3.3} given inputs = ['t', 'v']
-
-        Returns
-        -------
-        x : dict
-            First state, with keys defined by model.states
-            e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
-        """
-
-        # REPLACE BELOW WITH LOGIC TO CALCULATE INITIAL STATE
-        # NOTE: KEYS FOR x0 MATCH 'states' LIST ABOVE
-
-        # YOU CAN ACCESS ANY PARAMETERS USING self.parameters[key]
-        x0 = {
-            'Examples State 1': 99.2,
-            'Examples State 2': False,
-            'Examples State 3': 44,
-            'Examples State 4': [1, 2, 3]
-        }
-        return self.StateContainer(x0)
+    # UNCOMMENT THIS FUNCTION FOR COMPLEX INITIALIZATION
+    # def initialize(self, u=None, z=None):
+    #     """
+    #     Calculate initial state given inputs and outputs
+    #
+    #     Parameters
+    #     ----------
+    #     u : dict
+    #         Inputs, with keys defined by model.inputs.
+    #         e.g., u = {'i':3.2} given inputs = ['i']
+    #     z : dict
+    #         Outputs, with keys defined by model.outputs.
+    #         e.g., z = {'t':12.4, 'v':3.3} given inputs = ['t', 'v']
+    #
+    #     Returns
+    #     -------
+    #     x : dict
+    #         First state, with keys defined by model.states
+    #         e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
+    #     """
+    #
+    #     # REPLACE BELOW WITH LOGIC TO CALCULATE INITIAL STATE
+    #     # NOTE: KEYS FOR x0 MATCH 'states' LIST ABOVE
+    #
+    #     # YOU CAN ACCESS ANY PARAMETERS USING self.parameters[key]
+    #     x0 = {
+    #         'Examples State 1': 99.2,
+    #         'Examples State 2': False,
+    #         'Examples State 3': 44,
+    #         'Examples State 4': 7.5
+    #     }
+    #     return self.StateContainer(x0)
 
     # UNCOMMENT THIS FUNCTION FOR CONTINUOUS MODELS
     # def dx(self, t, x, u):
     #     """
     #     Returns the first derivative of state `x` at a specific time `t`, given state and input
-
+    #
     #     Parameters
     #     ----------
     #     t : number
@@ -150,13 +166,13 @@ def initialize(self, u=None, z=None):
     #     u : dict
     #         Inputs, with keys defined by model.inputs.
     #         e.g., u = {'i':3.2} given inputs = ['i']
-
+    #
     #     Returns
     #     -------
     #     dx : dict
     #         First derivitive of state, with keys defined by model.states
     #         e.g., dx = {'abc': 3.1, 'def': -2.003} given states = ['abc', 'def']
-        
+    # 
     #     Example
     #     -------
     #     | m = DerivProgModel() # Replace with specific model being simulated
@@ -165,7 +181,7 @@ def initialize(self, u=None, z=None):
     #     | x = m.initialize(u, z) # Initialize first state
     #     | dx = m.dx(3.0, x, u) # Returns first derivative of state at 3 seconds given input u
     #     """
-
+    #
     #     # REPLACE THE FOLLOWING WITH SOMETHING SPECIFC TO YOUR MODEL
     #     dxdt = {
     #         'Examples State 1': 0.1,
@@ -179,7 +195,7 @@ def initialize(self, u=None, z=None):
     # def next_state(self, t, x, u, dt):
     #     """
     #     State transition equation: Calculate next state
-
+    #
     #     Parameters
     #     ----------
     #     t : number
@@ -194,29 +210,25 @@ def initialize(self, u=None, z=None):
     #     dt : number
     #         Timestep size in seconds (≥ 0)
     #         e.g., dt = 0.1
-        
-
+    #
     #     Returns
     #     -------
     #     x : dict
     #         Next state, with keys defined by model.states
     #         e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
     #     """
-
+    #
     #     next_x = x
     #     # ADD LOGIC TO CALCULATE next_x from x
-
+    #
     #     return self.StateContainer(next_x)
 
-    def output(self, t, x):
+    def output(self, x):
         """
         Calculate next statem, forward one timestep
 
         Parameters
         ----------
-        t : number
-            Current timestamp in seconds (≥ 0.0)
-            e.g., t = 3.4
         x : dict
             state, with keys defined by model.states
             e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
@@ -237,15 +249,12 @@ def output(self, t, x):
 
         return z
 
-    def event_state(self, t, x):
+    def event_state(self, x):
         """
-        Calculate event states (i.e., measures of progress towards event (0-1, where 0 means event has occured))
+        Calculate event states (i.e., measures of progress towards event (0-1, where 0 means event has occurred))
 
         Parameters
         ----------
-        t : number
-            Current timestamp in seconds (≥ 0.0)
-            e.g., t = 3.4
         x : dict
             state, with keys defined by model.states
             e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
@@ -266,15 +275,12 @@ def event_state(self, t, x):
         return event_x
         
     # Note: Thresholds met equation below is not strictly necessary. By default threshold_met will check if event_state is ≤ 0 for each event
-    def threshold_met(self, t, x):
+    def threshold_met(self, x):
         """
         For each event threshold, calculate if it has been met
 
         Parameters
         ----------
-        t : number
-            Current timestamp in seconds (≥ 0.0)
-            e.g., t = 3.4
         x : dict
             state, with keys defined by model.states
             e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
diff --git a/requirements.txt b/requirements.txt
index a7a11b7b8..d6003cc15 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
 matplotlib>=3.5.1
 scipy>=1.7.3
 pandas>=1.3.5
+tensorflow>=2.9.1
diff --git a/scripts/gen_docs.sh b/scripts/gen_docs.sh
deleted file mode 100644
index 81404c214..000000000
--- a/scripts/gen_docs.sh
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/bin/sh
-# Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
-
-# Update documentation. Note you must have sphinx installed
-#  run pip install sphinx to install sphinx
-python -m sphinx sphinx_config docs
diff --git a/setup.py b/setup.py
index cf0c45f22..52c98a80a 100644
--- a/setup.py
+++ b/setup.py
@@ -11,11 +11,11 @@
 
 setup(
     name = 'prog_models',
-    version = '1.3.1', #pkg_resources.require("prog_models")[0].version,
+    version = '1.4.0', #pkg_resources.require("prog_models")[0].version,
     description = 'The NASA Prognostic Model Package is a python modeling framework focused on defining and building models for prognostics (computation of remaining useful life) of engineering systems, and provides a set of prognostics models for select components developed within this framework, suitable for use in prognostics applications for these components.',
     long_description=long_description,
     long_description_content_type='text/markdown',
-    url = 'https://github.com/nasa/prog_models',
+    url = 'https://nasa.github.io/progpy/prog_models_guide.html',
     author = 'Christopher Teubert',
     author_email = 'christopher.a.teubert@nasa.gov',
     classifiers = [
@@ -34,20 +34,21 @@
         'Programming Language :: Python :: 3.10',
         'Programming Language :: Python :: 3 :: Only'
     ],
-    keywords = ['prognostics', 'diagnostics', 'fault detection', 'fdir', 'physics modeling', 'prognostics and health management', 'PHM', 'health management', 'surrogate modeling', 'model tuning', 'simulation'],
+    keywords = ['prognostics', 'diagnostics', 'fault detection', 'fdir', 'physics modeling', 'prognostics and health management', 'PHM', 'health management', 'surrogate modeling', 'model tuning', 'simulation', 'ivhm'],
     package_dir = {"":"src"},
     packages = find_packages(where = 'src'),
     python_requires='>=3.7, <3.11',
     install_requires = [
         'scipy',
         'pandas',
-        'matplotlib'
+        'matplotlib',
+        'tensorflow'
     ],
     license = 'NOSA',
     project_urls={  # Optional
         'Bug Reports': 'https://github.com/nasa/prog_models/issues',
-        'Docs': 'https://nasa.github.io/prog_models',
-        'Organization': 'https://prognostics.nasa.gov/',
+        'Docs': 'https://nasa.github.io/progpy/prog_models_guide.html',
+        'Organization': 'https://www.nasa.gov/content/diagnostics-prognostics',
         'Source': 'https://github.com/nasa/prog_models',
     },
 )
diff --git a/sphinx_config/_templates/layout.html b/sphinx_config/_templates/layout.html
deleted file mode 100644
index 8c1a51a3b..000000000
--- a/sphinx_config/_templates/layout.html
+++ /dev/null
@@ -1,6 +0,0 @@
-{% extends '!layout.html' %}
-
-{% block footer %}
-
-Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
-{% endblock %}
diff --git a/sphinx_config/conf.py b/sphinx_config/conf.py
deleted file mode 100644
index 6094248c8..000000000
--- a/sphinx_config/conf.py
+++ /dev/null
@@ -1,61 +0,0 @@
-# Copyright © 2021 United States Government as represented by the Administrator of the
-# National Aeronautics and Space Administration.  All Rights Reserved.
-
-# Configuration file for the Sphinx documentation builder.
-#
-# This file only contains a selection of the most common options. For a full
-# list see the documentation:
-# https://www.sphinx-doc.org/en/master/usage/configuration.html
-
-# -- Path setup --------------------------------------------------------------
-
-# If extensions (or modules to document with autodoc) are in another directory,
-# add these directories to sys.path here. If the directory is relative to the
-# documentation root, use os.path.abspath to make it absolute, like shown here.
-
-import os
-import sys
-sys.path.insert(0, os.path.abspath('../src'))
-sys.path.insert(0, os.path.abspath('..'))
-sys.path.insert(0, os.path.abspath('.'))
-
-# -- Project information -----------------------------------------------------
-
-project = 'Prognostics Models Python Package'
-copyright = '2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.'
-author = 'Chris Teubert, Chetan Kulkarni, and Matteo Corbetta'
-
-# The full version, including alpha/beta/rc tags
-from prog_models import __version__ as version
-release = version
-
-# -- General configuration ---------------------------------------------------
-
-# Add any Sphinx extension module names here, as strings. They can be
-# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
-# ones.
-extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage', "sphinx.ext.githubpages", 'sphinx.ext.napoleon', 'sphinx_reredirects']
-
-# Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
-
-# List of patterns, relative to source directory, that match files and
-# directories to ignore when looking for source files.
-# This pattern also affects html_static_path and html_extra_path.
-exclude_patterns = []
-
-# -- Options for HTML output -------------------------------------------------
-
-# The theme to use for HTML and HTML Help pages.  See the documentation for
-# a list of builtin themes.
-#
-html_theme = 'alabaster'
-
-# Add any paths that contain custom static files (such as style sheets) here,
-# relative to this directory. They are copied after the builtin static files,
-# so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
-
-redirects = {
-     "*": "https://nasa.github.io/progpy"
-}
diff --git a/sphinx_config/datasets.rst b/sphinx_config/datasets.rst
deleted file mode 100644
index 81eef83f2..000000000
--- a/sphinx_config/datasets.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-Datasets
-================================================================
-
-Documentation moved to https://nasa.github.io/progpy
diff --git a/sphinx_config/dev_guide.rst b/sphinx_config/dev_guide.rst
deleted file mode 100644
index a9c5602de..000000000
--- a/sphinx_config/dev_guide.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-Developers Guide & Project Plan
-================================
-
-Documentation moved to https://nasa.github.io/progpy
diff --git a/sphinx_config/getting_started.rst b/sphinx_config/getting_started.rst
deleted file mode 100644
index 0a95e38ba..000000000
--- a/sphinx_config/getting_started.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-Getting Started
-===============
-
-Documentation moved to https://nasa.github.io/progpy
diff --git a/sphinx_config/index.rst b/sphinx_config/index.rst
deleted file mode 100644
index dd8e4e863..000000000
--- a/sphinx_config/index.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-Prognostics Models Python Package
-=============================================================
-
-Documentation moved to https://nasa.github.io/progpy
diff --git a/sphinx_config/linear_model.rst b/sphinx_config/linear_model.rst
deleted file mode 100644
index 318d144f8..000000000
--- a/sphinx_config/linear_model.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-Linear Model
-=======================
-
-Documentation moved to https://nasa.github.io/progpy
diff --git a/sphinx_config/models.rst b/sphinx_config/models.rst
deleted file mode 100644
index 437e05329..000000000
--- a/sphinx_config/models.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-Models
-=============================================================
-
-Documentation moved to https://nasa.github.io/progpy
diff --git a/sphinx_config/prognostics_model.rst b/sphinx_config/prognostics_model.rst
deleted file mode 100644
index 6959b900e..000000000
--- a/sphinx_config/prognostics_model.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-Prognostics Model
-=======================
-
-Documentation moved to https://nasa.github.io/progpy
diff --git a/sphinx_config/release.rst b/sphinx_config/release.rst
deleted file mode 100644
index d09807139..000000000
--- a/sphinx_config/release.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-Release Notes
-=======================
-
-Documentation moved to https://nasa.github.io/progpy
diff --git a/sphinx_config/simresult.rst b/sphinx_config/simresult.rst
deleted file mode 100644
index b7db88439..000000000
--- a/sphinx_config/simresult.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-Simulation Result Data Structure
-================================
-
-Documentation moved to https://nasa.github.io/progpy
diff --git a/src/prog_models/__init__.py b/src/prog_models/__init__.py
index 0ee582ee4..d22ae93cd 100644
--- a/src/prog_models/__init__.py
+++ b/src/prog_models/__init__.py
@@ -5,4 +5,4 @@
 from .linear_model import LinearModel
 from .exceptions import ProgModelException, ProgModelInputException, ProgModelTypeError
 
-__version__ = '1.3.1'
+__version__ = '1.4.0'
diff --git a/src/prog_models/data_models/__init__.py b/src/prog_models/data_models/__init__.py
new file mode 100644
index 000000000..5226ec39b
--- /dev/null
+++ b/src/prog_models/data_models/__init__.py
@@ -0,0 +1,11 @@
+# Copyright © 2021 United States Government as represented by the Administrator of the
+# National Aeronautics and Space Administration.  All Rights Reserved.
+
+from .data_model import DataModel
+from .dmd import DMDModel
+from .lstm_model import LSTMStateTransitionModel
+
+SURROAGATE_METHOD_LOOKUP = {
+    'dmd': DMDModel.from_model,
+    'lstm': LSTMStateTransitionModel.from_model
+}
diff --git a/src/prog_models/data_models/data_model.py b/src/prog_models/data_models/data_model.py
new file mode 100644
index 000000000..8a19d5c19
--- /dev/null
+++ b/src/prog_models/data_models/data_model.py
@@ -0,0 +1,160 @@
+# Copyright © 2021 United States Government as represented by the Administrator of the
+# National Aeronautics and Space Administration.  All Rights Reserved.
+
+from abc import ABC, abstractclassmethod
+import numpy as np
+import sys
+
+from .. import PrognosticsModel
+
+
+class DataModel(PrognosticsModel, ABC):
+    """
+    .. versionadded:: 1.4.0
+
+    Abstract Base Class for all Data Models (e.g., :py:class:`LSTMStateTransitionModel`). Defines the interface and all common tools. To create a new Data-Driven model, first subclass this, then define the abstract methods from this class and :py:class:`prog_models.PrognosticsModel`. 
+
+    See Also:
+        PrognosticsModel
+    """
+
+    @abstractclassmethod
+    def from_data(cls, **kwargs) -> "DataModel":
+        """
+        Create a Data Model from data. This class is overwritten by specific data-driven classes (e.g., :py:class:`LSTMStateTransitionModel`)
+
+        Keyword Arguments:
+            times (list[list]): list of input data for use in data. Each element is the times for a single run of size (n_times)
+            inputs (list[np.array]): list of :term:`input` data for use in data. Each element is the inputs for a single run of size (n_times, n_inputs)
+            states (list[np.array]): list of :term:`state` data for use in data. Each element is the states for a single run of size (n_times, n_states)
+            outputs (list[np.array]): list of :term:`output` data for use in data. Each element is the outputs for a single run of size (n_times, n_outputs)
+            event_states (list[np.array]): list of :term:`event state` data for use in data. Each element is the event states for a single run of size (n_times, n_event_states)
+            input_keys (list[str]): 
+                List of :term:`input` keys
+            state_keys (list[str]): 
+                List of :term:`state` keys
+            output_keys (list[str]): 
+                List of :term:`output` keys
+            event_keys (list[str]): 
+                List of :term:`event` keys
+        
+        See specific data class for more additional keyword arguments
+
+        Returns:
+            DataModel: Trained PrognosticsModel
+
+        Example:
+            |
+
+                >>> # Replace DataModel with specific classname below
+                >>> m = DataModel.from_data(data)
+        """
+        pass
+
+    def __setstate__(self, state):
+        # Set the state (after un-pickling)
+        # If you use the __getstate__ method format below, you don't have to override setstate
+        (args, config) = state
+        self.__init__(*args, **config)
+
+    def __getstate__(self):
+        # This is necessary to support pickling
+        # Override this, replacing the [] with any arguments from the constructor
+        return ([], self.parameters.data)
+    
+    def summary(self, file = sys.stdout):
+        """
+        Print a summary of the model
+        """
+        print(self.__class__.__name__, file=file)
+
+    @staticmethod
+    def check_data_format(inputs, outputs, states = None, event_states = None, t_mets = None):
+        if len(inputs) == 0:
+            raise ValueError("No data provided. inputs must be in format [run1_inputs, ...] and have at least one element")
+        if len(inputs) != len(outputs):
+            raise ValueError("Inputs must be same length as outputs")
+        if states is not None and len(inputs) != len(states):
+            raise ValueError("System States must be same length as inputs")
+        if event_states is not None and len(inputs) != len(event_states):
+            raise ValueError("Event States must be same length as inputs")
+        if t_mets is not None and len(inputs) != len(t_mets):
+            raise ValueError("Thresholds met must be same length as inputs")
+        
+    @classmethod
+    def from_model(cls, m: PrognosticsModel, load_functions: list, **kwargs) -> "DataModel":
+        """
+        Create a Data Model from an existing PrognosticsModel (i.e., a surrogate model). Generates data through simulation with supplied load functions. Then calls :py:func:`from_data` to generate the model.
+
+        Args:
+            m (PrognosticsModel): 
+                Model to generate data from
+            load_functions (list[function]): 
+                Each index is a callable loading function of (t, x = None) -> z used to predict :term:`future load` at a given time (t) and :term:`state` (x)
+
+        Keyword Args:
+            add_dt (bool): If the timestep should be added as an input
+
+        Addditional configuration parameters from :py:func:`prog_models.PrognosticsModel.simulate_to_threshold`. These can be an array (of same length as load_functions) of config for each individual sim, or one value to apply to all
+        Additional configuration parameters from `from_data`
+
+        Returns:
+            DataModel: Trained PrognosticsModel
+        """
+         # Configure
+        config = { # Defaults
+            'add_dt': True,
+            'input_keys': m.inputs.copy(),
+            'output_keys': m.outputs.copy(),
+            'state_keys': m.states.copy(),
+            'event_keys': m.events.copy()
+        }
+        config.update(kwargs)
+
+        if callable(load_functions):
+            # Only one function
+            load_functions = [load_functions]
+
+        sim_cfg_params = ['dt', 't0', 'integration_method', 'save_freq', 'save_pts', 'horizon', 'first_output', 'threshold_keys', 'x', 'thresholds_met_eqn']
+
+        # Check format of cfg item and split into cfg for each sim if scalar
+        for cfg in sim_cfg_params:
+            if cfg in config:
+                if np.isscalar(config[cfg]) or isinstance(config[cfg], tuple) or callable(config[cfg]):
+                    # Single element to be applied to all
+                    config[cfg] = [config[cfg] for _ in load_functions]
+                elif len(config[cfg]) != len(load_functions):
+                    raise ValueError(f"If providing multiple values for sim config item, must provide the same number of values as number of load functions. For {cfg} provided {len(config[cfg])}, expected {len(load_functions)}.")
+                # Otherwise, already in correct form
+
+        if 'dt' not in config:
+            config['dt'] = [1.0 for _ in load_functions]  # default
+        if 'save_freq' not in config:
+            config['save_freq'] = config['dt']
+
+        # Create sim config for each element
+        sim_cfg = [{
+            cfg : config[cfg][i]
+               for cfg in sim_cfg_params if cfg in config
+        } for i in range(len(load_functions))]
+
+        # Simulate            
+        data = [m.simulate_to_threshold(load, **sim_cfg[i]) for (i, load) in enumerate(load_functions)]
+
+        # Prepare data
+        times = [d.times for d in data]
+        if config['add_dt']:
+            config['input_keys'].append('dt')
+            if len(data[0].inputs) > 0 and len(data[0].inputs[0]) == 0:
+                # No inputs
+                inputs = [np.array([[config['dt'][i]] for _ in data[i].inputs], dtype=float) for i in range(len(data))]
+            else:
+                inputs = [np.array([np.hstack((u_i.matrix[:][0].T, [config['dt'][i]])) for u_i in d.inputs], dtype=float) for i, d in enumerate(data)]
+        else:
+            inputs = [d.inputs for d in data]
+        outputs = [d.outputs for d in data]
+        states = [d.states for d in data]
+        event_states = [d.event_states for d in data]
+        t_met = [[list(m.threshold_met(x).values()) for x in state] for state in states]
+
+        return cls.from_data(times = times, inputs = inputs, states = states, outputs = outputs, event_states = event_states, t_met= t_met, **config)
diff --git a/src/prog_models/data_models/dmd.py b/src/prog_models/data_models/dmd.py
new file mode 100644
index 000000000..1f814c338
--- /dev/null
+++ b/src/prog_models/data_models/dmd.py
@@ -0,0 +1,504 @@
+# Copyright © 2021 United States Government as represented by the Administrator of the
+# National Aeronautics and Space Administration.  All Rights Reserved.
+
+from numbers import Number
+import numpy as np
+from scipy.interpolate import interp1d
+import types
+from warnings import warn
+
+from ..exceptions import ProgModelInputException
+from ..sim_result import SimResult, LazySimResult
+from .. import LinearModel, PrognosticsModel
+from . import DataModel
+
+class DMDModel(LinearModel, DataModel):
+    """
+    .. versionadded:: 1.3.0
+
+    A subclass of :py:class:`prog_models.LinearModel` and :py:class:`prog_models.data_models.DataModel` that uses Dynamic Mode Decomposition to simulate a system throughout time.
+    
+    Given an initial :term:`state` of the system and the expected :term:`input` throughout time, this class defines a model that can approximate the dynamics of the system throughout time until threshold is met. This model can be fully data-driven (using from_data) or a :term:`surrogate` of another model (using from_model) where internal states of a high-fidelity model augment the purely data-driven method. 
+
+    Args
+    ---------
+        dmd_matrix : np.ndarray
+            Matrix used by DMD
+
+    Keyword args
+    ------------
+        input_keys : list[str]
+            List of input keys
+        dt : float
+            Time step
+        output_keys : list[str]
+            List of output keys
+        x0 : dict or StateContainer
+            Initial state of the system
+        state_keys : list[str]
+            List of state keys
+        event_keys : list[str]
+            List of event keys
+
+    See Also
+    ---------
+
+        :py:class:`LinearModel`
+
+        :py:class:`DataModel`
+
+    Note
+    -------
+    DMD does not generate accurate approximations for all models, especially highly non-linear sections, and can be sensitive to the training data time step. 
+
+    In general, the approximation is less accurate if the DMD matrix is unstable. 
+    Additionally, this implementation does not yet include all functionalities of DMD (e.g. reducing the system's dimensions through SVD). Further functionalities will be included in future releases. \n
+    """
+
+    A = None
+    B = None
+    C = None
+    F = None
+
+    def __new__(cls, dmd_matrix, *args, **kwargs):
+        if isinstance(dmd_matrix, PrognosticsModel):
+            # Keep for backwards compatability (in first version, model was passed into constructor)
+            warn('Passing a PrognosticsModel into DMDModel is deprecated and will be removed in v1.5.  Please use DMDModel.from_model instead', DeprecationWarning)
+            return cls.from_model(dmd_matrix, args[0], **kwargs) 
+        return DataModel.__new__(cls)
+
+    def __getnewargs__(self):
+        return (self.dmd_matrix, )
+
+    def __init__(self, dmd_matrix, *_, **kwargs):
+        if isinstance(dmd_matrix, PrognosticsModel):
+            # Initialized in __new__
+            # Remove in future version
+            return
+
+        if 'input_keys' not in kwargs:
+            raise ValueError('input_keys must be specified')
+        if 'dt' not in kwargs:
+            raise ValueError('dt must be specified')
+        if 'output_keys' not in kwargs:
+            raise ValueError('output_keys must be specified')
+        if 'x0' not in kwargs:
+            raise ValueError('x0 must be specified')
+
+        params = {
+            'state_keys': [],
+            'event_keys': []
+        }
+        params.update(**kwargs)
+
+        self.inputs = params['input_keys']
+        n_inputs = len(params['input_keys'])
+        
+        self.states = params['state_keys'] + params['output_keys'] + params['event_keys']
+        n_states = len(params['state_keys'])
+        
+        self.outputs = params['output_keys']
+        n_outputs = len(params['output_keys'])
+        
+        has_overwritten_threshold_eqn = hasattr(self, 'events')
+        if has_overwritten_threshold_eqn: 
+             # To support overridding to add custom threshold equation
+            n_events = 0
+        else:
+            self.events = params['event_keys']
+            n_events = len(self.events)
+
+
+        n_total = n_states + n_outputs + n_events
+
+        self.A = dmd_matrix[:,0:n_total]
+        self.B = np.vstack(dmd_matrix[:,n_total:n_total+n_inputs]) 
+        self.C = np.zeros((n_outputs,n_total))
+        for iter1 in range(n_outputs):
+            self.C[iter1,n_states+iter1] = 1 
+        if not has_overwritten_threshold_eqn:
+            self.F = np.zeros((n_events,n_total))
+        for iter2 in range(n_events):
+            self.F[iter2,n_states+n_outputs+iter2] = 1 
+
+        super().__init__(**params)
+        
+        self.dt = params['dt']
+        self.dmd_matrix = dmd_matrix
+        self.parameters['dmd_matrix'] = dmd_matrix  # This simplifies pickling (all data in parameters)
+        
+        if 'x0' in self.parameters and not isinstance(self.parameters['x0'], self.StateContainer):
+            self.parameters['x0'] = self.StateContainer(params['x0'])
+
+    @classmethod
+    def from_data(cls, times, inputs, outputs, states = None, event_states = None, **kwargs):
+        """
+        Create a DMD model from data
+
+        Args:
+            times (list[list]): 
+                list of input data for use in data. Each element is the times for a single run of size (n_times)
+            inputs (list[np.array]): 
+                list of :term:`input` data for use in data. Each element is the inputs for a single run of size (n_times, n_inputs)
+            outputs (list[np.array]): 
+                list of :term:`output` data for use in data. Each element is the outputs for a single run of size (n_times, n_outputs)
+            states (list[np.array], optional): 
+                list of :term:`state` data for use in data. Each element is the states for a single run of size (n_times, n_states)
+            event_states (list[np.array], optional): 
+                list of :term:`event state` data for use in data. Each element is the event states for a single run of size (n_times, n_event_states)
+
+        Keyword Args:
+            trim_data_to (int, optional): 
+                Fraction (0-1) of data resulting from :py:func:`prog_models.PrognosticsModel.simulate_to_threshold` used to train DMD surrogate model
+                e.g. if trim_data_to = 0.7 and the simulated data spans from t=0 to 100, the surrogate model is trained on the data from t=0 to 70 \n   
+                Note: To trim data to a set time, use the 'horizon' parameter  
+            stability_tol (int, optional):
+                Value that determines the tolerance for DMD matrix stability
+            training_noise (int, optional):
+                Noise added to the training data sampled from a standard normal distribution with standard deviation of training_noise
+            input_keys (list[str]): 
+                List of :term:`input` keys
+            state_keys (list[str]): 
+                List of :term:`state` keys
+            output_keys (list[str]):
+                List of :term:`output` keys
+            event_keys (list[str]):
+                List of :term:`event` keys
+        
+        Additionally, other keyword arguments from :py:func:`prog_models.PrognosticsModel.simulate_to_threshold`
+
+        Attributes:
+            dmd_matrix (np.array): Dynamic Mode Decomposition Matrix
+            dt (float): Time step of data
+
+        Returns:
+            DMDModel: Model generated from data
+        """
+        # Configure
+        config = { # Defaults
+            'trim_data_to': 1,
+            'stability_tol': 1e-05,
+            'training_noise': 1e-05,
+            'input_keys': None,
+            'state_keys': None,
+            'output_keys': None,
+            'event_keys': None, 
+            'dt': None
+        }
+        config.update(kwargs)
+
+        # Input validation
+        if not isinstance(config['trim_data_to'], Number) or config['trim_data_to']>1 or config['trim_data_to']<=0:
+            raise ProgModelInputException("Invalid 'trim_data_to' input value, must be between 0 and 1.")
+        if not isinstance(config['stability_tol'], Number) or  config['stability_tol'] < 0:
+            raise ProgModelInputException(f"Invalid 'stability_tol' input value {config['stability_tol']}, must be a positive number.")
+        if not isinstance(config['training_noise'], Number) or config['training_noise'] < 0:
+            raise ProgModelInputException(f"Invalid 'training_noise' input value {config['training_noise']}, must be a positive number.")
+        DataModel.check_data_format(inputs, outputs, states, event_states)
+        if isinstance(config['dt'], list):
+            # This means one dt for each run
+            # Use mean
+            config['dt'] = sum(config['dt'])/len(config['dt'])
+        elif config['dt'] is None:
+            # Use times from data - calculate mean dt
+            dts = [t[j+1] - t[j] for t in times for j in range(len(t)-1)]
+            config['dt'] = sum(dts)/len(dts)
+        if 'save_freq' not in config:
+            config['save_freq'] = config['dt']
+        elif isinstance(config['save_freq'], list):
+            # This means one save_freq for each run
+            # Use mean
+            config['save_freq'] = sum(config['save_freq'])/len(config['save_freq'])
+
+        # Handle Keys
+        if config['input_keys'] is None:
+            for u in inputs:
+                if isinstance(u, SimResult):
+                    config['input_keys'] = list(u[0].keys())
+                    break
+            if config['input_keys'] is None:
+                # Wasn't able to fill it in
+                config['input_keys'] = [f'u{i}' for i in range(len(inputs[0][0]))]
+        if config['state_keys'] is None:
+            if states is None:
+                config['state_keys'] = []
+            else:
+                for x in states:
+                    if isinstance(x, SimResult):
+                        config['state_keys'] = list(x[0].keys())
+                        break
+                if config['state_keys'] is None:
+                    # Wasn't able to fill it in
+                    config['state_keys'] = [f'x{i}' for i in range(len(states[0][0]))]
+        if config['output_keys'] is None:
+            for z in outputs:
+                if isinstance(z, SimResult):
+                    config['output_keys'] = list(z[0].keys())
+                    break
+            if config['output_keys'] is None:
+                # Wasn't able to fill it in
+                config['output_keys'] = [f'z{i}' for i in range(len(outputs[0][0]))]
+        if config['event_keys'] is None:
+            if event_states is None:
+                config['event_keys'] = []
+            else:
+                for es in event_states:
+                    if isinstance(es, SimResult):
+                        config['event_keys'] = list(es[0].keys())
+                        break
+                if config['event_keys'] is None:
+                    # Wasn't able to fill it in
+                    config['event_keys'] = [f'es{i}' for i in range(len(event_states[0][0]))]
+
+        for state_key in config['state_keys']:
+            if state_key in config['input_keys'] or state_key in config['output_keys'] or state_key in config['event_keys']:
+                config['state_keys'].remove(state_key)
+                warn(f"State value '{state_key}' is duplicated in inputs, outputs, or events; duplicate has been removed.")
+
+        for input_key in config['input_keys']:
+            if input_key in config['output_keys'] or input_key in config['event_keys']:
+                warn(f"Input value '{input_key}' is duplicated in outputs or events")     
+
+        for output_key in config['output_keys']:
+            if output_key in config['event_keys']:
+                warn(f"Output value '{output_key}' is duplicated in events")
+
+        n_inputs = len(config['input_keys'])
+        n_states = len(config['state_keys'])
+        n_outputs = len(config['output_keys'])
+        n_events = len(config['event_keys'])
+
+        # Initialize lists to hold individual matrices
+        x_list = []
+        xprime_list = []
+        time_list = []
+
+        # Train DMD model
+        n_runs = len(inputs)
+        for run in range(n_runs):
+            t = times[run]
+            u = inputs[run]
+            z = outputs[run]
+
+            if len(u) != len(z):
+                raise ProgModelInputException(f"Must have same number of steps for inputs and outputs in a single run. Not true for run {run}")
+            if len(u) == 0:
+                raise ProgModelInputException(f"Each run must have at least one timestep, not true for Run {run}")
+
+            if isinstance(u, SimResult):
+                u = u.to_numpy(config['input_keys'])
+            
+            if states != None:
+                x = states[run]
+                if len(x) != len(u):
+                    raise ProgModelInputException(f"Must have same number of steps for inputs, states, and outputs in a single run. Not true for states in run {run}")
+                if isinstance(x, SimResult):     
+                    x = x.to_numpy(config['state_keys'])
+            else:
+                x = np.array([[] for _ in u])
+
+            if isinstance(z, SimResult):
+                z = z.to_numpy(config['output_keys'])
+
+            if event_states != None:
+                es = event_states[run]
+                if len(es) != len(u):
+                    raise ProgModelInputException(f"Must have same number of steps for inputs, event_states, and outputs in a single run. Not true for event_states in run {run}")
+                if isinstance(es, SimResult):    
+                    es = es.to_numpy(config['event_keys'])
+            else:
+                es = np.array([[] for _ in u])
+
+            # Trim
+            if config['trim_data_to'] != 1:
+                index = int(len(u)*config['trim_data_to'])
+                t = t[:index]
+                u = u[:index]
+                x = x[:index]
+                z = z[:index]
+                es = es[:index]       
+            
+            # Interpolate
+            # This is done when dt and save_freq are not equal. 
+            # dt is the frequency at which simulation is performed to generate data, 
+            # while save_freq is the frequency for the dmd_matrix
+            if config['save_freq'] != config['dt'] or config['dt'] is None:
+                if isinstance(config['save_freq'], (tuple, list, np.ndarray)):
+                    # Tuple used to specify start and frequency
+                    t_step = config['save_freq'][1]
+                    # Use starting time or the next multiple
+                    t_start = config['save_freq'][0]
+                    start = max(t_start, t[0])
+                else:
+                    # Otherwise - start is t0
+                    t_step = config['save_freq']
+                    start = t[0]
+                    
+                t_new = np.arange(start, t[-1], t_step)
+                u = interp1d(t, u, axis=0)(t_new)
+                if n_states != 0:
+                    x = interp1d(t, x, axis=0)(t_new)
+                z = interp1d(t, z, axis=0)(t_new)
+                if n_events != 0:  
+                    # Optimization - avoid interpolation of empty array
+                    es = interp1d(t, es, axis=0)(t_new)
+                t = t_new  # Reset time to new timestep
+
+            # Apply training noise
+            if config['training_noise'] != 0:
+                n_steps = len(u)
+                u += np.random.randn(n_steps, n_inputs)*config['training_noise']
+                if n_states != 0:
+                    x += np.random.randn(n_steps, n_states)*config['training_noise']
+                z += np.random.randn(n_steps, n_outputs)*config['training_noise']
+                if n_events != 0:  
+                    # Optimization - avoid generation for empty array
+                    es += np.random.randn(n_steps, n_events)*config['training_noise']
+
+            # Initialize DMD matrices
+            time_list.append(t)
+            x_mat = np.hstack((x, z, es, u)).T
+            x_list.append(x_mat[:, :-1])
+            xprime_list.append(x_mat[:-n_inputs if n_inputs != 0 else None, 1:])    
+
+        # Format training data for DMD and solve for matrix A, in the form X' = AX 
+        print('Generate DMD Surrogate Model')
+
+        if 'x0' not in config:
+            config['x0'] = np.mean(np.array([x[:-n_inputs if n_inputs != 0 else None, 0] for x in x_list]), axis=0)[np.newaxis].T
+     
+        # Convert lists of datasets into arrays, sequentially stacking data in the horizontal direction
+        x_mat = np.hstack((x_list[:]))
+        xprime_mat = np.hstack((xprime_list[:]))
+
+        # Calculate DMD matrix using the Moore-Penrose pseudo-inverse:
+        dmd_matrix = np.dot(xprime_mat,np.linalg.pinv(x_mat))        
+
+        # Check for stability of dmd_matrix
+        eig_val, _ = np.linalg.eig(dmd_matrix[:,0:-n_inputs if n_inputs > 0 else None])            
+        
+        if sum(eig_val>1) != 0:
+            for eig_val_i in eig_val:
+                if eig_val_i>1 and eig_val_i-1>config['stability_tol']:
+                    warn("The DMD matrix is unstable, may result in poor approximation.")
+
+        del config['dt']
+
+        # Build Model
+        return cls(
+            dmd_matrix, 
+            dt = config['save_freq'], 
+            **config)
+        
+    @classmethod
+    def from_model(cls, m, load_functions, **kwargs):
+        process_noise_temp = {key: 0 for key in m.events}
+        config = {
+            'add_dt': False,
+            'process_noise': {**m.parameters['process_noise'],**m.parameters['measurement_noise'],**process_noise_temp},
+            'measurement_noise': m.parameters['measurement_noise'],
+            'process_noise_dist': m.parameters.get('process_noise_dist', 'normal'),
+            'measurement_noise_dist': m.parameters.get('measurement_noise_dist', 'normal')
+        }  # Defaults specific to this class
+        config.update(kwargs)
+        # Build Model
+        m_dmd = super().from_model(m, load_functions, **config)
+
+        # Override initialize for dmd to use base model (since we have it in the original model)
+        def init_dmd(self, u=None, z=None):
+            x = m.initialize(u,z)
+            x.update(m.output(x))
+            x.update(m.event_state(x))
+
+            return self.StateContainer(x)
+        m_dmd.initialize = types.MethodType(init_dmd, m_dmd)
+
+        return m_dmd
+
+    def next_state(self, x, u, _):   
+        x.matrix = np.matmul(self.A, x.matrix) + self.E
+        if self.B.shape[1] != 0:
+           x.matrix += np.matmul(self.B, u.matrix)
+        
+        return x   
+
+    def simulate_to_threshold(self, future_loading_eqn, first_output = None, threshold_keys = None, **kwargs):
+        # Save keyword arguments same as DMD training for approximation 
+        kwargs_sim = kwargs.copy()
+        kwargs_sim['save_freq'] = self.dt
+        kwargs_sim['dt'] = self.dt
+
+        # Simulate to threshold at DMD time step
+        results = super().simulate_to_threshold(future_loading_eqn,first_output, threshold_keys, **kwargs_sim)
+        
+        # Interpolate results to be at user-desired time step
+        if 'dt' in kwargs:
+            warn("dt is not used in DMD approximation")
+
+        # Default parameters 
+        config = {
+            'dt': None,
+            'save_freq': None,
+            'save_pts': []
+        }
+        config.update(kwargs)
+
+        if (config['save_freq'] == self.dt or
+            (isinstance(config['save_freq'], tuple) and
+                config['save_freq'][0]%self.dt < 1e-9 and
+                config['save_freq'][1] == self.dt)
+            ) and config['save_pts'] == []:
+            # In this case, the user wants what the DMD approximation returns 
+            return results 
+
+        # In this case, the user wants something different than what the DMD approximation retuns, so we must interpolate 
+        # Define time vector based on user specifications
+        time_basic = [results.times[0], results.times[-1]]
+        time_basic.extend(config['save_pts'])                       
+        if config['save_freq'] != None:
+            if isinstance(config['save_freq'], tuple):
+                # Tuple used to specify start and frequency
+                t_step = config['save_freq'][1]
+                # Use starting time or the next multiple
+                t_start = config['save_freq'][0]
+                start = max(t_start, results.times[0] - (results.times[0]-t_start)%t_step)
+                time_array = np.arange(start+t_step,results.times[-1],t_step)
+            else: 
+                time_array = np.arange(results.times[0]+config['save_freq'],results.times[-1],config['save_freq'])
+            time_basic.extend(time_array.tolist())
+        time_interp = sorted(time_basic)
+
+        # Interpolate States
+        states_dict_temp = {}
+        for states_name in self.states:
+            states_list_temp = [results.states[iter1a][states_name] for iter1a in range(len(results.states))]
+            states_dict_temp[states_name] = interp1d(results.times,states_list_temp)(time_interp)
+        states_interp = [
+            self.StateContainer({key: state[i] for key, state in states_dict_temp.items()})
+            for i in range(len(time_interp))
+        ]
+            
+        # Interpolate Inputs
+        inputs_dict_temp = {}
+        for inputs_name in self.inputs:
+            inputs_list_temp = [results.inputs[iter1a][inputs_name] for iter1a in range(len(results.inputs))]
+            inputs_dict_temp[inputs_name] = interp1d(results.times,inputs_list_temp)(time_interp)
+        inputs_interp = [
+            self.InputContainer({key: input[i] for key, input in inputs_dict_temp.items()}) for i in range(len(time_interp))
+        ]
+
+        states = SimResult(time_interp,states_interp)
+        inputs = SimResult(time_interp,inputs_interp)
+        outputs = LazySimResult(self.output, time_interp, states_interp)
+        event_states = LazySimResult(self.event_state, time_interp, states_interp)
+
+        return self.SimulationResults(
+            time_interp,
+            inputs,
+            states,
+            outputs,
+            event_states
+        )
+
+# Kept for backwards compatability
+SurrogateDMDModel = DMDModel
diff --git a/src/prog_models/data_models/lstm_model.py b/src/prog_models/data_models/lstm_model.py
new file mode 100644
index 000000000..e0f7c7296
--- /dev/null
+++ b/src/prog_models/data_models/lstm_model.py
@@ -0,0 +1,689 @@
+# Copyright © 2021 United States Government as represented by the Administrator of the
+# National Aeronautics and Space Administration.  All Rights Reserved.
+
+from itertools import chain
+import matplotlib.pyplot as plt
+from numbers import Number
+import numpy as np
+import sys
+from tensorflow import keras
+from tensorflow.keras import layers
+from warnings import warn
+
+from . import DataModel
+from ..sim_result import SimResult
+
+
+class LSTMStateTransitionModel(DataModel):
+    """
+    .. versionadded:: 1.4.0
+
+    A State Transition Model with no :term:`event` using an Keras LSTM Model.
+    State transition models map from the :term:`input` at time t and :term:`output` at time t-1 plus historical data from a set window to the :term`output` at time t.
+
+    Most users will use the `LSTMStateTransitionModel.from_data` method to create a model, but the model can be created by passing in a model directly into the constructor. The LSTM model in this method maps from [u_t-n+1, z_t-n, ..., u_t, z_t-1] to z_t. Past :term:`input` are stored in the :term:`model` internal :term:`state`. Actual calculation of :term:`output` is performed when :py:func`LSTMStateTransitionModel.output` is called. When using in simulation that may not be until the simulation results are accessed.
+
+    Args:
+        output_model (keras.Model): If a state model is present, maps from the state_model outputs to model :term:`output`. Otherwise, maps from model inputs to model :term:`output`
+        state_model (keras.Model, optional): Keras model to use for state transition
+        event_state_model (keras.Model, optional): If a state model is present, maps from the state_model outputs to :term:`event state`. Otherwise, maps from model inputs to :term:`event state`
+        t_met_model (keras.Model, optional): If a state model is present, maps from the state_model outputs to if the threshold has been met. Otherwise, maps from model inputs to if the threshold has not been met
+
+    Keyword Args:
+        input_keys (list[str]): List of input keys
+        output_keys (list[str]): List of output keys
+        event_keys (list[str]): List of event keys
+
+    Attributes:
+        model (keras.Model): Keras model to use for state transition
+
+    See Also:
+        LSTMStateTransitionModel.from_data
+        examples.lstm_model
+    """
+
+    default_params = {
+        'process_noise': 0,  # Default 0 noise
+        'measurement_noise': 0,  # Default 0 noise
+    }
+
+    def __init__(self, output_model, state_model = None, event_state_model = None, t_met_model=None, **kwargs):
+        n_outputs = output_model.output.shape[1]
+        n_internal = 0 if state_model is None else state_model.output.shape[1]
+        input_shape = output_model.input.shape if state_model is None else state_model.input.shape
+        n_inputs = input_shape[-1]-n_outputs
+        if event_state_model is not None:
+            n_events = event_state_model.output.shape[-1]
+            if t_met_model is not None and t_met_model.output.shape[-1]/2 != n_events:
+                raise ValueError("t_met output length must be twice that of event state")
+        elif t_met_model is not None:
+            n_events = t_met_model.output.shape[-1]/2
+        else:
+            n_events = 0
+
+        # Setup inputs, outputs, states 
+        self.outputs = kwargs.get('output_keys', [f'z{i}' for i in range(n_outputs)])
+        self.events = kwargs.get(
+            'event_keys', 
+            getattr(self, 'events', [f'event{i}' for i in range(n_events)])) # If overridden- use that
+        input_keys = kwargs.get('input_keys', [f'u{i}' for i in range(n_inputs)])
+        self.inputs = input_keys.copy()
+        # Outputs from the last step are part of input
+        self.inputs.extend([f'{z_key}_t-1' for z_key in self.outputs])
+
+        # States are in format [u_t-n+1, z_t-n, ..., u_t, z_t-1]
+        self.states = []
+        for j in range(input_shape[1]-1, -1, -1):
+            self.states.extend([f'{input_i}_t-{j}' for input_i in input_keys])
+            self.states.extend([f'{output_i}_t-{j+1}' for output_i in self.outputs])
+        self.states.extend([f'_model_output{i}' for i in range(n_internal)])
+
+        kwargs['window'] = input_shape[1]
+        self.history = kwargs.get('history', None)
+        if 'history' in kwargs:
+            # Delete to prevent copying below
+            del kwargs['history']
+
+        super().__init__(**kwargs)
+
+        # Set parameters without copying
+        # Putting it in the parameters dictionary simplifies pickling
+        self.parameters.__setitem__('state_model', state_model, _copy = False)
+        self.parameters.__setitem__('output_model', output_model, _copy = False)
+        self.parameters.__setitem__('event_state_model', event_state_model, _copy = False)
+        self.parameters.__setitem__('t_met_model', t_met_model, _copy = False)
+        self.parameters.__setitem__('history', self.history, _copy = False)        
+
+    def __getstate__(self):
+        warn("LSTMStateTransitionModel uses a Keras model, which does not always support pickling. We recommend that you use the keras save and load model functions instead with m.model", RuntimeWarning)
+        return ((), self.parameters.data)
+
+    def __eq__(self, other):
+        # Needed because we add .model, which is not present in the parent class
+        if not isinstance(other, LSTMStateTransitionModel):
+            return False
+        return super().__eq__(other) and self.model == other.model
+
+    def initialize(self, u=None, z=None):
+        """
+        Initialize the model with the given inputs and outputs. For LSTMStateTransitionModel, the initial state is set to None. As data is added (i.e., next_state is called), the initial state is updated.
+
+        This is one of the rare models that cannot be initialized right away.
+
+        Args:
+            u (InputContainer, optional): first input
+            z (OutputContainer, optional): first output
+
+        Returns:
+            StateContainer: First State
+        """
+        return self.StateContainer(np.array([[None] for _ in self.states]))
+
+    def next_state(self, x, u, _):
+        # Rotate new input into state
+        input_data = u.matrix
+
+        if self.parameters['state_model'] is None:
+            states = x.matrix[len(input_data):]
+            return self.StateContainer(np.vstack((states, input_data)))
+            
+        states = x.matrix[len(input_data):-self.parameters['state_model'].output_shape[1]]
+        states = np.vstack((states, input_data))
+
+        if states[0,0] is None:
+            return self.StateContainer(np.vstack((states, x.matrix[-self.parameters['state_model'].output_shape[1]:])))
+        else:
+            # Enough data has been received to calculate output
+            # Format input into np array with shape (1, window, num_inputs)
+            m_input = states.reshape(1, self.parameters['window'], len(self.inputs))
+            m_input = np.array(m_input, dtype=np.float)
+            internal_states = self.parameters['state_model'](m_input).numpy().T
+        return self.StateContainer(np.vstack((states, internal_states)))
+
+    def output(self, x):
+        if x.matrix[0,0] is None:
+            warn(f"Output estimation is not available until at least {1+self.parameters['window']} timesteps have passed.")
+            return self.OutputContainer(np.array([[None] for _ in self.outputs]))
+
+        # Enough data has been received to calculate output
+        # Pass internal states into model to calculate output
+        if self.parameters['state_model'] is None:
+            m_input = x.matrix.reshape(1, self.parameters['window'], len(self.inputs))
+            internal_states = np.array(m_input, dtype=np.float)
+        else:
+            internal_states = x.matrix[-self.parameters['state_model'].output_shape[1]:].T
+        m_output = self.parameters['output_model'](internal_states)
+
+        if 'normalization' in self.parameters:
+            m_output *= self.parameters['normalization'][1]
+            m_output += self.parameters['normalization'][0]
+
+        return self.OutputContainer(m_output.numpy().T)
+
+    def event_state(self, x):
+        if self.parameters['event_state_model'] is None:
+            warn('No event state model exists- returning empty event state')
+            return {key: None for key in self.events}
+        
+        if x.matrix[0,0] is None:
+            warn(f"Event state estimation is not available until at least {1+self.parameters['window']} timesteps have passed.")
+            return {key: None for key in self.events}
+
+        # Enough data has been received to calculate output
+        # Pass internal states into model to calculate output
+        if self.parameters['state_model'] is None:
+            m_input = x.matrix.reshape(1, self.parameters['window'], len(self.inputs))
+            internal_states = np.array(m_input, dtype=np.float)
+        else:
+            internal_states = x.matrix[-self.parameters['state_model'].output_shape[1]:].T
+        m_event_state = self.parameters['event_state_model'](internal_states)
+
+        return {key: value for key, value in zip(self.events, m_event_state[0])}
+
+    def threshold_met(self, x):
+        if self.parameters['t_met_model'] is None:
+            warn('No threshold met model exists- returning empty t_met')
+            return {key: None for key in self.events}
+        
+        if x.matrix[0,0] is None:
+            warn(f"Threshold met estimation is not available until at least {1+self.parameters['window']} timesteps have passed.")
+            return {key: None for key in self.events}
+
+        # Enough data has been received to calculate output
+        # Pass internal states into model to calculate output
+        if self.parameters['state_model'] is None:
+            m_input = x.matrix.reshape(1, self.parameters['window'], len(self.inputs))
+            internal_states = np.array(m_input, dtype=np.float)
+        else:
+            internal_states = x.matrix[-self.parameters['state_model'].output_shape[1]:].T
+        m_t_met = self.parameters['t_met_model'](internal_states)
+        m_t_met = [np.argmax(m_t_met[0][i*2:(i+1)*2]) == 0 for i in range(len(self.events))]
+
+        return {key: value for key, value in zip(self.events, m_t_met)}
+
+    def summary(self, file= sys.stdout, expand_nested=False, show_trainable=False):
+        print('LSTM State Transition Model: ', file = file)
+        print("Inputs: ", self.inputs, file = file)
+        print("Outputs: ", self.outputs, file = file)
+        print("Window_size: ", self.parameters['window'], file = file)
+        if self.parameters['state_model'] is not None:
+            print('\nState Model: ', file = file)
+            self.parameters['state_model'].summary(print_fn= file.write, expand_nested = expand_nested, show_trainable = show_trainable)
+        
+        print('\nOutput Model: ', file = file)
+        self.parameters['output_model'].summary(print_fn= file.write, expand_nested = expand_nested, show_trainable = show_trainable)
+
+        if self.parameters['event_state_model'] is not None:
+            print('\nEvent State Model: ', file = file)
+            self.parameters['event_state_model'].summary(print_fn= file.write, expand_nested = expand_nested, show_trainable = show_trainable)
+        
+    @staticmethod
+    def pre_process_data(inputs, outputs, event_states=None, t_met = None, window=10, **kwargs):
+        """
+        Pre-process data for the LSTMStateTransitionModel. This is run inside from_data to convert the data into the desired format 
+
+        Args:
+            inputs (List[ndarray or SimulationResult]): Data to be processed. Each element is of format, ndarray or SimulationResult
+            outputs (List[ndarray or SimulationResult]): Data to be processed. Each element is of format, ndarray or SimulationResult
+            event_states (List[ndarray or SimulationResult]): Data to be processed. Each element is of format, ndarray or SimulationResult
+            t_met (List[ndarray or SimulationResult]): Data to be processed. Each element is of format, ndarray or SimulationResult
+            window (int): Length of a single sequence
+
+        Returns:
+            Tuple[ndarray, ndarray]: pre-processed data (input, output). Where input is of size (num_sequences, window, num_inputs) and output is of size (num_sequences, num_outputs)
+        """
+
+        u_all = []
+        z_all = []
+        es_all = []
+        t_all = []
+
+        DataModel.check_data_format(inputs, outputs)
+
+        if event_states is not None and len(inputs) != len(event_states):
+            raise ValueError("Inputs must be same length as event_states")
+
+        for i in range(len(inputs)):
+            # Each item (u, z) is a 1-d array, a 2-d array, or a SimResult
+
+            # Process Input
+            u = inputs[i]
+            if isinstance(u, SimResult):
+                if len(u[0].keys()) == 0:
+                    # No inputs
+                    u = []
+                else:
+                    u = np.array([u_i.matrix[:,0] for u_i in u])
+
+                if len(u) > window:
+                    raise TypeError(f"Not enough data for window size {window}. Only {len(u)} elements present.")
+
+            if isinstance(u, (list, np.ndarray)):
+                if len(u) == 0:
+                    # No inputs
+                    u_i = []
+                elif np.isscalar(u[0]):
+                    # Input is 1-d array (i.e., 1 input)
+                    # Note: 1 is added to account for current time (current input used to predict output at time i)
+                    u_i = [[[u[i+j]] for j in range(1, window+1)] for i in range(len(u)-window)]
+                elif isinstance(u[0], (list, np.ndarray)):
+                    # Input is d-d array
+                    # Note: 1 is added to account for current time (current input used to predict output at time i) 
+                    n_inputs = len(u[0])
+                    u_i = [[[u[i+j][k] for k in range(n_inputs)] for j in range(1,window+1)] for i in range(len(u)-window)]
+                else:
+                    raise TypeError(f"Unsupported input type: {type(u)} for internal element (data[0][i]")  
+            else:
+                raise TypeError(f"Unsupported data type: {type(u)}. input u must be in format List[Tuple[np.array, np.array]] or List[Tuple[SimResult, SimResult]]")
+
+
+            # Process Output
+            z = outputs[i]
+            if isinstance(z, SimResult):
+                if len(z[0].keys()) == 0:
+                    # No outputs
+                    z = []
+                else:
+                    z = np.array([z_i.matrix[:,0] for z_i in z])
+
+            if isinstance(z, (list, np.ndarray)):
+                if len(z) != len(u) and len(u) != 0 and len(z) != 0:
+                    # Checked here to avoid SimResults from accidentially triggering this check
+                    raise IndexError(f"Number of outputs ({len(z)}) does not match number of inputs ({len(u)})")
+
+                if len(z) == 0:
+                    # No outputs
+                    z_i = []
+                elif np.isscalar(z[0]):
+                    # Output is 1-d array (i.e., 1 output)
+                    z_i = [[z[i]] for i in range(window, len(z))]
+                elif isinstance(z[0], (list, np.ndarray)):
+                    # Input is d-d array
+                    n_outputs = len(z[0])
+                    z_i = [[z[i][k] for k in range(n_outputs)] for i in range(window, len(z))]
+                else:
+                    raise TypeError(f"Unsupported input type: {type(z)} for internal element (output[i])")  
+
+                # Also add to input (past outputs are part of input)
+                z_ii = [[z[i+j] for j in range(window)] for i in range(len(z_i))]
+                # ISSUE- TODO z_ii has too many dimensions
+                if len(u_i) == 0:
+                    u_i = z_ii
+                else:
+                    for k in range(len(z_ii)):
+                        for j in range(window):
+                            u_i[k][j].extend(z_ii[k][j])
+            else:
+                raise TypeError(f"Unsupported data type: {type(z)}. output z must be in format List[Tuple[np.array, np.array]] or List[Tuple[SimResult, SimResult]]")
+            
+            if event_states is not None:
+                es = event_states[i]
+                if isinstance(es, SimResult):
+                    if len(es[0].keys()) == 0:
+                        # No event_states
+                        es = []
+                    else:
+                        es = np.array([[es_i[key] for key in es_i.keys()] for es_i in es])
+
+                if isinstance(es, (list, np.ndarray)):
+                    if len(es) != len(u) and len(u) != 0 and len(es) != 0:
+                        # Checked here to avoid SimResults from accidentially triggering this check
+                        raise IndexError(f"Number of event_states ({len(es)}) does not match number of inputs ({len(u)})")
+
+                    if len(es) == 0:
+                        # No outputs
+                        es_i = []
+                    elif np.isscalar(es[0]):
+                        # Output is 1-d array (i.e., 1 output)
+                        es_i = [[es[i]] for i in range(window, len(es))]
+                    elif isinstance(es[0], (list, np.ndarray)):
+                        # Input is d-d array
+                        n_events = len(es[0])
+                        es_i = [[es[i][k] for k in range(n_events)] for i in range(window, len(es))]
+                    else:
+                        raise TypeError(f"Unsupported input type: {type(es)} for internal element (es[i])")  
+
+                else:
+                    raise TypeError(f"Unsupported data type: {type(es)}. event state must be in format List[Tuple[np.array, np.array]] or List[Tuple[SimResult, SimResult]]")
+            else:
+                es_i = []
+
+            if t_met is not None:
+                t = t_met[i]
+                if isinstance(t, SimResult):
+                    if len(t[0].keys()) == 0:
+                        # No event_states
+                        t = []
+                    else:
+                        t = np.array([[t_i[key] for key in t_i.keys()] for t_i in t])
+
+                if isinstance(t, (list, np.ndarray)):
+                    if len(t) != len(u) and len(u) != 0 and len(t) != 0:
+                        # Checked here to avoid SimResults from accidentially triggering this check
+                        raise IndexError(f"Number of t_met ({len(t)}) does not match number of inputs ({len(u)})")
+
+                    if len(t) == 0:
+                        # No outputs
+                        t_i = []
+                    elif np.isscalar(t[0]):
+                        # Output is 1-d array (i.e., 1 output)
+                        t_i = [[1, 0] if t[i] else [0, 1] for i in range(window, len(t))]
+                    elif isinstance(t[0], (list, np.ndarray)):
+                        # Input is d-d array
+                        n_events = len(t[0])
+                        # True = 1, 0; False = 0, 1
+                        t_i = [list(chain.from_iterable((1, 0) if t[i][k] else (0, 1) for k in range(n_events))) for i in range(window, len(t))]
+                    else:
+                        raise TypeError(f"Unsupported input type: {type(t[0])} for internal element (t[i])")  
+
+                else:
+                    raise TypeError(f"Unsupported data type: {type(t)}. t_met must be in format List[Tuple[np.array, np.array]] or List[Tuple[SimResult, SimResult]]")
+            else:
+                t_i = []
+
+            u_all.extend(u_i)
+            z_all.extend(z_i)
+            es_all.extend(es_i)
+            t_all.extend(t_i)
+        
+        u_all = np.array(u_all, dtype=np.float)
+        z_all = np.array(z_all, dtype=np.float)
+        es_all = np.array(es_all, dtype=np.float)
+        t_all = np.array(t_all, dtype=np.float)
+        return (u_all, z_all, es_all, t_all)
+
+    @classmethod
+    def from_data(cls, inputs, outputs, event_states = None, t_met = None, **kwargs):
+        """
+        Generate a LSTMStateTransitionModel from data
+
+        Args:
+            inputs (list[np.array]): 
+                list of :term:`input` data for use in data. Each element is the inputs for a single run of size (n_times, n_inputs)
+            outputs (list[np.array]): 
+                list of :term:`output` data for use in data. Each element is the outputs for a single run of size (n_times, n_outputs)
+            event_states (list[np.array], optional): 
+                list of :term:`event state` data for use in data. Each element is the event state for a single run of size (n_times, n_events)
+            t_met (list[np.array], optional): 
+                list of :term:`threshold` met data for use in data. Each element is if the threshold has been met for a single run of size (n_times, n_events) 
+
+        Keyword Args:
+            window (int): 
+                Number of historical points used in the model. I.e, if window is 3, the model will map from [t-3, t-2, t-1] to t
+            input_keys (list[str]): 
+                List of keys to use to identify :term:`input`. If not supplied u[#] will be used to idenfiy inputs
+            output_keys (list[str]): 
+                List of keys to use to identify :term:`output`. If not supplied z[#] will be used to idenfiy outputs
+            event_keys (list[str]):
+                List of keys to use to identify events for :term:`event state` and :term:`threshold` met. If not supplied event[#] will be used to identify events
+            validation_percentage (float): 
+                Percentage of data to use for validation, between 0-1
+            epochs (int): 
+                Number of epochs (i.e., iterations) to train the model. More epochs means better results (to a point), but more time to train. Note: large numbers of epochs may result in overfitting.
+            layers (int): 
+                Number of LSTM layers to use. More layers can represent more complex systems, but are less efficient. Note: 2 layers is typically enough for most complex systems. Default: 1
+            units (int or list[int]): 
+                number of units (i.e., dimensionality of output state) used in each lstm layer. Using a scalar value will use the same number of units for each layer.
+            activation (str or list[str]): 
+                Activation function to use for each layer
+            dropout (float): 
+                Dropout rate to be applied. Dropout helps avoid overfitting
+            normalize (bool): 
+                If the data should be normalized. This is recommended for most cases.
+            early_stopping (bool):
+                If early stopping is desired. Default is True
+            early_stop.cfg (dict):
+                Configuration to pass into early stopping callback (if enabled). See keras documentation (https://keras.io/api/callbacks/early_stopping) for options. E.g., {'patience': 5}
+            workers (int):
+                Number of workers to use when training. One worker indicates no multiprocessing
+
+        Returns:
+            LSTMStateTransitionModel: Generated Model
+
+        See Also:
+            https://www.tensorflow.org/api_docs/python/tf/keras/layers/LSTM
+        """
+        params = { # default_params
+            'window': 128,
+            'validation_split': 0.25,
+            'epochs': 100,
+            'prediction_steps': 1,
+            'layers': 1,
+            'units': 16,
+            'activation': 'tanh',
+            'dropout': 0.1,
+            'normalize': True,
+            'early_stop': True,
+            'early_stop.cfg': {'patience': 3, 'monitor': 'loss'},
+            'workers': 1
+        }.copy()  # Copy is needed to avoid updating default
+
+        params.update(LSTMStateTransitionModel.default_params)
+        params.update(kwargs)
+
+        # Input Validation
+        if not np.isscalar(params['window']):
+            raise TypeError(f"window must be an integer greater than 0, not {type(params['window'])}")
+        if params['window'] <= 0:
+            raise ValueError(f"window must be greater than 0, got {params['window']}")
+        if not np.isscalar(params['layers']):
+            raise TypeError(f"layers must be an integer greater than 0, not {type(params['layers'])}")
+        if params['layers'] <= 0:
+            raise ValueError(f"layers must be greater than 0, got {params['layers']}")
+        if np.isscalar(params['units']):
+            params['units'] = [params['units'] for _ in range(params['layers'])]
+        if not isinstance(params['units'], (list, np.ndarray)):
+            raise TypeError(f"units must be a list of integers, not {type(params['units'])}")
+        if len(params['units']) != params['layers']:
+            raise ValueError(f"units must be a list of integers of length {params['layers']}, got {params['units']}")
+        for i in range(params['layers']):
+            if params['units'][i] <= 0:
+                raise ValueError(f"units[{i}] must be greater than 0, got {params['units'][i]}")
+        if not isinstance(params['dropout'], Number):
+            raise TypeError(f"dropout must be an float greater than or equal to 0, not {type(params['dropout'])}")
+        if params['dropout'] < 0:
+            raise ValueError(f"dropout must be greater than or equal to 0, got {params['dropout']}")
+        if not isinstance(params['activation'], (list, np.ndarray)):
+            params['activation'] = [params['activation'] for _ in range(params['layers'])]
+        if not np.isscalar(params['validation_split']):
+            raise TypeError(f"validation_split must be an float between 0 and 1, not {type(params['validation_split'])}")
+        if params['validation_split'] < 0 or params['validation_split'] >= 1:
+            raise ValueError(f"validation_split must be between 0 and 1, got {params['validation_split']}")
+        if not np.isscalar(params['epochs']):
+            raise TypeError(f"epochs must be an integer greater than 0, not {type(params['epochs'])}")
+        if params['epochs'] < 1:
+            raise ValueError(f"epochs must be greater than 0, got {params['epochs']}")
+        if not isinstance(params['workers'], int):
+            raise TypeError(f"workers must be positive integer, got {type(params['workers'])}")
+        if params['workers'] < 1:
+            raise ValueError(f"workers must be positive integer, got {params['workers']}")
+        if np.isscalar(inputs):  # Is scalar (e.g., SimResult)
+            inputs = [inputs]
+        if np.isscalar(outputs):
+            outputs = [outputs]
+        if not isinstance(params['normalize'], bool):
+            raise TypeError(f"normalize must be a boolean, not {type(params['normalize'])}")
+
+        # Prepare datasets
+        (u_all, z_all, es_all, t_all) = LSTMStateTransitionModel.pre_process_data(inputs, outputs, event_states = event_states, t_met = t_met, **params)
+
+        # Normalize
+        if params['normalize']:
+            n_inputs = len(inputs[0][0])
+            u_mean = np.mean(u_all[:,0,:n_inputs], axis=0)
+            u_std = np.std(u_all[:,0,:n_inputs], axis=0)
+            # If there's no variation- don't normalize 
+            u_std[u_std == 0] = 1
+            z_mean = np.mean(z_all, axis=0)
+            z_std = np.std(z_all, axis=0)
+            # If there's no variation- don't normalize 
+            z_std[z_std == 0] = 1
+
+            # Add output (since z_t-1 is last input)
+            u_mean = np.hstack((u_mean, z_mean))
+            u_std = np.hstack((u_std, z_std))
+
+            z_all = (z_all - z_mean)/z_std
+
+            # u_mean and u_std act on the column vector form (from inputcontainer)
+            # so we need to transpose them to a column vector
+            params['normalization'] = (z_mean, z_std)
+        
+        # Build model
+        callbacks = [
+            keras.callbacks.ModelCheckpoint("best_model.keras", save_best_only=True)
+        ]
+
+        if params['early_stop']:
+            callbacks.append(keras.callbacks.EarlyStopping(**params['early_stop.cfg']))
+
+        inputs = keras.Input(shape=u_all.shape[1:])
+        x = inputs
+        if params['normalize']:
+            x = layers.Normalization(mean = u_mean, variance = u_std**2)(inputs)
+        for i in range(params['layers']):
+            if i == params['layers'] - 1:
+                # Last layer
+                x = layers.LSTM(params['units'][i], activation=params['activation'][i])(x)
+            else:
+                # Intermediate layer
+                x = layers.LSTM(params['units'][i], activation=params['activation'][i], return_sequences=True)(x)
+        
+        if params['dropout'] > 0:
+            # Dropout prevents overfitting
+            x = layers.Dropout(params['dropout'])(x)
+
+        outputs = [layers.Dense(z_all.shape[1] if z_all.ndim == 2 else 1, name='output')(x)]
+        output_data = [z_all]
+        
+        if event_states is not None:
+            outputs.append(layers.Dense(es_all.shape[1] if es_all.ndim == 2 else 1, name='event_state')(x))
+            output_data.append(es_all)
+        
+        if t_met is not None and t_all.shape[1] > 0:
+            n_events = round(t_all.shape[1]/2)
+            # Layer for each event
+            t_met_layers = [layers.Dense(2, activation="softmax") for _ in range(n_events)]
+            t_met_layers_output = [layer(x) for layer in t_met_layers]
+            if len(t_met_layers) == 1:
+                outputs.append(t_met_layers_output[-1])
+            else:
+                # Concatenate layers
+                outputs.append(layers.Concatenate(name='t_met')(t_met_layers_output))
+            output_data.append(t_all)
+        
+        model = keras.Model(inputs, outputs)
+        model.compile(optimizer="rmsprop", loss="mse", metrics=["mae"])
+        
+        # Train model
+        history = model.fit(u_all, output_data, epochs=params['epochs'], callbacks = callbacks, validation_split = params['validation_split'], workers = params['workers'],  use_multiprocessing = params['workers'] > 1)
+
+        model = keras.models.load_model("best_model.keras")
+
+        # Split model into separate models
+        n_state_layers = params['layers'] + 1 + (params['dropout'] > 0) + (params['normalize'])
+        output_layer_input = layers.Input(model.layers[n_state_layers-1].output.shape[1:])
+        output_layer = model.get_layer('output')(output_layer_input)
+        state_model = keras.Model(model.input, model.layers[n_state_layers-1].output)
+        output_model = keras.Model(output_layer_input, output_layer)
+        if event_states is None:
+            event_state_model = None
+        else:
+            event_state_layer = model.get_layer('event_state')(output_layer_input)
+            event_state_model = keras.Model(output_layer_input, event_state_layer)            
+
+        if t_met is None:
+            t_met_model = None
+        else:
+            t_met_layers = [layer(output_layer_input) for layer in t_met_layers]
+            if len(t_met_layers) == 1:
+                t_met_layer = t_met_layers[0]
+            else:  # Concat layer exists
+                t_met_layer = model.get_layer('t_met')(t_met_layers)
+            t_met_model = keras.Model(output_layer_input, t_met_layer)  
+
+        return cls(output_model, state_model, event_state_model, t_met_model, history = history, **params)
+        
+    def simulate_to_threshold(self, future_loading_eqn, first_output = None, threshold_keys = None, **kwargs):
+        t = kwargs.get('t0', 0)
+        dt = kwargs.get('dt', 0.1)
+        x = kwargs.get('x', self.initialize(future_loading_eqn(t), first_output))
+
+        # configuring next_time function to define prediction time step, default is constant dt
+        if callable(dt):
+            dt_mode = 'function'
+        elif isinstance(dt, tuple):
+            dt_mode = dt[0]
+            dt = dt[1]               
+        elif isinstance(dt, str):
+            dt_mode = dt
+            if dt_mode == 'constant':
+                dt = 1.0  # Default
+            else:
+                dt = np.inf
+        else:
+            dt_mode = 'constant'
+
+        if dt_mode in ('constant', 'auto'):
+            def next_time(t, x):
+                return dt
+        elif dt_mode != 'function':
+            raise Exception(f"'dt' mode {dt_mode} not supported. Must be 'constant', 'auto', or a function")
+
+        # Simulate until passing minimum number of steps
+        # TODO Suggestion (Matteo): 
+        # normalize data before prediction loop starts; de-normalize them after loop.
+        # This way, normalization could be handled using functions that normalize nd arrays all at once, 
+        # avoiding to normalize data at each step of the simulation. 
+        # I don't know if this interferes with how the PrognosticsModel class works. 
+        while x.matrix[0,0] is None:
+            if 'horizon' in kwargs and t > kwargs['horizon']:
+                raise Exception(f'Not enough timesteps to reach minimum number of steps for model simulation')
+            dt = next_time(t, x)
+            t = t + dt/2
+            # Use state at midpoint of step to best represent the load during the duration of the step
+            u = future_loading_eqn(t, x)
+            t = t + dt/2
+            x = self.next_state(x, u, dt)
+
+        # Now do actual simulate_to_threshold
+        kwargs['t0'] = t
+        x.matrix = np.array(x.matrix, dtype=np.float)
+        kwargs['x'] = x
+        if 'horizon' in kwargs:
+            if kwargs['horizon'] < t:
+                raise ValueError('Prediction horizon does not allow enough steps to fully initialize model')
+            kwargs['horizon'] = kwargs['horizon'] - t
+        return super().simulate_to_threshold(future_loading_eqn, first_output, threshold_keys, **kwargs)
+    
+    def plot_history(self, metrics = None):
+        """
+        Plot the trianing history for the keras model. 
+
+        Args:
+            metrics (list[str], optional): Metrics to plot (e.g., [loss]). Defaults to all metrics in history.
+
+        Raises:
+            Exception: No history is available (e.g., because you supplied a model to the constructor but didn't provide the history as a kwarg)
+
+        Returns:
+            list[plt.figure]: List of Figures, one for each metric
+        """
+        if self.history is None:
+            raise Exception("Cannot plot history- no history is available")
+
+        if metrics is None: 
+            metrics = self.history.history.keys()
+        
+        plts = []
+        for key in metrics:
+            if key[:4] == 'val_':
+                plt.figure(plts[list(metrics).index(key[4:])].number)
+            else:
+                plts.append(plt.figure())
+            plt.plot(self.history.history[key], label = key)
+            plt.xlabel('epochs')
+            plt.ylabel(key)
+            plt.legend()
+        
+        return plts
diff --git a/src/prog_models/datasets/nasa_battery.py b/src/prog_models/datasets/nasa_battery.py
index a62728317..8a5fc87c5 100644
--- a/src/prog_models/datasets/nasa_battery.py
+++ b/src/prog_models/datasets/nasa_battery.py
@@ -9,34 +9,34 @@
 
 # Map of battery to url for data
 urls = {
-    'RW1': "https://ti.arc.nasa.gov/c/27/",
-    'RW2': "https://ti.arc.nasa.gov/c/27/",
-    'RW3': "https://ti.arc.nasa.gov/c/26/",
-    'RW4': "https://ti.arc.nasa.gov/c/26/",
-    'RW5': "https://ti.arc.nasa.gov/c/26/",
-    'RW6': "https://ti.arc.nasa.gov/c/26/",
-    'RW7': "https://ti.arc.nasa.gov/c/27/",
-    'RW8': "https://ti.arc.nasa.gov/c/27/",
-    'RW9': "https://ti.arc.nasa.gov/c/25/",
-    'RW10': "https://ti.arc.nasa.gov/c/25/",
-    'RW11': "https://ti.arc.nasa.gov/c/25/",
-    'RW12': "https://ti.arc.nasa.gov/c/25/",
-    'RW13': "https://ti.arc.nasa.gov/c/31/",
-    'RW14': "https://ti.arc.nasa.gov/c/31/",
-    'RW15': "https://ti.arc.nasa.gov/c/31/",
-    'RW16': "https://ti.arc.nasa.gov/c/31/",
-    'RW17': "https://ti.arc.nasa.gov/c/29/",
-    'RW18': "https://ti.arc.nasa.gov/c/29/",
-    'RW19': "https://ti.arc.nasa.gov/c/29/",
-    'RW20': "https://ti.arc.nasa.gov/c/29/",
-    'RW21': "https://ti.arc.nasa.gov/c/30/",
-    'RW22': "https://ti.arc.nasa.gov/c/30/",
-    'RW23': "https://ti.arc.nasa.gov/c/30/",
-    'RW24': "https://ti.arc.nasa.gov/c/30/",
-    'RW25': "https://ti.arc.nasa.gov/c/28/",
-    'RW26': "https://ti.arc.nasa.gov/c/28/",
-    'RW27': "https://ti.arc.nasa.gov/c/28/",
-    'RW28': "https://ti.arc.nasa.gov/c/28/",
+    'RW1': "https://data.nasa.gov/download/ed33-vxp2/application%2Fzip",
+    'RW2': "https://data.nasa.gov/download/ed33-vxp2/application%2Fzip",
+    'RW3': "https://data.nasa.gov/download/qghr-qkfw/application%2Fzip",
+    'RW4': "https://data.nasa.gov/download/qghr-qkfw/application%2Fzip",
+    'RW5': "https://data.nasa.gov/download/qghr-qkfw/application%2Fzip",
+    'RW6': "https://data.nasa.gov/download/qghr-qkfw/application%2Fzip",
+    'RW7': "https://data.nasa.gov/download/ed33-vxp2/application%2Fzip",
+    'RW8': "https://data.nasa.gov/download/ed33-vxp2/application%2Fzip",
+    'RW9': "https://data.nasa.gov/download/ugxu-9kjx/application%2Fzip",
+    'RW10': "https://data.nasa.gov/download/ugxu-9kjx/application%2Fzip",
+    'RW11': "https://data.nasa.gov/download/ugxu-9kjx/application%2Fzip",
+    'RW12': "https://data.nasa.gov/download/ugxu-9kjx/application%2Fzip",
+    'RW13': "https://data.nasa.gov/download/sb48-rsbc/application%2Fzip",
+    'RW14': "https://data.nasa.gov/download/sb48-rsbc/application%2Fzip",
+    'RW15': "https://data.nasa.gov/download/sb48-rsbc/application%2Fzip",
+    'RW16': "https://data.nasa.gov/download/sb48-rsbc/application%2Fzip",
+    'RW17': "https://data.nasa.gov/download/tcjd-g74p/application%2Fzip",
+    'RW18': "https://data.nasa.gov/download/tcjd-g74p/application%2Fzip",
+    'RW19': "https://data.nasa.gov/download/tcjd-g74p/application%2Fzip",
+    'RW20': "https://data.nasa.gov/download/tcjd-g74p/application%2Fzip",
+    'RW21': "https://data.nasa.gov/download/5uxu-h2h6/application%2Fzip",
+    'RW22': "https://data.nasa.gov/download/5uxu-h2h6/application%2Fzip",
+    'RW23': "https://data.nasa.gov/download/5uxu-h2h6/application%2Fzip",
+    'RW24': "https://data.nasa.gov/download/5uxu-h2h6/application%2Fzip",
+    'RW25': "https://data.nasa.gov/download/gah6-q2es/application%2Fzip",
+    'RW26': "https://data.nasa.gov/download/gah6-q2es/application%2Fzip",
+    'RW27': "https://data.nasa.gov/download/gah6-q2es/application%2Fzip",
+    'RW28': "https://data.nasa.gov/download/gah6-q2es/application%2Fzip",
 }
 
 cache = {}  # Cache for downloaded data
@@ -61,11 +61,6 @@ def load_data(batt_id : str) -> tuple:
     Raises:
         ValueError: Battery id must be a string or int
         ConnectionError: Failed to download data. This may be because of issues with your internet connection or the datasets may have moved. Please check your internet connection and make sure you're using the latest version of prog_models.
-
-    Note:
-        Due to the NASA web modernization effort the dataset may be moved to a different URL. If that happens, this feature will break and the user will get a connection error. When/if that happens, we will quickly release an updated version with the new dataset URL. Update to the latest version.
-    
-        In all other instances of connection error or failed downloading, please submit an issue on the repository page (https://github.com/nasa/prog_models/issues) for our team to look into.
     """
     if isinstance(batt_id, int):
         # Convert to string
@@ -86,7 +81,11 @@ def load_data(batt_id : str) -> tuple:
             raise ConnectionRefusedError("Data download failed. This may be because of issues with your internet connection or the datasets may have moved. Please check your internet connection and make sure you're using the latest version of prog_models. If the problem persists, please submit an issue on the prog_models issue page (https://github.com/nasa/prog_models/issues) for further investigation.")
 
         # Unzip response
-        cache[url] = zipfile.ZipFile(io.BytesIO(response.content))
+        try:
+            cache[url] = zipfile.ZipFile(io.BytesIO(response.content))
+        except zipfile.BadZipFile:
+            # In this case the url may have been forwarded to another page
+            raise ConnectionRefusedError("Data unzip failed- The site may be down or the datasets may have moved. Please try again later and make sure you're using the latest version of prog_models. If the problem persists, please submit an issue on the prog_models issue page (https://github.com/nasa/prog_models/issues) for further investigation.")
 
     f = cache[url].open(f'{cache[url].infolist()[0].filename}Matlab/{batt_id}.mat')
 
diff --git a/src/prog_models/datasets/nasa_cmapss.py b/src/prog_models/datasets/nasa_cmapss.py
index a113ae04e..56290fcb4 100644
--- a/src/prog_models/datasets/nasa_cmapss.py
+++ b/src/prog_models/datasets/nasa_cmapss.py
@@ -7,7 +7,7 @@
 import zipfile
 
 cache = None
-URL = "https://ti.arc.nasa.gov/c/6/"
+URL = "https://data.nasa.gov/download/ff5v-kuh6/application%2Fzip"
 
 
 def load_data(dataset_id : int) -> tuple:
@@ -43,9 +43,7 @@ def load_data(dataset_id : int) -> tuple:
 
     Data sets consists of multiple multivariate time series. Each data set is further divided into training and test subsets. Each time series is from a different engine i.e., the data can be considered to be from a fleet of engines of the same type. Each engine starts with different degrees of initial wear and manufacturing variation which is unknown to the user. This wear and variation is considered normal, i.e., it is not considered a fault condition. There are three operational settings that have a substantial effect on engine performance. These settings are also included in the data. The data is contaminated with sensor noise.
 
-    The engine is operating normally at the start of each time series, and develops a fault at some point during the series. In the training set, the fault grows in magnitude until system failure. In the test set, the time series ends some time prior to system failure. The objective of the competition is to predict the number of remaining operational cycles before failure in the test set, i.e., the number of operational cycles after the last cycle that the engine will continue to operate. Also provided a vector of true Remaining Useful Life (RUL) values for the test data.
-
-    Reference: A. Saxena, K. Goebel, D. Simon, and N. Eklund, Damage Propagation Modeling for Aircraft Engine Run-to-Failure Simulation, in the Proceedings of the Ist International Conference on Prognostics and Health Management (PHM08), Denver CO, Oct 2008.
+    The engine is operating normally at the start of each time series, and develops a fault at some point during the series. In the training set, the fault grows in magnitude until system failure. In the test set, the time series ends some time prior to system failure. The objective of the competition is to predict the number of remaining operational cycles before failure in the test set, i.e., the number of operational cycles after the last cycle that the engine will continue to operate. Also provided a vector of true Remaining Useful Life (RUL) values for the test data. [0]_
 
     Args:
         dataset_id (int): Dataset id
@@ -73,10 +71,8 @@ def load_data(dataset_id : int) -> tuple:
         ValueError: Data not in dataset (should be 1-4)
         ConnectionError: Failed to download data. This may be because of issues with your internet connection or the datasets may have moved. Please check your internet connection and make sure you're using the latest version of prog_models.
     
-    Note:
-        Due to the NASA web modernization effort the dataset may be moved to a different URL. If that happens, this feature will break and the user will get a connection error. When/if that happens, we will quickly release an updated version with the new dataset URL. Update to the latest version.
-
-        In all other instances of connection error or failed downloading, please submit an issue on the repository page (https://github.com/nasa/prog_models/issues) for our team to look into.
+    References:
+        .. [0] A. Saxena, K. Goebel, D. Simon, and N. Eklund, Damage Propagation Modeling for Aircraft Engine Run-to-Failure Simulation, in the Proceedings of the Ist International Conference on Prognostics and Health Management (PHM08), Denver CO, Oct 2008.
     """
     global cache
 
@@ -92,7 +88,11 @@ def load_data(dataset_id : int) -> tuple:
             raise ConnectionError("Data download failed. This may be because of issues with your internet connection or the datasets may have moved. Please check your internet connection and make sure you're using the latest version of prog_models. If the problem persists, please submit an issue on the prog_models issue page (https://github.com/nasa/prog_models/issues) for further investigation.")
 
         # Unzip response
-        cache = zipfile.ZipFile(io.BytesIO(response.content))
+        try:
+            cache = zipfile.ZipFile(io.BytesIO(response.content))
+        except zipfile.BadZipFile:
+            # In this case the url may have been forwarded to another page
+            raise ConnectionRefusedError("Data unzip failed- The site may be down or the datasets may have moved. Please try again later and make sure you're using the latest version of prog_models. If the problem persists, please submit an issue on the prog_models issue page (https://github.com/nasa/prog_models/issues) for further investigation.")
 
     # Read Files
     with cache.open(f'test_{dataset_id}.txt', mode='r') as f:
diff --git a/src/prog_models/exceptions.py b/src/prog_models/exceptions.py
index 8a28a9faf..7af1d0222 100644
--- a/src/prog_models/exceptions.py
+++ b/src/prog_models/exceptions.py
@@ -1,6 +1,7 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the
 # National Aeronautics and Space Administration.  All Rights Reserved.
 
+
 class ProgModelException(Exception):
     """
     Base Prognostics Model Exception
diff --git a/src/prog_models/linear_model.py b/src/prog_models/linear_model.py
index 910fd4562..2298dbae8 100644
--- a/src/prog_models/linear_model.py
+++ b/src/prog_models/linear_model.py
@@ -1,30 +1,33 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the
 # National Aeronautics and Space Administration.  All Rights Reserved.
 
-from . import PrognosticsModel
 from abc import ABC, abstractmethod
 import numpy as np
 
+from . import PrognosticsModel
+
 
 class LinearModel(PrognosticsModel, ABC):
     """
     A linear prognostics :term:`model`. Used when behavior can be described using a simple linear time-series model defined by the following equations:
 
     .. math::
-        \frac{dx}{dt} = Ax + Bu + E
+        \dfrac{dx}{dt} = Ax + Bu + E
+
         z = Cx + D
+
         es = Fx + G
 
     where x is :term:`state`, u is :term:`input`, z is :term:`output` and es is :term:`event state`
 
     Linear Models must inherit from this class and define the following properties:
-        * A: 2-d numpy.array[float], dimensions: n_states x n_states
-        * B: 2-d numpy.array[float], optional (zeros by default), dimensions: n_states x n_inputs
-        * C: 2-d numpy.array[float], optional (zeros by default), dimensions: n_outputs x n_states
-        * D: 1-d numpy.array[float], dimensions: n_outputs x 1
-        * E: 1-d numpy.array[float], optional (zeros by default), dimensions: n_states x 1
-        * F: 2-d numpy.array[float], dimensions: n_es x n_states
-        * G: 1-d numpy.array[float], optional (zeros by default), dimensions: n_es x 1
+        * A: 2-d np.array[float], dimensions: n_states x n_states
+        * B: 2-d np.array[float], optional (zeros by default), dimensions: n_states x n_inputs
+        * C: 2-d np.array[float], optional (zeros by default), dimensions: n_outputs x n_states
+        * D: 1-d np.array[float], dimensions: n_outputs x 1
+        * E: 1-d np.array[float], optional (zeros by default), dimensions: n_states x 1
+        * F: 2-d np.array[float], dimensions: n_es x n_states
+        * G: 1-d np.array[float], optional (zeros by default), dimensions: n_es x 1
         * inputs:  list[str] - :term:`input` keys
         * states:  list[str] - :term:`state` keys
         * outputs: list[str] - :term:`output` keys
@@ -102,7 +105,9 @@ def G(self):
         return np.zeros((self.n_events, 1))
 
     def dx(self, x, u):
-        dx_array = np.matmul(self.A, x.matrix) + np.matmul(self.B, u.matrix) + self.E
+        dx_array = np.matmul(self.A, x.matrix) + self.E
+        if len(u.matrix) > 0:
+            dx_array += np.matmul(self.B, u.matrix)
         return self.StateContainer(dx_array)
         
     def output(self, x):
@@ -112,3 +117,7 @@ def output(self, x):
     def event_state(self, x):
         es_array = np.matmul(self.F, x.matrix) + self.G
         return {key: value[0] for key, value in zip(self.events, es_array)}
+    
+    def threshold_met(self, x):
+        es_array = np.matmul(self.F, x.matrix) + self.G
+        return {key: value[0] <= 0 for key, value in zip(self.events, es_array)}
diff --git a/src/prog_models/models/__init__.py b/src/prog_models/models/__init__.py
index 5e8aebe8b..1b24d4412 100644
--- a/src/prog_models/models/__init__.py
+++ b/src/prog_models/models/__init__.py
@@ -6,6 +6,8 @@
 from .centrifugal_pump import CentrifugalPump, CentrifugalPumpBase, CentrifugalPumpWithWear
 from .pneumatic_valve import PneumaticValve, PneumaticValveBase, PneumaticValveWithWear
 from .dcmotor import DCMotor
+from .dcmotor_singlephase import DCMotorSP
 from .esc import ESC
 from .powertrain import Powertrain
 from .thrown_object import ThrownObject
+from .paris_law import ParisLawCrackGrowth
diff --git a/src/prog_models/models/battery_circuit.py b/src/prog_models/models/battery_circuit.py
index b7aa7a01d..19d4fcd39 100644
--- a/src/prog_models/models/battery_circuit.py
+++ b/src/prog_models/models/battery_circuit.py
@@ -1,11 +1,11 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the
 # National Aeronautics and Space Administration.  All Rights Reserved.
 
-from .. import PrognosticsModel
-
 from math import inf
 import numpy as np
 
+from .. import PrognosticsModel
+
 
 class BatteryCircuit(PrognosticsModel):
     """
@@ -52,9 +52,27 @@ class BatteryCircuit(PrognosticsModel):
           Maximum Capacity
         VEOD : float
           End of Discharge Voltage Threshold
-        Cb0, Cbp0, Cbp1, Cbp2, Cbp3 : float 
-          Battery Capacity Parameters
-        Rs, Cs, Rcp0, Rcp1, Rcp2, Ccp : float
+        Cb0 : float 
+          Battery Capacity Parameter
+        Cbp0 : float 
+          Battery Capacity Parameter
+        Cbp1 : float 
+          Battery Capacity Parameter
+        Cbp2 : float 
+          Battery Capacity Parameter
+        Cbp3 : float 
+          Battery Capacity Parameter
+        Rs : float
+          R-C Pair Parameter
+        Cs : float
+          R-C Pair Parameter
+        Rcp0 : float
+          R-C Pair Parameter
+        Rcp1 : float
+          R-C Pair Parameter
+        Rcp2 : float
+          R-C Pair Parameter
+        Ccp : float
           R-C Pair Parameter
         Ta : float
           Ambient Temperature
@@ -119,9 +137,6 @@ class BatteryCircuit(PrognosticsModel):
         'qb': (0, inf)
     }
 
-    def initialize(self, u=None, z=None):
-        return self.StateContainer(self.parameters['x0'])
-
     def dx(self, x : dict, u : dict):
         # Keep this here- accessing member can be expensive in python- this optimization reduces runtime by almost half!
         parameters = self.parameters
@@ -144,10 +159,10 @@ def dx(self, x : dict, u : dict):
         ics = ib - Vcs/Rs
 
         return self.StateContainer(np.array([
-            [Tbdot],  # tb
-            [-ib],    # qb
-            [icp],    # qcp
-            [ics]     # qcs
+            np.atleast_1d(Tbdot),  # tb
+            np.atleast_1d(-ib),    # qb
+            np.atleast_1d(icp),    # qcp
+            np.atleast_1d(ics)     # qcs
         ]))
     
     def event_state(self, x : dict) -> dict:
@@ -175,8 +190,8 @@ def output(self, x : dict):
         Vb = x['qb']/Cb
 
         return self.OutputContainer(np.array([
-            [x['tb']],            # t
-            [Vb - Vcp - Vcs]]))   # v
+            np.atleast_1d(x['tb']),            # t
+            np.atleast_1d(Vb - Vcp - Vcs)]))   # v
 
     def threshold_met(self, x : dict) -> dict:
         parameters = self.parameters
diff --git a/src/prog_models/models/battery_electrochem.py b/src/prog_models/models/battery_electrochem.py
index e499a93b8..f869d762d 100644
--- a/src/prog_models/models/battery_electrochem.py
+++ b/src/prog_models/models/battery_electrochem.py
@@ -1,12 +1,12 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the
 # National Aeronautics and Space Administration.  All Rights Reserved.
 
-from .. import PrognosticsModel
-
 from copy import deepcopy
 import numpy as np
+from scipy.optimize import fsolve
 import warnings
 
+from .. import PrognosticsModel
 
 # Constants of nature
 R = 8.3144621  # universal gas constant, J/K/mol
@@ -93,6 +93,63 @@ def update_qnSBmax(params : dict) -> dict:
         }
     }
 
+def update_v0(params: dict) -> dict:
+    # update the initial voltage 
+
+    if 'qnS' not in params['x0']:
+        # qnS not yet set
+        return {}
+
+    An = params['An']
+    # Negative Surface
+    xnS = params['x0']['qnS']/params['qSMax']
+    xnS2 = xnS+xnS  # Note: in python x+x is more efficient than 2*x
+    one_minus_xnS = 1 - xnS
+    xnS2_minus_1 = xnS2 - 1
+    VenParts = [
+        An[0] *xnS2_minus_1/F,  # Ven0
+        An[1] *(xnS2_minus_1**2  - (xnS2*one_minus_xnS))/F,  # Ven1
+        An[2] *(xnS2_minus_1**3  - (4 *xnS*one_minus_xnS)*xnS2_minus_1)/F,  #Ven2
+        An[3] *(xnS2_minus_1**4  - (6 *xnS*one_minus_xnS)*xnS2_minus_1**2) /F,  #Ven3
+        An[4] *(xnS2_minus_1**5  - (8 *xnS*one_minus_xnS)*xnS2_minus_1**3) /F,  #Ven4
+        An[5] *(xnS2_minus_1**6  - (10*xnS*one_minus_xnS)*xnS2_minus_1**4) /F,  #Ven5
+        An[6] *(xnS2_minus_1**7  - (12*xnS*one_minus_xnS)*xnS2_minus_1**5) /F,  #Ven6
+        An[7] *(xnS2_minus_1**8  - (14*xnS*one_minus_xnS)*xnS2_minus_1**6) /F,  #Ven7
+        An[8] *(xnS2_minus_1**9  - (16*xnS*one_minus_xnS)*xnS2_minus_1**7) /F,  #Ven8
+        An[9] *(xnS2_minus_1**10 - (18*xnS*one_minus_xnS)*xnS2_minus_1**8) /F,  #Ven9
+        An[10]*(xnS2_minus_1**11 - (20*xnS*one_minus_xnS)*xnS2_minus_1**9) /F,  #Ven10
+        An[11]*(xnS2_minus_1**12 - (22*xnS*one_minus_xnS)*xnS2_minus_1**10)/F,  #Ven11
+        An[12]*(xnS2_minus_1**13 - (24*xnS*one_minus_xnS)*xnS2_minus_1**11)/F   #Ven12
+    ]
+    Ven = params['U0n'] + R*params['x0']['tb']/F*np.log(one_minus_xnS/xnS) + sum(VenParts)
+
+    # Positive Surface
+    Ap = params['Ap']
+    xpS = params['x0']['qpS']/params['qSMax']
+    one_minus_xpS = (1 - xpS)
+    xpS2 = xpS + xpS
+    xpS2_minus_1 = xpS2 - 1
+    VepParts = [
+        Ap[0] *(xpS2_minus_1)/F,  #Vep0
+        Ap[1] *((xpS2_minus_1)**2  - (xpS2*one_minus_xpS))/F,  #Vep1 
+        Ap[2] *((xpS2_minus_1)**3  - (4 *xpS*one_minus_xpS)*(xpS2_minus_1)) /F,  #Vep2
+        Ap[3] *((xpS2_minus_1)**4  - (6 *xpS*one_minus_xpS)*(xpS2_minus_1)**(2)) /F,  #Vep3
+        Ap[4] *((xpS2_minus_1)**5  - (8 *xpS*one_minus_xpS)*(xpS2_minus_1)**(3)) /F,  #Vep4
+        Ap[5] *((xpS2_minus_1)**6  - (10*xpS*one_minus_xpS)*(xpS2_minus_1)**(4)) /F,  #Vep5
+        Ap[6] *((xpS2_minus_1)**7  - (12*xpS*one_minus_xpS)*(xpS2_minus_1)**(5)) /F,  #Vep6
+        Ap[7] *((xpS2_minus_1)**8  - (14*xpS*one_minus_xpS)*(xpS2_minus_1)**(6)) /F,  #Vep7
+        Ap[8] *((xpS2_minus_1)**9  - (16*xpS*one_minus_xpS)*(xpS2_minus_1)**(7)) /F,  #Vep8
+        Ap[9] *((xpS2_minus_1)**10 - (18*xpS*one_minus_xpS)*(xpS2_minus_1)**(8)) /F,  #Vep9
+        Ap[10]*((xpS2_minus_1)**11 - (20*xpS*one_minus_xpS)*(xpS2_minus_1)**(9)) /F,  #Vep10
+        Ap[11]*((xpS2_minus_1)**12 - (22*xpS*one_minus_xpS)*(xpS2_minus_1)**(10))/F,  #Vep11
+        Ap[12]*((xpS2_minus_1)**13 - (24*xpS*one_minus_xpS)*(xpS2_minus_1)**(11))/F   #Vep12
+    ]
+    Vep = params['U0p'] + R*params['x0']['tb']/F*np.log(one_minus_xpS/xpS) + sum(VepParts)
+
+    return {
+        'v0': Vep - Ven - params['x0']['Vo'] - params['x0']['Vsn'] - params['x0']['Vsp']
+    }
+
 def update_qSBmax(params : dict) -> dict:
     # max charge at surface, bulk (pos and neg)
     return {
@@ -103,10 +160,9 @@ def update_qSBmax(params : dict) -> dict:
 
 class BatteryElectroChemEOD(PrognosticsModel):
     """
-    Vectorized prognostics :term:`model` for a battery, represented by an electrochemical equations as described in the following paper:
-    `M. Daigle and C. Kulkarni, "Electrochemistry-based Battery Modeling for Prognostics," Annual Conference of the Prognostics and Health Management Society 2013, pp. 249-261, New Orleans, LA, October 2013. https://papers.phmsociety.org/index.php/phmconf/article/view/2252`. This model predicts the end of discharge event. 
+    Vectorized prognostics :term:`model` for a battery, represented by an electrochemical equations as described in [0]_. This model predicts the end of discharge event. 
 
-    The default model parameters included are for Li-ion batteries, specifically 18650-type cells. Experimental discharge curves for these cells can be downloaded from the `Prognostics Center of Excellence Data Repository https://ti.arc.nasa.gov/tech/dash/groups/pcoe/prognostic-data-repository/`.
+    The default model parameters included are for Li-ion batteries, specifically 18650-type cells. Experimental discharge curves for these cells can be downloaded from the Prognostics Center of Excellence Data Repository [1]_.
 
     :term:`Events<event>`: (1)
         EOD: End of Discharge
@@ -128,6 +184,9 @@ class BatteryElectroChemEOD(PrognosticsModel):
         | t: Temperature of battery (°C) 
         | v: Voltage supplied by battery
 
+    :term:`Performance Metrics<performance metric>`: (1)
+        | max_i : The maximum current (amps) that can be sustained before steady-state voltage falls below VEOD. Decreases with discharge 
+
     Keyword Args
     ------------
         process_noise : Optional, float or Dict[str, float]
@@ -175,9 +234,13 @@ class BatteryElectroChemEOD(PrognosticsModel):
             for surface overpotential (neg)
         tsp : float
             for surface overpotential (pos)
-        U0p, Ap : float
+        U0p : float
+            Redlich-Kister parameter (+ electrode)
+        Ap : float
             Redlich-Kister parameter (+ electrode)
-        U0n, An : float
+        U0n : float
+            Redlich-Kister parameter (- electrode)
+        An : float
             Redlich-Kister parameter (- electrode)
         VEOD : float
             End of Discharge Voltage Threshold
@@ -187,11 +250,17 @@ class BatteryElectroChemEOD(PrognosticsModel):
     See Also
     --------
     BatteryElectroChemEOL, BatteryElectroChem, BatteryElectroChemEODEOL
+
+    References 
+    -------------
+     .. [0] M. Daigle and C. Kulkarni, "Electrochemistry-based Battery Modeling for Prognostics," Annual Conference of the Prognostics and Health Management Society 2013, pp. 249-261, New Orleans, LA, October 2013. https://papers.phmsociety.org/index.php/phmconf/article/view/2252
+     .. [1] Prognostics Center of Excellence Data Repository https://ti.arc.nasa.gov/tech/dash/groups/pcoe/prognostic-data-repository/.
     """
     events = ['EOD']
     inputs = ['i']
     states = ['tb', 'Vo', 'Vsn', 'Vsp', 'qnB', 'qnS', 'qpB', 'qpS']
     outputs = ['t', 'v']
+    performance_metric_keys = ['max_i']
     is_vectorized = True
 
     default_parameters = {  # Set to defaults
@@ -258,9 +327,6 @@ class BatteryElectroChemEOD(PrognosticsModel):
         'xnMax': [update_qmax, update_qnmax, update_qnSBmax]
     }
 
-    def initialize(self, u=None, z=None):
-        return self.StateContainer(self.parameters['x0'])
-
     def dx(self, x : dict, u : dict):
         params = self.parameters
         # Negative Surface
@@ -305,15 +371,76 @@ def dx(self, x : dict, u : dict):
         Tbdot = voltage_eta*u['i']/mC + (params['x0']['tb'] - x['tb'])/tau # Newman
 
         return self.StateContainer(np.array([
-            [Tbdot],
-            [Vodot],
-            [Vsndot],
-            [Vspdot],
-            [qnBdot],
-            [qnSdot],
-            [qpBdot],
-            [qpSdot]
+            np.atleast_1d(Tbdot),
+            np.atleast_1d(Vodot),
+            np.atleast_1d(Vsndot),
+            np.atleast_1d(Vspdot),
+            np.atleast_1d(qnBdot),
+            np.atleast_1d(qnSdot),
+            np.atleast_1d(qpBdot),
+            np.atleast_1d(qpSdot)
         ]))
+
+    def performance_metrics(self, x):
+        params = self.parameters
+        An = params['An']
+        # Negative Surface
+        xnS = x['qnS']/params['qSMax']
+        xnS2 = xnS+xnS  # Note: in python x+x is more efficient than 2*x
+        one_minus_xnS = 1 - xnS
+        xnS2_minus_1 = xnS2 - 1
+        VenParts = [
+            An[0] *xnS2_minus_1/F,  # Ven0
+            An[1] *(xnS2_minus_1**2  - (xnS2*one_minus_xnS))/F,  # Ven1
+            An[2] *(xnS2_minus_1**3  - (4 *xnS*one_minus_xnS)*xnS2_minus_1)/F,  #Ven2
+            An[3] *(xnS2_minus_1**4  - (6 *xnS*one_minus_xnS)*xnS2_minus_1**2) /F,  #Ven3
+            An[4] *(xnS2_minus_1**5  - (8 *xnS*one_minus_xnS)*xnS2_minus_1**3) /F,  #Ven4
+            An[5] *(xnS2_minus_1**6  - (10*xnS*one_minus_xnS)*xnS2_minus_1**4) /F,  #Ven5
+            An[6] *(xnS2_minus_1**7  - (12*xnS*one_minus_xnS)*xnS2_minus_1**5) /F,  #Ven6
+            An[7] *(xnS2_minus_1**8  - (14*xnS*one_minus_xnS)*xnS2_minus_1**6) /F,  #Ven7
+            An[8] *(xnS2_minus_1**9  - (16*xnS*one_minus_xnS)*xnS2_minus_1**7) /F,  #Ven8
+            An[9] *(xnS2_minus_1**10 - (18*xnS*one_minus_xnS)*xnS2_minus_1**8) /F,  #Ven9
+            An[10]*(xnS2_minus_1**11 - (20*xnS*one_minus_xnS)*xnS2_minus_1**9) /F,  #Ven10
+            An[11]*(xnS2_minus_1**12 - (22*xnS*one_minus_xnS)*xnS2_minus_1**10)/F,  #Ven11
+            An[12]*(xnS2_minus_1**13 - (24*xnS*one_minus_xnS)*xnS2_minus_1**11)/F   #Ven12
+        ]
+        Ven = params['U0n'] + R*x['tb']/F*np.log(one_minus_xnS/xnS) + sum(VenParts)
+
+        # Positive Surface
+        Ap = params['Ap']
+        xpS = x['qpS']/params['qSMax']
+        one_minus_xpS = 1 - xpS
+        xpS2 = xpS + xpS
+        xpS2_minus_1 = xpS2 - 1
+        VepParts = [
+            Ap[0] *(xpS2_minus_1)/F,  #Vep0
+            Ap[1] *((xpS2_minus_1)**2  - xpS2*one_minus_xpS)/F,  #Vep1 
+            Ap[2] *((xpS2_minus_1)**3  - 4 *xpS*one_minus_xpS*xpS2_minus_1)/F,  #Vep2
+            Ap[3] *((xpS2_minus_1)**4  - 6 *xpS*one_minus_xpS*xpS2_minus_1**2) /F,  #Vep3
+            Ap[4] *((xpS2_minus_1)**5  - 8 *xpS*one_minus_xpS*xpS2_minus_1**3) /F,  #Vep4
+            Ap[5] *((xpS2_minus_1)**6  - 10*xpS*one_minus_xpS*xpS2_minus_1**4) /F,  #Vep5
+            Ap[6] *((xpS2_minus_1)**7  - 12*xpS*one_minus_xpS*xpS2_minus_1**5) /F,  #Vep6
+            Ap[7] *((xpS2_minus_1)**8  - 14*xpS*one_minus_xpS*xpS2_minus_1**6) /F,  #Vep7
+            Ap[8] *((xpS2_minus_1)**9  - 16*xpS*one_minus_xpS*xpS2_minus_1**7) /F,  #Vep8
+            Ap[9] *((xpS2_minus_1)**10 - 18*xpS*one_minus_xpS*xpS2_minus_1**8) /F,  #Vep9
+            Ap[10]*((xpS2_minus_1)**11 - 20*xpS*one_minus_xpS*xpS2_minus_1**9) /F,  #Vep10
+            Ap[11]*((xpS2_minus_1)**12 - 22*xpS*one_minus_xpS*xpS2_minus_1**10)/F,  #Vep11
+            Ap[12]*((xpS2_minus_1)**13 - 24*xpS*one_minus_xpS*xpS2_minus_1**11)/F   #Vep12
+        ]
+        Vep = params['U0p'] + R*x['tb']/F*np.log(one_minus_xpS/xpS) + sum(VepParts)
+
+        v_part = R_F*x['tb']/params['alpha']
+        Jp0 = params['kp']*(one_minus_xpS*xpS)**params['alpha']
+        Jn0 = params['kn']*(one_minus_xnS*xnS)**params['alpha']
+
+        C1 = params['Sn']*(2*Jn0)
+        C2 = params['Sp']*(2*Jp0)
+        
+        # Solve for the current that would cause the steady state voltage to hit VEOD
+        def f(i):
+            return Vep - Ven - i*params['Ro'] - v_part*(np.arcsinh(i/C1) + np.arcsinh(i/C2)) - params['VEOD']
+        
+        return {'max_i': fsolve(f, [3])}
         
     def event_state(self, x : dict) -> dict:
         # The most "correct" indication of SOC is based on charge (charge_EOD), 
@@ -348,23 +475,25 @@ def event_state(self, x : dict) -> dict:
         # Positive Surface
         Ap = params['Ap']
         xpS = x['qpS']/params['qSMax']
+        one_minus_xpS = 1 - xpS
         xpS2 = xpS + xpS
+        xpS2_minus_1 = xpS2 - 1
         VepParts = [
-            Ap[0] *(xpS2-1)/F,  #Vep0
-            Ap[1] *((xpS2-1)**2  - (xpS2*(1-xpS)))/F,  #Vep1 
-            Ap[2] *((xpS2-1)**3  - (4 *xpS*(1-xpS))/(xpS2-1)**(-1)) /F,  #Vep2
-            Ap[3] *((xpS2-1)**4  - (6 *xpS*(1-xpS))/(xpS2-1)**(-2)) /F,  #Vep3
-            Ap[4] *((xpS2-1)**5  - (8 *xpS*(1-xpS))/(xpS2-1)**(-3)) /F,  #Vep4
-            Ap[5] *((xpS2-1)**6  - (10*xpS*(1-xpS))/(xpS2-1)**(-4)) /F,  #Vep5
-            Ap[6] *((xpS2-1)**7  - (12*xpS*(1-xpS))/(xpS2-1)**(-5)) /F,  #Vep6
-            Ap[7] *((xpS2-1)**8  - (14*xpS*(1-xpS))/(xpS2-1)**(-6)) /F,  #Vep7
-            Ap[8] *((xpS2-1)**9  - (16*xpS*(1-xpS))/(xpS2-1)**(-7)) /F,  #Vep8
-            Ap[9] *((xpS2-1)**10 - (18*xpS*(1-xpS))/(xpS2-1)**(-8)) /F,  #Vep9
-            Ap[10]*((xpS2-1)**11 - (20*xpS*(1-xpS))/(xpS2-1)**(-9)) /F,  #Vep10
-            Ap[11]*((xpS2-1)**12 - (22*xpS*(1-xpS))/(xpS2-1)**(-10))/F,  #Vep11
-            Ap[12]*((xpS2-1)**13 - (24*xpS*(1-xpS))/(xpS2-1)**(-11))/F   #Vep12
+            Ap[0] *(xpS2_minus_1)/F,  #Vep0
+            Ap[1] *((xpS2_minus_1)**2  - (xpS2*one_minus_xpS))/F,  #Vep1 
+            Ap[2] *((xpS2_minus_1)**3  - (4 *xpS*one_minus_xpS)*(xpS2_minus_1)) /F,  #Vep2
+            Ap[3] *((xpS2_minus_1)**4  - (6 *xpS*one_minus_xpS)*(xpS2_minus_1)**(2)) /F,  #Vep3
+            Ap[4] *((xpS2_minus_1)**5  - (8 *xpS*one_minus_xpS)*(xpS2_minus_1)**(3)) /F,  #Vep4
+            Ap[5] *((xpS2_minus_1)**6  - (10*xpS*one_minus_xpS)*(xpS2_minus_1)**(4)) /F,  #Vep5
+            Ap[6] *((xpS2_minus_1)**7  - (12*xpS*one_minus_xpS)*(xpS2_minus_1)**(5)) /F,  #Vep6
+            Ap[7] *((xpS2_minus_1)**8  - (14*xpS*one_minus_xpS)*(xpS2_minus_1)**(6)) /F,  #Vep7
+            Ap[8] *((xpS2_minus_1)**9  - (16*xpS*one_minus_xpS)*(xpS2_minus_1)**(7)) /F,  #Vep8
+            Ap[9] *((xpS2_minus_1)**10 - (18*xpS*one_minus_xpS)*(xpS2_minus_1)**(8)) /F,  #Vep9
+            Ap[10]*((xpS2_minus_1)**11 - (20*xpS*one_minus_xpS)*(xpS2_minus_1)**(9)) /F,  #Vep10
+            Ap[11]*((xpS2_minus_1)**12 - (22*xpS*one_minus_xpS)*(xpS2_minus_1)**(10))/F,  #Vep11
+            Ap[12]*((xpS2_minus_1)**13 - (24*xpS*one_minus_xpS)*(xpS2_minus_1)**(11))/F   #Vep12
         ]
-        Vep = params['U0p'] + R*x['tb']/F*np.log((1-xpS)/xpS) + sum(VepParts)
+        Vep = params['U0p'] + R*x['tb']/F*np.log(one_minus_xpS/xpS) + sum(VepParts)
         v = Vep - Ven - x['Vo'] - x['Vsn'] - x['Vsp']
 
         charge_EOD = (x['qnS'] + x['qnB'])/self.parameters['qnMax']
@@ -401,27 +530,29 @@ def output(self, x : dict):
         # Positive Surface
         Ap = params['Ap']
         xpS = x['qpS']/params['qSMax']
+        one_minus_xpS = 1 - xpS
         xpS2 = xpS + xpS
+        xpS2_minus_1 = xpS2 - 1
         VepParts = [
-            Ap[0] *(xpS2-1)/F,  #Vep0
-            Ap[1] *((xpS2-1)**2  - (xpS2*(1-xpS)))/F,  #Vep1 
-            Ap[2] *((xpS2-1)**3  - (4 *xpS*(1-xpS))/(xpS2-1)**(-1)) /F,  #Vep2
-            Ap[3] *((xpS2-1)**4  - (6 *xpS*(1-xpS))/(xpS2-1)**(-2)) /F,  #Vep3
-            Ap[4] *((xpS2-1)**5  - (8 *xpS*(1-xpS))/(xpS2-1)**(-3)) /F,  #Vep4
-            Ap[5] *((xpS2-1)**6  - (10*xpS*(1-xpS))/(xpS2-1)**(-4)) /F,  #Vep5
-            Ap[6] *((xpS2-1)**7  - (12*xpS*(1-xpS))/(xpS2-1)**(-5)) /F,  #Vep6
-            Ap[7] *((xpS2-1)**8  - (14*xpS*(1-xpS))/(xpS2-1)**(-6)) /F,  #Vep7
-            Ap[8] *((xpS2-1)**9  - (16*xpS*(1-xpS))/(xpS2-1)**(-7)) /F,  #Vep8
-            Ap[9] *((xpS2-1)**10 - (18*xpS*(1-xpS))/(xpS2-1)**(-8)) /F,  #Vep9
-            Ap[10]*((xpS2-1)**11 - (20*xpS*(1-xpS))/(xpS2-1)**(-9)) /F,  #Vep10
-            Ap[11]*((xpS2-1)**12 - (22*xpS*(1-xpS))/(xpS2-1)**(-10))/F,  #Vep11
-            Ap[12]*((xpS2-1)**13 - (24*xpS*(1-xpS))/(xpS2-1)**(-11))/F   #Vep12
+            Ap[0] *(xpS2_minus_1)/F,  #Vep0
+            Ap[1] *((xpS2_minus_1)**2  - (xpS2*one_minus_xpS))/F,  #Vep1 
+            Ap[2] *((xpS2_minus_1)**3  - (4 *xpS*one_minus_xpS)*(xpS2_minus_1)) /F,  #Vep2
+            Ap[3] *((xpS2_minus_1)**4  - (6 *xpS*one_minus_xpS)*(xpS2_minus_1)**(2)) /F,  #Vep3
+            Ap[4] *((xpS2_minus_1)**5  - (8 *xpS*one_minus_xpS)*(xpS2_minus_1)**(3)) /F,  #Vep4
+            Ap[5] *((xpS2_minus_1)**6  - (10*xpS*one_minus_xpS)*(xpS2_minus_1)**(4)) /F,  #Vep5
+            Ap[6] *((xpS2_minus_1)**7  - (12*xpS*one_minus_xpS)*(xpS2_minus_1)**(5)) /F,  #Vep6
+            Ap[7] *((xpS2_minus_1)**8  - (14*xpS*one_minus_xpS)*(xpS2_minus_1)**(6)) /F,  #Vep7
+            Ap[8] *((xpS2_minus_1)**9  - (16*xpS*one_minus_xpS)*(xpS2_minus_1)**(7)) /F,  #Vep8
+            Ap[9] *((xpS2_minus_1)**10 - (18*xpS*one_minus_xpS)*(xpS2_minus_1)**(8)) /F,  #Vep9
+            Ap[10]*((xpS2_minus_1)**11 - (20*xpS*one_minus_xpS)*(xpS2_minus_1)**(9)) /F,  #Vep10
+            Ap[11]*((xpS2_minus_1)**12 - (22*xpS*one_minus_xpS)*(xpS2_minus_1)**(10))/F,  #Vep11
+            Ap[12]*((xpS2_minus_1)**13 - (24*xpS*one_minus_xpS)*(xpS2_minus_1)**(11))/F   #Vep12
         ]
-        Vep = params['U0p'] + R*x['tb']/F*np.log((1-xpS)/xpS) + sum(VepParts)
+        Vep = params['U0p'] + R*x['tb']/F*np.log(one_minus_xpS/xpS) + sum(VepParts)
 
         return self.OutputContainer(np.array([
-            [x['tb'] - 273.15],
-            [Vep - Ven - x['Vo'] - x['Vsn'] - x['Vsp']]
+            np.atleast_1d(x['tb'] - 273.15),
+            np.atleast_1d(Vep - Ven - x['Vo'] - x['Vsn'] - x['Vsp'])
         ]))
 
     def threshold_met(self, x : dict) -> dict:
@@ -435,10 +566,9 @@ def threshold_met(self, x : dict) -> dict:
 
 class BatteryElectroChemEOL(PrognosticsModel):
     """
-    Vectorized prognostics :term:`model` for a battery degredation, represented by an electrochemical model as described in the following paper:
-    `M. Daigle and C. Kulkarni, "End-of-discharge and End-of-life Prediction in Lithium-ion Batteries with Electrochemistry-based Aging Models," AIAA SciTech Forum 2016, San Diego, CA. https://arc.aiaa.org/doi/pdf/10.2514/6.2016-2132`
+    Vectorized prognostics :term:`model` for a battery degredation, represented by an electrochemical model as described in [0]_
 
-    The default model parameters included are for Li-ion batteries, specifically 18650-type cells. Experimental discharge curves for these cells can be downloaded from the `Prognostics Center of Excellence Data Repository https://ti.arc.nasa.gov/tech/dash/groups/pcoe/prognostic-data-repository/`.
+    The default model parameters included are for Li-ion batteries, specifically 18650-type cells. Experimental discharge curves for these cells can be downloaded from the Prognostics Center of Excellence Data Repository[1]_.
 
     :term:`Events<event>`: (1)
         InsufficientCapacity: Insufficient battery capacity
@@ -468,8 +598,12 @@ class BatteryElectroChemEOL(PrognosticsModel):
         measurement_noise_dist : Optional, String
           distribution for measurement noise (e.g., normal, uniform, triangular)
         qMaxThreshold : float
-            Threshold for qMax (for threshold_met and event_state), after which the InsufficientCapacity event has occured. Note: Battery manufacturers specify a threshold of 70-80% of qMax
-        wq, wr, wd : float
+            Threshold for qMax (for threshold_met and event_state), after which the InsufficientCapacity event has occurred. Note: Battery manufacturers specify a threshold of 70-80% of qMax
+        wq : float
+            Wear rate for qMax, Ro, and D respectively
+        wr : float
+            Wear rate for qMax, Ro, and D respectively
+        wd : float
             Wear rate for qMax, Ro, and D respectively
         x0 : dict[str, float]
             Initial :term:`state`
@@ -477,6 +611,11 @@ class BatteryElectroChemEOL(PrognosticsModel):
     See Also
     --------
     BatteryElectroChemEOD, BatteryElectroChem, BatteryElectroChemEODEOL
+
+    References
+    -----------
+    .. [0] M. Daigle and C. Kulkarni, "End-of-discharge and End-of-life Prediction in Lithium-ion Batteries with Electrochemistry-based Aging Models," AIAA SciTech Forum 2016, San Diego, CA. https://arc.aiaa.org/doi/pdf/10.2514/6.2016-2132
+    .. [1] Prognostics Center of Excellence Data Repository https://ti.arc.nasa.gov/tech/dash/groups/pcoe/prognostic-data-repository/.
     """
     states = ['qMax', 'Ro', 'D']
     events = ['InsufficientCapacity']
@@ -499,16 +638,13 @@ class BatteryElectroChemEOL(PrognosticsModel):
         'qMax': (0, np.inf)
     }
 
-    def initialize(self, u=None, z=None):
-        return self.StateContainer(self.parameters['x0'])
-
     def dx(self, _, u : dict):
         params = self.parameters
 
         return self.StateContainer(np.array([
-            [params['wq'] * abs(u['i'])],
-            [params['wr'] * abs(u['i'])],
-            [params['wd'] * abs(u['i'])]
+            np.atleast_1d(params['wq'] * abs(u['i'])),
+            np.atleast_1d(params['wr'] * abs(u['i'])),
+            np.atleast_1d(params['wd'] * abs(u['i']))
         ]))
 
     def event_state(self, x : dict) -> dict:
@@ -539,13 +675,9 @@ def OverwrittenWarning(params):
 
 class BatteryElectroChemEODEOL(BatteryElectroChemEOL, BatteryElectroChemEOD):
     """
-    Prognostics :term:`model` for a battery degredation and discharge, represented by an electrochemical model as described in the following papers:
-
-    1. `M. Daigle and C. Kulkarni, "End-of-discharge and End-of-life Prediction in Lithium-ion Batteries with Electrochemistry-based Aging Models," AIAA SciTech Forum 2016, San Diego, CA. https://arc.aiaa.org/doi/pdf/10.2514/6.2016-2132`
+    Prognostics :term:`model` for a battery degredation and discharge, represented by an electrochemical model as described in [0]_ and [1]_
 
-    2. `M. Daigle and C. Kulkarni, "Electrochemistry-based Battery Modeling for Prognostics," Annual Conference of the Prognostics and Health Management Society 2013, pp. 249-261, New Orleans, LA, October 2013. https://papers.phmsociety.org/index.php/phmconf/article/view/2252`
-
-    The default model parameters included are for Li-ion batteries, specifically 18650-type cells. Experimental discharge curves for these cells can be downloaded from the `Prognostics Center of Excellence Data Repository https://ti.arc.nasa.gov/tech/dash/groups/pcoe/prognostic-data-repository/`.
+    The default model parameters included are for Li-ion batteries, specifically 18650-type cells. Experimental discharge curves for these cells can be downloaded from the Prognostics Center of Excellence Data Repository [2]_.
 
     :term:`Events<event>`: (2)
         | EOD: End of Discharge
@@ -567,7 +699,13 @@ class BatteryElectroChemEODEOL(BatteryElectroChemEOL, BatteryElectroChemEOD):
 
     Note
     ----
-        For keyword arguments, see BatteryElectroChemEOD, BatteryElectroChemEOL
+    For keyword arguments, see BatteryElectroChemEOD, BatteryElectroChemEOL
+
+    References
+    -----------
+    .. [0] M. Daigle and C. Kulkarni, "Electrochemistry-based Battery Modeling for Prognostics," Annual Conference of the Prognostics and Health Management Society 2013, pp. 249-261, New Orleans, LA, October 2013. https://papers.phmsociety.org/index.php/phmconf/article/view/2252
+    .. [1] M. Daigle and C. Kulkarni, "End-of-discharge and End-of-life Prediction in Lithium-ion Batteries with Electrochemistry-based Aging Models," AIAA SciTech Forum 2016, San Diego, CA. https://arc.aiaa.org/doi/pdf/10.2514/6.2016-2132
+    .. [2] Prognostics Center of Excellence Data Repository https://ti.arc.nasa.gov/tech/dash/groups/pcoe/prognostic-data-repository/.
     """
     inputs = BatteryElectroChemEOD.inputs
     outputs = BatteryElectroChemEOD.outputs
@@ -589,9 +727,6 @@ def __init__(self, **kwargs):
         self.param_callbacks['Ro'] = [OverwrittenWarning]
         super().__init__(**kwargs)
 
-    def initialize(self, u : dict = {}, z : dict = {}):
-        return self.StateContainer(self.parameters['x0'])
-
     def dx(self, x : dict, u : dict):
         # Set EOD Parameters (corresponding to health)
         with warnings.catch_warnings():
diff --git a/src/prog_models/models/centrifugal_pump.py b/src/prog_models/models/centrifugal_pump.py
index 00328430e..b3bd33065 100644
--- a/src/prog_models/models/centrifugal_pump.py
+++ b/src/prog_models/models/centrifugal_pump.py
@@ -1,17 +1,16 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the
 # National Aeronautics and Space Administration.  All Rights Reserved.
 
-from .. import prognostics_model
-
 from copy import deepcopy
 import numpy as np
 import warnings
 
+from .. import prognostics_model
+
 
 class CentrifugalPumpBase(prognostics_model.PrognosticsModel):
     """
-    Prognostics :term:`model` for a Centrifugal Pump as described in the following paper:
-    `M. Daigle and K. Goebel, "Model-based Prognostics with Concurrent Damage Progression Processes," IEEE Transactions on Systems, Man, and Cybernetics: Systems, vol. 43, no. 4, pp. 535-546, May 2013. https://www.researchgate.net/publication/260652495_Model-Based_Prognostics_With_Concurrent_Damage_Progression_Processes`
+    Prognostics :term:`model` for a Centrifugal Pump as described in [0]_.
 
     :term:`Events<event>`: (4)
         | ImpellerWearFailure: Failure of the impeller due to wear
@@ -60,8 +59,12 @@ class CentrifugalPumpBase(prognostics_model.PrognosticsModel):
           distribution for :term:`measurement noise` (e.g., normal, uniform, triangular)
         pAtm : float
             Atmospheric pressure
-        a0, a1, a2 : float
-            empirical coefficients for flow torque eqn
+        a0 : float
+            empirical coefficient for flow torque eqn
+        a1 : float
+            empirical coefficient for flow torque eqn
+        a2 : float
+            empirical coefficient for flow torque eqn
         A : float
             impeller blade area
         b : float
@@ -73,7 +76,8 @@ class CentrifugalPumpBase(prognostics_model.PrognosticsModel):
             impeller/shaft/motor lumped inertia
         r : float 
             lumped friction parameter (minus bearing friction)
-        R1, R2 : float
+        R1 : float
+        R2 : float
         L1 : float
         FluidI: float
             Pump fluid inertia
@@ -89,7 +93,11 @@ class CentrifugalPumpBase(prognostics_model.PrognosticsModel):
         HRadial1, HRadial2 : float
         mcOil : float
         HOil1, HOil2, HOil3 : float
-        wA, wRadial, wThrust : float
+        wA : float
+            Wear rates. See also CentrifugalPumpWithWear
+        wRadial : float
+            Wear rates. See also CentrifugalPumpWithWear
+        wThrust : float
             Wear rates. See also CentrifugalPumpWithWear
         lim : dict
             Parameter limits
@@ -99,6 +107,10 @@ class CentrifugalPumpBase(prognostics_model.PrognosticsModel):
     See Also
     --------
     CentrifugalPumpWithWear
+
+    References
+    ----------
+    .. [0] M. Daigle and K. Goebel, "Model-based Prognostics with Concurrent Damage Progression Processes," IEEE Transactions on Systems, Man, and Cybernetics: Systems, vol. 43, no. 4, pp. 535-546, May 2013. https://www.researchgate.net/publication/260652495_Model-Based_Prognostics_With_Concurrent_Damage_Progression_Processes
     """
     events = ['ImpellerWearFailure', 'PumpOilOverheat', 'RadialBearingOverheat', 'ThrustBearingOverheat']
     inputs = ['Tamb', 'V', 'pdisch', 'psuc', 'wsync']
@@ -221,15 +233,15 @@ def next_state(self, x : dict, u : dict, dt : float):
         Qdot = 1/params['FluidI']*(Qo-x['Q'])
 
         return self.StateContainer(np.array([
-            [x['w'] + wdot * dt],
-            [x['Q'] + Qdot * dt],
-            [x['Tt'] + Ttdot * dt],
-            [x['Tr'] + Trdot * dt],
-            [x['To'] + Todot * dt],
-            [x['A'] + Adot * dt],
-            [x['rRadial'] + rRadialdot * dt],
-            [x['rThrust'] + rThrustdot * dt],
-            [QLeak]
+            np.atleast_1d(x['w'] + wdot * dt),
+            np.atleast_1d(x['Q'] + Qdot * dt),
+            np.atleast_1d(x['Tt'] + Ttdot * dt),
+            np.atleast_1d(x['Tr'] + Trdot * dt),
+            np.atleast_1d(x['To'] + Todot * dt),
+            np.atleast_1d(x['A'] + Adot * dt),
+            np.atleast_1d(x['rRadial'] + rRadialdot * dt),
+            np.atleast_1d(x['rThrust'] + rThrustdot * dt),
+            np.atleast_1d(QLeak)
         ]))
 
     def output(self, x : dict):
@@ -271,8 +283,7 @@ class CentrifugalPumpWithWear(CentrifugalPumpBase):
     """
     Prognostics :term:`model` for a centrifugal pump with wear parameters as part of the model state. This is identical to CentrifugalPumpBase, only CentrifugalPumpBase has the wear params as parameters instead of states
 
-    This class implements a Centrifugal Pump model as described in the following paper:
-    `M. Daigle and K. Goebel, "Model-based Prognostics with Concurrent Damage Progression Processes," IEEE Transactions on Systems, Man, and Cybernetics: Systems, vol. 43, no. 4, pp. 535-546, May 2013. https://www.researchgate.net/publication/260652495_Model-Based_Prognostics_With_Concurrent_Damage_Progression_Processes`
+    This class implements a Centrifugal Pump model as described in [0]_.
 
     :term:`Events<event>`: (4) 
         See CentrifugalPumpBase
@@ -295,6 +306,10 @@ class CentrifugalPumpWithWear(CentrifugalPumpBase):
     See Also
     --------
     CentrifugalPumpBase
+
+    References
+    ----------
+    .. [0] M. Daigle and K. Goebel, "Model-based Prognostics with Concurrent Damage Progression Processes," IEEE Transactions on Systems, Man, and Cybernetics: Systems, vol. 43, no. 4, pp. 535-546, May 2013. https://www.researchgate.net/publication/260652495_Model-Based_Prognostics_With_Concurrent_Damage_Progression_Processes
     """
     inputs = CentrifugalPumpBase.inputs
     outputs = CentrifugalPumpBase.outputs
@@ -324,9 +339,9 @@ def next_state(self, x : dict, u : dict, dt : float) -> dict:
         next_x = CentrifugalPumpBase.next_state(self, x, u, dt)
 
         next_x.matrix = np.vstack((next_x.matrix, np.array([
-            [x['wA']],
-            [x['wRadial']],
-            [x['wThrust']]
+            np.atleast_1d(x['wA']),
+            np.atleast_1d(x['wRadial']),
+            np.atleast_1d(x['wThrust'])
         ])))
         return next_x
 
diff --git a/src/prog_models/models/dcmotor.py b/src/prog_models/models/dcmotor.py
index 3efe8a903..edacf75f5 100644
--- a/src/prog_models/models/dcmotor.py
+++ b/src/prog_models/models/dcmotor.py
@@ -1,9 +1,10 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the
 # National Aeronautics and Space Administration.  All Rights Reserved.
 
-from prog_models import PrognosticsModel
 import numpy as np
 
+from prog_models import PrognosticsModel
+
 RAD_TO_DEG = 180/np.pi
 PI2 = 2 * np.pi
 
@@ -80,12 +81,7 @@ class DCMotor(PrognosticsModel):
 
     :term:`Model<model>` of DC Motor.
 
-    References:
-    Matteo Corbetta, Chetan S. Kulkarni. An approach for uncertainty quantification and management of unmanned aerial vehicle health. 
-    Annual Conference of the PHM Society, Scottsdale, AZ, 2019. http://papers.phmsociety.org/index.php/phmconf/article/view/847
-
-    George E. Gorospe Jr, Chetan S. Kulkarni, Edward Hogge, Andrew Hsu, and Natalie Ownby. A Study of the Degradation of Electronic Speed Controllers forBrushless DC Motors.
-    Asia Pacific Conference of the Prognostics and Health Management Society, 2017. https://ntrs.nasa.gov/citations/20200000579
+    References: [0]_, [1]_.
 
     This model was developed by NASA's System Wide Safety (SWS) Project. https://www.nasa.gov/aeroresearch/programs/aosp/sws/
 
@@ -135,6 +131,13 @@ class DCMotor(PrognosticsModel):
             Load moment of inertia (neglecting motor shaft inertia) (Kg*m^2)
         x0 : dict[str, float]
             Initial :term:`state`
+
+    References
+    ----------
+    .. [0] Matteo Corbetta, Chetan S. Kulkarni. An approach for uncertainty quantification and management of unmanned aerial vehicle health. 
+    Annual Conference of the PHM Society, Scottsdale, AZ, 2019. http://papers.phmsociety.org/index.php/phmconf/article/view/847
+    .. [1] George E. Gorospe Jr, Chetan S. Kulkarni, Edward Hogge, Andrew Hsu, and Natalie Ownby. A Study of the Degradation of Electronic Speed Controllers forBrushless DC Motors.
+    Asia Pacific Conference of the Prognostics and Health Management Society, 2017. https://ntrs.nasa.gov/citations/20200000579
     """
     states = ['i_a', 'i_b', 'i_c', 'v_rot', 'theta']
     inputs = ['v_a', 'v_b', 'v_c', 't_l']
@@ -176,9 +179,6 @@ class DCMotor(PrognosticsModel):
         }
     }
 
-    def initialize(self, u=None, z=None):
-        return self.StateContainer(self.parameters['x0'])
-
     def next_state(self, x, u, dt):
         (F_a, F_b, F_c) = backemf(x['theta'])
 
diff --git a/src/prog_models/models/dcmotor_singlephase.py b/src/prog_models/models/dcmotor_singlephase.py
new file mode 100644
index 000000000..fa50ed1ea
--- /dev/null
+++ b/src/prog_models/models/dcmotor_singlephase.py
@@ -0,0 +1,173 @@
+# Copyright © 2021 United States Government as represented by the Administrator of the
+# National Aeronautics and Space Administration.  All Rights Reserved.
+
+import math
+import numpy as np
+
+from prog_models import PrognosticsModel
+
+# Derived Paramaters
+def update_L1(params):
+    return {
+        'L1': params['L'] - params['M']
+    }
+
+def update_Kv(params):
+    # (rad/s)/V, inverse of Kt
+    return {
+        'Kv':    1.0 / params['Kt']
+    }
+
+def update_Km(params):
+    # Nm/sqrt(W), motor constant, will be calculated on next line
+    return {
+        'Km': params['Kt'] / math.sqrt(params['R'])
+    }
+
+def update_B(params):
+    # Estimate B according to empirical model 0.0051 * Km^1.9485 from:
+    # Jeong et al., Improvement of Electric Propulsion System Model for Performance Analysis of Large-Size Multicopter UAVs. MDPI Applied Sciences.
+    # This paper was for "large multicopter UAVs." 
+    return {
+        'B': 0.0051 * params['Km']**1.9485
+    }
+
+def update_J(params):
+    # J is the sum of Jl (load) and Js (shaft)
+    return {
+        'J': params['Jl'] + params['Js']
+    }
+
+
+class DCMotorSP(PrognosticsModel):
+    """
+    .. versionadded:: 1.4.0
+
+    :term:`Model<model>` of single-phase DC Motor, as defined by the following equations:
+
+    .. math:: \dfrac{d_i}{dt} = \dfrac{1}{L1}*(V-E-R*i)
+
+    .. math:: \dfrac{d_{\omega}}{dt} = \dfrac{1}{J_t} * (T_e - T_l - B * \omega)
+
+    where:
+
+    * i       current, A
+
+    * :math:`\omega`   rotor speed, :math:`\dfrac{rad}{s}`
+
+    * V       input voltage, V
+
+    * E       back-emf voltage, V
+
+    * R       armature resistance, Ohm
+
+    * :math:`J_t`      total inertia (rotor + propeller or load), :math:`kg*m^2`
+
+    * :math:`T_e`     driving torque (electrical), :math:`N*m`
+
+    * :math:`T_l`     load torque (mechanical), :math:`N*m`
+
+    * B       friction coefficient, :math:`\dfrac{N*m}{rad/s}`
+
+    * t       time, :math:`s`
+
+    :term:`Events<event>`: (0)
+        | None
+
+    :term:`Inputs/Loading<input>`: (2)
+        | v: Voltage
+        | t_l: Torque from load
+
+    :term:`States<state>`: (2)
+        | i: current (A)
+        | v_rot: Rotational velocity (rad/sec)
+
+    :term:`Outputs<output>`: (1)
+        | v_rot: Rotational velocity (rad/sec)
+
+    Keyword Args
+    ------------
+        process_noise : Optional, float or Dict[str, float]
+            :term:`Process noise<process noise>` (applied at dx/next_state). 
+            Can be number (e.g., .2) applied to every state, a dictionary of values for each 
+            state (e.g., {'x1': 0.2, 'x2': 0.3}), or a function (x) -> x
+        process_noise_dist : Optional, String
+            distribution for :term:`process noise` (e.g., normal, uniform, triangular)
+        measurement_noise : Optional, float or Dict[str, float]
+            :term:`Measurement noise<measurement noise>` (applied in output eqn).
+            Can be number (e.g., .2) applied to every output, a dictionary of values for each
+            output (e.g., {'z1': 0.2, 'z2': 0.3}), or a function (z) -> z
+        measurement_noise_dist : Optional, String
+            distribution for :term:`measurement noise` (e.g., normal, uniform, triangular)
+        L : float
+            Self-inductance (H)
+        M: float
+            Mutual inductance (H)
+        R: float
+            Armature Resistance (Ohm)
+        Kt: float
+            back emf constant / Torque constant (V/rad/sec)  
+        B: float
+            Friction in motor / Damping (Not a function of thrust) (Nm/(rad/s))
+        J: float
+            Total load moment of inertia (motor shaft + load) (Kg*m^2) - alternately, you can set these separately as Js and Jl
+        Js: float
+            Moment of inertia of motor shaft (kg*m^2) - one component of J
+        Jl: float
+            Moment of inertia from load (kg*m^2) - one component of J. Note load is whatever the motor is attached to (e.g., propeller, valve, axil, etc.)
+        x0 : dict[str, float]
+            Initial :term:`state`
+    """
+    states  = ['i', 'v_rot']
+    inputs  = ['v', 't_l']
+    outputs = ['v_rot']
+
+    param_callbacks = {
+        'L': [update_L1],
+        'M': [update_L1],
+        'R': [update_Km, update_B],
+        'Kt': [update_Kv, update_Km, update_B],
+        'Km': [update_B],
+        'Js': [update_J],
+        'Jl': [update_J]
+        }
+
+    default_parameters = dict(L=83.0e-6,
+                              M=0.0,
+                              R=0.081,
+                              Kt=0.0265258,
+                              Js=2.69e-5,
+                              Jl=1e-4,
+                              x0={'i': 0.0, 'v_rot': 0.0}
+                              )
+
+    def dx(self, x: dict, u: dict):
+        
+        # Get parameters
+        parameters     = self.parameters
+        friction_coeff = parameters['B']
+        inertia        = parameters['J']
+        inductance     = parameters['L']
+
+        # Get input
+        load_torque    = u['t_l']
+        voltage        = u['v']
+
+        # Forcing on electrical and mechanical equations
+        back_emf_v     = parameters['Kt'] * x['v_rot']
+        el_torque      = parameters['Kt'] * x['i']
+
+        # Rate of change (state-space) form
+        didt    = 1.0 / inductance * (voltage - back_emf_v - parameters['R'] * x['i'])
+        dvrotdt = 1.0 / inertia * (el_torque - load_torque - friction_coeff * x['v_rot'])
+
+        return self.StateContainer(np.array([
+            np.atleast_1d(didt),            # current
+            np.atleast_1d(dvrotdt)          # rotor speed
+        ]))
+
+    def output(self, x : dict):
+        rotor_speed = x['v_rot']
+        return self.OutputContainer(np.array([
+            np.atleast_1d(rotor_speed),
+            ]))
diff --git a/src/prog_models/models/esc.py b/src/prog_models/models/esc.py
index 98943f650..298ad6ce8 100644
--- a/src/prog_models/models/esc.py
+++ b/src/prog_models/models/esc.py
@@ -1,10 +1,12 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the
 # National Aeronautics and Space Administration.  All Rights Reserved.
-from prog_models import PrognosticsModel
+
 from math import floor
 import numpy as np
 import scipy.signal as signal
 
+from prog_models import PrognosticsModel
+
 RAD_TO_DEG = 180/np.pi
 PI2 = 2 * np.pi
 CL = [[1, -1, 0],
@@ -29,12 +31,7 @@ class ESC(PrognosticsModel):
     This model replicates the behavior of the speed controller with pulse-width modulation (PWM) and commutation matrix.
     Duty cycle simulated with a square wave using scipy signal.square function. 
 
-    References:
-    Matteo Corbetta, Chetan S. Kulkarni. An approach for uncertainty quantification and management of unmanned aerial vehicle health. 
-    Annual Conference of the PHM Society, Scottsdale, AZ, 2019. http://papers.phmsociety.org/index.php/phmconf/article/view/847
-
-    George E. Gorospe Jr, Chetan S. Kulkarni, Edward Hogge, Andrew Hsu, and Natalie Ownby. A Study of the Degradation of Electronic Speed Controllers forBrushless DC Motors.
-    Asia Pacific Conference of the Prognostics and Health Management Society, 2017. https://ntrs.nasa.gov/citations/20200000579
+    References: [0]_, [1]_.
 
     This model was developed by NASA's System Wide Safety (SWS) Project. https://www.nasa.gov/aeroresearch/programs/aosp/sws/
 
@@ -76,6 +73,13 @@ class ESC(PrognosticsModel):
             Frequency of PWM signal [Hz], default value in default_parameters.
         x0 : dict[str, float]
             Initial :term:`state`
+
+    References
+    ----------
+    .. [0] Matteo Corbetta, Chetan S. Kulkarni. An approach for uncertainty quantification and management of unmanned aerial vehicle health. 
+    Annual Conference of the PHM Society, Scottsdale, AZ, 2019. http://papers.phmsociety.org/index.php/phmconf/article/view/847
+    .. [1] George E. Gorospe Jr, Chetan S. Kulkarni, Edward Hogge, Andrew Hsu, and Natalie Ownby. A Study of the Degradation of Electronic Speed Controllers forBrushless DC Motors.
+    Asia Pacific Conference of the Prognostics and Health Management Society, 2017. https://ntrs.nasa.gov/citations/20200000579
     """
     default_parameters = {
         'sawtooth_freq': 16000, # Hz
@@ -93,15 +97,16 @@ class ESC(PrognosticsModel):
     inputs = ['duty', 'theta', 'v']
     outputs = states
 
-    def initialize(self, u = None, z = None):
-        return self.StateContainer(self.parameters['x0'])
-
     def next_state(self, x, u, dt):
         pw = np.maximum(signal.square(PI2 * self.parameters['sawtooth_freq'] * x['t'], duty=u['duty']), 0)
         V = pw*u['v']
         SP = commutation(u['theta'])
         VP = [V * sp_i for sp_i in SP]
-        return self.StateContainer(np.array([[VP[0]], [VP[1]], [VP[2]], [x['t'] + dt]]))
+        return self.StateContainer(np.array([
+            np.atleast_1d(VP[0]), 
+            np.atleast_1d(VP[1]), 
+            np.atleast_1d(VP[2]), 
+            np.atleast_1d(x['t'] + dt)]))
 
     def output(self, x):
         return self.OutputContainer(x)
diff --git a/src/prog_models/models/paris_law.py b/src/prog_models/models/paris_law.py
new file mode 100644
index 000000000..9efc839c5
--- /dev/null
+++ b/src/prog_models/models/paris_law.py
@@ -0,0 +1,98 @@
+# Copyright © 2021 United States Government as represented by the Administrator of the
+# National Aeronautics and Space Administration.  All Rights Reserved.
+
+from numpy import inf
+
+from prog_models import PrognosticsModel
+
+
+class ParisLawCrackGrowth(PrognosticsModel): 
+    """
+    .. versionadded:: 1.4.0
+    A simple Paris Law Crack Growth :term:`model`
+
+    Events: (1)
+        CGF :   Crack Growth Fracture 
+
+    Inputs/Loading: (2)
+      |k_max :  Maximum crack growth 
+      |k_min :  Minimum crack growth 
+
+    States: (1)
+      |c_l :    crack length
+  
+    Outputs: (1)
+       |c_l :   crack length
+
+    Keyword Args
+    ------------
+        process_noise : Optional, float or Dict[str, float]
+          :term:`Process noise<process noise>` (applied at dx/next_state). 
+          Can be number (e.g., .2) applied to every state, a dictionary of values for each 
+          state (e.g., {'x1': 0.2, 'x2': 0.3}), or a function (x) -> x
+        process_noise_dist : Optional, String
+          distribution for :term:`process noise` (e.g., normal, uniform, triangular)
+        measurement_noise : Optional, float or Dict[str, float]
+          :term:`Measurement noise<measurement noise>` (applied in output eqn).
+          Can be number (e.g., .2) applied to every output, a dictionary of values for each
+          output (e.g., {'z1': 0.2, 'z2': 0.3}), or a function (z) -> z
+        measurement_noise_dist : Optional, String
+          distribution for :term:`measurement noise` (e.g., normal, uniform, triangular)
+        crack_limit : float
+            crack length limit after which the crack growth fracture event is triggered
+        c : float
+            Material Constant
+        m : float
+            Material Constant
+        dndt : float
+            cycles per second
+        x0 : dict[str, float]
+            Initial :term:`state`
+    """ 
+    # Event: Crack Growth Fracture
+    events = ['CGF']
+    # Inputs are ['k_min', 'k_max']
+    inputs = ['k_max','k_min']
+    # State: Crack Length
+    states = ['c_l']
+    # Output: Crack Length
+    outputs = ['c_l']
+
+    # The default parameters
+    default_parameters = {
+        'crack_limit': 1e-4,
+        'c': 3.24,
+        'm': 0.1527,
+        'dndt': 10, 
+        'x0' : {
+            'c_l': 0.00001,
+        }  
+    }
+    
+    state_limits = {
+        'c_l': (0, inf),
+    }
+
+    # The model equations
+    def dx(self, x : dict, u : dict):
+        parameters = self.parameters
+        r = (parameters['c']*(u['k_max'] - u['k_min'])**parameters['m'])*parameters['dndt'] # Paris Law Equation with respect to time
+        dxdt = {
+             'c_l': r,
+         }
+        return self.StateContainer(dxdt)
+
+    def output(self, x):
+        return self.OutputContainer(x) 
+
+    def event_state(self, x : dict) -> dict: 
+       return {
+            'CGF' : 1- x['c_l'] / self.parameters['crack_limit']
+        }
+
+    def threshold_met(self, x):
+        t_met = {
+           'CGF': x['c_l'] > self.parameters['crack_limit']
+        }
+        return t_met
+         
\ No newline at end of file
diff --git a/src/prog_models/models/pneumatic_valve.py b/src/prog_models/models/pneumatic_valve.py
index 34eed0a33..eb346f51f 100644
--- a/src/prog_models/models/pneumatic_valve.py
+++ b/src/prog_models/models/pneumatic_valve.py
@@ -1,11 +1,12 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the
 # National Aeronautics and Space Administration.  All Rights Reserved.
 
-from .. import prognostics_model
 from copy import deepcopy
 import numpy as np
 import warnings
 
+from .. import prognostics_model
+
 def calc_x(x : float, forces : float, Ls : float, new_x : float) -> float:
     lower_wall = (x==0 and forces<0) or (new_x<0)
     upper_wall = (x==Ls and forces>0) or (new_x>Ls)
@@ -107,7 +108,10 @@ class PneumaticValveBase(prognostics_model.PrognosticsModel):
             Temperature of supply gas (K)
         gas_gamma, gas_z, gas_R : float
             Supply gas parameters
-        At, Ct, Ab, Cb : float
+        At : float
+        Ct : float
+        Ab : float
+        Cb : float
         AbMax : float
             Max limit for state Aeb
         AtMax : float
@@ -298,16 +302,16 @@ def next_state(self, x : dict, u : dict, dt : float):
             dp = [u['pL'] - u['pR']] * len(x['x'])
 
         return self.StateContainer(np.array([
-            [x['Aeb'] + params['wb'] * dt],     # Aeb
-            [x['Aet'] + params['wt'] * dt],     # Aet
-            [x['Ai'] + Aidot * dt],             # Ai
-            [x['k'] + kdot * dt],               # k
-            [x['mBot'] + mBotdot * dt],         # mBot
-            [x['mTop'] + mTopdot * dt],         # mTop
-            [x['r'] + rdot * dt],               # r
-            [vel],                              # v
-            [pos],                              # x
-            [dp]                                # pL - pR
+            np.atleast_1d(x['Aeb'] + params['wb'] * dt),     # Aeb
+            np.atleast_1d(x['Aet'] + params['wt'] * dt),     # Aet
+            np.atleast_1d(x['Ai'] + Aidot * dt),             # Ai
+            np.atleast_1d(x['k'] + kdot * dt),               # k
+            np.atleast_1d(x['mBot'] + mBotdot * dt),         # mBot
+            np.atleast_1d(x['mTop'] + mTopdot * dt),         # mTop
+            np.atleast_1d(x['r'] + rdot * dt),               # r
+            np.atleast_1d(vel),                              # v
+            np.atleast_1d(pos),                              # x
+            np.atleast_1d(dp)                                # pL - pR
         ]))
     
     def output(self, x : dict):
@@ -322,12 +326,12 @@ def output(self, x : dict):
         pressureBot = x['mBot']*params['R']*params['gas_temp']/params['gas_mass']/volumeBot
 
         return self.OutputContainer(np.array([
-            [trueFlow],             # Q
-            [indicatorBotm],        # indicatorBotm
-            [indicatorTopm],        # indicatorTopm
-            [1e-6 *pressureBot],    # pBot
-            [1e-6 *pressureTop],    # pTop
-            [x['x']]                # x
+            np.atleast_1d(trueFlow),             # Q
+            np.atleast_1d(indicatorBotm),        # indicatorBotm
+            np.atleast_1d(indicatorTopm),        # indicatorTopm
+            np.atleast_1d(1e-6 *pressureBot),    # pBot
+            np.atleast_1d(1e-6 *pressureTop),    # pTop
+            np.atleast_1d(x['x'])                # x
         ]))
 
     def event_state(self, x : dict) -> dict:
@@ -415,11 +419,11 @@ def next_state(self, x : dict, u : dict, dt : float) -> dict:
 
         # Append this way because the keys in the structure but the values are missing - this is due to the behavior of subclassed models calling their parent functions.
         next_x.matrix = np.vstack((next_x.matrix, np.array([
-            [x['wb']],
-            [x['wi']],
-            [x['wk']],
-            [x['wr']],
-            [x['wt']]
+            np.atleast_1d(x['wb']),
+            np.atleast_1d(x['wi']),
+            np.atleast_1d(x['wk']),
+            np.atleast_1d(x['wr']),
+            np.atleast_1d(x['wt'])
         ])))
         return next_x
 
diff --git a/src/prog_models/models/thrown_object.py b/src/prog_models/models/thrown_object.py
index 8c755f6c3..807712aa2 100644
--- a/src/prog_models/models/thrown_object.py
+++ b/src/prog_models/models/thrown_object.py
@@ -1,9 +1,10 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the
 # National Aeronautics and Space Administration.  All Rights Reserved.
 
-from .. import PrognosticsModel
 import numpy as np
 
+from .. import PrognosticsModel
+
 def calc_lumped_param(params):
     return {
         'lumped_param': 0.5 * params['rho']* params['cd'] * params['A'] / params['m']
@@ -43,37 +44,44 @@ class ThrownObject(PrognosticsModel):
           distribution for :term:`measurement noise` (e.g., normal, uniform, triangular)
         g : Optional, float
             Acceleration due to gravity (m/s^2). Default is 9.81 m/s^2 (standard gravity)
+        rho : Optional, float
+            Air density (kg/m^3). Default is 1.225 (air density at sea level). Used in drag calculation
+        A : Optional, float
+            Cross sectional area of object (m^2)
+        m : Optional, float
+            Mass of object (kg)
+        cd : Optional, float
+            Coefficient of drag
         thrower_height : Optional, float
             Height of the thrower (m). Default is 1.83 m
         throwing_speed : Optional, float
             Speed at which the ball is thrown (m/s). Default is 40 m/s
     """
 
-    inputs = []  # no inputs, no way to control
+    inputs = []
     states = [
-        'x',    # Position (m) 
-        'v'    # Velocity (m/s)
+        'x',
+        'v'
         ]
     outputs = [
-        'x'     # Position (m)
+        'x'
     ]
     events = [
-        'falling',  # Event- object is falling
-        'impact'    # Event- object has impacted ground
+        'falling',
+        'impact'
     ]
 
     is_vectorized = True
 
-    # The Default parameters. Overwritten by passing parameters dictionary into constructor
     default_parameters = {
-        'thrower_height': 1.83,  # m
-        'throwing_speed': 40,  # m/s
-        'g': -9.81,  # Acceleration due to gravity in m/s^2
-        'rho': 1.225, # Air density at sea level 1.225 kg/m^3
-        'A': 0.05, # m^2 - Cross sectional area 
-        'm': 0.145, # kg - Mass of thing  
-        'cd': 0.007, # Coefficient of drag
-        'process_noise': 0.0  # amount of noise in each step
+        'thrower_height': 1.83,
+        'throwing_speed': 40,
+        'g': -9.81,
+        'rho': 1.225,
+        'A': 0.05,
+        'm': 0.145, 
+        'cd': 0.007,
+        'process_noise': 0.0
     }
 
     param_callbacks = {
@@ -91,18 +99,16 @@ def initialize(self, u=None, z=None):
     
     def next_state(self, x : dict, u : dict, dt : float):
         next_x =  x['x'] + x['v']*dt
-        drag_acc = self.parameters['lumped_param'] * x['v']*x['v']
+        drag_acc = self.parameters['lumped_param'] * x['v'] * x['v']
         next_v = x['v'] + (self.parameters['g'] - drag_acc*np.sign(x['v']))*dt
         return self.StateContainer(np.array([
-            [next_x],
-            [next_v]  # Acceleration of gravity
+            np.atleast_1d(next_x),
+            np.atleast_1d(next_v)  # Acceleration of gravity
         ]))
 
     def output(self, x : dict):
         return self.OutputContainer(np.array([[x['x']]]))
 
-    # This is actually optional. Leaving thresholds_met empty will use the event state to define thresholds.
-    #  Threshold = Event State == 0. However, this implementation is more efficient, so we included it
     def threshold_met(self, x : dict) -> dict:
         return {
             'falling': x['v'] < 0,
@@ -110,9 +116,11 @@ def threshold_met(self, x : dict) -> dict:
         }
 
     def event_state(self, x : dict) -> dict: 
-        x_max = x['x'] + np.square(x['v'])/(-self.parameters['g']*2) # Use speed and position to estimate maximum height
-        x_max = np.where(x['v'] > 0, x['x'], x_max) # 1 until falling begins
+        # Use speed and position to estimate maximum height
+        x_max = x['x'] + np.square(x['v'])/(-self.parameters['g']*2) 
+        # 1 until falling begins
+        x_max = np.where(x['v'] > 0, x['x'], x_max) 
         return {
-            'falling': np.maximum(x['v']/self.parameters['throwing_speed'],0),  # Throwing speed is max speed
-            'impact': np.maximum(x['x']/x_max,0)  # then it's fraction of height
+            'falling': np.maximum(x['v']/self.parameters['throwing_speed'], 0),  # Throwing speed is max speed
+            'impact': np.maximum(x['x']/x_max, 0)  # then it's fraction of height
         }
diff --git a/src/prog_models/prognostics_model.py b/src/prog_models/prognostics_model.py
index c3269e6ce..c32f66a12 100644
--- a/src/prog_models/prognostics_model.py
+++ b/src/prog_models/prognostics_model.py
@@ -1,20 +1,22 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the
 # National Aeronautics and Space Administration.  All Rights Reserved.
 
-from typing import Callable, List
-from .exceptions import ProgModelInputException, ProgModelTypeError, ProgModelException, ProgModelStateLimitWarning
 from abc import abstractmethod, ABC
-from numbers import Number
-import numpy as np
-from scipy.interpolate import interp1d
+from collections import abc, namedtuple
 from copy import deepcopy
 import itertools
+import json
+from numbers import Number
+import numpy as np
+from typing import Callable, Iterable, List
 from warnings import warn
-from collections import abc, namedtuple
+
+from .exceptions import ProgModelInputException, ProgModelTypeError, ProgModelException, ProgModelStateLimitWarning
 from .sim_result import SimResult, LazySimResult
 from .utils import ProgressBar
 from .utils.containers import DictLikeMatrixWrapper
 from .utils.parameters import PrognosticsModelParameters
+from .utils.serialization import *
 
 
 class PrognosticsModel(ABC):
@@ -28,8 +30,8 @@ class PrognosticsModel(ABC):
     Keyword Args
     ------------
         process_noise : Optional, float or Dict[str, float]
-          :term:`Process noise<process noise>` (applied at dx/next_state). 
-          Can be number (e.g., .2) applied to every state, a dictionary of values for each 
+          :term:`Process noise<process noise>` (applied at dx/next_state).
+          Can be number (e.g., .2) applied to every state, a dictionary of values for each
           state (e.g., {'x1': 0.2, 'x2': 0.3}), or a function (x) -> x
         process_noise_dist : Optional, String
           distribution for :term:`process noise` (e.g., normal, uniform, triangular)
@@ -39,7 +41,7 @@ class PrognosticsModel(ABC):
           output (e.g., {'z1': 0.2, 'z2': 0.3}), or a function (z) -> z
         measurement_noise_dist : Optional, String
           distribution for :term:`measurement noise` (e.g., normal, uniform, triangular)
-    
+
     Additional parameters specific to the model
 
     Raises
@@ -71,7 +73,7 @@ class PrognosticsModel(ABC):
         performance_metric_keys: List[str], optional
             Identifiers for each performance metric
         events: List[str], optional
-            Identifiers for each :term:`event` predicted 
+            Identifiers for each :term:`event` predicted
         StateContainer : DictLikeMatrixWrapper
             Class for state container - used for representing :term:`state`
         OutputContainer : DictLikeMatrixWrapper
@@ -95,33 +97,13 @@ class PrognosticsModel(ABC):
     # inputs = []     # Identifiers for each input
     # states = []     # Identifiers for each state
     # outputs = []    # Identifiers for each output
-    performance_metric_keys = []  # Identifies for each performance metric
-    events = []       # Identifiers for each event
+    # performance_metric_keys = []  # Identifies for each performance metric
+    # events = []       # Identifiers for each event
     param_callbacks = {}  # Callbacks for derived parameters
 
-    observables_keys = performance_metric_keys # for backwards compatability        
     SimulationResults = namedtuple('SimulationResults', ['times', 'inputs', 'states', 'outputs', 'event_states'])
 
     def __init__(self, **kwargs):
-        if not hasattr(self, 'inputs'):
-            raise ProgModelTypeError('Must have `inputs` attribute')
-        
-        if not hasattr(self, 'states'):
-            raise ProgModelTypeError('Must have `states` attribute')
-        if len(self.states) <= 0:
-            raise ProgModelTypeError('`states` attribute must have at least one state key')
-        try:
-            iter(self.states)
-        except TypeError:
-            raise ProgModelTypeError('model.states must be iterable')
-
-        if not hasattr(self, 'outputs'):
-            raise ProgModelTypeError('Must have `outputs` attribute')
-        try:
-            iter(self.outputs)
-        except TypeError:
-            raise ProgModelTypeError('model.outputs must be iterable')
-        
         # Default params for any model
         params = PrognosticsModel.default_parameters.copy()
 
@@ -134,173 +116,196 @@ def __init__(self, **kwargs):
         except TypeError:
             raise ProgModelTypeError("couldn't update parameters. Check that all parameters are valid")
 
-        self.__setstate__(params)
+        PrognosticsModel.__setstate__(self, params)
 
     def __eq__(self, other : "PrognosticsModel") -> bool:
         """
         Check if two models are equal
         """
         return self.__class__ == other.__class__ and self.parameters == other.parameters
-    
+
     def __str__(self) -> str:
         return "{} Prognostics Model (Events: {})".format(type(self).__name__, self.events)
 
     def __getstate__(self) -> dict:
         return self.parameters.data
 
-    def __setstate__(self, state : dict) -> None:
-        self.parameters = PrognosticsModelParameters(self, state, self.param_callbacks)
+    def __setstate__(self, params : dict) -> None:
+        # This method is called when depickling and in construction. It builds the model from the parameters
 
+        if not hasattr(self, 'inputs'):
+            self.inputs = []
         self.n_inputs = len(self.inputs)
+
+        if not hasattr(self, 'states'):
+            raise ProgModelTypeError('Must have `states` attribute')
+        if len(self.states) <= 0:
+            raise ProgModelTypeError('`states` attribute must have at least one state key')
+        try:
+            iter(self.states)
+        except TypeError:
+            raise ProgModelTypeError('model.states must be iterable')
         self.n_states = len(self.states)
+
+        if not hasattr(self, 'events'):
+            self.events = []
         self.n_events = len(self.events)
+
+        if not hasattr(self, 'outputs'):
+            raise ProgModelTypeError('Must have `outputs` attribute')
+        try:
+            iter(self.outputs)
+        except TypeError:
+            raise ProgModelTypeError('model.outputs must be iterable')
         self.n_outputs = len(self.outputs)
+
+        if not hasattr(self, 'performance_metric_keys'):
+            self.performance_metric_keys = []
         self.n_performance = len(self.performance_metric_keys)
 
-        # Setup Containers 
+        # Setup Containers
         # These containers should be used instead of dictionaries for models that use the internal matrix state
         states = self.states
+
         class StateContainer(DictLikeMatrixWrapper):
             def __init__(self, data):
                 super().__init__(states, data)
         self.StateContainer = StateContainer
 
         inputs = self.inputs
+
         class InputContainer(DictLikeMatrixWrapper):
             def __init__(self, data):
                 super().__init__(inputs, data)
         self.InputContainer = InputContainer
 
         outputs = self.outputs
+
         class OutputContainer(DictLikeMatrixWrapper):
             def __init__(self, data):
                 super().__init__(outputs, data)
         self.OutputContainer = OutputContainer
-    
-    @abstractmethod
-    def initialize(self, u : dict = None, z :dict = None) -> dict:
+
+        self.parameters = PrognosticsModelParameters(self, params, self.param_callbacks)
+
+    def initialize(self, u = None, z = None):
         """
-        Calculate initial state given inputs and outputs
+        Calculate initial state given inputs and outputs. If not defined for a model, it will return parameters['x0']
 
         Parameters
         ----------
-        u : dict
+        u : InputContainer
             Inputs, with keys defined by model.inputs \n
-            e.g., u = {'i':3.2} given inputs = ['i']
-        z : dict
+            e.g., u = m.InputContainer({'i':3.2}) given inputs = ['i']
+        z : OutputContainer
             Outputs, with keys defined by model.outputs \n
-            e.g., z = {'t':12.4, 'v':3.3} given inputs = ['t', 'v']
+            e.g., z = m.OutputContainer({'t':12.4, 'v':3.3}) given outputs = ['t', 'v']
 
         Returns
         -------
-        x : dict
+        x : StateContainer
             First state, with keys defined by model.states \n
-            e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
+            e.g., x = StateContainer({'abc': 332.1, 'def': 221.003}) given states = ['abc', 'def']
 
         Example
         -------
-        | m = PrognosticsModel() # Replace with specific model being simulated
-        | u = {'u1': 3.2}
-        | z = {'z1': 2.2}
-        | x = m.initialize(u, z) # Initialize first state
+            :
+                m = PrognosticsModel() # Replace with specific model being simulated
+                u = {'u1': 3.2}
+                z = {'z1': 2.2}
+                x = m.initialize(u, z) # Initialize first state
         """
-        return {}
+        return self.StateContainer(self.parameters['x0'])
 
-    def apply_measurement_noise(self, z : dict) -> dict:
+    def apply_measurement_noise(self, z):
         """
         Apply measurement noise to the measurement
 
         Parameters
         ----------
-        z : dict
+        z : OutputContainer
             output, with keys defined by model.outputs \n
-            e.g., z = {'abc': 332.1, 'def': 221.003} given outputs = ['abc', 'def']
- 
+            e.g., z = m.OutputContainer({'abc': 332.1, 'def': 221.003}) given outputs = ['abc', 'def']
+
         Returns
         -------
-        z : dict
+        z : OutputContainer
             output, with applied noise, with keys defined by model.outputs \n
-            e.g., z = {'abc': 332.2, 'def': 221.043} given outputs = ['abc', 'def']
+            e.g., z = m.OutputContainer({'abc': 332.2, 'def': 221.043}) given outputs = ['abc', 'def']
 
         Example
         -------
         | m = PrognosticsModel() # Replace with specific model being simulated
-        | z = {'z1': 2.2}
+        | z = m.OutputContainer({'z1': 2.2})
         | z = m.apply_measurement_noise(z)
 
         Note
         ----
         Configured using parameters `measurement_noise` and `measurement_noise_dist`
         """
-        return self.OutputContainer({key: z[key] \
-            + np.random.normal(
-                0, self.parameters['measurement_noise'][key],
-                size=None if np.isscalar(z[key]) else len(z[key]))
-                for key in z.keys()})
-        
-    def apply_process_noise(self, x : dict, dt : int =1) -> dict:
+        z.matrix += np.random.normal(0, self.parameters['measurement_noise'].matrix, size=z.matrix.shape)
+        return z
+
+    def apply_process_noise(self, x, dt : int = 1):
         """
         Apply process noise to the state
 
         Parameters
         ----------
-        x : dict
+        x : StateContainer
             state, with keys defined by model.states \n
-            e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
+            e.g., x = m.StateContainer({'abc': 332.1, 'def': 221.003}) given states = ['abc', 'def']
         dt : Number, optional
             Time step (e.g., dt = 0.1)
 
         Returns
         -------
-        x : dict
+        x : StateContainer
             state, with applied noise, with keys defined by model.states
-            e.g., x = {'abc': 332.2, 'def': 221.043} given states = ['abc', 'def']
+            e.g., x = m.StateContainer({'abc': 332.2, 'def': 221.043}) given states = ['abc', 'def']
 
         Example
         -------
         | m = PrognosticsModel() # Replace with specific model being simulated
-        | u = {'u1': 3.2}
-        | z = {'z1': 2.2}
+        | u = m.InputContainer({'u1': 3.2})
+        | z = m.OutputContainer({'z1': 2.2})
         | x = m.initialize(u, z) # Initialize first state
-        | x = m.apply_process_noise(x) 
+        | x = m.apply_process_noise(x)
 
         Note
         ----
         Configured using parameters `process_noise` and `process_noise_dist`
         """
-        return self.StateContainer({key: x[key] +
-                dt*np.random.normal(
-                    0, self.parameters['process_noise'][key],
-                    size=None if np.isscalar(x[key]) else len(x[key]))
-                    for key in x.keys()})
+        x.matrix += dt*np.random.normal(0, self.parameters['process_noise'].matrix, size=x.matrix.shape)
+        return x
 
-    def dx(self, x : dict, u : dict) -> dict:
+    def dx(self, x, u):
         """
         Calculate the first derivative of state `x` at a specific time `t`, given state and input
 
         Parameters
         ----------
-        x : dict
+        x : StateContainer
             state, with keys defined by model.states \n
-            e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
-        u : dict
+            e.g., x = m.StateContainer({'abc': 332.1, 'def': 221.003}) given states = ['abc', 'def']
+        u : InputContainer
             Inputs, with keys defined by model.inputs \n
-            e.g., u = {'i':3.2} given inputs = ['i']
+            e.g., u = m.InputContainer({'i':3.2}) given inputs = ['i']
 
         Returns
         -------
-        dx : dict
+        dx : StateContainer
             First derivitive of state, with keys defined by model.states \n
-            e.g., dx = {'abc': 3.1, 'def': -2.003} given states = ['abc', 'def']
-        
+            e.g., dx = m.StateContainer({'abc': 3.1, 'def': -2.003}) given states = ['abc', 'def']
+
         Example
         -------
         | m = DerivProgModel() # Replace with specific model being simulated
-        | u = {'u1': 3.2}
-        | z = {'z1': 2.2}
+        | u = m.InputContainer({'u1': 3.2})
+        | z = m.OutputContainer({'z1': 2.2})
         | x = m.initialize(u, z) # Initialize first state
         | dx = m.dx(x, u) # Returns first derivative of state given input u
-        
+
         See Also
         --------
         next_state
@@ -311,38 +316,37 @@ def dx(self, x : dict, u : dict) -> dict:
         and `next_state` for discrete, where the behavior cannot be described by the first derivative
         """
         raise ProgModelException('dx not defined - please use next_state()')
-        
-    def next_state(self, x : dict, u : dict, dt : int) -> dict: 
+
+    def next_state(self, x, u, dt : float):
         """
         State transition equation: Calculate next state
 
         Parameters
         ----------
-        x : dict
+        x : StateContainer
             state, with keys defined by model.states \n
-            e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
-        u : dict
+            e.g., x = m.StateContainer({'abc': 332.1, 'def': 221.003}) given states = ['abc', 'def']
+        u : InputContainer
             Inputs, with keys defined by model.inputs \n
-            e.g., u = {'i':3.2} given inputs = ['i']
+            e.g., u = m.InputContainer({'i':3.2}) given inputs = ['i']
         dt : number
             Timestep size in seconds (≥ 0) \n
             e.g., dt = 0.1
-        
 
         Returns
         -------
-        x : dict
+        x : StateContainer
             Next state, with keys defined by model.states
-            e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
+            e.g., x = m.StateContainer({'abc': 332.1, 'def': 221.003}) given states = ['abc', 'def']
 
         Example
         -------
         | m = PrognosticsModel() # Replace with specific model being simulated
-        | u = {'u1': 3.2}
-        | z = {'z1': 2.2}
+        | u = m.InputContainer({'u1': 3.2})
+        | z = m.OutputContainer({'z1': 2.2})
         | x = m.initialize(u, z) # Initialize first state
         | x = m.next_state(x, u, 0.1) # Returns state at 3.1 seconds given input u
-        
+
         See Also
         --------
         dx
@@ -351,26 +355,30 @@ def next_state(self, x : dict, u : dict, dt : int) -> dict:
         ----
         A model should overwrite either `next_state` or `dx`. Override `dx` for continuous models, and `next_state` for discrete, where the behavior cannot be described by the first derivative
         """
-        
         # Note: Default is to use the dx method (continuous model) - overwrite next_state for continuous
         dx = self.dx(x, u)
-        return self.StateContainer({key: x[key] + dx[key]*dt for key in dx.keys()})
+        if isinstance(x, DictLikeMatrixWrapper) and isinstance(dx, DictLikeMatrixWrapper):
+            return self.StateContainer(x.matrix + dx.matrix * dt)
+        elif isinstance(dx, dict) or isinstance(x, dict):
+            return self.StateContainer({key: x[key] + dx[key]*dt for key in dx.keys()})
+        else:
+            raise ValueError(f"ValueError: dx return must be of type StateContainer, was {type(dx)}")
 
-    def apply_limits(self, x : dict) -> dict:
+    def apply_limits(self, x):
         """
         Apply state bound limits. Any state outside of limits will be set to the closest limit.
 
         Parameters
         ----------
-        x : dict
+        x : StateContainer or dict
             state, with keys defined by model.states \n
-            e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
+            e.g., x = m.StateContainer({'abc': 332.1, 'def': 221.003}) given states = ['abc', 'def']
 
         Returns
         -------
-        x : dict
+        x : StateContainer or dict
             Bounded state, with keys defined by model.states
-            e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
+            e.g., x = m.StateContainer({'abc': 332.1, 'def': 221.003}) given states = ['abc', 'def']
         """
         for (key, limit) in self.state_limits.items():
             if x[key] < limit[0]:
@@ -381,37 +389,36 @@ def apply_limits(self, x : dict) -> dict:
                 x[key] = limit[1]
         return x
 
-    
-    def __next_state(self, x : dict, u : dict, dt : int) -> dict:
+    def __next_state(self, x, u, dt : float):
         """
         State transition equation: Calls next_state(), calculating the next state, and then adds noise
 
         Parameters
         ----------
-        x : dict
+        x : StateContainer
             state, with keys defined by model.states \n
-            e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
-        u : dict
+            e.g., x = m.StateContainer({'abc': 332.1, 'def': 221.003}) given states = ['abc', 'def']
+        u : InputContainer
             Inputs, with keys defined by model.inputs \n
-            e.g., u = {'i':3.2} given inputs = ['i']
+            e.g., u = m.InputContainer({'i':3.2}) given inputs = ['i']
         dt : number
             Timestep size in seconds (≥ 0) \n
             e.g., dt = 0.1
 
         Returns
         -------
-        x : dict
+        x : StateContainer
             Next state, with keys defined by model.states
-            e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
+            e.g., x = m.StateContainer({'abc': 332.1, 'def': 221.003}) given states = ['abc', 'def']
 
         Example
         -------
         | m = PrognosticsModel() # Replace with specific model being simulated
-        | u = {'u1': 3.2}
-        | z = {'z1': 2.2}
+        | u = m.InputContainer({'u1': 3.2})
+        | z = m.OutputContainer({'z1': 2.2})
         | x = m.initialize(u, z) # Initialize first state
         | x = m.__next_state(x, u, 0.1) # Returns state, with noise, at 3.1 seconds given input u
-        
+
         See Also
         --------
         next_state
@@ -421,22 +428,21 @@ def __next_state(self, x : dict, u : dict, dt : int) -> dict:
         A model should not overwrite '__next_state'
         A model should overwrite either `next_state` or `dx`. Override `dx` for continuous models, and `next_state` for discrete, where the behavior cannot be described by the first derivative.
         """
-        
         # Calculate next state and add process noise
         next_state = self.apply_process_noise(self.next_state(x, u, dt), dt)
 
         # Apply Limits
         return self.apply_limits(next_state)
 
-    def performance_metrics(self, x : dict) -> dict:
+    def performance_metrics(self, x) -> dict:
         """
         Calculate performance metrics where
 
         Parameters
         ----------
-        x : dict
+        x : StateContainer
             state, with keys defined by model.states \n
-            e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
+            e.g., x = m.StateContainer({'abc': 332.1, 'def': 221.003}) given states = ['abc', 'def']
         
         Returns
         -------
@@ -447,62 +453,64 @@ def performance_metrics(self, x : dict) -> dict:
         Example
         -------
         | m = PrognosticsModel() # Replace with specific model being simulated
-        | u = {'u1': 3.2}
-        | z = {'z1': 2.2}
+        | u = m.InputContainer({'u1': 3.2})
+        | z = m.OutputContainer({'z1': 2.2})
         | x = m.initialize(u, z) # Initialize first state
         | pm = m.performance_metrics(x) # Returns {'tMax':33, 'iMax':19}
         """
         return {}
-    
-    observables = performance_metrics
+
+    observables = performance_metrics  # For backwards compatibility
 
     @abstractmethod
-    def output(self, x : dict) -> dict:
+    def output(self, x):
         """
         Calculate outputs given state
 
         Parameters
         ----------
-        x : dict
+        x : StateContainer
             state, with keys defined by model.states \n
-            e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
-        
+            e.g., x = m.StateContainer({'abc': 332.1, 'def': 221.003}) given states = ['abc', 'def']
+
         Returns
         -------
-        z : dict
+        z : OutputContainer
             Outputs, with keys defined by model.outputs. \n
-            e.g., z = {'t':12.4, 'v':3.3} given outputs = ['t', 'v']
+            e.g., z = m.OutputContainer({'t':12.4, 'v':3.3}) given outputs = ['t', 'v']
 
         Example
         -------
         | m = PrognosticsModel() # Replace with specific model being simulated
-        | u = {'u1': 3.2}
-        | z = {'z1': 2.2}
+        | u = m.InputContainer({'u1': 3.2})
+        | z = m.OutputContainer({'z1': 2.2})
         | x = m.initialize(u, z) # Initialize first state
-        | z = m.output(x) # Returns {'o1': 1.2}
+        | z = m.output(x) # Returns m.OutputContainer({'z1': 2.2})
         """
         return {}
 
-    def __output(self, x : dict) -> dict:
+    def __output(self, x):
         """
         Calls output, which calculates next state forward one timestep, and then adds noise
 
         Parameters
         ----------
-        x : dict
+        x : StateContainer
             state, with keys defined by model.states \n
-            e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
-        
+            e.g., x = m.StateContainer({'abc': 332.1, 'def': 221.003}) given states = ['abc', 'def']
+
         Returns
         -------
-        z : dict
+        z : OutputContainer
             Outputs, with keys defined by model.outputs. \n
-            e.g., z = {'t':12.4, 'v':3.3} given outputs = ['t', 'v']
+            e.g., z = m.OutputContainer({'t':12.4, 'v':3.3} )given outputs = ['t', 'v']
 
         Example
         -------
         | m = PrognosticsModel() # Replace with specific model being simulated
-        | z = {'o1': 1.2}
+        | u = m.InputContainer({'u1': 3.2})
+        | z = m.OutputContainer({'z1': 2.2})
+        | x = m.initialize(u, z) # Initialize first state
         | z = m.__output(3.0, x) # Returns {'o1': 1.2} with noise added
         """
 
@@ -512,16 +520,16 @@ def __output(self, x : dict) -> dict:
         # Add measurement noise
         return self.apply_measurement_noise(z)
 
-    def event_state(self, x : dict) -> dict:
+    def event_state(self, x) -> dict:
         """
-        Calculate event states (i.e., measures of progress towards event (0-1, where 0 means event has occured))
+        Calculate event states (i.e., measures of progress towards event (0-1, where 0 means event has occurred))
 
         Parameters
         ----------
-        x : dict
+        x : StateContainer
             state, with keys defined by model.states\n
-            e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
-        
+            e.g., x = m.StateContainer({'abc': 332.1, 'def': 221.003}) given states = ['abc', 'def']
+
         Returns
         -------
         event_state : dict
@@ -531,30 +539,35 @@ def event_state(self, x : dict) -> dict:
         Example
         -------
         | m = PrognosticsModel() # Replace with specific model being simulated
-        | u = {'u1': 3.2}
-        | z = {'z1': 2.2}
+        | u = m.InputContainer({'u1': 3.2})
+        | z = m.OutputContainer({'z1': 2.2})
         | x = m.initialize(u, z) # Initialize first state
         | event_state = m.event_state(x) # Returns {'e1': 0.8, 'e2': 0.6}
 
         Note
         ----
-        Default is to return an empty array (for system models that do not include any events)
-        
+        If not overridden, will return 0.0 if threshold_met returns True, otherwise 1.0. If neither threshold_met or event_state is overridden, will return an empty dictionary (i.e., no events)
+
         See Also
         --------
         threshold_met
         """
-        return {}
+        if type(self).threshold_met == PrognosticsModel.threshold_met:
+            # Neither Threshold Met nor Event States are overridden
+            return {}
+
+        return {key: 1.0-float(t_met) \
+            for (key, t_met) in self.threshold_met(x).items()} 
     
-    def threshold_met(self, x : dict) -> dict:
+    def threshold_met(self, x) -> dict:
         """
         For each event threshold, calculate if it has been met
 
         Parameters
         ----------
-        x : dict
+        x : StateContainer
             state, with keys defined by model.states\n
-            e.g., x = {'abc': 332.1, 'def': 221.003} given states = ['abc', 'def']
+            e.g., x = m.StateContainer({'abc': 332.1, 'def': 221.003}) given states = ['abc', 'def']
         
         Returns
         -------
@@ -565,23 +578,27 @@ def threshold_met(self, x : dict) -> dict:
         Example
         -------
         | m = PrognosticsModel() # Replace with specific model being simulated
-        | u = {'u1': 3.2}
-        | z = {'z1': 2.2}
+        | u = m.InputContainer({'u1': 3.2})
+        | z = m.OutputContainer({'z1': 2.2})
         | x = m.initialize(u, z) # Initialize first state
         | threshold_met = m.threshold_met(x) # returns {'e1': False, 'e2': False}
 
         Note
         ----
-        If not overwritten, the default behavior is to say the threshold is met if the event state is <= 0
+        If not overridden, will return True if event_state is <= 0, otherwise False. If neither threshold_met or event_state is overridden, will return an empty dictionary (i.e., no events)
         
         See Also
         --------
         event_state
         """
+        if type(self).event_state == PrognosticsModel.event_state:
+            # Neither Threshold Met nor Event States are overridden
+            return {}
+
         return {key: event_state <= 0 \
             for (key, event_state) in self.event_state(x).items()} 
 
-    def simulate_to(self, time : float, future_loading_eqn : Callable, first_output : dict = None, **kwargs) -> namedtuple:
+    def simulate_to(self, time : float, future_loading_eqn : Callable, first_output = None, **kwargs) -> namedtuple:
         """
         Simulate prognostics model for a given number of seconds
 
@@ -592,8 +609,8 @@ def simulate_to(self, time : float, future_loading_eqn : Callable, first_output
             e.g., time = 200
         future_loading_eqn : callable
             Function of (t) -> z used to predict future loading (output) at a given time (t)
-        first_output : dict, optional
-            First measured output, needed to initialize state for some classes. Can be omitted for classes that dont use this
+        first_output : OutputContainer, optional
+            First measured output, needed to initialize state for some classes. Can be omitted for classes that don't use this
         options: kwargs, optional
             Configuration options for the simulation \n
             Note: configuration of the model is set through model.parameters \n
@@ -601,16 +618,16 @@ def simulate_to(self, time : float, future_loading_eqn : Callable, first_output
         
         Returns
         -------
-        times: number
+        times: list[float]
             Times for each simulated point
-        inputs: [dict]
+        inputs: SimResult
             Future input (from future_loading_eqn) for each time in times
-        states: [dict]
+        states: SimResult
             Estimated states for each time in times
-        outputs: [dict]
+        outputs: SimResult
             Estimated outputs for each time in times
-        event_states: [dict]
-            Estimated event state (e.g., SOH), between 1-0 where 0 is event occurance, for each time in times
+        event_states: SimResult
+            Estimated event state (e.g., SOH), between 1-0 where 0 is event occurrence, for each time in times
         
         Raises
         ------
@@ -627,11 +644,10 @@ def simulate_to(self, time : float, future_loading_eqn : Callable, first_output
         |         return 3.0
         |     else:
         |         return 5.0
-        | first_output = {'o1': 3.2, 'o2': 1.2}
+        | first_output = m.OutputContainer({'o1': 3.2, 'o2': 1.2})
         | m = PrognosticsModel() # Replace with specific model being simulated
         | (times, inputs, states, outputs, event_states) = m.simulate_to(200, future_load_eqn, first_output)
         """
-        
         # Input Validation
         if not isinstance(time, Number) or time < 0:
             raise ProgModelInputException("'time' must be positive, was {} (type: {})".format(time, type(time)))
@@ -645,7 +661,7 @@ def simulate_to(self, time : float, future_loading_eqn : Callable, first_output
 
         return self.simulate_to_threshold(future_loading_eqn, first_output, **kwargs)
  
-    def simulate_to_threshold(self, future_loading_eqn : Callable, first_output : dict = None, threshold_keys : list = None, **kwargs) -> namedtuple:
+    def simulate_to_threshold(self, future_loading_eqn : Callable, first_output = None, threshold_keys : list = None, **kwargs) -> namedtuple:
         """
         Simulate prognostics model until any or specified threshold(s) have been met
 
@@ -658,21 +674,26 @@ def simulate_to_threshold(self, future_loading_eqn : Callable, first_output : di
         -----------------
         t0 : Number, optional
             Starting time for simulation in seconds (default: 0.0) \n
-        dt : Number or function, optional
-            time step (s), e.g. dt = 0.1 or function (t, x) -> dt\n
+        dt : Number, tuple, string, or function, optional
+            Number: constant time step (s), e.g. dt = 0.1\n
+            function (t, x) -> dt\n
+            Tuple: (mode, dt), where modes could be constant or auto. If auto, dt is maximum step size\n
+            string: mode - 'auto' or 'constant'\n
+        integration_method: String, optional
+            Integration method, e.g. 'rk4' or 'euler' (default: 'euler')
         save_freq : Number, optional
             Frequency at which output is saved (s), e.g., save_freq = 10 \n
         save_pts : List[Number], optional
             Additional ordered list of custom times where output is saved (s), e.g., save_pts= [50, 75] \n
         horizon : Number, optional
             maximum time that the model will be simulated forward (s), e.g., horizon = 1000 \n
-        first_output : dict, optional
-            First measured output, needed to initialize state for some classes. Can be omitted for classes that dont use this
+        first_output : OutputContainer, optional
+            First measured output, needed to initialize state for some classes. Can be omitted for classes that don't use this
         threshold_keys: List[str] or str, optional
             Keys for events that will trigger the end of simulation.
             If blank, simulation will occur if any event will be met ()
-        x : dict, optional
-            initial state dict, e.g., x= {'x1': 10, 'x2': -5.3}\n
+        x : StateContainer, optional
+            initial state dict, e.g., x= m.StateContainer({'x1': 10, 'x2': -5.3})\n
         thresholds_met_eqn : function/lambda, optional
             custom equation to indicate logic for when to stop sim f(thresholds_met) -> bool\n
         print : bool, optional
@@ -683,7 +704,7 @@ def simulate_to_threshold(self, future_loading_eqn : Callable, first_output : di
     
         Returns
         -------
-        times: Array[number]
+        times: list[float]
             Times for each simulated point
         inputs: SimResult
             Future input (from future_loading_eqn) for each time in times
@@ -692,7 +713,7 @@ def simulate_to_threshold(self, future_loading_eqn : Callable, first_output : di
         outputs: SimResult
             Estimated outputs for each time in times
         event_states: SimResult
-            Estimated event state (e.g., SOH), between 1-0 where 0 is event occurance, for each time in times
+            Estimated event state (e.g., SOH), between 1-0 where 0 is event occurrence, for each time in times
         
         Raises
         ------
@@ -704,13 +725,13 @@ def simulate_to_threshold(self, future_loading_eqn : Callable, first_output : di
 
         Example
         -------
+        | m = PrognosticsModel() # Replace with specific model being simulated
         | def future_load_eqn(t):
         |     if t< 5.0: # Load is 3.0 for first 5 seconds
-        |         return {'load': 3.0}
+        |         return m.InputContainer({'load': 3.0})
         |     else:
-        |         return {'load': 5.0}
-        | first_output = {'o1': 3.2, 'o2': 1.2}
-        | m = PrognosticsModel() # Replace with specific model being simulated
+        |         return m.InputContainer({'load': 5.0})
+        | first_output = m.OutputContainer({'o1': 3.2, 'o2': 1.2})
         | (times, inputs, states, outputs, event_states) = m.simulate_to_threshold(future_load_eqn, first_output)
 
         Note
@@ -734,7 +755,8 @@ def simulate_to_threshold(self, future_loading_eqn : Callable, first_output : di
         # Configure
         config = { # Defaults
             't0': 0.0,
-            'dt': 1.0,
+            'dt': ('auto', 1.0),
+            'integration_method': 'euler',
             'save_pts': [],
             'save_freq': 10.0,
             'horizon': 1e100, # Default horizon (in s), essentially inf
@@ -744,7 +766,7 @@ def simulate_to_threshold(self, future_loading_eqn : Callable, first_output : di
         config.update(kwargs)
         
         # Configuration validation
-        if not isinstance(config['dt'], Number) and not callable(config['dt']):
+        if not isinstance(config['dt'], (Number, tuple, str)) and not callable(config['dt']):
             raise ProgModelInputException("'dt' must be a number or function, was a {}".format(type(config['dt'])))
         if isinstance(config['dt'], Number) and config['dt'] < 0:
             raise ProgModelInputException("'dt' must be positive, was {}".format(config['dt']))
@@ -758,7 +780,7 @@ def simulate_to_threshold(self, future_loading_eqn : Callable, first_output : di
         if not isinstance(config['horizon'], Number):
             raise ProgModelInputException("'horizon' must be a number, was a {}".format(type(config['horizon'])))
         if config['horizon'] < 0:
-            raise ProgModelInputException("'save_freq' must be positive, was {}".format(config['horizon']))
+            raise ProgModelInputException("'horizon' must be positive, was {}".format(config['horizon']))
         if 'x' in config and not all([state in config['x'] for state in self.states]):
             raise ProgModelInputException("'x' must contain every state in model.states")
         if 'thresholds_met_eqn' in config and not callable(config['thresholds_met_eqn']):
@@ -775,13 +797,19 @@ def simulate_to_threshold(self, future_loading_eqn : Callable, first_output : di
             x = deepcopy(config['x'])
         else:
             x = self.initialize(u, first_output)
+
+        if not isinstance(x, self.StateContainer):
+            x = self.StateContainer(x)
         
         # Optimization
         next_state = self.__next_state
         output = self.__output
-        thresthold_met_eqn = self.threshold_met
+        threshold_met_eqn = self.threshold_met
         event_state = self.event_state
+        load_eqn = future_loading_eqn
         progress = config['progress']
+
+        # Threshold Met Equations
         def check_thresholds(thresholds_met):
             t_met = [thresholds_met[key] for key in threshold_keys]
             if len(t_met) > 0 and not np.isscalar(list(t_met)[0]):
@@ -793,6 +821,8 @@ def check_thresholds(thresholds_met):
         elif threshold_keys is None: 
             # Note: Setting threshold_keys to be all events if it is None
             threshold_keys = self.events
+        elif len(threshold_keys) == 0:
+            check_thresholds = lambda _: False
 
         # Initialization of save arrays
         saved_times = []
@@ -842,22 +872,101 @@ def update_all():
         # configuring next_time function to define prediction time step, default is constant dt
         if callable(config['dt']):
             next_time = config['dt']
+            dt_mode = 'function'
+        elif isinstance(config['dt'], tuple):
+            dt_mode = config['dt'][0]
+            dt = config['dt'][1]                
+        elif isinstance(config['dt'], str):
+            dt_mode = config['dt']
+            if dt_mode == 'constant':
+                dt = 1.0  # Default
+            else:
+                dt = np.inf
         else:
+            dt_mode = 'constant'
             dt = config['dt']  # saving to optimize access in while loop
+
+        if dt_mode == 'constant':
             def next_time(t, x):
                 return dt
+        elif dt_mode == 'auto':
+            def next_time(t, x):
+                return min(dt, next_save-t, save_pts[save_pt_index]-t)
+        elif dt_mode != 'function':
+            raise ProgModelInputException(f"'dt' mode {dt_mode} not supported. Must be 'constant', 'auto', or a function")
         
+        # Auto Container wrapping
+        dt0 = next_time(t, x) - t
+        if not isinstance(u, DictLikeMatrixWrapper):
+            # Wrapper around the future loading equation
+            def load_eqn(t, x):
+                u = future_loading_eqn(t, x)
+                return self.InputContainer(u)
+
+        if not isinstance(self.next_state(x.copy(), u, dt0), DictLikeMatrixWrapper):
+            # Wrapper around next_state
+            def next_state(x, u, dt):
+                # Calculate next state, and convert
+                x_new = self.next_state(x, u, dt)
+                x_new = self.StateContainer(x_new)
+
+                # Calculate next state and add process noise
+                next_state = self.apply_process_noise(x_new, dt)
+
+                # Apply Limits
+                return self.apply_limits(next_state)
+
+        if not isinstance(self.output(x), DictLikeMatrixWrapper):
+            # Wrapper around the output equation
+            def output(x):
+                # Calculate output, convert to outputcontainer
+                z = self.output(x)
+                z = self.OutputContainer(z)
+
+                # Add measurement noise
+                return self.apply_measurement_noise(z)
+
         # Simulate
         update_all()
         if progress:
             simulate_progress = ProgressBar(100, "Progress")
             last_percentage = 0
+
+        if config['integration_method'].lower() == 'rk4':
+            # Using RK4 Method
+            dx = self.dx
+
+            try:
+                dx(x, load_eqn(t, x))
+            except ProgModelException:
+                raise ProgModelException("dx(x, u) must be defined to use RK4 method")
+
+            apply_limits = self.apply_limits
+            apply_process_noise = self.apply_process_noise
+            StateContainer = self.StateContainer
+            def next_state(x, u, dt):
+                dx1 = StateContainer(dx(x, u))
+                
+                x2 = StateContainer({key: x[key] + dt*dx_i/2 for key, dx_i in dx1.items()})
+                dx2 = dx(x2, u)
+
+                x3 = StateContainer({key: x[key] + dt*dx_i/2 for key, dx_i in dx2.items()})
+                dx3 = dx(x3, u)
+                
+                x4 = StateContainer({key: x[key] + dt*dx_i for key, dx_i in dx3.items()})   
+                dx4 = dx(x4, u)
+
+                x = StateContainer({key: x[key]+ dt/3*(dx1[key]/2 + dx2[key] + dx3[key] + dx4[key]/2) for key in dx1.keys()})
+                return apply_limits(apply_process_noise(x))
+        elif config['integration_method'].lower() != 'euler':
+            raise ProgModelInputException(f"'integration_method' mode {config['integration_method']} not supported. Must be 'euler' or 'rk4'")
        
         while t < horizon:
             dt = next_time(t, x)
             t = t + dt/2
             # Use state at midpoint of step to best represent the load during the duration of the step
-            u = future_loading_eqn(t, x)
+            # This is sometimes referred to as 'leapfrog integration'
+            u = load_eqn(t, x)
             t = t + dt/2
             x = next_state(x, u, dt)
 
@@ -865,7 +974,10 @@ def next_time(t, x):
             if (t >= next_save):
                 next_save = next(iterator)
                 update_all()
-            if (t >= save_pts[save_pt_index]):
+                if (t >= save_pts[save_pt_index]):
+                    # Prevent double saving when save_pt and save_freq align
+                    save_pt_index += 1
+            elif (t >= save_pts[save_pt_index]):
                 save_pt_index += 1
                 update_all()
 
@@ -879,7 +991,7 @@ def next_time(t, x):
                     last_percentage = converted_iteration
 
             # Check thresholds
-            if check_thresholds(thresthold_met_eqn(x)):
+            if check_thresholds(threshold_met_eqn(x)):
                 break
         
         # Save final state
@@ -892,13 +1004,13 @@ def next_time(t, x):
             saved_outputs = LazySimResult(self.output, saved_times, saved_states) 
             saved_event_states = LazySimResult(self.event_state, saved_times, saved_states)
         else:
-            saved_outputs = SimResult(saved_times, saved_outputs)
-            saved_event_states = SimResult(saved_times, saved_event_states)
+            saved_outputs = SimResult(saved_times, saved_outputs, _copy=False)
+            saved_event_states = SimResult(saved_times, saved_event_states, _copy=False)
         
         return self.SimulationResults(
             saved_times, 
-            SimResult(saved_times, saved_inputs), 
-            SimResult(saved_times, saved_states), 
+            SimResult(saved_times, saved_inputs, _copy=False), 
+            SimResult(saved_times, saved_states, _copy=False), 
             saved_outputs, 
             saved_event_states
         )
@@ -906,7 +1018,7 @@ def next_time(t, x):
     @staticmethod
     def generate_model(keys : dict, initialize_eqn : Callable, output_eqn : Callable, next_state_eqn : Callable = None, dx_eqn : Callable = None, event_state_eqn : Callable = None, threshold_eqn : Callable = None, config : dict = {'process_noise': 0.1}) -> "PrognosticsModel":
         """
-        Generate a new prognostics model from individual model functions
+        [DEPRECIATED] Generate a new prognostics model from individual model functions
 
         Parameters
         ----------
@@ -949,6 +1061,8 @@ def generate_model(keys : dict, initialize_eqn : Callable, output_eqn : Callable
         |
         | m = PrognosticsModel.generate_model(keys, initialize_eqn, next_state_eqn, output_eqn, event_state_eqn, threshold_eqn)
         """
+        warn("PrognosticsModel.generate_model is deprecated and will be removed in v1.5. Subclass PrognosticsModel instead", DeprecationWarning)
+
         # Input validation
         if not callable(initialize_eqn):
             raise ProgModelTypeError("Initialize Function must be callable")
@@ -1016,37 +1130,54 @@ def calc_error(self, times : List[float], inputs : List[dict], outputs : List[di
         """Calculate Mean Squared Error (MSE) between simulated and observed
 
         Args:
-            times ([double]): array of times for each sample
-            inputs ([dict]): array of input dictionaries where input[x] corresponds to time[x]
-            outputs ([dict]): array of output dictionaries where output[x] corresponds to time[x]
-            kwargs: Configuration parameters, such as:\n
-             | x0 [dict]: Initial state
-             | dt [double] : time step
+            times (list[float]): array of times for each sample
+            inputs (list[dict]): array of input dictionaries where input[x] corresponds to time[x]
+            outputs (list[dict]): array of output dictionaries where output[x] corresponds to time[x]
+        
+        Keyword Args:
+            x0 (dict, optional): Initial state
+            dt (double, optional): time step
 
         Returns:
             double: Total error
         """
-        params = {
-            'x0': self.initialize(inputs[0], outputs[0]),
-            'dt': 1e99
-        }
-        params.update(kwargs)
-        x = params['x0']
+        if isinstance(times[0], Iterable):
+            # Calculate error for each
+            error = [self.calc_error(t, i, z, **kwargs) for (t, i, z) in zip(times, inputs, outputs)]
+            return sum(error)/len(error)
+
+        x = kwargs.get('x0', self.initialize(inputs[0], outputs[0]))
+        dt = kwargs.get('dt', 1e99)
+
+        if not isinstance(x, self.StateContainer):
+            x = [self.StateContainer(x_i) for x_i in x]
+
+        if not isinstance(inputs[0], self.InputContainer):
+            inputs = [self.InputContainer(u_i) for u_i in inputs]
+        
+        if not isinstance(outputs[0], self.OutputContainer):
+            outputs = [self.OutputContainer(z_i) for z_i in outputs]
+
+        counter = 0  # Needed to account for skipped (i.e., none) values
         t_last = times[0]
         err_total = 0
-
+        z_obs = self.output(x)  # Initialize
         for t, u, z in zip(times, inputs, outputs):
             while t_last < t:
-                t_new = min(t_last + params['dt'], t)
+                t_new = min(t_last + dt, t)
                 x = self.next_state(x, u, t_new-t_last)
-                t_last = t_new
-            z_obs = self.output(x)
-            if any([np.isnan(z_i) for z_i in z_obs.values()]):
-                warn("Model unstable- NaN reached in simulation (t={})".format(t))
-                break
-            err_total += sum([(z[key] - z_obs[key])**2 for key in z.keys()])
-
-        return err_total/len(times)
+                t_last = t
+                if t >= t_last:
+                    # Only recalculate if required
+                    z_obs = self.output(x)
+            if not (None in z_obs.matrix or None in z.matrix):
+                if any(np.isnan(z_obs.matrix)):
+                    warn("Model unstable- NaN reached in simulation (t={})".format(t))
+                    break
+                err_total += np.sum(np.square(z.matrix - z_obs.matrix), where= ~np.isnan(z.matrix))
+                counter += 1
+
+        return err_total/counter
     
     def estimate_params(self, runs : List[tuple], keys : List[str], **kwargs) -> None:
         """Estimate the model parameters given data. Overrides model parameters
@@ -1054,9 +1185,11 @@ def estimate_params(self, runs : List[tuple], keys : List[str], **kwargs) -> Non
         Args:
             runs (array[tuple]): data from all runs, where runs[0] is the data from run 0. Each run consists of a tuple of arrays of times, input dicts, and output dicts
             keys ([string]): Parameter keys to optimize
-            kwargs: Configuration parameters. Supported parameters include: \n
-             | method: Optimization method- see scikit.optimize.minimize 
-             | options: Options passed to optimizer
+        
+        Keyword Args: 
+            method (string, optional): Optimization method- see scipy.optimize.minimize for options
+            bounds (tuple): Bounds for optimization in format ((lower1, upper1), (lower2, upper2), ...)
+            options (dict): Options passed to optimizer. see scipy.optimize.minimize for options
 
         See: examples.param_est
         """
@@ -1064,14 +1197,32 @@ def estimate_params(self, runs : List[tuple], keys : List[str], **kwargs) -> Non
 
         config = {
             'method': 'nelder-mead',  # Optimization method
+            'bounds': ((-np.inf, np.inf) for _ in keys),
             'options': {'xatol': 1e-8}  # Options passed to optimizer
         }
         config.update(kwargs)
 
+        if 'x0' in kwargs and not isinstance(kwargs['x0'], self.StateContainer):
+            # Convert here so it isn't done every call of calc_error
+            kwargs['x0'] = [self.StateContainer(x_i) for x_i in kwargs['x0']]
+
         # Set noise to 0
         m_noise, self.parameters['measurement_noise'] = self.parameters['measurement_noise'], 0
         p_noise, self.parameters['process_noise'] = self.parameters['process_noise'], 0
 
+        for i, (times, inputs, outputs) in enumerate(runs):
+            has_changed = False
+            if not isinstance(inputs[0], self.InputContainer):
+                inputs = [self.InputContainer(u_i) for u_i in inputs]
+                has_changed = True
+
+            if isinstance(outputs, np.ndarray):
+                outputs = [self.OutputContainer(u_i) for u_i in outputs]
+                has_changed = True
+
+            if has_changed:
+                runs[i] = (times, inputs, outputs)
+
         def optimization_fcn(params):
             for key, param in zip(keys, params):
                 self.parameters[key] = param
@@ -1081,12 +1232,12 @@ def optimization_fcn(params):
                     err += self.calc_error(run[0], run[1], run[2], **kwargs)
                 except Exception:
                     return 1e99 
-                    # If it doesn't work (i.e., throws an error), dont use it
+                    # If it doesn't work (i.e., throws an error), don't use it
             return err
         
         params = np.array([self.parameters[key] for key in keys])
 
-        res = minimize(optimization_fcn, params, method=config['method'], options=config['options'])
+        res = minimize(optimization_fcn, params, method=config['method'], bounds = config['bounds'], options=config['options'])
         for x, key in zip(res.x, keys):
             self.parameters[key] = x
 
@@ -1107,26 +1258,20 @@ def generate_surrogate(self, load_functions, method = 'dmd', **kwargs):
 
         Keyword Arguments
         -----------------
-        Includes all keyword arguments from simulate_to_threshold (except save_pts), and the following additional keywords: 
-
         dt : Number or function, optional
             Same as in simulate_to_threshold; for DMD, this value is the time step of the training data\n
         save_freq : Number, optional
             Same as in simulate_to_threshold; for DMD, this value is the time step with which the surrogate model is generated  \n
-        trim_data_to: int, optional
-            Value between 0 and 1 that determines fraction of data resulting from simulate_to_threshold that is used to train DMD surrogate model
-            e.g. if trim_data_to = 0.7 and the simulated data spans from t=0 to t=100, the surrogate model is trained on the data from t=0 to t=70 \n   
-            Note: To trim data to a set time, use the 'horizon' parameter\n   
-        states: list, optional
+        state_keys: list, optional
             List of state keys to be included in the surrogate model generation. keys must be a subset of those defined in the PrognosticsModel  \n
-        inputs: list, optional
+        input_keys: list, optional
             List of input keys to be included in the surrogate model generation. keys must be a subset of those defined in the PrognosticsModel  \n
-        outputs: list, optional
+        output_keys: list, optional
             List of output keys to be included in the surrogate model generation. keys must be a subset of those defined in the PrognosticsModel  \n
-        events: list, optional
-            List of event_state keys to be included in the surrogate model generation. keys must be a subset of those defined in the PrognosticsModel  \n      
-        stability_tol: int, optional
-            Value that determines the tolerance for DMD matrix stability\n
+        event_keys: list, optional
+            List of event_state keys to be included in the surrogate model generation. keys must be a subset of those defined in the PrognosticsModel  \n   
+        ...: optional
+            Keyword arguments from simulate_to_threshold (except save_pts)
 
         Returns
         -------
@@ -1136,35 +1281,39 @@ def generate_surrogate(self, load_functions, method = 'dmd', **kwargs):
         Example
         -------
         See examples/generate_surrogate
-
-        Note
-        -------
-        This is a first draft of a surrogate model generation using Dynamic Mode Decomposition. 
-        DMD does not generate accurate approximations for all models, especially highly non-linear sections, and can be sensitive to the training data time step. 
-        In general, the approximation is less accurate if the DMD matrix is unstable. 
-        Additionally, this implementation does not yet include all functionalities of DMD (e.g. reducing the system's dimensions through SVD). Further functionalities will be included in future releases. \n
         """
 
-        if method != 'dmd':
-            raise ProgModelInputException(f"Method {method} is not supported. DMD is currently the only method available.")
+        from .data_models import SURROAGATE_METHOD_LOOKUP
+
+        if method not in SURROAGATE_METHOD_LOOKUP.keys():
+            raise ProgModelInputException("Method {} not supported. Supported methods: {}".format(method, SURROAGATE_METHOD_LOOKUP.keys()))
 
         # Configure
         config = { # Defaults
             'save_freq': 1.0, 
-            'trim_data_to': 1,
-            'states': self.states,
-            'inputs': self.inputs,
-            'outputs': self.outputs,
-            'events': self.events,
-            'stability_tol': 1e-05
+            'state_keys': self.states.copy(),
+            'input_keys': self.inputs.copy(),
+            'output_keys': self.outputs.copy(),
+            'event_keys': self.events.copy(),
         }
         config.update(kwargs)
 
-        # List of user-define values to include in surrogate model: 
-        states_dmd = self.states.copy()
-        inputs_dmd = self.inputs.copy()
-        outputs_dmd = self.outputs.copy()
-        events_dmd = self.events.copy()
+        if 'inputs' in config:
+            warn("Use 'input_keys' instead of 'inputs'. 'inputs' will be deprecated in v1.5")
+            config['input_keys'] = config['inputs']
+            del config['inputs']
+        if 'states' in config:
+            warn("Use 'state_keys' instead of 'states'. 'states' will be deprecated in v1.5")
+            config['state_keys'] = config['states']
+            del config['states']
+        if 'outputs' in config:
+            warn("Use 'output_keys' instead of 'outputs'. 'outputs' will be deprecated in v1.5")
+            config['output_keys'] = config['outputs']
+            del config['outputs']
+        if 'events' in config:
+            warn("Use 'event_keys' instead of 'events'. 'events' will be deprecated in v1.5")
+            config['event_keys'] = config['events']
+            del config['events']
 
         # Validate user inputs 
         try:
@@ -1176,314 +1325,68 @@ def generate_surrogate(self, load_functions, method = 'dmd', **kwargs):
             raise ProgModelInputException("load_functions must contain at least one element")
         if 'save_pts' in config.keys():
             raise ProgModelInputException("'save_pts' is not a valid input for DMD Surrogate Model.")
-        if not isinstance(config['trim_data_to'], Number) or config['trim_data_to']>1 or config['trim_data_to']<=0:
-            raise ProgModelInputException("Invalid 'trim_data_to' input value, must be between 0 and 1.")
-        if not isinstance(config['stability_tol'], Number) or  config['stability_tol'] < 0:
-            raise ProgModelInputException(f"Invalid 'stability_tol' input value {config['stability_tol']}, must be a positive number.")
-
-        if isinstance(config['inputs'], str):
-            config['inputs'] = [config['inputs']]
-        if not all([x in self.inputs for x in config['inputs']]):
-            raise ProgModelInputException(f"Invalid 'inputs' input value ({config['inputs']}), must be a subset of the model's inputs ({self.inputs}).")
-        
-        if isinstance(config['states'], str):
-            config['states'] = [config['states']]
-        if not all([x in self.states for x in config['states']]):
-            raise ProgModelInputException(f"Invalid 'states' input value ({config['states']}), must be a subset of the model's states ({self.states}).")
-
-        if isinstance(config['outputs'], str):
-            config['outputs'] = [config['outputs']]
-        if not all([x in self.outputs for x in config['outputs']]):
-            raise ProgModelInputException(f"Invalid 'outputs' input value ({config['outputs']}), must be a subset of the model's states ({self.outputs}).")
-
-        if isinstance(config['events'], str):
-            config['events'] = [config['events']]
-        if not all([x in self.events for x in config['events']]):
-            raise ProgModelInputException(f"Invalid 'events' input value ({config['events']}), must be a subset of the model's states ({self.events}).")
-
-        # Initialize lists to hold individual matrices
-        x_list = []
-        xprime_list = []
-        time_list = []
-
-        # Generate Data to train surrogate model: 
-        for iter_load, load_fcn_now in enumerate(load_functions):
-            print('Generating training data: loading profile {} of {}'.format(iter_load+1, len(load_functions)))
-
-            # Simulate to threshold 
-            (times, inputs, states, outputs, event_states) = self.simulate_to_threshold(load_fcn_now, **config)
-        
-            # Interpolate results to time step of save_freq
-            time_data_interp = np.arange(times[0], times[-1], config['save_freq'])
-
-            states_data_interp = {}
-            inputs_data_interp = {}
-
-            for state_name in self.states:
-                states_data_temp = [states[iter_data1][state_name] for iter_data1 in range(len(states))]
-                states_data_interp[state_name] = interp1d(times,states_data_temp)(time_data_interp)
-            for input_name in self.inputs:
-                inputs_data_temp = [inputs[iter_data4][input_name] for iter_data4 in range(len(inputs))]
-                inputs_data_interp[input_name] = interp1d(times,inputs_data_temp)(time_data_interp)
-
-            states_data = [
-                self.StateContainer({
-                    key: value[iter_dataT] for key, value in states_data_interp.items()
-                }) for iter_dataT in range(len(time_data_interp))
-                ]
-            inputs_data = [
-                self.InputContainer({
-                    key: value[iter_dataT] for key, value in inputs_data_interp.items()
-                }) for iter_dataT in range(len(time_data_interp))
-                ]
-
-            times = time_data_interp.tolist()
-            states = SimResult(time_data_interp,states_data)
-            inputs = SimResult(time_data_interp,inputs_data)
-            outputs = LazySimResult(self.output, time_data_interp, states_data) 
-            event_states = LazySimResult(self.event_state, time_data_interp, states_data)
-            
-            def user_val_set(iter_loop : list, config_key : str, remove_from : dict, del_from) -> None:
-                """Sub-function for performing check and removal for user designated values.
-            
-                Args:
-                    iter_loop : list
-                        List of keys to iterate through.
-                    config_key : str
-                        String key to check keys against config
-                    remove_from : dict
-                        Dictionary dmd to remove key from
-                    del_from : list or dict
-                        Final data structure to remove key and data from 
-                """
-                for key in iter_loop:
-                    if key not in config[config_key]:
-                        if iter_load == 0:
-                            remove_from.remove(key)
-                        for i in range(len(times)):
-                            del del_from[i][key]
-                           
-            if len(config['states']) != len(self.states):
-                user_val_set(self.states,  'states', states_dmd, states)
-            if len(config['inputs']) != len(self.inputs):
-                user_val_set(self.inputs,  'inputs', inputs_dmd, inputs)
-            if len(config['outputs']) != len(self.outputs):
-                user_val_set(self.outputs,  'outputs', outputs_dmd, outputs) 
-            if len(config['events']) != len(self.events):
-                user_val_set(self.events,  'events', events_dmd, event_states)  
-
-            # Initialize DMD matrices
-            x_mat_temp = np.zeros((len(states[0])+len(outputs[0])+len(event_states[0])+len(inputs[0]),len(times)-1)) 
-            xprime_mat_temp = np.zeros((len(states[0])+len(outputs[0])+len(event_states[0]),len(times)-1)) 
-
-            # Save DMD matrices
-            for i, time in enumerate(times[:-1]): 
-                time_now = time + np.divide(config['save_freq'],2) 
-                load_now = load_fcn_now(time_now) # Evaluate load_function at (t_now + t_next)/2 to be consistent with next_state implementation
-                if len(config['inputs']) != len(self.inputs): # Delete any input values not specified by user to be included in surrogate model 
-                    for key in self.inputs:
-                        if key not in config['inputs']:
-                            del load_now[key]
-
-                states_now = states[i].matrix 
-                states_next = states[i+1].matrix 
-  
-                stack = (
-                        states_now,
-                        outputs[i].matrix,
-                        np.array([list(event_states[i].values())]).T,
-                        np.array([[load_now[key]] for key in load_now.keys()])
-                    )
-                x_mat_temp[:,i] = np.vstack(tuple(v for v in stack if v.shape != (0, )))[:,0]  # Filter out empty values (e.g., if there is no input)
-                stack2 = (
-                    states_next,
-                    outputs[i+1].matrix,
-                    np.array([list(event_states[i+1].values())]).T
-                )
-                xprime_mat_temp[:,i] = np.vstack(tuple(v for v in stack2 if v.shape != (1,0)))[:,0]  # Filter out empty values (e.g., if there is no output)
-                
-            # Save matrices in list, where each index in list corresponds to one of the user-defined loading equations 
-            x_list.append(x_mat_temp)
-            xprime_list.append(xprime_mat_temp)
-            time_list.append(times)
-
-        # Format training data for DMD and solve for matrix A, in the form X' = AX 
-        print('Generate DMD Surrogate Model')
-
-        # Cut data to user-defined length 
-        if config['trim_data_to'] != 1:
-            for iter3 in range(len(load_functions)):
-                trim_index = round(len(time_list[iter3])*(config['trim_data_to'])) 
-                x_list[iter3] = x_list[iter3][:,0:trim_index]
-                xprime_list[iter3] = xprime_list[iter3][:,0:trim_index]
-     
-        # Convert lists of datasets into arrays, sequentially stacking data in the horizontal direction
-        x_mat = np.hstack((x_list[:]))
-        xprime_mat = np.hstack((xprime_list[:]))
-
-        # Calculate DMD matrix using the Moore-Penrose pseudo-inverse:
-        dmd_matrix = np.dot(xprime_mat,np.linalg.pinv(x_mat))
-
-        # Save size of states, inputs, outputs, event_states, and current instance of PrognosticsModel
-        num_states = len(states[0].matrix)
-        num_inputs = len(inputs[0].matrix)
-        num_outputs = len(outputs[0].matrix)
-        num_event_states = len(event_states[0])
-        num_total = num_states + num_outputs + num_event_states 
-        prog_model = self
-        dmd_dt = config['save_freq']
-        process_noise_temp = {key: 0 for key in prog_model.events}  # Process noise for event states is zero
-
-        # Check for stability of dmd_matrix
-        eig_val, _ = np.linalg.eig(dmd_matrix[:,0:-num_inputs if num_inputs > 0 else None])            
-        
-        if sum(eig_val>1) != 0:
-            for eig_val_i in eig_val:
-                if eig_val_i>1 and eig_val_i-1>config['stability_tol']:
-                    warn("The DMD matrix is unstable, may result in poor approximation.")
 
-        from .linear_model import LinearModel
+        if isinstance(config['input_keys'], str):
+            config['input_keys'] = [config['input_keys']]
+        if not all([x in self.inputs for x in config['input_keys']]):
+            raise ProgModelInputException(f"Invalid 'input_keys' value ({config['input_keys']}), must be a subset of the model's inputs ({self.inputs}).")
         
+        if isinstance(config['state_keys'], str):
+            config['state_keys'] = [config['state_keys']]
+        if not all([x in self.states for x in config['state_keys']]):
+            raise ProgModelInputException(f"Invalid 'state_keys' input value ({config['state_keys']}), must be a subset of the model's states ({self.states}).")
+
+        if isinstance(config['output_keys'], str):
+            config['output_keys'] = [config['output_keys']]
+        if not all([x in self.outputs for x in config['output_keys']]):
+            raise ProgModelInputException(f"Invalid 'output_keys' input value ({config['output_keys']}), must be a subset of the model's outputs ({self.outputs}).")
+
+        if isinstance(config['event_keys'], str):
+            config['event_keys'] = [config['event_keys']]
+        if not all([x in self.events for x in config['event_keys']]):
+            raise ProgModelInputException(f"Invalid 'event_keys' input value ({config['event_keys']}), must be a subset of the model's events ({self.events}).")
+
+        return SURROAGATE_METHOD_LOOKUP[method](self, load_functions, **config)
+    
+    def to_json(self):
+        """
+        Serialize parameters as JSON objects 
 
-        class SurrogateModelDMD(LinearModel):
-            """
-            A subclass of LinearModel that uses Dynamic Mode Decomposition to simulate a system throughout time.
-            
-            Given an initial state of the system (including internal states, outputs, and event_states), and the expected inputs throuhgout time, this class defines a surrogate model that can approximate the internal states, outputs, and event_states throughout time until threshold is met.
-
-            Keyword Args
-            ------------
-                process_noise : Optional, float or Dict[Srt, float]
-
-            See Also
-            ------
-                LinearModel
-
-            Attributes
-            ----------
-                initialize : 
-                    Calculate initial state, augmented with outputs and event_states
-
-                next_state : 
-                    State transition equation: Calculate next state with matrix multiplication (overrides 'dx' defined in LinearModel)
-
-                simulate_to_threshold:
-                    Simulate prognostics model until defined threshold is met, using simulate_to_threshold defined in PrognosticsModel, then interpolate results to be at user-defined times
-            """
-
-            # Default parameters: set process_noise and measurement_noise to be defined based on PrognosticsModel values
-            default_parameters = {
-                'process_noise': {**prog_model.parameters['process_noise'],**prog_model.parameters['measurement_noise'],**process_noise_temp},
-                'measurement_noise': prog_model.parameters['measurement_noise'],
-                'process_noise_dist': prog_model.parameters.get('process_noise_dist', 'normal'),
-                'measurement_noise_dist': prog_model.parameters.get('measurement_noise_dist', 'normal')
-            }
-
-            # Define appropriate matrices for LinearModel
-            A = dmd_matrix[:,0:num_total]
-            B = np.vstack(dmd_matrix[:,num_total:num_total+num_inputs]) 
-            C = np.zeros((num_outputs,num_total))
-            for iter1 in range(num_outputs):
-                C[iter1,num_states+iter1] = 1 
-            F = np.zeros((num_event_states,num_total))
-            for iter2 in range(num_event_states):
-                F[iter2,num_states+num_outputs+iter2] = 1 
-
-            states = states_dmd + outputs_dmd + events_dmd
-            inputs = inputs_dmd
-            outputs = outputs_dmd 
-            events = events_dmd
-
-            dt = dmd_dt  # Step size (so it can be accessed programmatically)
-
-            def initialize(self, u=None, z=None):
-                x = prog_model.initialize(u,z)
-                x.update(prog_model.output(x))
-                x.update(prog_model.event_state(x))
-
-                return self.StateContainer(x)
-
-            def next_state(self, x, u, _):   
-                x.matrix = np.matmul(self.A, x.matrix) + np.matmul(self.B, u.matrix) + self.E
-                
-                return x   
+        Returns:
+            JSON: Serialized PrognosticsModel parameters as JSON object
 
-            def simulate_to_threshold(self, future_loading_eqn, first_output = None, threshold_keys = None, **kwargs):
-                # Save keyword arguments same as DMD training for approximation 
-                kwargs_sim = kwargs.copy()
-                kwargs_sim['save_freq'] = dmd_dt
-                kwargs_sim['dt'] = dmd_dt
+        See Also
+        --------
+        from_json
 
-                # Simulate to threshold at DMD time step
-                results = super().simulate_to_threshold(future_loading_eqn,first_output, threshold_keys, **kwargs_sim)
-                
-                # Interpolate results to be at user-desired time step
-                if 'dt' in kwargs:
-                    warn("dt is not used in DMD approximation")
-
-                # Default parameters 
-                config = {
-                    'dt': None,
-                    'save_freq': None,
-                    'save_pts': []
-                }
-                config.update(kwargs)
-
-                if (config['save_freq'] == dmd_dt or
-                    (isinstance(config['save_freq'], tuple) and
-                        config['save_freq'][0]%dmd_dt < 1e-9 and
-                        config['save_freq'][1] == dmd_dt)
-                    ) and config['save_pts'] == []:
-                    # In this case, the user wants what the DMD approximation returns 
-                    return results 
-
-                # In this case, the user wants something different than what the DMD approximation retuns, so we must interpolate 
-                # Define time vector based on user specifications
-                time_basic = [results.times[0], results.times[-1]]
-                time_basic.extend(config['save_pts'])                       
-                if config['save_freq'] != None:
-                    if isinstance(config['save_freq'], tuple):
-                        # Tuple used to specify start and frequency
-                        t_step = config['save_freq'][1]
-                        # Use starting time or the next multiple
-                        t_start = config['save_freq'][0]
-                        start = max(t_start, results.times[0] - (results.times[0]-t_start)%t_step)
-                        time_array = np.arange(start+t_step,results.times[-1],t_step)
-                    else: 
-                        time_array = np.arange(results.times[0]+config['save_freq'],results.times[-1],config['save_freq'])
-                    time_basic.extend(time_array.tolist())
-                time_interp = sorted(time_basic)
-
-                # Interpolate States
-                states_dict_temp = {}
-                for states_name in self.states:
-                    states_list_temp = [results.states[iter1a][states_name] for iter1a in range(len(results.states))]
-                    states_dict_temp[states_name] = interp1d(results.times,states_list_temp)(time_interp)
-                states_interp = [
-                    self.StateContainer({key: state[i] for key, state in states_dict_temp.items()})
-                    for i in range(len(time_interp))
-                ]
-                    
-                # Interpolate Inputs
-                inputs_dict_temp = {}
-                for inputs_name in self.inputs:
-                    inputs_list_temp = [results.inputs[iter1a][inputs_name] for iter1a in range(len(results.inputs))]
-                    inputs_dict_temp[inputs_name] = interp1d(results.times,inputs_list_temp)(time_interp)
-                inputs_interp = [
-                    self.InputContainer({key: input[i] for key, input in inputs_dict_temp.items()}) for i in range(len(time_interp))
-                ]
-
-                states = SimResult(time_interp,states_interp)
-                inputs = SimResult(time_interp,inputs_interp)
-                outputs = LazySimResult(self.output, time_interp, states_interp)
-                event_states = LazySimResult(self.event_state, time_interp, states_interp)
-
-                return self.SimulationResults(
-                    time_interp,
-                    inputs,
-                    states,
-                    outputs,
-                    event_states
-                )
-                
-        return SurrogateModelDMD()
+        Note
+        ----
+        This method only serializes the values of the prognostics model parameters (model.parameters)
+        """
+        return json.dumps(self.parameters.data, cls=CustomEncoder)
+    
+    @classmethod
+    def from_json(cls, data):
+        """
+        Create a new prognostics model from a previously generated model that was serialized as a JSON object
+
+        Args:
+            data: 
+                JSON serialized parameters necessary to build a model 
+                See to_json method 
+
+        Returns:
+            PrognosticsModel: Model generated from serialized parameters 
+
+        See Also
+        ---------
+        to_json
+
+        Note
+        ----
+        This serialization only works for models that include all parameters necessary to generate the model in model.parameters. 
+        """
+
+        extract_parameters = json.loads(data, object_hook = custom_decoder)
+ 
+        return cls(**extract_parameters)
diff --git a/src/prog_models/sim_result.py b/src/prog_models/sim_result.py
index da4dc7bae..645ffefe2 100644
--- a/src/prog_models/sim_result.py
+++ b/src/prog_models/sim_result.py
@@ -1,11 +1,13 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
+
 from collections import UserList, defaultdict
+from copy import deepcopy
+from matplotlib.pyplot import figure
+import numpy as np
 from typing import Callable, Dict, List
 
-from matplotlib.pyplot import figure
-from numpy import sign
+from .utils.containers import DictLikeMatrixWrapper
 from .visualize import plot_timeseries
-from copy import deepcopy
 
 
 class SimResult(UserList):
@@ -19,9 +21,16 @@ class SimResult(UserList):
 
     __slots__ = ['times', 'data']  # Optimization 
     
-    def __init__(self, times : list = [], data : list = []):
-        self.times = times.copy()
-        self.data = deepcopy(data)
+    def __init__(self, times : list = None, data : list = None, _copy = True):
+        if times is None or data is None:
+            self.times = [] 
+            self.data = []
+        else:
+            self.times = times.copy()
+            if _copy:
+                self.data = deepcopy(data)
+            else:
+                self.data = data
 
     def __eq__(self, other : "SimResult") -> bool:
         """Compare 2 SimResults
@@ -38,9 +47,9 @@ def index(self, other : dict, *args, **kwargs) -> int:
         """
         Get the index of the first sample where other occurs
 
-        Args: 
+        Args:
             other (dict)
-    
+
         Returns:
             int: Index of first sample where other occurs
         """
@@ -54,7 +63,7 @@ def extend(self, other : "SimResult") -> None:
             other (SimResult/LazySimResult)
 
         """
-        if other.__class__ in [SimResult, LazySimResult]:
+        if isinstance(other, SimResult):
             self.times.extend(other.times)
             self.data.extend(other.data)
         else:
@@ -71,7 +80,7 @@ def pop(self, index : int = -1) -> dict:
         """
         self.times.pop(index)
         return self.data.pop(index)
-    
+
     def remove(self, d : float = None, t : float = None) -> None:
         """Remove an element
 
@@ -81,14 +90,14 @@ def remove(self, d : float = None, t : float = None) -> None:
         """
         if sum([i is None for i in (d, t)]) != 1:
             raise ValueError("ValueError: Only one named argument (d, t) can be specified.")
-       
+
         if (t is not None):
             self.data.pop(self.times.index(t))
             self.times.remove(t)
         else:
             self.times.pop(self.data.index(d))
             self.data.remove(d)
-        
+
     def clear(self) -> None:
         """Clear the SimResult"""
         self.times = []
@@ -105,12 +114,42 @@ def time(self, index : int) -> float:
         """
         return self.times[index]
 
+    def to_numpy(self, keys = None) -> np.ndarray:
+        """
+        Convert from simresult to numpy array
+
+        Args:
+            keys: Subset of keys to return as part of numpy array (by default, all)
+
+        Returns:
+            np.ndarray: numpy array representing simresult
+        """
+        if len(self.data) == 0:
+            return np.array([[]], dtype=np.float64)
+        if len(self.data[0]) == 0:
+            return np.array([[] for _ in self.data], dtype=np.float64)
+        if isinstance(self.data[0], DictLikeMatrixWrapper) and keys is None:
+            return np.array([u_i.matrix[:, 0] for u_i in self.data], dtype=np.float64)
+        if keys is None:
+            keys = self.data[0].keys()
+        return np.array([[u_i[key] for key in keys] for u_i in self.data], dtype=np.float64)
+
     def plot(self, **kwargs) -> figure:
         """
         Plot the simresult as a line plot
 
-        Args: 
-            kwargs: Configuration parameters for plot_timeseries
+        Keyword Args:
+            keys (list[str]): list of keys to plot. If not provided, all keys in the series are plotted.
+            figsize (tuple[float, float]): width and height of the figure
+            compact (bool): If true, all timeseries are displayed in one plot (multiple colored lines)
+            xlabel (str) : label for the x-axis. Default is 'time'
+            ylabel (str) : label for the y-axis. Default is 'state'
+            title (str) : plot title. Default is no title
+            title_fontsize (str or float): plot title fontsize. Default is 'x-large'
+            suptitle (str) : plot suptitle. Default is no suptitle
+            ticklabel_fontsize (str or float): tick label font sizes. Default is 'small'
+            tight_layout (bool): whether to use tight layout (minimize figure blank space around the graph)
+            display_labels (str): whether to display x and y-labels in the figure (['no', 'minimal', 'all'])
 
         Returns:
             Figure
@@ -119,12 +158,12 @@ def plot(self, **kwargs) -> figure:
 
     def monotonicity(self) -> Dict[str, float]:
         """
-        Calculate monotonicty for a single prediction. 
+        Calculate monotonicty for a single prediction.
         Given a single simulation result, for each event: go through all predicted states and compare those to the next one.
         Calculates monotonicity for each event key using its associated mean value in UncertainData.
 
         Where N is number of measurements and sign indicates sign of calculation.
-        
+
         Coble, J., et. al. (2021). Identifying Optimal Prognostic Parameters from Data: A Genetic Algorithms Approach. Annual Conference of the PHM Society.
         http://www.papers.phmsociety.org/index.php/phmconf/article/view/1404
         Baptistia, M., et. al. (2022). Relation between prognostics predictor evaluation metrics and local interpretability SHAP values. Aritifical Intelligence, Volume 306.
@@ -139,15 +178,15 @@ def monotonicity(self) -> Dict[str, float]:
         # Collect and organize mean values for each event
         by_event = defaultdict(list)
         for uncertaindata in self.data:
-            for key,value in uncertaindata.items():
+            for key, value in uncertaindata.items():
                 by_event[key].append(value)
 
         # For each event, calculate monotonicity using formula
         result = {}
-        for key,l in by_event.items():
+        for key, l in by_event.items():
             mono_sum = 0
             for i in range(len(l)-1): 
-                mono_sum += sign(l[i+1] - l[i])
+                mono_sum += np.sign(l[i+1] - l[i])
             result[key] = abs(mono_sum / (len(l)-1))
         return result
 
@@ -155,11 +194,12 @@ def __not_implemented(self):  # lgtm [py/inheritance/signature-mismatch]
         raise NotImplementedError("Not Implemented")
 
     # Functions of list not implemented
-    # Specified here to stop users from accidentally trying to use them (due to this classes similarity to list)
-    append = __not_implemented 
-    count = __not_implemented 
+    # Specified here to stop users from accidentally trying to use them 
+    # (due to this classes similarity to list)
+    append = __not_implemented
+    count = __not_implemented
     insert = __not_implemented
-    reverse = __not_implemented 
+    reverse = __not_implemented
     # lgtm [py/missing-equals]
 
 
@@ -167,7 +207,7 @@ class LazySimResult(SimResult):  # lgtm [py/missing-equals]
     """
     Used to store the result of a simulation, which is only calculated on first request
     """
-    def __init__(self, fcn : Callable, times : list = [], states : list = []) -> None:
+    def __init__(self, fcn : Callable, times : list = None, states : list = None, _copy = True) -> None:
         """
         Args:
             fcn (callable): function (x) -> z where x is the state and z is the data
@@ -175,9 +215,16 @@ def __init__(self, fcn : Callable, times : list = [], states : list = []) -> Non
             data (array(dict)): Data points where data[n] corresponds to times[n]
         """
         self.fcn = fcn
-        self.times = times.copy()
-        self.states = deepcopy(states)
         self.__data = None
+        if times is None or states is None:
+            self.times = []
+            self.states = []
+        else:
+            self.times = times.copy()
+            if _copy:
+                self.states = deepcopy(states)
+            else:
+                self.states = states
 
     def __reduce__(self):
         return (self.__class__.__base__, (self.times, self.data))
@@ -197,7 +244,7 @@ def clear(self) -> None:
         self.__data = None
         self.states = []
 
-    def extend(self, other : "LazySimResult") -> None:
+    def extend(self, other : "LazySimResult", _copy=True) -> None:
         """
         Extend the LazySimResult with another LazySimResult object
         Raise ValueError if SimResult is passed
@@ -208,8 +255,11 @@ def extend(self, other : "LazySimResult") -> None:
 
         """
         if (isinstance(other, self.__class__)):
-            self.times.extend(other.times)  # lgtm [py/modification-of-default-value]
-            self.states.extend(deepcopy(other.states))  # lgtm [py/modification-of-default-value]
+            self.times.extend(other.times)
+            if _copy:
+                self.states.extend(deepcopy(other.states))
+            else:
+                self.states.extend(other.states)
             if self.__data is None or not other.is_cached():
                 self.__data = None
             else:
@@ -236,15 +286,15 @@ def pop(self, index : int = -1) -> dict:
 
     def remove(self, d : float = None, t : float = None, s = None) -> None:
         """Remove an element
-         
+
         Args:
-            d: Data value to be removed.
-            t: Time value to be removed.
-            s: State value to be removed.
-        """ 
+            d: Data value to be removed
+            t: Time value to be removed
+            s: State value to be removed
+        """
         if sum([i is None for i in (d, t, s)]) != 2:
             raise ValueError("ValueError: Only one named argument (d, t, s) can be specified.")
-       
+
         if (t is not None):
             target_index = self.times.index(t)
             self.times.pop(target_index)
@@ -277,4 +327,3 @@ def data(self) -> List[dict]:
         if self.__data is None:
             self.__data = [self.fcn(x) for x in self.states]
         return self.__data
-    
diff --git a/src/prog_models/utils/containers.py b/src/prog_models/utils/containers.py
index fca6aabd0..97eb4e648 100644
--- a/src/prog_models/utils/containers.py
+++ b/src/prog_models/utils/containers.py
@@ -1,10 +1,10 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the
 # National Aeronautics and Space Administration.  All Rights Reserved.
 
+import numpy as np
 from typing import Union
-from ..exceptions import ProgModelTypeError
 
-import numpy as np
+from ..exceptions import ProgModelTypeError
 
 
 class DictLikeMatrixWrapper():
@@ -23,9 +23,14 @@ def __init__(self, keys : list, data : Union[dict, np.array]):
         if isinstance(data, np.matrix):
             self.matrix = np.array(data, dtype=np.float64)
         elif isinstance(data, np.ndarray):
+            if data.ndim == 1:
+                data = data[np.newaxis].T
             self.matrix = data
         elif isinstance(data, (dict, DictLikeMatrixWrapper)):
-            self.matrix = np.array([[data[key]] for key in keys], dtype=np.float64)
+            self.matrix = np.array(
+                [
+                    [data[key]] if key in data else [None] for key in keys
+                ], dtype=np.float64)
         else:
             raise ProgModelTypeError(f"Input must be a dictionary or numpy array, not {type(data)}")     
 
@@ -33,7 +38,10 @@ def __reduce__(self):
         return (DictLikeMatrixWrapper, (self._keys, self.matrix))
 
     def __getitem__(self, key : str) -> int:
-        return self.matrix[self._keys.index(key)][0]
+        row = self.matrix[self._keys.index(key)]
+        if len(row) == 1:
+            return self.matrix[self._keys.index(key)][0]
+        return self.matrix[self._keys.index(key)]
 
     def __setitem__(self, key : str, value : int) -> None:
         self.matrix[self._keys.index(key)] = np.atleast_1d(value)
@@ -69,10 +77,14 @@ def keys(self) -> list:
         return self._keys
 
     def values(self) -> np.array:
-        return np.array([value[0] for value in self.matrix])
+        if len(self.matrix) > 0 and len(self.matrix[0]) == 1:
+            return np.array([value[0] for value in self.matrix])
+        return self.matrix
 
     def items(self) -> zip:
-        return zip(self._keys, np.array([value[0] for value in self.matrix]))
+        if len(self.matrix) > 0 and len(self.matrix[0]) == 1:
+            return zip(self._keys, np.array([value[0] for value in self.matrix]))
+        return zip(self._keys, self.matrix)
 
     def update(self, other : "DictLikeMatrixWrapper") -> None:
         for key in other.keys():
@@ -88,4 +100,6 @@ def __contains__(self, key : str) -> bool:
         return key in self._keys
 
     def __repr__(self) -> str:
-        return str({key: value[0] for key, value in zip(self._keys, self.matrix)})
+        if len(self.matrix) > 0 and len(self.matrix[0]) == 1:
+            return str({key: value[0] for key, value in zip(self._keys, self.matrix)})
+        return str(dict(zip(self._keys, self.matrix)))
diff --git a/src/prog_models/utils/noise_functions.py b/src/prog_models/utils/noise_functions.py
index 1875ea01b..5dea5575b 100644
--- a/src/prog_models/utils/noise_functions.py
+++ b/src/prog_models/utils/noise_functions.py
@@ -7,21 +7,13 @@
 # Measurement Noise Functions
 # ---------------------------
 def uniform_measurement_noise(self, z : dict):
-    return self.OutputContainer({key: z[key] + \
-        np.random.uniform(-self.parameters['measurement_noise'][key], self.parameters['measurement_noise'][key], size=None if np.isscalar(z[key]) else len(z[key])) \
-            for key in self.outputs}) 
+    return self.OutputContainer(z.matrix + np.random.uniform(-1*self.parameters['measurement_noise'].matrix, self.parameters['measurement_noise'].matrix, size=z.matrix.shape))
 
 def triangular_measurement_noise(self, z : dict):
-    return self.OutputContainer({key: z[key] + \
-        np.random.triangular(-self.parameters['measurement_noise'][key], 0, self.parameters['measurement_noise'][key], size=None if np.isscalar(z[key]) else len(z[key])) \
-            for key in self.outputs})
+    return self.OutputContainer(z.matrix + np.random.triangular(-1*self.parameters['measurement_noise'].matrix, 0, self.parameters['measurement_noise'].matrix, size=z.matrix.shape))
 
 def normal_measurement_noise(self, z : dict):
-    return self.OutputContainer({key: z[key] \
-        + np.random.normal(
-            0, self.parameters['measurement_noise'][key],
-            size=None if np.isscalar(z[key]) else len(z[key]))
-            for key in z.keys()})
+    return self.OutputContainer(z.matrix + np.random.normal(0, self.parameters['measurement_noise'].matrix, size=z.matrix.shape))
 
 def no_measurement_noise(self, z : dict) -> dict:
     return z
@@ -39,21 +31,13 @@ def no_measurement_noise(self, z : dict) -> dict:
 # ---------------------------
 
 def triangular_process_noise(self, x : dict, dt : int =1):
-    return self.StateContainer({key: x[key] + \
-        dt*np.random.triangular(-self.parameters['process_noise'][key], 0, self.parameters['process_noise'][key], size=None if np.isscalar(x[key]) else len(x[key])) \
-            for key in self.states})
+    return self.StateContainer(x.matrix + dt*np.random.triangular(-1*self.parameters['process_noise'].matrix, 0, self.parameters['process_noise'].matrix, size=x.matrix.shape))
 
 def uniform_process_noise(self, x : dict, dt : int =1):
-    return self.StateContainer({key: x[key] + \
-        dt*np.random.uniform(-self.parameters['process_noise'][key], self.parameters['process_noise'][key], size=None if np.isscalar(x[key]) else len(x[key])) \
-            for key in self.states})
+    return self.StateContainer(x.matrix + dt*np.random.uniform(-1*self.parameters['process_noise'].matrix, self.parameters['process_noise'].matrix, size=x.matrix.shape))
 
 def normal_process_noise(self, x : dict, dt : int =1):
-    return self.StateContainer({key: x[key] +
-            dt*np.random.normal(
-                0, self.parameters['process_noise'][key],
-                size=None if np.isscalar(x[key]) else len(x[key]))
-                for key in x.keys()})
+    return self.StateContainer(x.matrix + dt*np.random.normal(0, self.parameters['process_noise'].matrix, size=x.matrix.shape))
 
 def no_process_noise(self, x : dict, dt :int =1) -> dict:
     return x
diff --git a/src/prog_models/utils/parameters.py b/src/prog_models/utils/parameters.py
index c6a5de70c..0a215cc28 100644
--- a/src/prog_models/utils/parameters.py
+++ b/src/prog_models/utils/parameters.py
@@ -3,17 +3,20 @@
 
 from collections import UserDict
 from copy import deepcopy
+import json
 from numbers import Number
 import types
 from typing import Callable
 
 from .noise_functions import measurement_noise_functions, process_noise_functions
+from .serialization import *
 from ..exceptions import ProgModelTypeError
 
 from typing import TYPE_CHECKING
 if TYPE_CHECKING: # Fix circular import issue in PrognosticsModelParameters init
     from prog_models.prognostics_model import PrognosticsModel
 
+
 class PrognosticsModelParameters(UserDict):
     """
     Prognostics Model Parameters - this class replaces a standard dictionary.
@@ -24,14 +27,13 @@ class PrognosticsModelParameters(UserDict):
         dict_in: Initial parameters
         callbacks: Any callbacks for derived parameters f(parameters) : updates (dict)
     """
-    def __init__(self, model : "PrognosticsModel", dict_in : dict = {}, callbacks : dict = {}):
+    def __init__(self, model : "PrognosticsModel", dict_in : dict = {}, callbacks : dict = {}, _copy: bool = True):
         super().__init__()
-        self.__m = model
+        self._m = model
         self.callbacks = {}
         # Note: Callbacks are set to empty to prevent calling callbacks with a partial or empty dict on line 32. 
         for (key, value) in dict_in.items():
-            # Deepcopy is needed here to force copying when value is an object (e.g., dict)
-            self[key] = deepcopy(value)
+            self.__setitem__(key, value, _copy=_copy)
 
         # Add and run callbacks
         # Has to be done here so the base parameters are all set 
@@ -42,7 +44,7 @@ def __init__(self, model : "PrognosticsModel", dict_in : dict = {}, callbacks :
                     changes = callback(self)
                     self.update(changes)
 
-    def __setitem__(self, key : str, value : float) -> None:
+    def __setitem__(self, key : str, value : float, _copy : bool = True) -> None:
         """Set model configuration, overrides dict.__setitem__()
 
         Args:
@@ -52,6 +54,10 @@ def __setitem__(self, key : str, value : float) -> None:
         Raises:
             ProgModelTypeError: Improper configuration for a model
         """
+        # Deepcopy is needed here to force copying when value is an object (e.g., dict)
+        if _copy:
+            value = deepcopy(value)
+        
         super().__setitem__(key, value)
 
         if key in self.callbacks:
@@ -61,11 +67,13 @@ def __setitem__(self, key : str, value : float) -> None:
         
         if key == 'process_noise' or key == 'process_noise_dist':
             if callable(self['process_noise']):  # Provided a function
-                self.__m.apply_process_noise = types.MethodType(self['process_noise'], self.__m)
+                self._m.apply_process_noise = types.MethodType(self['process_noise'], self._m)
             else:  # Not a function
                 # Process noise is single number - convert to dict
                 if isinstance(self['process_noise'], Number):
-                    self['process_noise'] = {key: self['process_noise'] for key in self.__m.states}
+                    self['process_noise'] = self._m.StateContainer({key: self['process_noise'] for key in self._m.states})
+                elif isinstance(self['process_noise'], dict):
+                    self['process_noise'] = self._m.StateContainer(self['process_noise'])
                 
                 # Process distribution type
                 if 'process_noise_dist' in self and self['process_noise_dist'].lower() not in process_noise_functions:
@@ -74,26 +82,28 @@ def __setitem__(self, key : str, value : float) -> None:
                 if all(value == 0 for value in self['process_noise'].values()):
                     # No noise, use none function
                     fcn = process_noise_functions['none']
-                    self.__m.apply_process_noise = types.MethodType(fcn, self.__m)
+                    self._m.apply_process_noise = types.MethodType(fcn, self._m)
                 elif 'process_noise_dist' in self:
                     fcn = process_noise_functions[self['process_noise_dist'].lower()]
-                    self.__m.apply_process_noise = types.MethodType(fcn, self.__m)
+                    self._m.apply_process_noise = types.MethodType(fcn, self._m)
                 else:
                     # Default to gaussian
                     fcn = process_noise_functions['gaussian']
-                    self.__m.apply_process_noise = types.MethodType(fcn, self.__m)
+                    self._m.apply_process_noise = types.MethodType(fcn, self._m)
                 
                 # Make sure every key is present (single value already handled above)
-                if not all([key in self['process_noise'] for key in self.__m.states]):
+                if not all([key in self['process_noise'] for key in self._m.states]):
                     raise ProgModelTypeError("Process noise must have every key in model.states")
 
         elif key == 'measurement_noise' or key == 'measurement_noise_dist':
             if callable(self['measurement_noise']):
-                self.__m.apply_measurement_noise = types.MethodType(self['measurement_noise'], self.__m)
+                self._m.apply_measurement_noise = types.MethodType(self['measurement_noise'], self._m)
             else:
                 # Process noise is single number - convert to dict
                 if isinstance(self['measurement_noise'], Number):
-                    self['measurement_noise'] = {key: self['measurement_noise'] for key in self.__m.outputs}
+                    self['measurement_noise'] = self._m.OutputContainer({key: self['measurement_noise'] for key in self._m.outputs})
+                elif isinstance(self['measurement_noise'], dict):
+                    self['measurement_noise'] = self._m.OutputContainer(self['measurement_noise'])
                 
                 # Process distribution type
                 if 'measurement_noise_dist' in self and self['measurement_noise_dist'].lower() not in measurement_noise_functions:
@@ -102,17 +112,17 @@ def __setitem__(self, key : str, value : float) -> None:
                 if all(value == 0 for value in self['measurement_noise'].values()):
                     # No noise, use none function
                     fcn = measurement_noise_functions['none']
-                    self.__m.apply_measurement_noise = types.MethodType(fcn, self.__m)
+                    self._m.apply_measurement_noise = types.MethodType(fcn, self._m)
                 elif 'measurement_noise_dist' in self:
                     fcn = measurement_noise_functions[self['measurement_noise_dist'].lower()]
-                    self.__m.apply_measurement_noise = types.MethodType(fcn, self.__m)
+                    self._m.apply_measurement_noise = types.MethodType(fcn, self._m)
                 else:
                     # Default to gaussian
                     fcn = measurement_noise_functions['gaussian']
-                    self.__m.apply_measurement_noise = types.MethodType(fcn, self.__m)
+                    self._m.apply_measurement_noise = types.MethodType(fcn, self._m)
                     
                 # Make sure every key is present (single value already handled above)
-                if not all([key in self['measurement_noise'] for key in self.__m.outputs]):
+                if not all([key in self['measurement_noise'] for key in self._m.outputs]):
                     raise ProgModelTypeError("Measurement noise must have ever key in model.outputs")
 
     def register_derived_callback(self, key : str, callback : Callable) -> None:
@@ -131,3 +141,27 @@ def register_derived_callback(self, key : str, callback : Callable) -> None:
         if key in self:
             updates = callback(self[key])
             self.update(updates)
+
+    def to_json(self):
+        """
+        Serialize parameters as JSON objects 
+        """
+        return json.dumps(self.data, cls=CustomEncoder)
+    
+    @classmethod
+    def from_json(cls, data):
+        """
+        Create a new parameters object from parameters that were serialized as a JSON object
+
+        Args:
+            data: 
+                JSON serialized parameters necessary to build parameters 
+                See to_json method 
+
+        Returns:
+            Parameters: Parameters generated from serialized parameters 
+        """
+
+        extract_parameters = json.loads(data, object_hook = custom_decoder)
+ 
+        return cls(**extract_parameters)
diff --git a/src/prog_models/utils/progress_bar.py b/src/prog_models/utils/progress_bar.py
index 46be8a335..4c4d7c297 100644
--- a/src/prog_models/utils/progress_bar.py
+++ b/src/prog_models/utils/progress_bar.py
@@ -1,6 +1,7 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the
 # National Aeronautics and Space Administration.  All Rights Reserved.
 
+
 class ProgressBar():
     def __init__(self, n : int, prefix : str='', suffix : str='', decimals : float=1, print_length : int=100, fill : str='█', print_end : str=" "):
         self.n = n
diff --git a/src/prog_models/utils/serialization.py b/src/prog_models/utils/serialization.py
new file mode 100644
index 000000000..5ba6e63dd
--- /dev/null
+++ b/src/prog_models/utils/serialization.py
@@ -0,0 +1,51 @@
+# Copyright © 2021 United States Government as represented by the Administrator of the
+# National Aeronautics and Space Administration.  All Rights Reserved.
+
+import json
+import numpy as np
+
+from .containers import DictLikeMatrixWrapper
+
+__all__ = ['CustomEncoder', 'custom_decoder']
+
+class CustomEncoder(json.JSONEncoder):
+    """
+    Custom encoder to serialize parameters 
+    """
+    def default(self, o):
+        if isinstance(o, np.ndarray):
+            return {'_original_type': 'ndarray', '_data': o.tolist()}
+        elif isinstance(o, DictLikeMatrixWrapper):
+            dict_temp = {k: v for k, v in o.items()}
+            dict_temp['_original_type'] = 'DictLikeMatrixWrapper'
+            return dict_temp
+        elif isinstance(o,np.bool_):
+            return bool(o) 
+        else: 
+            import pickle
+            from base64 import b64encode
+            pkl_temp = b64encode(pickle.dumps(o))
+            save_temp = {}
+            save_temp['_data'] = pkl_temp.decode()
+            save_temp['_original_type'] = 'pickled'
+            return save_temp
+
+def custom_decoder(o):
+    """
+    Custom decoder to deserialize parameters 
+    """
+    if isinstance(o,dict) and '_original_type' in o.keys():
+        if o['_original_type'] == 'ndarray':
+            return np.array(o['_data'])
+        elif o['_original_type'] == 'DictLikeMatrixWrapper':
+            del o['_original_type']
+            return DictLikeMatrixWrapper(list(o.keys()),o)
+        elif o['_original_type'] == 'pickled':
+            import pickle
+            from base64 import b64decode
+            pkl_temp1 = o['_data'].encode()
+            pkl_temp2 = b64decode(pkl_temp1)
+            return pickle.loads(pkl_temp2)
+        else: 
+            raise Exception(f"Type {o['_original_type']} not supported by PrognosticsModel json decoder")
+    return o
diff --git a/src/prog_models/visualize.py b/src/prog_models/visualize.py
index f6a039a7b..09a4f2a57 100644
--- a/src/prog_models/visualize.py
+++ b/src/prog_models/visualize.py
@@ -85,9 +85,9 @@ def set_plot_options(opt : dict) -> dict:
           * 'xlabel'  : string, label for the x-axis. Default is 'time'
           * 'ylabel'  : string, label for the y-axis. Default is 'state'
           * 'title'   : string or empty list or None, plot title. Default is empty list (no title)
-          * 'title_fontsize' : string or float, plot title fontsixe. Default is 'x-large'
+          * 'title_fontsize' : string or float, plot title fontsize. Default is 'x-large'
           * 'suptitle'       : string or empty list or None, plot suptitle. Default is empty list (no suptitle)
-          * 'ticklabel_fontsize' : string or float, tick label font sizes. Defaullt is 'small'
+          * 'ticklabel_fontsize' : string or float, tick label font sizes. Default is 'small'
           * 'tight_layout' : Boolean, whether to use tight layout (minimize figure blank space around the graph)
           * 'display_labels' : string, whether to display x and y-labels in the figure.
           * 'keys' : list of keys to plot. If not provided, all keys in the series are plotted.
diff --git a/tutorial.ipynb b/tutorial.ipynb
index d37d6bca5..703c4d3cf 100644
--- a/tutorial.ipynb
+++ b/tutorial.ipynb
@@ -23,10 +23,13 @@
     "* __States__: Internal parameters (typically hidden states) used to represent the state of the system- can be the same as inputs/outputs but do not have to be. \n",
     "\n",
     "The `prog_models` package has the following structure\n",
-    "* `prog_models.PrognosticsModel` - parent class for all prognostics models - defines interfaces that a model must implement, and tools for simulating said model \n",
+    "* `prog_models.data_models` - package containing algorithms for data-driven models, and parent class `prog_models.data_models.DataModel`\n",
+    "* `prog_models.datasets` - package containing tools for downloading a few relevant datasets\n",
     "* `prog_models.models.*` - implemented models (e.g., pump, valve, battery)\n",
+    "* `prog_models.LinearModel` - Parent class for simple linear models\n",
+    "* `prog_models.PrognosticsModel` - parent class for all prognostics models - defines interfaces that a model must implement, and tools for simulating said model \n",
     "\n",
-    "In addition to the `prog_models` package, this repo includes many examples illustrating how to use the package (see `examples/`), a template for implementing a new model (`prog_model_template`), documentation (<https://nasa.github.io/prog_models>), and this tutorial (`tutorial.ipynb`).\n",
+    "In addition to the `prog_models` package, this repo includes many examples illustrating how to use the package (see `examples/`), a template for implementing a new model (`prog_model_template`), documentation (<https://nasa.github.io/progpy>), and this tutorial (`tutorial.ipynb`).\n",
     "\n",
     "Before you start, make sure to install prog_models using the following command:\n",
     "\n",
@@ -48,7 +51,7 @@
    "source": [
     "This first example is for using the included prognostics models. \n",
     "\n",
-    "The `prog_models.models` package includes implemented models, including ones for pumps, valves, and batteries. See <https://nasa.github.io/prog_models/models.html> for a full description of the included models.\n",
+    "The `prog_models.models` package includes implemented models, including ones for pumps, valves, and batteries. See <https://nasa.github.io/progpy/api_ref/prog_models/IncludedModels.html> for a full description of the included models.\n",
     "\n",
     "First thing to do is to import the model you would like to use:"
    ]
@@ -66,7 +69,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "This imports the BatteryCircuit model distributed with the `prog_models` package. See <https://nasa.github.io/prog_models/models.html> for details on this model.\n",
+    "This imports the BatteryCircuit model distributed with the `prog_models` package. See <https://nasa.github.io/progpy/api_ref/prog_models/IncludedModels.html> for details on this model.\n",
     "\n",
     "Next, let's create a new battery using the default settings:"
    ]
@@ -103,7 +106,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "These parameters describe the specific system (in this case battery) the model is simulating. See <https://nasa.github.io/prog_models/models.html> for a full list of configurable model parameters. Let's use the model properties to check out some details of this model, first the model configuration:"
+    "These parameters describe the specific system (in this case battery) the model is simulating. See <https://nasa.github.io/progpy/api_ref/prog_models/IncludedModels.html> for a full list of configurable model parameters. Let's use the model properties to check out some details of this model, first the model configuration:"
    ]
   },
   {
@@ -540,6 +543,9 @@
     "    }    \n",
     "\n",
     "    # First function: Initialize. This function is used to initialize the first state of the model.\n",
+    "    # This is only needed in cases with complex initialization. \n",
+    "    # In this case we need it because of max_x\n",
+    "    # If not included, parameters['x0'] is returned as the initial state\n",
     "    # In this case we do not need input (u) or output (z) to initialize the model, \n",
     "    #   so we set them to None, but that's not true for every model.\n",
     "    # u and z are Input and Output, respectively. \n",
@@ -897,7 +903,7 @@
    "metadata": {},
    "source": [
     "## Conclusion\n",
-    "This is just the basics, there's much more to learn. Please see the documentation at <https://nasa.github.io/prog_models> and the examples in the `examples/` folder for more details on how to use the package, including:\n",
+    "This is just the basics, there's much more to learn. Please see the documentation at <https://nasa.github.io/progpy/guide> and the examples in the `examples/` folder for more details on how to use the package, including:\n",
     "* `examples.derived_params` : Example building models with derived parameters.\n",
     "* `examples.state_limits`: Example building models with limits on state variables.\n",
     "* `examples.param_est`: Example using the parameter estimation feature \n",
@@ -910,7 +916,8 @@
     "* `examples.new_model`: Example building a new model\n",
     "* `examples.noise`: Example demonstrating how noise can be added in simulation\n",
     "* `examples.vectorized`: Example simulating a vectorized model\n",
-    "* `examples.sim`, `examples.sim_battery_eol`, `examples.sim_pump`, `examples.sim_valve`: Examples using specific models from `prog_models.models`\n",
+    "* `examples.sim`, `examples.sim_battery_eol`, `examples.sim_pump`, `examples.sim_valve`, `examples.sim_powertrain`, `examples.sim_dcmotor_singlephase`: Examples using specific models from `prog_models.models`\n",
+    "* `examples.lstm_model`, `examples.full_lstm_model`, and `examples.custom_model`: Examples using data-driven models\n",
     "\n",
     "Thank you for trying out this tutorial. Open an issue on github (https://github.com/nasa/prog_models/issues) or email Chris Teubert (christopher.a.teubert@nasa.gov) with any questions or issues."
    ]
@@ -924,11 +931,9 @@
   }
  ],
  "metadata": {
-  "interpreter": {
-   "hash": "ff94885aa2d97705a9dae03869c2058fa855d1acd9df351499300343e2e591a2"
-  },
   "kernelspec": {
-   "display_name": "Python 3.8.12 64-bit",
+   "display_name": "Python 3.8.9 64-bit",
+   "language": "python",
    "name": "python3"
   },
   "language_info": {
@@ -941,14 +946,19 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.8.13"
+   "version": "3.8.9"
   },
   "metadata": {
    "interpreter": {
     "hash": "c1e35f02e3a88578371dd5b5d88a204463a98b2d7cd5222657e170520db47be1"
    }
   },
-  "orig_nbformat": 2
+  "orig_nbformat": 2,
+  "vscode": {
+   "interpreter": {
+    "hash": "31f2aee4e71d21fbe5cf8b01ff0e069b9275f58929596ceb00d14d90e3e16cd6"
+   }
+  }
  },
  "nbformat": 4,
  "nbformat_minor": 2


Test patch:
diff --git a/scripts/test_copyright.py b/scripts/test_copyright.py
index 8326be6bb..1ba31a6fe 100644
--- a/scripts/test_copyright.py
+++ b/scripts/test_copyright.py
@@ -14,7 +14,7 @@ def check_copyright(directory : str, invalid_files : list) -> bool:
         if os.path.isdir(path):
             result = result and check_copyright(path, invalid_files)
         # If path is a file, ensure it is of type py and check for copyright
-        elif os.path.isfile(path) and path[-2:] == "py":
+        elif os.path.isfile(path) and path.split(".")[-1] in ("py", "pyw", "py3"):
             file = open(path, 'r')
             copyright_met = False
             # Iterate over lines in file, check each line against COPYRIGHT_TAG
diff --git a/tests/__main__.py b/tests/__main__.py
index f12b06f0c..91bbbdbfa 100644
--- a/tests/__main__.py
+++ b/tests/__main__.py
@@ -11,29 +11,34 @@
 from .test_datasets import main as datasets_main
 from .test_powertrain import main as powertrain_main
 from .test_surrogates import main as surrogates_main
+from .test_data_model import main as lstm_main
 
 from io import StringIO
+import matplotlib.pyplot as plt
 import sys
-import unittest
+from timeit import timeit
+from unittest.mock import patch
+
 from examples import sim as sim_example
 
 def _test_ex():
-    # set stdout (so it wont print)
-    _stdout = sys.stdout
-    sys.stdout = StringIO()
-
     # Run example
     sim_example.run_example()
 
-    # Reset stdout 
-    sys.stdout = _stdout
-
 if __name__ == '__main__':
     was_successful = True
 
     try:
-        from timeit import timeit
-        print("\nExample Runtime: ", timeit(_test_ex, number=10))
+        # set stdout (so it wont print)
+        sys.stdout = StringIO()
+        
+        with patch('matplotlib.pyplot.show'):
+            runtime = timeit(_test_ex, number=10)
+            plt.close('all')
+
+        # Reset stdout 
+        sys.stdout = sys.__stdout__
+        print(f"\nExample Runtime: {runtime}")
     except Exception as e:
         print("\Benchmarking Failed: ", e)
         was_successful = False
@@ -96,5 +101,10 @@ def _test_ex():
     except Exception:
         was_successful = False
 
+    try:
+        lstm_main()
+    except Exception:
+        was_successful = False
+
     if not was_successful:
         raise Exception("Failed test")
diff --git a/tests/benchmarking.py b/tests/benchmarking.py
new file mode 100644
index 000000000..c311cb544
--- /dev/null
+++ b/tests/benchmarking.py
@@ -0,0 +1,103 @@
+# Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
+
+from io import StringIO
+import sys
+import time
+
+FORMAT_STR = '| {:40s} |'
+CLOCK = time.process_time
+
+if __name__ == "__main__":
+    print('| Test | Time (s) |\n| --- | --- |')
+
+    print(FORMAT_STR.format('import main'), end='')
+    t = CLOCK()
+    import prog_models
+    t2 = CLOCK()
+    print(f'{t2-t} |')
+
+    print(FORMAT_STR.format('import thrown object'), end='')
+    t = CLOCK()
+    from prog_models.models import ThrownObject
+    t2 = CLOCK()
+    print(f'{t2-t} |')
+
+    print(FORMAT_STR.format('model initialization'), end='')
+    t = CLOCK()
+    m = ThrownObject()
+    t2 = CLOCK()
+    print(f'{t2-t} |')
+
+    print(FORMAT_STR.format('set noise'), end='')
+    t = CLOCK()
+    m.parameters['process_dist'] = 'none'
+    t2 = CLOCK()
+    print(f'{t2-t} |')
+
+    def future_load(t, x=None):
+        return m.InputContainer({})
+
+    print(FORMAT_STR.format('simulate'), end='')
+    t = CLOCK()
+    m.simulate_to_threshold(future_load, threshold_keys='impact')
+    t2 = CLOCK()
+    print(f'{t2-t} |')
+
+    print(FORMAT_STR.format('simulate with saving'), end='')
+    t = CLOCK()
+    m.simulate_to_threshold(future_load, threshold_keys='impact', save_freq = 0.5)
+    t2 = CLOCK()
+    print(f'{t2-t} |')
+
+    print(FORMAT_STR.format('simulate with saving, dt'), end='')
+    t = CLOCK()
+    m.simulate_to_threshold(future_load, threshold_keys='impact', save_freq = 0.5, dt=0.1)
+    t2 = CLOCK()
+    print(f'{t2-t} |')
+
+    print(FORMAT_STR.format('simulate with printing results, dt'), end='')
+    temp_out = StringIO()
+    sys.stdout = temp_out
+    t = CLOCK()
+    m.simulate_to_threshold(future_load, threshold_keys='impact', save_freq = 0.5, dt=0.1, print = True)
+    t2 = CLOCK()
+    sys.stdout = sys.__stdout__
+    print(f'{t2-t} |')
+    
+    result = m.simulate_to_threshold(future_load, threshold_keys='impact', save_freq = 0.5, dt=0.1)
+
+    print(FORMAT_STR.format('Plot results'), end='')
+    t = CLOCK()
+    result.outputs.plot()
+    t2 = CLOCK()
+    print(f'{t2-t} |')
+
+    print(FORMAT_STR.format('Metrics'), end='')
+    t = CLOCK()
+    result.event_states.monotonicity()
+    t2 = CLOCK()
+    print(f'{t2-t} |')
+
+    print(FORMAT_STR.format('Surrogate Model Generation'), end='')
+    temp_out = StringIO()
+    sys.stdout = temp_out
+    sys.stderr = temp_out
+    t = CLOCK()
+    m2 = m.generate_surrogate([future_load], threshold_keys='impact')
+    t2 = CLOCK()
+    sys.stdout = sys.__stdout__
+    sys.stderr = sys.__stderr__
+    print(f'{t2-t} |')
+
+    print(FORMAT_STR.format('surrogate sim'), end='')
+    t = CLOCK()
+    m2.simulate_to_threshold(future_load, threshold_keys='impact')
+    t2 = CLOCK()
+    print(f'{t2-t} |')
+
+    print(FORMAT_STR.format('surrogate sim, dt'), end='')
+    t = CLOCK()
+    m2.simulate_to_threshold(future_load, threshold_keys='impact', save_freq=0.25)
+    t2 = CLOCK()
+    print(f'{t2-t} |')
+
diff --git a/tests/test_base_models.py b/tests/test_base_models.py
index 68bf86c97..fe63bee95 100644
--- a/tests/test_base_models.py
+++ b/tests/test_base_models.py
@@ -1,12 +1,18 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
 
+from copy import deepcopy
 import io
+import numpy as np
+from os.path import dirname, join
+import pickle
 import sys
 import unittest
-import numpy as np
+
+# This ensures that the directory containing ProgModelTemplate is in the python search directory
+sys.path.append(join(dirname(__file__), ".."))
+
 from prog_models import *
 from prog_models.models import *
-from copy import deepcopy
 
 
 class MockModel():
@@ -19,16 +25,18 @@ class MockModel():
     }
 
     def initialize(self, u = {}, z = {}):
-        return deepcopy(self.parameters['x0'])
+        return self.StateContainer(self.parameters['x0'])
 
     def next_state(self, x, u, dt):
-        x['a']+= u['i1']*dt
-        x['c']-= u['i2']
-        x['t']+= dt
-        return x
+        return self.StateContainer({
+                    'a': x['a'] + u['i1']*dt,
+                    'b': x['b'],
+                    'c': x['c'] - u['i2'],
+                    't': x['t'] + dt
+                })
 
     def output(self, x):
-        return {'o1': x['a'] + x['b'] + x['c']}
+        return self.OutputContainer({'o1': x['a'] + x['b'] + x['c']})
 
 
 class MockProgModel(MockModel, prognostics_model.PrognosticsModel):
@@ -59,6 +67,41 @@ def derived_callback3(config):
         'p4': -2 * config['p2'], 
     }
 
+class LinearThrownObject(LinearModel):
+    inputs = [] 
+    states = ['x', 'v']
+    outputs = ['x']
+    events = ['impact']
+
+    A = np.array([[0, 1], [0, 0]])
+    E = np.array([[0], [-9.81]])
+    C = np.array([[1, 0]])
+    F = None # Will override method
+
+    default_parameters = {
+        'thrower_height': 1.83,  # m
+        'throwing_speed': 40,  # m/s
+        'g': -9.81  # Acceleration due to gravity in m/s^2
+    }
+
+    def initialize(self, u=None, z=None):
+        return self.StateContainer({
+            'x': self.parameters['thrower_height'],  # Thrown, so initial altitude is height of thrower
+            'v': self.parameters['throwing_speed']  # Velocity at which the ball is thrown - this guy is a professional baseball pitcher
+            })
+    
+    def threshold_met(self, x):
+        return {
+            'falling': x['v'] < 0,
+            'impact': x['x'] <= 0
+        }
+
+    def event_state(self, x): 
+        x_max = x['x'] + np.square(x['v'])/(-self.parameters['g']*2) # Use speed and position to estimate maximum height
+        return {
+            'falling': np.maximum(x['v']/self.parameters['throwing_speed'],0),  # Throwing speed is max speed
+            'impact': np.maximum(x['x']/x_max,0) if x['v'] < 0 else 1  # 1 until falling begins, then it's fraction of height
+        }
 
 class MockModelWithDerived(MockProgModel):
     param_callbacks = {
@@ -68,6 +111,49 @@ class MockModelWithDerived(MockProgModel):
 
 
 class TestModels(unittest.TestCase):
+    def setUp(self):
+        # set stdout (so it wont print)
+        sys.stdout = io.StringIO()
+
+    def tearDown(self):
+        sys.stdout = sys.__stdout__
+
+    def test_non_container(self):
+        class MockProgModelStateDict(MockProgModel):
+            def next_state(self, x, u, dt):
+                return {
+                    'a': x['a'] + u['i1']*dt,
+                    'b': x['b'],
+                    'c': x['c'] - u['i2'],
+                    't': x['t'] + dt
+                }
+        
+        m = MockProgModelStateDict(process_noise_dist='none', measurement_noise_dist='none')
+        def load(t, x=None):
+            return {'i1': 1, 'i2': 2.1}
+
+        # Any event, default
+        (times, inputs, states, outputs, event_states) = m.simulate_to_threshold(load, {'o1': 0.8}, **{'dt': 0.5, 'save_freq': 1.0})
+        self.assertAlmostEqual(times[-1], 5.0, 5)
+        self.assertAlmostEqual(outputs[-1]['o1'], -13.2)
+        self.assertIsInstance(outputs[-1], m.OutputContainer)
+
+        class MockProgModelStateNdarray(MockProgModel):
+            def next_state(self, x, u, dt):
+                return np.array([
+                    [x['a'] + u['i1']*dt],
+                    [x['b']],
+                    [x['c'] - u['i2']],
+                    [x['t'] + dt]]
+                )
+
+        m = MockProgModelStateNdarray(process_noise_dist='none', measurement_noise_dist='none')
+
+        # Any event, default
+        (times, inputs, states, outputs, event_states) = m.simulate_to_threshold(load, {'o1': 0.8}, **{'dt': 0.5, 'save_freq': 1.0})
+        self.assertAlmostEqual(times[-1], 5.0, 5)
+
+
     def test_templates(self):
         import prog_model_template
         m = prog_model_template.ProgModelTemplate()
@@ -176,11 +262,8 @@ def next_state(self, x, u, dt):
         except ProgModelTypeError:
             pass
 
-        try: 
-            m = missing_inputs()
-            self.fail("Should not have worked, missing 'inputs'")
-        except ProgModelTypeError:
-            pass
+        m = missing_inputs()
+        self.assertEqual(len(m.inputs), 0)
 
         try: 
             m = missing_outputs()
@@ -188,11 +271,8 @@ def next_state(self, x, u, dt):
         except ProgModelTypeError:
             pass
 
-        try: 
-            m = missing_initiialize()
-            self.fail("Should not have worked, missing 'initialize' method")
-        except TypeError:
-            pass
+        m = missing_initiialize()
+        # Should work- initialize is now optional
 
         try: 
             m = missing_output()
@@ -222,15 +302,6 @@ def add_one(self, x):
         x = getattr(m, "apply_{}".format(noise_key))({key: 1 for key in keys})
         self.assertEqual(x[keys[0]], 2)
 
-        try:
-            noise = {}
-            for i in range(len(keys)-1):
-                noise[keys[i]] = i
-            m = MockProgModel(**{noise_key: noise})
-            self.fail("Should have raised exception at missing process_noise key")
-        except ProgModelTypeError:
-            pass
-
         try:
             noise = []
             m = MockProgModel(**{noise_key: noise})
@@ -274,7 +345,9 @@ def test_prog_model(self):
         m = MockProgModel() # Should work- sets default
         m = MockProgModel(process_noise = 0.0)
         x0 = m.initialize()
-        self.assertDictEqual(x0, m.parameters['x0'])
+        self.assertSetEqual(set(x0.keys()), set(m.parameters['x0'].keys()))
+        for key, value in m.parameters['x0'].items():
+            self.assertEqual(value, x0[key])
         x = m.next_state(x0, {'i1': 1, 'i2': 2.1}, 0.1)
         self.assertAlmostEqual(x['a'], 1.1, 6)
         self.assertAlmostEqual(x['c'], -5.3, 6)
@@ -292,6 +365,39 @@ def test_prog_model(self):
         t = m.threshold_met({'t': 10})
         self.assertTrue(t['e1'])
 
+    def test_default_es_and_tm(self):
+        # Test 1: TM only
+        class NoES(MockModel, prognostics_model.PrognosticsModel):
+            events = ['e1', 'e2']
+
+            def threshold_met(self, _):
+                return {'e1': False, 'e2': True}
+
+        m = NoES()
+
+        self.assertDictEqual(m.threshold_met({}), {'e1': False, 'e2': True})
+        self.assertDictEqual(m.event_state({}), {'e1': 1.0, 'e2': 0.0})
+
+        # Test 2: ES only
+        class NoTM(MockModel, prognostics_model.PrognosticsModel):
+            events = ['e1', 'e2']
+
+            def event_state(self, _):
+                return {'e1': 0.0, 'e2': 1.0}
+        
+        m = NoTM()
+
+        self.assertDictEqual(m.threshold_met({}), {'e1': True, 'e2': False})
+        self.assertDictEqual(m.event_state({}), {'e1': 0.0, 'e2': 1.0})
+
+        # Test 3: Neither ES or TM 
+        class NoESTM(MockModel, prognostics_model.PrognosticsModel):
+            events = []
+
+        m = NoESTM()
+        self.assertDictEqual(m.threshold_met({}), {})
+        self.assertDictEqual(m.event_state({}), {})
+
     def test_model_gen(self):
         keys = {
             'states': ['a', 'b', 'c', 't'],
@@ -524,7 +630,6 @@ def dx(t, x, u):
 
     def test_pickle(self):
         m = MockProgModel(p1 = 1.3)
-        import pickle
         pickle.dump(m, open('model_test.pkl', 'wb'))
         m2 = pickle.load(open('model_test.pkl', 'rb'))
         isinstance(m2, MockProgModel)
@@ -661,7 +766,9 @@ def load(t, x=None):
         # Check inputs
         self.assertEqual(len(inputs), 5)
         for i in inputs:
-            self.assertDictEqual(i, {'i1': 1, 'i2': 2.1}, "Future loading error")
+            i0 = {'i1': 1, 'i2': 2.1}
+            for key in i.keys():
+                self.assertEqual(i[key], i0[key], "Future loading error")
 
         # Check states
         self.assertEqual(len(states), 5)
@@ -718,11 +825,10 @@ def test_vectorization(self):
         m = MockProgModel(process_noise = 0.0)
         def load(t, x=None):
             return {'i1': 1, 'i2': 2.1}
-        from numpy import array
-        a = array([1, 2, 3, 4, 4.5])
-        b = array([5]*5)
-        c = array([-3.2, -7.4, -11.6, -15.8, -17.9])
-        t = array([0, 0.5, 1, 1.5, 2])
+        a = np.array([1, 2, 3, 4, 4.5])
+        b = np.array([5]*5)
+        c = np.array([-3.2, -7.4, -11.6, -15.8, -17.9])
+        t = np.array([0, 0.5, 1, 1.5, 2])
         dt = 0.5
         x0 = {'a': deepcopy(a), 'b': deepcopy(b), 'c': deepcopy(c), 't': deepcopy(t)}
         x = m.next_state(x0, load(0), dt)
@@ -792,6 +898,49 @@ def load(t, x=None):
         except ProgModelInputException:
             pass
 
+    def test_sim_modes(self):
+        m = ThrownObject(process_noise = 0, measurement_noise = 0)
+        def load(t, x=None):
+            return m.InputContainer({})
+
+        # Default mode should be auto
+        result = m.simulate_to_threshold(load, save_freq = 0.75, save_pts = [1.5, 2.5])
+        self.assertListEqual(result.times, [0, 0.75, 1.5, 2.25, 2.5, 3, 3.75])  
+
+        # Auto step size
+        result = m.simulate_to_threshold(load, dt = 'auto', save_freq = 0.75, save_pts = [1.5, 2.5])
+        self.assertListEqual(result.times, [0, 0.75, 1.5, 2.25, 2.5, 3, 3.75])  
+
+        # Auto step size with a max of 2
+        result = m.simulate_to_threshold(load, dt = ('auto', 2), save_freq = 0.75, save_pts = [1.5, 2.5])
+        self.assertListEqual(result.times, [0, 0.75, 1.5, 2.25, 2.5, 3, 3.75])  
+
+        # Constant step size of 2
+        result = m.simulate_to_threshold(load, dt = ('constant', 2), save_freq = 0.75, save_pts = [1.5, 2.5])
+        self.assertListEqual(result.times, [0, 2, 4])  
+
+        # Constant step size of 2
+        result = m.simulate_to_threshold(load, dt = 2, save_freq = 0.75, save_pts = [1.5, 2.5])
+        self.assertListEqual(result.times, [0, 2, 4])  
+
+        result = m.simulate_to_threshold(load, dt = 2, save_pts = [2.5])
+        self.assertListEqual(result.times, [0, 4])  
+
+    def test_sim_rk4(self):
+        # With non-linear model
+        m = ThrownObject()
+        def load(t, x=None):
+            return m.InputContainer({})
+        
+        with self.assertRaises(ProgModelException):
+            m.simulate_to_threshold(load, integration_method='rk4')
+
+        # With linear model
+        m = LinearThrownObject(process_noise = 0, measurement_noise = 0)
+
+        result = m.simulate_to_threshold(load, dt = 0.1, integration_method='rk4')
+        self.assertAlmostEqual(result.times[-1], 8.3)
+
     # when range specified when state doesnt exist or entered incorrectly
     def test_state_limits(self):
         m = MockProgModel()
@@ -801,7 +950,7 @@ def test_state_limits(self):
         x0 = m.initialize()
 
         def load(t, x=None):
-            return {'i1': 1, 'i2': 2.1}
+            return m.InputContainer({'i1': 1, 'i2': 2.1})
 
         # inside bounds
         x0['t'] = 0
@@ -894,43 +1043,7 @@ def load(t, x=None):
             pass
 
     def test_linear_model(self):
-        class ThrownObject(LinearModel):
-            inputs = [] 
-            states = ['x', 'v']
-            outputs = ['x']
-            events = ['impact']
-
-            A = np.array([[0, 1], [0, 0]])
-            E = np.array([[0], [-9.81]])
-            C = np.array([[1, 0]])
-            F = None # Will override method
-
-            default_parameters = {
-                'thrower_height': 1.83,  # m
-                'throwing_speed': 40,  # m/s
-                'g': -9.81  # Acceleration due to gravity in m/s^2
-            }
-
-            def initialize(self, u=None, z=None):
-                return self.StateContainer({
-                    'x': self.parameters['thrower_height'],  # Thrown, so initial altitude is height of thrower
-                    'v': self.parameters['throwing_speed']  # Velocity at which the ball is thrown - this guy is a professional baseball pitcher
-                    })
-            
-            def threshold_met(self, x):
-                return {
-                    'falling': x['v'] < 0,
-                    'impact': x['x'] <= 0
-                }
-
-            def event_state(self, x): 
-                x_max = x['x'] + np.square(x['v'])/(-self.parameters['g']*2) # Use speed and position to estimate maximum height
-                return {
-                    'falling': np.maximum(x['v']/self.parameters['throwing_speed'],0),  # Throwing speed is max speed
-                    'impact': np.maximum(x['x']/x_max,0) if x['v'] < 0 else 1  # 1 until falling begins, then it's fraction of height
-                }
-
-        m = ThrownObject()
+        m = LinearThrownObject()
         m.simulate_to_threshold(lambda t, x = None: m.InputContainer({}))
         # len() = events states inputs outputs
         #         1      2      0      1
@@ -1163,15 +1276,7 @@ def event_state(self, x):
             m.matrixCheck()
 
     def test_F_property_not_none(self):
-        class ThrownObject(LinearModel):
-            inputs = [] 
-            states = ['x', 'v']
-            outputs = ['x']
-            events = ['impact']
-
-            A = np.array([[0, 1], [0, 0]])
-            E = np.array([[0], [-9.81]])
-            C = np.array([[1, 0]])
+        class ThrownObject(LinearThrownObject):
             F = np.array([[1, 0]]) # Will override method
 
             default_parameters = {
@@ -1180,25 +1285,6 @@ class ThrownObject(LinearModel):
                 'g': -9.81  # Acceleration due to gravity in m/s^2
             }
 
-            def initialize(self, u=None, z=None):
-                return self.StateContainer({
-                    'x': self.parameters['thrower_height'],  # Thrown, so initial altitude is height of thrower
-                    'v': self.parameters['throwing_speed']  # Velocity at which the ball is thrown - this guy is a professional baseball pitcher
-                    })
-            
-            def threshold_met(self, x):
-                return {
-                    'falling': x['v'] < 0,
-                    'impact': x['x'] <= 0
-                }
-
-            def event_state(self, x): 
-                x_max = x['x'] + np.square(x['v'])/(-self.parameters['g']*2) # Use speed and position to estimate maximum height
-                return {
-                    'falling': np.maximum(x['v']/self.parameters['throwing_speed'],0),  # Throwing speed is max speed
-                    'impact': np.maximum(x['x']/x_max,0) if x['v'] < 0 else 1  # 1 until falling begins, then it's fraction of height
-                }
-
         m = ThrownObject()
         m.simulate_to_threshold(lambda t, x = None: m.InputContainer({}))
         m.matrixCheck()
@@ -1206,41 +1292,10 @@ def event_state(self, x):
         self.assertTrue(np.array_equal(m.F, np.array([[1, 0]])))
 
     def test_init_matrix_as_list(self):
-        class ThrownObject(LinearModel):
-            inputs = [] 
-            states = ['x', 'v']
-            outputs = ['x']
-            events = ['impact']
-
+        class ThrownObject(LinearThrownObject):
             A = [[0, 1], [0, 0]]
             E = [[0], [-9.81]]
             C = [[1, 0]]
-            F = None # Will override method
-
-            default_parameters = {
-                'thrower_height': 1.83,  # m
-                'throwing_speed': 40,  # m/s
-                'g': -9.81  # Acceleration due to gravity in m/s^2
-            }
-
-            def initialize(self, u=None, z=None):
-                return self.StateContainer({
-                    'x': self.parameters['thrower_height'],  # Thrown, so initial altitude is height of thrower
-                    'v': self.parameters['throwing_speed']  # Velocity at which the ball is thrown - this guy is a professional baseball pitcher
-                    })
-            
-            def threshold_met(self, x):
-                return {
-                    'falling': x['v'] < 0,
-                    'impact': x['x'] <= 0
-                }
-
-            def event_state(self, x): 
-                x_max = x['x'] + np.square(x['v'])/(-self.parameters['g']*2) # Use speed and position to estimate maximum height
-                return {
-                    'falling': np.maximum(x['v']/self.parameters['throwing_speed'],0),  # Throwing speed is max speed
-                    'impact': np.maximum(x['x']/x_max,0) if x['v'] < 0 else 1  # 1 until falling begins, then it's fraction of height
-                }
 
         m = ThrownObject()
         m.matrixCheck()
@@ -1252,28 +1307,8 @@ def event_state(self, x):
         self.assertTrue(np.array_equal(m.C, np.array([[1, 0]])))
 
     def test_event_state_function(self):
-        class ThrownObject(LinearModel):
-            inputs = [] 
-            states = ['x', 'v']
-            outputs = ['x']
-            events = ['impact']
-
-            A = [[0, 1], [0, 0]]
-            E = [[0], [-9.81]]
-            C = [[1, 0]]
+        class ThrownObject(LinearThrownObject):
             F = None # Will override method
-
-            default_parameters = {
-                'thrower_height': 1.83,  # m
-                'throwing_speed': 40,  # m/s
-                'g': -9.81  # Acceleration due to gravity in m/s^2
-            }
-
-            def initialize(self, u=None, z=None):
-                return self.StateContainer({
-                    'x': self.parameters['thrower_height'],  # Thrown, so initial altitude is height of thrower
-                    'v': self.parameters['throwing_speed']  # Velocity at which the ball is thrown - this guy is a professional baseball pitcher
-                    })
             
             def threshold_met(self, x):
                 return {
@@ -1293,7 +1328,6 @@ def load(t, x=None):
 
         # Test progress bar matching
         simulate_results = m.simulate_to_threshold(load, {'o1': 0.8}, **{'dt': 0.5, 'save_freq': 1.0}, print=False, progress=True)
-        sys.stdout = sys.__stdout__
         capture_split =  [l+"%" for l in capturedOutput.getvalue().split("%") if l][:11]
         percentage_vals = [0, 9, 19, 30, 40, 50, 60, 70, 80, 90, 100]
         for i in range(len(capture_split)):
@@ -1353,10 +1387,6 @@ def run_tests():
     unittest.main()
     
 def main():
-    # This ensures that the directory containing ProgModelTemplate is in the python search directory
-    from os.path import dirname, join
-    sys.path.append(join(dirname(__file__), ".."))
-
     l = unittest.TestLoader()
     runner = unittest.TextTestRunner()
     print("\n\nTesting Base Models")
diff --git a/tests/test_battery.py b/tests/test_battery.py
index 0c5656648..1629817d6 100644
--- a/tests/test_battery.py
+++ b/tests/test_battery.py
@@ -1,5 +1,7 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
 
+from io import StringIO
+import sys
 import unittest
 
 from prog_models.models import BatteryCircuit, BatteryElectroChem, BatteryElectroChemEOL, BatteryElectroChemEOD, BatteryElectroChemEODEOL
@@ -18,7 +20,15 @@ def future_loading(t, x=None):
         i = 3
     return {'i': i}
 
+
 class TestBattery(unittest.TestCase):
+    def setUp(self):
+        # set stdout (so it wont print)
+        sys.stdout = StringIO()
+
+    def tearDown(self):
+        sys.stdout = sys.__stdout__
+    
     def test_battery_circuit(self):
         batt = BatteryCircuit()
         (times, inputs, states, outputs, event_states) = batt.simulate_to(200, future_loading, {'t': 18.95, 'v': 4.183})
diff --git a/tests/test_centrifugal_pump.py b/tests/test_centrifugal_pump.py
index 3bcf7d40a..fb417ea91 100644
--- a/tests/test_centrifugal_pump.py
+++ b/tests/test_centrifugal_pump.py
@@ -1,10 +1,20 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
 
+from io import StringIO
+import sys
 import unittest
+
 from prog_models.models.centrifugal_pump import CentrifugalPump, CentrifugalPumpWithWear, CentrifugalPumpBase
 
 
 class TestCentrifugalPump(unittest.TestCase):
+    def setUp(self):
+        # set stdout (so it wont print)
+        sys.stdout = StringIO()
+
+    def tearDown(self):
+        sys.stdout = sys.__stdout__
+    
     def test_centrifugal_pump_base(self):
         pump = CentrifugalPumpBase(process_noise= 0)
 
diff --git a/tests/test_data_model.py b/tests/test_data_model.py
new file mode 100644
index 000000000..4a72e2b2c
--- /dev/null
+++ b/tests/test_data_model.py
@@ -0,0 +1,327 @@
+# Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
+# This ensures that the directory containing examples is in the python search directories 
+
+from copy import deepcopy
+from io import StringIO
+import matplotlib.pyplot as plt
+import pickle
+import sys
+import unittest
+from unittest.mock import patch
+import warnings
+
+from prog_models.data_models import LSTMStateTransitionModel, DataModel, DMDModel
+from prog_models.models import ThrownObject
+
+
+class TestDataModel(unittest.TestCase):      
+    def setUp(self):
+        # set stdout (so it wont print)
+        sys.stdout = StringIO()
+
+    def tearDown(self):
+        sys.stdout = sys.__stdout__
+      
+    def _test_simple_case(self, 
+        DataModelType, 
+        m = ThrownObject(), 
+        max_error=2, 
+        TIMESTEP = 0.01, 
+        WITH_STATES = True, 
+        WITH_DT = True,
+        **kwargs):
+
+        # Step 1: Generate data
+        def future_loading(t, x=None):
+            return m.InputContainer({})  # No input for thrown object 
+
+        data = m.simulate_to_threshold(future_loading, threshold_keys='impact', save_freq=TIMESTEP, dt=TIMESTEP)
+
+        if WITH_STATES:
+            kwargs['states'] = [data.states, data.states]
+
+        if WITH_DT:
+            kwargs['dt'] = TIMESTEP
+
+        # Step 2: Generate model
+        m2 = DataModelType.from_data(
+            times = [data.times, data.times],
+            inputs = [data.inputs, data.inputs],
+            outputs = [data.outputs, data.outputs],
+            event_states = [data.event_states, data.event_states],  
+            output_keys = list(m.outputs),
+            save_freq = TIMESTEP,
+            validation_split=0.5,
+            **kwargs)  
+        
+        self.assertIsInstance(m2, DataModelType)
+        self.assertIsInstance(m2, DataModel)
+        self.assertListEqual(m2.outputs, list(m.outputs))
+        
+        # Step 3: Use model to simulate_to time of threshold
+        t_counter = 0
+        x_counter = m.initialize()
+        if isinstance(m2, DMDModel):
+            future_loading2 = future_loading
+        else:
+            def future_loading2(t, x = None):
+                # Future Loading is a bit complicated here 
+                # Loading for the resulting model includes the data inputs, 
+                # and the output from the last timestep
+                nonlocal t_counter, x_counter
+                z = m.output(x_counter)
+                z = m2.InputContainer(z.matrix)
+                x_counter = m.next_state(x_counter, future_loading(t), t - t_counter)
+                t_counter = t
+                return z
+        
+        results2 = m2.simulate_to(data.times[-1], future_loading2, dt=TIMESTEP, save_freq=TIMESTEP)
+
+        # Have to do it this way because the other way (i.e., using the LSTM model- the states are not a subset)
+        # Compare RMSE of the results to the original data
+        error = m.calc_error(results2.times, results2.inputs, results2.outputs)
+        self.assertLess(error, max_error)
+
+        _stdout = sys.stdout
+        sys.stdout = StringIO()
+        actual_out = StringIO()
+        m2.summary(file = actual_out)
+        self.assertEqual(sys.stdout.getvalue(), '')
+        self.assertNotEqual(actual_out.getvalue(), '')
+        sys.stdout = _stdout
+
+        return m2
+
+    def test_early_stopping(self):
+        m = ThrownObject()
+        def future_loading(t, x=None):
+            return m.InputContainer({})  # No input for thrown object 
+
+        cfg = {
+            'dt': 0.1,
+            'save_freq': 0.1,
+            'window': 4,
+            'epochs': 75
+        }
+
+        # No early stopping 
+        _stdout = sys.stdout
+        sys.stdout = StringIO()
+        m2 = LSTMStateTransitionModel.from_model(m, [future_loading], early_stop=False, **cfg)
+        end = sys.stdout.getvalue().rsplit("Epoch ",1)[1]
+        value = int(end.split(f"/{cfg['epochs']}", 1)[0])
+        self.assertEqual(value, cfg['epochs'])
+
+        # With early stopping (default)
+        sys.stdout = StringIO()
+        # Default = True
+        m2 = LSTMStateTransitionModel.from_model(m, [future_loading], **cfg)
+        end = sys.stdout.getvalue().rsplit("Epoch ",1)[1]
+        value = int(end.split(f"/{cfg['epochs']}", 1)[0])
+        self.assertNotEqual(value, cfg['epochs'])
+        sys.stdout = _stdout
+
+    def test_lstm_simple(self):
+        m = self._test_simple_case(LSTMStateTransitionModel, window=5, epochs=20, max_error=3)
+        self.assertListEqual(m.inputs, ['x_t-1'])
+        # Use set below so there's no issue with ordering
+        keys = ['x_t-1', 'x_t-2', 'x_t-3', 'x_t-4', 'x_t-5']
+        keys.extend([f'_model_output{i}' for i in range(16)])
+        self.assertSetEqual(set(m.states), set(keys))
+
+        # Create from model
+        LSTMStateTransitionModel(m.parameters['output_model'], m.parameters['state_model'], output_keys = ['x'])
+        try:
+        # Test pickling model m
+            with self.assertWarns(RuntimeWarning):
+            # Will raise warning suggesting using save and load from keras.
+                pickled_m = pickle.dumps(m)
+                m2 = pickle.loads(pickled_m)
+                self.assertIsInstance(m2, LSTMStateTransitionModel)
+                self.assertIsInstance(m2, DataModel)
+                self.assertListEqual(m2.outputs, ['x'])
+        except:
+            warnings.warn("Pickling not supported for LSTMStateTransitionModel on this system")
+            pass
+
+        # Deepcopy test
+        m3 = deepcopy(m2)
+        # More tests in examples.lstm_model
+
+    def test_dmd_simple(self):
+        self._test_simple_case(DMDModel, max_error=25)
+
+        # Inferring dt
+        self._test_simple_case(DMDModel, max_error=8, WITH_DT = False)
+
+        # Without velocity, DMD doesn't perform well
+        m = self._test_simple_case(DMDModel, WITH_STATES = False, max_error=100)
+
+        # Test pickling model m
+        pickled_m = pickle.dumps(m)
+        m2 = pickle.loads(pickled_m)
+        self.assertIsInstance(m2, DMDModel)
+        self.assertIsInstance(m2, DataModel)
+        self.assertListEqual(m2.outputs, ['x'])
+
+        # Deepcopy test
+        m3 = deepcopy(m2)
+
+    def test_lstm_from_model_thrown_object(self):
+        TIMESTEP = 0.01
+
+        # Step 1: Generate dataed
+        m = ThrownObject()
+
+        def future_loading(t, x=None):
+            return m.InputContainer({})  # No input for thrown object 
+        m3 = LSTMStateTransitionModel.from_model(
+            m,
+            [future_loading for _ in range(5)],
+            dt = [TIMESTEP, TIMESTEP/2, TIMESTEP/4, TIMESTEP*2, TIMESTEP*4],
+            window=2, 
+            epochs=20)  
+
+        # Should get keys from original model
+        self.assertSetEqual(set(m3.inputs), set(['dt', 'x_t-1']))
+        self.assertSetEqual(set(m3.outputs), set(m.outputs))
+
+         # Step 3: Use model to simulate_to time of threshold
+        t_counter = 0
+        x_counter = m.initialize()
+        def future_loading2(t, x = None):
+            # Future Loading is a bit complicated here 
+            # Loading for the resulting model includes the data inputs, 
+            # and the output from the last timestep
+            nonlocal t_counter, x_counter
+            z = m.output(x_counter)
+            z = m3.InputContainer(
+                {
+                    'x_t-1': z['x'],
+                    'dt': TIMESTEP
+                })
+            x_counter = m.next_state(x_counter, future_loading(t), t - t_counter)
+            t_counter = t
+            return z
+        
+        results2 = m3.simulate_to(7.5, future_loading2, dt=TIMESTEP, save_freq=TIMESTEP)
+
+        # Have to do it this way because the other way (i.e., using the LSTM model- the states are not a subset)
+        # Compare RMSE of the results to the original data
+        error = m.calc_error(results2.times, results2.inputs, results2.outputs)
+        self.assertLess(error, 2)
+
+        # Now with no 'dt'
+        #----------------------------------------------------
+        m3 = LSTMStateTransitionModel.from_model(
+            m,
+            [future_loading for _ in range(5)],
+            dt = [TIMESTEP, TIMESTEP/2, TIMESTEP/4, TIMESTEP*2, TIMESTEP*4],
+            window=2, 
+            epochs=30,
+            add_dt = False)  
+
+        # Should get keys from original model
+        self.assertSetEqual(set(m3.inputs), set(['x_t-1']))
+        self.assertSetEqual(set(m3.outputs), set(m.outputs))
+
+         # Step 3: Use model to simulate_to time of threshold
+        t_counter = 0
+        x_counter = m.initialize()
+        def future_loading2(t, x = None):
+            # Future Loading is a bit complicated here 
+            # Loading for the resulting model includes the data inputs, 
+            # and the output from the last timestep
+            nonlocal t_counter, x_counter
+            z = m.output(x_counter)
+            z = m3.InputContainer(
+                {
+                    'x_t-1': z['x']
+                })
+            x_counter = m.next_state(x_counter, future_loading(t), t - t_counter)
+            t_counter = t
+            return z
+        
+        results2 = m3.simulate_to(7.5, future_loading2, dt=TIMESTEP, save_freq=TIMESTEP)
+
+        # Have to do it this way because the other way (i.e., using the LSTM model- the states are not a subset)
+        # Compare RMSE of the results to the original data
+        error = m.calc_error(results2.times, results2.inputs, results2.outputs)
+        self.assertLess(error, 2)
+
+    def test_improper_input(self):
+        # Input, output format
+        with self.assertRaises(Exception, msg='No outputs'):
+            LSTMStateTransitionModel.from_data(inputs=[1])
+        with self.assertRaises(Exception, msg='No inputs'):
+            LSTMStateTransitionModel.from_data(outputs=[1])
+        with self.assertRaises(ValueError, msg='Empty inputs/outputs'):
+            LSTMStateTransitionModel.from_data(inputs=[], outputs=[])
+        with self.assertRaises(ValueError, msg="Uneven Inputs/outputs"):
+            LSTMStateTransitionModel.from_data([1], [1, 2])
+        with self.assertRaises(ValueError, msg="Uneven Inputs/outputs-2"):
+            LSTMStateTransitionModel.from_data([1, 2, 3], [1, 2])
+        with self.assertRaises(TypeError, msg="Element Format"):
+            LSTMStateTransitionModel.from_data(1, [2])
+        with self.assertRaises(TypeError, msg="Element Format-2"):
+            LSTMStateTransitionModel.from_data([1], 2)
+        with self.assertRaises(TypeError, msg="Not iterable"):
+            LSTMStateTransitionModel.from_data(12, 12)
+
+        # Other Configurables
+        with self.assertRaises(ValueError, msg="Too small sequence length"):
+            LSTMStateTransitionModel.from_data([1], [2], window=0)
+        with self.assertRaises(TypeError, msg="Non-scalar sequence length"):
+            LSTMStateTransitionModel.from_data([1], [2], window=[])
+
+        with self.assertRaises(ValueError, msg="Too few layers"):
+            LSTMStateTransitionModel.from_data([1], [2], layers=0)
+        with self.assertRaises(TypeError, msg="Non-scalar layers"):
+            LSTMStateTransitionModel.from_data([1], [2], layers=[])
+
+        with self.assertRaises(ValueError, msg="Too few epochs"):
+            LSTMStateTransitionModel.from_data([1], [2], epochs=0)
+        with self.assertRaises(TypeError, msg="Non-scalar epochs"):
+            LSTMStateTransitionModel.from_data([1], [2], epochs=[])
+
+        with self.assertRaises(TypeError, msg="Normalize type"):
+            LSTMStateTransitionModel.from_data([1], [2], normalize=[1, 2])
+
+        with self.assertRaises(TypeError, msg="Dropout type"):
+            LSTMStateTransitionModel.from_data([1], [2], dropout = 'abc')   
+        with self.assertRaises(ValueError, msg="Negative dropout"):
+            LSTMStateTransitionModel.from_data([1], [2], dropout = -1)   
+
+        with self.assertRaises(ValueError, msg="Zero units"):
+            LSTMStateTransitionModel.from_data([1], [2], units = 0)  
+
+        with self.assertRaises(ValueError, msg="Units, layers mismatch"):
+            LSTMStateTransitionModel.from_data([1], [2], units = [1], layers=2)  
+        with self.assertRaises(ValueError, msg="Units, layers mismatch-2"):
+            LSTMStateTransitionModel.from_data([1], [2], units = [1, 2, 3], layers=2)     
+
+        with self.assertRaises(TypeError, msg="Validation Split type"):
+            LSTMStateTransitionModel.from_data([1], [2], validation_split = 'abc')   
+        with self.assertRaises(ValueError, msg="Negative Validation Split"):
+            LSTMStateTransitionModel.from_data([1], [2], validation_split = -1e-5)    
+        with self.assertRaises(ValueError, msg="Validation Split of 1"):
+            LSTMStateTransitionModel.from_data([1], [2], validation_split = 1.0)    
+
+# This allows the module to be executed directly
+def run_tests():
+    unittest.main()
+
+def main():
+    l = unittest.TestLoader()
+    runner = unittest.TextTestRunner()
+    print("\n\nTesting Data Models")
+
+    with patch('matplotlib.pyplot.show'):
+        result = runner.run(l.loadTestsFromTestCase(TestDataModel)).wasSuccessful()
+    plt.close('all')
+    
+    if not result:
+        raise Exception("Failed test")
+
+if __name__ == '__main__':
+    main()
diff --git a/tests/test_datasets.py b/tests/test_datasets.py
index 4c545969c..22de2e597 100644
--- a/tests/test_datasets.py
+++ b/tests/test_datasets.py
@@ -1,22 +1,39 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
 
+from io import StringIO
 import sys
 import unittest
 
+
 class TestDatasets(unittest.TestCase):
+    def setUp(self):
+        # set stdout (so it wont print)
+        sys.stdout = StringIO()
+
+    def tearDown(self):
+        sys.stdout = sys.__stdout__
+
     # Bad URL tests
     def test_nasa_battery_bad_url_download(self):
         from prog_models.datasets import nasa_battery
-        BAD_URL = "BADURLTEST"
-        nasa_battery.urls = {'RW1':"https://"+BAD_URL}
+        nasa_battery.urls = {'RW1':"https://BADURLTEST"}
+        with self.assertRaises(ConnectionError):
+            (desc, data) = nasa_battery.load_data(1)
+        # Legit website, but it's not the repos
+        nasa_battery.urls = {'RW1':"https://github.com/nasa/prog_models"}
         with self.assertRaises(ConnectionError):
             (desc, data) = nasa_battery.load_data(1)
+
     def test_nasa_cmapss_bad_url_download(self):
         from prog_models.datasets import nasa_cmapss
         BAD_URL = "https://"+"BADURLTEST"
         nasa_cmapss.URL = BAD_URL
         with self.assertRaises(ConnectionError):
             (train, test, results) = nasa_cmapss.load_data(1)
+        # Legit website, but it's not the repos
+        nasa_cmapss.URL = "https://github.com/nasa/prog_models"
+        with self.assertRaises(ConnectionError):
+            (train, test, results) = nasa_cmapss.load_data(1)
     # Testing for successful download located in manual testing files; test_manual.py
 
 # This allows the module to be executed directly
@@ -24,10 +41,6 @@ def run_tests():
     unittest.main()
 
 def main():
-    # This ensures that the directory containing ProgModelTemplate is in the python search directory
-    from os.path import dirname, join
-    sys.path.append(join(dirname(__file__), ".."))
-
     l = unittest.TestLoader()
     runner = unittest.TextTestRunner()
     print("\n\nTesting Datasets")
diff --git a/tests/test_dict_like_matrix_wrapper.py b/tests/test_dict_like_matrix_wrapper.py
index 2427f23a4..cc892920c 100644
--- a/tests/test_dict_like_matrix_wrapper.py
+++ b/tests/test_dict_like_matrix_wrapper.py
@@ -1,13 +1,23 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
 
-import unittest
+from io import StringIO
 import numpy as np
+import pickle
+import sys
+import unittest
 
 from prog_models.utils.containers import DictLikeMatrixWrapper
 from prog_models import ProgModelTypeError
 
 
 class TestDictLikeMatrixWrapper(unittest.TestCase):
+    def setUp(self):
+        # set stdout (so it wont print)
+        sys.stdout = StringIO()
+
+    def tearDown(self):
+        sys.stdout = sys.__stdout__
+    
     def _checks(self, c1):
         self.assertListEqual(c1.keys(), ['a', 'b'])
         self.assertListEqual(list(c1.values()), [1, 2])
@@ -33,7 +43,6 @@ def _checks(self, c1):
         self.assertEqual(c1['b'], -2)
 
         # Pickling
-        import pickle
         c2 = pickle.loads(pickle.dumps(c1))
         self.assertTrue((c2.matrix == np.array([[-1], [-2]])).all())
         self.assertEqual(c2['a'], -1)
@@ -84,7 +93,6 @@ def test_broken_init(self):
 
     def test_pickle(self):
         c1 = DictLikeMatrixWrapper(['a', 'b'], {'a': 1, 'b': 2})
-        import pickle
         c2 = pickle.loads(pickle.dumps(c1))
         self.assertTrue((c2.matrix == np.array([[1], [2]])).all())
 
diff --git a/tests/test_examples.py b/tests/test_examples.py
index d364e21d9..6e70ff21c 100644
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -1,24 +1,23 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
 # This ensures that the directory containing examples is in the python search directories 
 
-import sys
+from importlib import import_module
+from io import StringIO 
 from os.path import dirname, join
-sys.path.append(join(dirname(__file__), ".."))
-
+import pkgutil
+import sys
 import unittest
-from io import StringIO 
+
+sys.path.append(join(dirname(__file__), ".."))  # needed to access examples
 from examples import *
-from unittest.mock import patch
-import pkgutil
-from importlib import import_module
 
-examples_skipped = ['dataset', 'sim_battery_eol']
+EXAMPLES_SKIPPED = ['dataset', 'sim_battery_eol']
 
 def make_test_function(example):
     def test(self):
         ex = import_module("examples." + example)
 
-        with patch('matplotlib.pyplot.show'):
+        with unittest.mock.patch('matplotlib.pyplot.show'):
             ex.run_example()
     return test
 
@@ -26,12 +25,11 @@ def test(self):
 class TestExamples(unittest.TestCase):
     def setUp(self):
         # set stdout (so it wont print)
-        self._stdout = sys.stdout
         sys.stdout = StringIO()
 
     def tearDown(self):
         # reset stdout
-        sys.stdout = self._stdout
+        sys.stdout = sys.__stdout__
 
 # This allows the module to be executed directly
 def run_tests():
@@ -40,7 +38,7 @@ def run_tests():
 def main():
     # Create tests for each example
     for _, name, _ in pkgutil.iter_modules(['examples']):
-        if name not in examples_skipped:
+        if name not in EXAMPLES_SKIPPED:
             test_func = make_test_function(name)
             setattr(TestExamples, 'test_{0}'.format(name), test_func)   
 
diff --git a/tests/test_manual.py b/tests/test_manual.py
index f4826c7f9..a5a36a00a 100644
--- a/tests/test_manual.py
+++ b/tests/test_manual.py
@@ -1,16 +1,33 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
 
+from io import StringIO
+from os.path import dirname, join
+from numpy import array
+from numpy.testing import assert_array_equal
+import pandas as pd
 import sys
 import unittest
+from unittest.mock import patch
+
+sys.path.append(join(dirname(__file__), ".."))  # Needed to access examples
+from examples import dataset, sim_battery_eol
+
+from prog_models.datasets import nasa_cmapss, nasa_battery
 
 """
 This file includes tests that are too long to be run as part of the automated tests. Instead, these tests are run manually as part of the release process.
 """
 
+
 class TestManual(unittest.TestCase):
-    # Test successful download of datasets
+    def setUp(self):
+        # set stdout (so it wont print)
+        sys.stdout = StringIO()
+
+    def tearDown(self):
+        sys.stdout = sys.__stdout__
+
     def test_nasa_battery_download(self):
-        from prog_models.datasets import nasa_battery
         (desc, data) = nasa_battery.load_data(1)
         
         # Verifying desc
@@ -21,30 +38,27 @@ def test_nasa_battery_download(self):
         self.assertDictEqual(desc['runs'][-1], {'type': 'D', 'desc': 'discharge (random walk)', 'date': '02-Jun-2014 16:43:48'})
 
         # Verifying data
-        from numpy import array
-        from numpy.testing import assert_array_equal
-        assert_array_equal(data[0][0], array([1.500000e-01, 1.800000e-02, 4.196000e+00, 1.837451e+01]))
-        assert_array_equal(data[0][-1], array([1.8897668e+05, 4.0000000e-02, 3.2000000e+00, 1.7886300e+01]))
-        assert_array_equal(data[8532][0], array([1.000000e-02, 0.000000e+00, 3.645000e+00, 3.124247e+01]))
-        assert_array_equal(data[8532][-1], array([0.54, 0, 3.716, 31.24247]))
-        assert_array_equal(data[-1][0], array([0.04, 3.004, 3.647, 28.08937]))
-        assert_array_equal(data[-1][-1], array([178.38, 3, 3.2, 32.53947]))
+        assert_array_equal(data[0].columns, pd.core.indexes.base.Index(['relativeTime', 'current', 'voltage', 'temperature'], dtype='object'))
+        
+        self.assertEqual(data[0]['current'][15], 0.04)
+        assert_array_equal(data[0].iloc[-1], array([1.8897668e+05, 4.0000000e-02, 3.2000000e+00, 1.7886300e+01]))
+        assert_array_equal(data[8532].iloc[0], array([1.000000e-02, 0.000000e+00, 3.645000e+00, 3.124247e+01]))
+        assert_array_equal(data[8532].iloc[-1], array([0.54, 0, 3.716, 31.24247]))
+        assert_array_equal(data[-1].iloc[0], array([0.04, 3.004, 3.647, 28.08937]))
+        assert_array_equal(data[-1].iloc[-1], array([178.38, 3, 3.2, 32.53947]))
 
     def test_nasa_cmapss_download(self):
-        from prog_models.datasets import nasa_cmapss
         (train, test, results) = nasa_cmapss.load_data(1)
+        
+        # Testing train data
+        assert_array_equal(train.iloc[0], array([1.00000e+00, 1.00000e+00, 2.30000e-03, 3.00000e-04, 1.00000e+02, 5.18670e+02, 6.43020e+02, 1.58529e+03, 1.39821e+03, 1.46200e+01, 2.16100e+01, 5.53900e+02, 2.38804e+03, 9.05017e+03, 1.30000e+00, 4.72000e+01, 5.21720e+02, 2.38803e+03, 8.12555e+03, 8.40520e+00, 3.00000e-02, 3.92000e+02, 2.38800e+03, 1.00000e+02, 3.88600e+01, 2.33735e+01]))
+        assert_array_equal(train.iloc[-1], array([1.00000e+02, 1.98000e+02, 1.30000e-03, 3.00000e-04, 1.00000e+02, 5.18670e+02, 6.42950e+02, 1.60162e+03, 1.42499e+03, 1.46200e+01, 2.16100e+01, 5.52480e+02, 2.38806e+03, 9.15503e+03, 1.30000e+00, 4.78000e+01, 5.21070e+02, 2.38805e+03, 8.21464e+03, 8.49030e+00, 3.00000e-02, 3.96000e+02, 2.38800e+03, 1.00000e+02, 3.87000e+01, 2.31855e+01]))
+        assert_array_equal(train.iloc[6548], array([5.20000e+01,  6.60000e+01, -1.90000e-03, -0.00000e+00, 1.00000e+02,  5.18670e+02,  6.42070e+02,  1.58397e+03, 1.39125e+03,  1.46200e+01,  2.16100e+01,  5.54590e+02, 2.38804e+03,  9.05261e+03,  1.30000e+00,  4.71200e+01, 5.22480e+02,  2.38803e+03,  8.13633e+03,  8.39150e+00, 3.00000e-02,  3.92000e+02,  2.38800e+03,  1.00000e+02, 3.90500e+01,  2.34304e+01]))
 
-        from numpy import array
-        from numpy.testing import assert_array_equal
-        # Testing train
-        assert_array_equal(train[0], array([1.00000e+00, 1.00000e+00, 2.30000e-03, 3.00000e-04, 1.00000e+02, 5.18670e+02, 6.43020e+02, 1.58529e+03, 1.39821e+03, 1.46200e+01, 2.16100e+01, 5.53900e+02, 2.38804e+03, 9.05017e+03, 1.30000e+00, 4.72000e+01, 5.21720e+02, 2.38803e+03, 8.12555e+03, 8.40520e+00, 3.00000e-02, 3.92000e+02, 2.38800e+03, 1.00000e+02, 3.88600e+01, 2.33735e+01]))
-        assert_array_equal(train[-1], array([1.00000e+02, 1.98000e+02, 1.30000e-03, 3.00000e-04, 1.00000e+02, 5.18670e+02, 6.42950e+02, 1.60162e+03, 1.42499e+03, 1.46200e+01, 2.16100e+01, 5.52480e+02, 2.38806e+03, 9.15503e+03, 1.30000e+00, 4.78000e+01, 5.21070e+02, 2.38805e+03, 8.21464e+03, 8.49030e+00, 3.00000e-02, 3.96000e+02, 2.38800e+03, 1.00000e+02, 3.87000e+01, 2.31855e+01]))
-        assert_array_equal(train[6548], array([5.20000e+01,  6.60000e+01, -1.90000e-03, -0.00000e+00, 1.00000e+02,  5.18670e+02,  6.42070e+02,  1.58397e+03, 1.39125e+03,  1.46200e+01,  2.16100e+01,  5.54590e+02, 2.38804e+03,  9.05261e+03,  1.30000e+00,  4.71200e+01, 5.22480e+02,  2.38803e+03,  8.13633e+03,  8.39150e+00, 3.00000e-02,  3.92000e+02,  2.38800e+03,  1.00000e+02, 3.90500e+01,  2.34304e+01]))
-
-        # Testing test
-        assert_array_equal(test[0], array([ 1.00000e+00,  1.00000e+00, -7.00000e-04, -4.00000e-04, 1.00000e+02,  5.18670e+02,  6.41820e+02,  1.58970e+03, 1.40060e+03,  1.46200e+01,  2.16100e+01,  5.54360e+02, 2.38806e+03,  9.04619e+03,  1.30000e+00,  4.74700e+01, 5.21660e+02,  2.38802e+03,  8.13862e+03,  8.41950e+00, 3.00000e-02,  3.92000e+02,  2.38800e+03,  1.00000e+02, 3.90600e+01,  2.34190e+01]))
-        assert_array_equal(test[-1], array([ 1.00000e+02,  2.00000e+02, -3.20000e-03, -5.00000e-04, 1.00000e+02,  5.18670e+02,  6.43850e+02,  1.60038e+03, 1.43214e+03,  1.46200e+01,  2.16100e+01,  5.50790e+02, 2.38826e+03,  9.06148e+03,  1.30000e+00,  4.82000e+01, 5.19300e+02,  2.38826e+03,  8.13733e+03,  8.50360e+00, 3.00000e-02,  3.96000e+02,  2.38800e+03,  1.00000e+02, 3.83700e+01,  2.30522e+01]))
-        assert_array_equal(test[6548], array([3.30000e+01, 1.37000e+02, 1.70000e-03, 2.00000e-04, 1.00000e+02, 5.18670e+02, 6.42380e+02, 1.58655e+03, 1.41089e+03, 1.46200e+01, 2.16100e+01, 5.53960e+02, 2.38807e+03, 9.06359e+03, 1.30000e+00, 4.74500e+01, 5.21950e+02, 2.38805e+03, 8.14151e+03, 8.43050e+00, 3.00000e-02, 3.91000e+02, 2.38800e+03, 1.00000e+02, 3.90000e+01, 2.33508e+01]))
+        # Testing test data
+        assert_array_equal(test.iloc[0], array([ 1.00000e+00,  1.00000e+00, -7.00000e-04, -4.00000e-04, 1.00000e+02,  5.18670e+02,  6.41820e+02,  1.58970e+03, 1.40060e+03,  1.46200e+01,  2.16100e+01,  5.54360e+02, 2.38806e+03,  9.04619e+03,  1.30000e+00,  4.74700e+01, 5.21660e+02,  2.38802e+03,  8.13862e+03,  8.41950e+00, 3.00000e-02,  3.92000e+02,  2.38800e+03,  1.00000e+02, 3.90600e+01,  2.34190e+01]))
+        assert_array_equal(test.iloc[-1], array([ 1.00000e+02,  2.00000e+02, -3.20000e-03, -5.00000e-04, 1.00000e+02,  5.18670e+02,  6.43850e+02,  1.60038e+03, 1.43214e+03,  1.46200e+01,  2.16100e+01,  5.50790e+02, 2.38826e+03,  9.06148e+03,  1.30000e+00,  4.82000e+01, 5.19300e+02,  2.38826e+03,  8.13733e+03,  8.50360e+00, 3.00000e-02,  3.96000e+02,  2.38800e+03,  1.00000e+02, 3.83700e+01,  2.30522e+01]))
+        assert_array_equal(test.iloc[6548], array([3.30000e+01, 1.37000e+02, 1.70000e-03, 2.00000e-04, 1.00000e+02, 5.18670e+02, 6.42380e+02, 1.58655e+03, 1.41089e+03, 1.46200e+01, 2.16100e+01, 5.53960e+02, 2.38807e+03, 9.06359e+03, 1.30000e+00, 4.74500e+01, 5.21950e+02, 2.38805e+03, 8.14151e+03, 8.43050e+00, 3.00000e-02, 3.91000e+02, 2.38800e+03, 1.00000e+02, 3.90000e+01, 2.33508e+01]))
         
         # Testing results
         assert_array_equal(results, array([112.,  98.,  69.,  82.,  91.,  93.,  91.,  95., 111.,  96.,  97.,
@@ -59,22 +73,18 @@ def test_nasa_cmapss_download(self):
         20.]))
 
     def test_dataset_example(self):
-        from examples import dataset
-        dataset.run_example()
+        with patch('matplotlib.pyplot.show'):
+            dataset.run_example()
 
     def test_sim_battery_eol_example(self):
-        from examples import sim_battery_eol
-        sim_battery_eol.run_example()
+        with patch('matplotlib.pyplot.show'):
+            sim_battery_eol.run_example()
 
 # This allows the module to be executed directly
 def run_tests():
     unittest.main()
 
 def main():
-    # This ensures that the directory containing ProgModelTemplate is in the python search directory
-    from os.path import dirname, join
-    sys.path.append(join(dirname(__file__), ".."))
-
     l = unittest.TestLoader()
     runner = unittest.TextTestRunner()
     print("\n\nTesting Manual")
diff --git a/tests/test_pneumatic_valve.py b/tests/test_pneumatic_valve.py
index ba7ce3e2c..9b71344e9 100644
--- a/tests/test_pneumatic_valve.py
+++ b/tests/test_pneumatic_valve.py
@@ -1,10 +1,21 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
 
+from io import StringIO
+from numpy import array
+import sys
 import unittest
+
 from prog_models.models.pneumatic_valve import PneumaticValve, PneumaticValveWithWear, PneumaticValveBase
-from numpy import array
+
 
 class TestPneumaticValve(unittest.TestCase):
+    def setUp(self):
+        # set stdout (so it wont print)
+        sys.stdout = StringIO()
+
+    def tearDown(self):
+        sys.stdout = sys.__stdout__
+
     def test_pneumatic_valve_vectorized(self):
         m = PneumaticValveWithWear(process_noise= 0)    
 
diff --git a/tests/test_powertrain.py b/tests/test_powertrain.py
index 8961220db..8c29b3b38 100644
--- a/tests/test_powertrain.py
+++ b/tests/test_powertrain.py
@@ -1,10 +1,20 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
 
+from io import StringIO
+import sys
 import unittest
+
 from prog_models.models import ESC, DCMotor, Powertrain
 
 
 class TestPowertrain(unittest.TestCase):
+    def setUp(self):
+        # set stdout (so it wont print)
+        sys.stdout = StringIO()
+
+    def tearDown(self):
+        sys.stdout = sys.__stdout__
+    
     def test_powertrain(self):
         esc = ESC()
         motor = DCMotor()
diff --git a/tests/test_serialization.py b/tests/test_serialization.py
new file mode 100644
index 000000000..5fc8e9c94
--- /dev/null
+++ b/tests/test_serialization.py
@@ -0,0 +1,120 @@
+# Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
+
+from io import StringIO
+import sys
+import unittest
+
+from prog_models.models import BatteryElectroChemEOD as Battery 
+from prog_models.data_models import DMDModel
+
+
+class TestSerialization(unittest.TestCase):
+    def setUp(self):
+        # set stdout (so it wont print)
+        sys.stdout = StringIO()
+
+    def tearDown(self):
+        sys.stdout = sys.__stdout__
+
+    def test_serialization_basic(self):
+        batt = Battery()
+
+        def future_loading_1(t, x=None):
+            # Variable (piece-wise) future loading scheme 
+            if (t < 500):
+                i = 3
+            elif (t < 1000):
+                i = 2
+            elif (t < 1500):
+                i = 0.5
+            else:
+                i = 4.5
+            return batt.InputContainer({'i': i})
+        
+        def future_loading_2(t, x=None):
+            # Variable (piece-wise) future loading scheme 
+            if (t < 300):
+                i = 2
+            elif (t < 800):
+                i = 3.5
+            elif (t < 1300):
+                i = 4
+            elif (t < 1600):
+                i = 1.5
+            else:
+                i = 5
+            return batt.InputContainer({'i': i})
+        
+        load_functions = [future_loading_1, future_loading_2]
+
+        options_surrogate = {
+            'save_freq': 1, # For DMD, this value is the time step for which the surrogate model is generated
+            'dt': 0.1, # For DMD, this value is the time step of the training data
+            'trim_data_to': 0.7 # Value between 0 and 1 that determines the fraction of data resulting from simulate_to_threshold that is used to train DMD surrogate model
+        }
+
+        batt.parameters['process_noise'] = 0
+
+        # Generate surrogate model  
+        surrogate_orig = batt.generate_surrogate(load_functions,**options_surrogate)
+
+        # Serialize parameters
+        save_json_dict = surrogate_orig.to_json()
+
+        # Generate new surrogated with serialized version
+        new_model = DMDModel.from_json(save_json_dict)
+
+        # Check serialization
+        for key in surrogate_orig.parameters.keys():
+            if key != 'dmd_matrix':
+                self.assertEqual(surrogate_orig.parameters[key], new_model.parameters[key]) 
+            else: 
+                self.assertEqual((surrogate_orig.parameters['dmd_matrix']==new_model.parameters['dmd_matrix']).all(), True)
+        
+        # Check deserialization
+        options_sim = {
+            'save_freq': 1 # Frequency at which results are saved, or equivalently time step in results
+        }
+
+        # Define loading profile 
+        def future_loading(t, x=None):
+            if (t < 600):
+                i = 3
+            elif (t < 1000):
+                i = 2
+            elif (t < 1500):
+                i = 1.5
+            else:
+                i = 4
+            return batt.InputContainer({'i': i})
+
+        # Simulate to threshold using DMD approximation
+        surrogate_results = surrogate_orig.simulate_to_threshold(future_loading,**options_sim)
+        new_results = new_model.simulate_to_threshold(future_loading, **options_sim) 
+
+        for i in range(min(len(surrogate_results.times), len(new_results.times))):
+            self.assertEqual(surrogate_results.times[i], new_results.times[i])
+            for key in surrogate_results.states[0].keys():
+                self.assertAlmostEqual(surrogate_results.states[i][key], new_results.states[i][key], delta = 3e-01)
+            for key in surrogate_results.inputs[0].keys():
+                self.assertAlmostEqual(surrogate_results.inputs[i][key], new_results.inputs[i][key], delta = 3e-01)
+            for key in surrogate_results.outputs[0].keys():
+                self.assertAlmostEqual(surrogate_results.outputs[i][key], new_results.outputs[i][key], delta = 3e-01)
+            for key in surrogate_results.event_states[0].keys():
+                self.assertAlmostEqual(surrogate_results.event_states[i][key], new_results.event_states[i][key], delta = 3e-01)
+
+# This allows the module to be executed directly
+def run_tests():
+    unittest.main()
+    
+def main():
+    l = unittest.TestLoader()
+    runner = unittest.TextTestRunner()
+    print("\n\nTesting Serialization of Surrogate Model")
+    result = runner.run(l.loadTestsFromTestCase(TestSerialization)).wasSuccessful()
+
+    if not result:
+        raise Exception("Failed test")
+
+if __name__ == '__main__':
+    main()
diff --git a/tests/test_sim_result.py b/tests/test_sim_result.py
index 3a1775e20..31a5e3631 100644
--- a/tests/test_sim_result.py
+++ b/tests/test_sim_result.py
@@ -1,11 +1,24 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
 
+from io import StringIO
+import numpy as np
+import pickle
+import sys
 import unittest
 
+from prog_models.models import BatteryElectroChemEOD
 from prog_models.sim_result import SimResult, LazySimResult
+from prog_models.utils.containers import DictLikeMatrixWrapper
 
 
 class TestSimResult(unittest.TestCase):
+    def setUp(self):
+        # set stdout (so it wont print)
+        sys.stdout = StringIO()
+
+    def tearDown(self):
+        sys.stdout = sys.__stdout__
+    
     def test_sim_result(self):
         NUM_ELEMENTS = 5
         time = list(range(NUM_ELEMENTS))
@@ -34,13 +47,12 @@ def test_pickle(self):
         time = list(range(NUM_ELEMENTS))
         state = [{'a': i * 2.5, 'b': i * 5} for i in range(NUM_ELEMENTS)]
         result = SimResult(time, state)
-        import pickle
         pickle.dump(result, open('model_test.pkl', 'wb'))
         result2 = pickle.load(open('model_test.pkl', 'rb'))
         self.assertEqual(result, result2)
 
     def test_extend(self):
-        NUM_ELEMENTS = 5 # Creating two result objects
+        NUM_ELEMENTS = 5  # Creating two result objects
         time = list(range(NUM_ELEMENTS))
         state = [{'a': i * 2.5, 'b': i * 2.5} for i in range(NUM_ELEMENTS)]
         result = SimResult(time, state)
@@ -53,11 +65,11 @@ def test_extend(self):
         self.assertEqual(result.data, [{'a': 0.0, 'b': 0.0}, {'a': 2.5, 'b': 2.5}, {'a': 5.0, 'b': 5.0}, {'a': 7.5, 'b': 7.5}, {'a': 10.0, 'b': 10.0}]) # Assert data is correct before extending
         self.assertEqual(result2.data, [{'a': 0, 'b': 0}, {'a': 5, 'b': 5}, {'a': 10, 'b': 10}, {'a': 15, 'b': 15}, {'a': 20, 'b': 20}, {'a': 25, 'b': 25}, {'a': 30, 'b': 30}, {'a': 35, 'b': 35}, {'a': 40, 'b': 40}, {'a': 45, 'b': 45}])
         
-        result.extend(result2) # Extend result with result2
+        result.extend(result2)  # Extend result with result2
         self.assertEqual(result.times, [0, 1, 2, 3, 4, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
         self.assertEqual(result.data, [{'a': 0.0, 'b': 0.0}, {'a': 2.5, 'b': 2.5}, {'a': 5.0, 'b': 5.0}, {'a': 7.5, 'b': 7.5}, {'a': 10.0, 'b': 10.0}, {'a': 0, 'b': 0}, {'a': 5, 'b': 5}, {'a': 10, 'b': 10}, {'a': 15, 'b': 15}, {'a': 20, 'b': 20}, {'a': 25, 'b': 25}, {'a': 30, 'b': 30}, {'a': 35, 'b': 35}, {'a': 40, 'b': 40}, {'a': 45, 'b': 45}])
 
-        self.assertRaises(ValueError, result.extend, 0) # Passing non-LazySimResult types to extend method
+        self.assertRaises(ValueError, result.extend, 0)  # Passing non-LazySimResult types to extend method
         self.assertRaises(ValueError, result.extend, [0,1])
         self.assertRaises(ValueError, result.extend, {})
         self.assertRaises(ValueError, result.extend, set())
@@ -89,13 +101,12 @@ def f(x):
         NUM_ELEMENTS = 5
         time = list(range(NUM_ELEMENTS))
         state = [{'a': i * 2.5, 'b': i * 2.5} for i in range(NUM_ELEMENTS)]
-        lazy_result = LazySimResult(f, time, state) # Ordinary LazySimResult with f, time, state
-        sim_result = SimResult(time, state) # Ordinary SimResult with time,state
+        lazy_result = LazySimResult(f, time, state)  # Ordinary LazySimResult with f, time, state
+        sim_result = SimResult(time, state)  # Ordinary SimResult with time,state
 
         converted_lazy_result = SimResult(lazy_result.times, lazy_result.data)
-        self.assertNotEqual(sim_result, converted_lazy_result) # converted is not the same as the original SimResult
+        self.assertNotEqual(sim_result, converted_lazy_result)  # converted is not the same as the original SimResult
 
-        import pickle # try pickle'ing
         pickle.dump(lazy_result, open('model_test.pkl', 'wb'))
         pickle_converted_result = pickle.load(open('model_test.pkl', 'rb'))
         self.assertEqual(converted_lazy_result, pickle_converted_result)
@@ -139,6 +150,40 @@ def test_pop(self):
         self.assertRaises(TypeError, result.pop, set())
         self.assertRaises(TypeError, result.pop, 1.5)
 
+    def test_to_numpy(self):
+        NUM_ELEMENTS = 10
+        time = list(range(NUM_ELEMENTS))
+        state = [{'a': i * 2.5, 'b': i * 5} for i in range(NUM_ELEMENTS)]
+        result = SimResult(time, state)
+        np_result = result.to_numpy()
+        self.assertIsInstance(np_result, np.ndarray)
+        self.assertEqual(np_result.shape, (NUM_ELEMENTS, 2))
+        self.assertEqual(np_result.dtype, np.dtype('float64'))
+        self.assertTrue(np.all(np_result==np.array([[i * 2.5, i * 5] for i in range(NUM_ELEMENTS)])))
+
+        # Subset of keys
+        result = result.to_numpy(['b'])
+        self.assertIsInstance(result, np.ndarray)
+        self.assertEqual(result.shape, (NUM_ELEMENTS, 1))
+        self.assertEqual(result.dtype, np.dtype('float64'))
+        self.assertTrue(np.all(result==np.array([[i * 5] for i in range(NUM_ELEMENTS)])))
+
+        # Now test when empty
+        result = SimResult([], [])
+        result = result.to_numpy()
+        self.assertIsInstance(result, np.ndarray)
+        self.assertEqual(result.shape, (1, 0))
+        self.assertEqual(result.dtype, np.dtype('float64'))
+
+        # Now test with StateContainer
+        state = [DictLikeMatrixWrapper(['a', 'b'], x) for x in state]
+        result = SimResult(time, state)
+        result = result.to_numpy()
+        self.assertIsInstance(result, np.ndarray)
+        self.assertEqual(result.shape, (NUM_ELEMENTS, 2))
+        self.assertEqual(result.dtype, np.dtype('float64'))
+        self.assertTrue(np.all(result==np.array([[i * 2.5, i * 5] for i in range(NUM_ELEMENTS)])))
+
     def test_remove(self):
         NUM_ELEMENTS = 5 # Creating two result objects
         time = list(range(NUM_ELEMENTS))
@@ -202,7 +247,6 @@ def test_time(self):
     def test_plot(self):
         # Testing model taken from events.py
         YELLOW_THRESH, RED_THRESH, THRESHOLD = 0.15, 0.1, 0.05
-        from prog_models.models import BatteryElectroChemEOD
         class MyBatt(BatteryElectroChemEOD):
             events = BatteryElectroChemEOD.events + ['EOD_warn_yellow', 'EOD_warn_red', 'EOD_requirement_threshold']
             def event_state(self, state):
@@ -232,7 +276,7 @@ def future_loading(t, x=None):
     def test_namedtuple_access(self):
         # Testing model taken from events.py
         YELLOW_THRESH, RED_THRESH, THRESHOLD = 0.15, 0.1, 0.05
-        from prog_models.models import BatteryElectroChemEOD
+        
         class MyBatt(BatteryElectroChemEOD):
             events = BatteryElectroChemEOD.events + ['EOD_warn_yellow', 'EOD_warn_red', 'EOD_requirement_threshold']
             def event_state(self, state):
@@ -439,8 +483,8 @@ def f(x):
         except IndexError:
             pass
 
-        # Catch bug that occured where lazysimresults weren't actually different
-        # This occured because the underlying arrays of time and state were not copied (see PR #158)
+        # Catch bug that occurred where lazysimresults weren't actually different
+        # This occurred because the underlying arrays of time and state were not copied (see PR #158)
         result = LazySimResult(f, time, state)
         result2 = LazySimResult(f, time, state)
         self.assertTrue(result == result2)
@@ -543,18 +587,13 @@ def run_tests():
     unittest.main()
     
 def main():
-    # This ensures that the directory containing ProgModelTemplate is in the python search directory
-    import sys
-    from os.path import dirname, join
-    sys.path.append(join(dirname(__file__), ".."))
-
     l = unittest.TestLoader()
     runner = unittest.TextTestRunner()
     print("\n\nTesting Sim Result")
     result = runner.run(l.loadTestsFromTestCase(TestSimResult)).wasSuccessful()
 
     if not result:
-        raise Exception("Failed test")
+         raise Exception("Failed test")
 
 if __name__ == '__main__':
     main()
diff --git a/tests/test_surrogates.py b/tests/test_surrogates.py
index 28842ab26..14e84c861 100644
--- a/tests/test_surrogates.py
+++ b/tests/test_surrogates.py
@@ -1,5 +1,7 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
 
+from io import StringIO
+import sys
 import unittest
 import warnings
 
@@ -8,6 +10,13 @@
 
 
 class TestSurrogate(unittest.TestCase):
+    def setUp(self):
+        # set stdout (so it wont print)
+        sys.stdout = StringIO()
+
+    def tearDown(self):
+        sys.stdout = sys.__stdout__
+
     def test_surrogate_improper_input(self):
         m = ThrownObject()
         def load_eqn(t = None, x = None):
@@ -44,16 +53,20 @@ def load_eqn(t = None, x = None):
             m.generate_surrogate([load_eqn], stability_tol = -1)
         with self.assertRaises(ProgModelInputException):
             m.generate_surrogate([load_eqn], stability_tol = 'invalid')
+        with self.assertRaises(ProgModelInputException):
+            m.generate_surrogate([load_eqn], training_noise = -1)
+        with self.assertRaises(ProgModelInputException):
+            m.generate_surrogate([load_eqn], training_noise = ['invalid'])
     
     def test_surrogate_basic_thrown_object(self):
-        m = ThrownObject()
+        m = ThrownObject(process_noise = 0, measurement_noise = 0)
         def load_eqn(t = None, x = None):
             return m.InputContainer({})
         
-        surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25, threshold_keys = 'impact')
+        surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25, threshold_keys = 'impact', training_noise=0)
         self.assertEqual(surrogate.dt, 0.25)
 
-        self.assertListEqual(surrogate.states, m.states + m.outputs + m.events)
+        self.assertListEqual(surrogate.states, [stateTest for stateTest in m.states if (stateTest not in m.outputs and stateTest not in m.events)] + m.outputs + m.events)
         self.assertListEqual(surrogate.inputs, m.inputs)
         self.assertListEqual(surrogate.outputs, m.outputs)
         self.assertListEqual(surrogate.events, m.events)
@@ -64,6 +77,8 @@ def load_eqn(t = None, x = None):
             'dt': 0.25
         }
         result = m.simulate_to_threshold(load_eqn, **options)
+        surrogate.parameters['measurement_noise'] = 0
+        surrogate.parameters['process_noise'] = 0
         surrogate_results = surrogate.simulate_to_threshold(load_eqn, **options)
 
         MSE = m.calc_error(surrogate_results.times, surrogate_results.inputs, surrogate_results.outputs)
@@ -112,15 +127,16 @@ def future_loading_2(t, x=None):
         options_surrogate = {
             'save_freq': 1, # For DMD, this value is the time step for which the surrogate model is generated
             'dt': 0.1, # For DMD, this value is the time step of the training data
-            'states': ['Vsn','Vsp','tb'], # Define internal states to be included in surrogate model
+            'state_keys': ['Vsn','Vsp','tb'], # Define internal states to be included in surrogate model
             'trim_data_to': 0.7, # Trim data to this fraction of the time series
-            'outputs': ['v'], # Define outputs to be included in surrogate model 
+            'output_keys': ['v'], # Define outputs to be included in surrogate model 
+            'training_noise': 0
         }
 
         surrogate = m.generate_surrogate(load_functions, **options_surrogate)
-        self.assertListEqual(surrogate.states, ['tb', 'Vsn', 'Vsp'] + options_surrogate['outputs'] + m.events)
+        self.assertSetEqual(set(surrogate.states), set(['tb', 'Vsn', 'Vsp'] + options_surrogate['output_keys'] + m.events))
         self.assertListEqual(surrogate.inputs, m.inputs)
-        self.assertListEqual(surrogate.outputs, options_surrogate['outputs'])
+        self.assertListEqual(surrogate.outputs, options_surrogate['output_keys'])
         self.assertListEqual(surrogate.events, m.events)
 
         options_sim = {
@@ -148,15 +164,17 @@ def future_loading(t, x=None):
         self.assertLess(MSE, 0.02) # Pretty good approx
     
     def test_surrogate_subsets(self):
-        m = ThrownObject()
+        m = ThrownObject(process_noise=0, measurement_noise = 0)
         def load_eqn(t = None, x = None):
             return m.InputContainer({})
 
         # Perfect subset
-        surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25, threshold_keys = 'impact', states=['x', 'v'])
+        surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25, threshold_keys = 'impact', state_keys=['x', 'v'], training_noise = 0)
+        surrogate.parameters['process_noise'] = 0
+        surrogate.parameters['measurement_noise'] = 0
         self.assertEqual(surrogate.dt, 0.25)
 
-        self.assertListEqual(surrogate.states, m.states + m.outputs + m.events + m.inputs)
+        self.assertListEqual(surrogate.states, [stateTest for stateTest in m.states if (stateTest not in m.inputs and stateTest not in m.outputs and stateTest not in m.events)] + m.outputs + m.events)
         self.assertListEqual(surrogate.inputs, m.inputs)
         self.assertListEqual(surrogate.outputs, m.outputs)
         self.assertListEqual(surrogate.events, m.events)
@@ -180,13 +198,13 @@ def load_eqn(t = None, x = None):
             self.assertEqual(surrogate_results.states[i]['impact'], surrogate_results.event_states[i]['impact'])
         
         # State subset
-        surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25, threshold_keys = 'impact', states = ['v'])
+        surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25, threshold_keys = 'impact', states = ['v'], training_noise = 0)
         self.assertListEqual(surrogate.states, ['v'] + m.outputs + m.events + m.inputs)
         self.assertListEqual(surrogate.inputs, m.inputs)
         self.assertListEqual(surrogate.outputs, m.outputs)
         self.assertListEqual(surrogate.events, m.events)
 
-        surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25, threshold_keys = 'impact', states = 'v')
+        surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25, threshold_keys = 'impact', states = 'v', training_noise = 0)
         self.assertListEqual(surrogate.states, ['v'] + m.outputs + m.events + m.inputs)
         self.assertListEqual(surrogate.inputs, m.inputs)
         self.assertListEqual(surrogate.outputs, m.outputs)
@@ -211,9 +229,9 @@ def load_eqn(t = None, x = None):
             self.assertEqual(surrogate_results.states[i]['impact'], surrogate_results.event_states[i]['impact'])
 
         # Events subset
-        surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25,      threshold_keys = 'impact', events = ['impact'])
-        surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25,      threshold_keys = 'impact', events = 'impact')
-        self.assertListEqual(surrogate.states, m.states + m.outputs + ['impact'] + m.inputs)
+        surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25,      threshold_keys = 'impact', events = ['impact'], training_noise = 0)
+        surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25,      threshold_keys = 'impact', events = 'impact', training_noise = 0)
+        self.assertListEqual(surrogate.states, [stateTest for stateTest in m.states if (stateTest not in m.inputs and stateTest not in m.outputs and stateTest not in m.events)] + m.outputs + ['impact'] + m.inputs)
         self.assertListEqual(surrogate.inputs, m.inputs)
         self.assertListEqual(surrogate.outputs, m.outputs)
         self.assertListEqual(surrogate.events, ['impact'])
@@ -235,7 +253,7 @@ def load_eqn(t = None, x = None):
             self.assertEqual(surrogate_results.states[i]['impact'], surrogate_results.event_states[i]['impact'])
 
         # Outputs - Empty
-        surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25,      threshold_keys = 'impact', outputs = [])
+        surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25,      threshold_keys = 'impact', outputs = [], training_noise = 0)
         self.assertListEqual(surrogate.states, m.states + m.events + m.inputs)
         self.assertListEqual(surrogate.inputs, m.inputs)
         self.assertListEqual(surrogate.outputs, [])
@@ -258,6 +276,166 @@ def load_eqn(t = None, x = None):
             self.assertAlmostEqual(surrogate_results.states[i]['impact'], result.event_states[i]['impact'], delta=0.1)
             self.assertEqual(surrogate_results.states[i]['impact'], surrogate_results.event_states[i]['impact'])
 
+    def test_surrogate_thrown_object_with_noise(self):
+        m = ThrownObject()
+        def load_eqn(t = None, x = None):
+            return m.InputContainer({})
+        
+        surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25, threshold_keys = 'impact',training_noise=0)
+        surrogate_noise = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25, threshold_keys = 'impact',training_noise=0.01)
+        self.assertEqual(surrogate.dt, 0.25)
+
+        self.assertListEqual(surrogate.states, surrogate_noise.states)
+        self.assertListEqual(surrogate.inputs, surrogate_noise.inputs)
+        self.assertListEqual(surrogate.outputs, surrogate_noise.outputs)
+        self.assertListEqual(surrogate.events, surrogate_noise.events)
+
+        options = {
+            'threshold_keys': 'impact',
+            'save_freq': 0.25,
+            'dt': 0.25
+        }
+
+        surrogate_results = surrogate.simulate_to_threshold(load_eqn, **options)
+        surrogate_noise_results = surrogate_noise.simulate_to_threshold(load_eqn, **options)
+
+        position = [surrogate_results.states[iter]['x'] for iter in range(len(surrogate_results.times))]
+        position_noise = [surrogate_noise_results.states[iter]['x'] for iter in range(len(surrogate_noise_results.times))]
+
+        MSE = sum([(position[iter] - position_noise[iter])**2 for iter in range(min(len(position),len(position_noise)))])/min(len(position),len(position_noise))
+        self.assertLess(MSE, 10) 
+        self.assertNotEqual(MSE, 0)
+
+        self.assertAlmostEqual(surrogate_results.times[-1], surrogate_noise_results.times[-1], delta=0.26)
+        for i in range(min(len(surrogate_results.times), len(surrogate_noise_results.times))):
+            self.assertListEqual(list(surrogate_noise_results.inputs[i].keys()), list(surrogate_noise_results.inputs[i].keys()))
+            self.assertAlmostEqual(surrogate_noise_results.states[i]['x'], surrogate_results.states[i]['x'], delta=6)
+            self.assertAlmostEqual(surrogate_noise_results.states[i]['v'], surrogate_results.states[i]['v'], delta=1)
+            self.assertAlmostEqual(surrogate_noise_results.states[i]['falling'], surrogate_results.states[i]['falling'], delta = 0.1)
+            self.assertAlmostEqual(surrogate_noise_results.states[i]['impact'], surrogate_results.states[i]['impact'], delta = 0.5)
+            self.assertEqual(surrogate_noise_results.states[i]['x'], surrogate_noise_results.outputs[i]['x'])
+            self.assertEqual(surrogate_noise_results.states[i]['falling'], surrogate_noise_results.event_states[i]['falling'])
+            self.assertEqual(surrogate_noise_results.states[i]['impact'], surrogate_noise_results.event_states[i]['impact'])
+            self.assertAlmostEqual(surrogate_noise_results.states[i]['falling'], surrogate_results.event_states[i]['falling'], delta=0.1)
+            self.assertAlmostEqual(surrogate_noise_results.states[i]['impact'], surrogate_results.event_states[i]['impact'], delta=0.5)
+            if i > 0:
+                self.assertNotEqual(surrogate_noise_results.states[i]['x'] - surrogate_results.states[i]['x'], 0)
+                self.assertNotEqual(surrogate_noise_results.states[i]['v'] - surrogate_results.states[i]['v'], 0)
+                self.assertNotEqual(surrogate_noise_results.states[i]['falling'] - surrogate_results.states[i]['falling'], 0)
+                self.assertNotEqual(surrogate_noise_results.states[i]['impact'] - surrogate_results.states[i]['impact'], 0)
+                self.assertNotEqual(surrogate_noise_results.states[i]['falling']-surrogate_results.event_states[i]['falling'], 0)
+                self.assertNotEqual(surrogate_noise_results.states[i]['impact'], surrogate_results.event_states[i]['impact'], 0)
+                
+    def test_surrogate_battery_with_noise(self):
+        m = BatteryElectroChemEOD(process_noise = 0)
+        def future_loading_1(t, x=None):
+            # Variable (piece-wise) future loading scheme 
+            if (t < 500):
+                i = 3
+            elif (t < 1000):
+                i = 2
+            elif (t < 1500):
+                i = 0.5
+            else:
+                i = 4.5
+            return m.InputContainer({'i': i})
+        
+        def future_loading_2(t, x=None):
+            # Variable (piece-wise) future loading scheme 
+            if (t < 300):
+                i = 2
+            elif (t < 800):
+                i = 3.5
+            elif (t < 1300):
+                i = 4
+            elif (t < 1600):
+                i = 1.5
+            else:
+                i = 5
+            return m.InputContainer({'i': i})
+        load_functions = [future_loading_1, future_loading_2]
+
+        options_surrogate = {
+            'save_freq': 1, # For DMD, this value is the time step for which the surrogate model is generated
+            'dt': 0.1, # For DMD, this value is the time step of the training data
+            'trim_data_to': 0.7, # Trim data to this fraction of the time series
+            'output_keys': ['v'], # Define outputs to be included in surrogate model 
+            'training_noise': 0
+        }
+        options_surrogate_noise = {
+            'save_freq': 1, # For DMD, this value is the time step for which the surrogate model is generated
+            'dt': 0.1, # For DMD, this value is the time step of the training data
+            'trim_data_to': 0.7, # Trim data to this fraction of the time series
+            'output_keys': ['v'], # Define outputs to be included in surrogate model 
+            'training_noise': 0.02
+        }
+
+        surrogate = m.generate_surrogate(load_functions, **options_surrogate)
+        surrogate_noise = m.generate_surrogate(load_functions, **options_surrogate_noise)
+        
+        self.assertListEqual(surrogate.states, surrogate_noise.states)
+        self.assertListEqual(surrogate.inputs, surrogate_noise.inputs)
+        self.assertListEqual(surrogate.outputs, surrogate_noise.outputs)
+        self.assertListEqual(surrogate.events, surrogate_noise.events)
+
+        options_sim = {
+            'save_freq': 1, # Frequency at which results are saved, or equivalently time step in results
+            'dt': 0.1,
+        }
+
+        # Define loading profile 
+        def future_loading(t, x=None):
+            if (t < 600):
+                i = 3
+            elif (t < 1000):
+                i = 2
+            elif (t < 1500):
+                i = 1.5
+            else:
+                i = 4
+            return m.InputContainer({'i': i})
+
+        surrogate_results = surrogate.simulate_to_threshold(future_loading, **options_sim)
+        surrogate_noise_results = surrogate_noise.simulate_to_threshold(future_loading, **options_sim)
+
+        voltage = [surrogate_results.states[iter]['v'] for iter in range(len(surrogate_results.times))]
+        voltage_noise = [surrogate_noise_results.states[iter]['v'] for iter in range(len(surrogate_noise_results.times))]
+
+        MSE = sum([(voltage[iter] - voltage_noise[iter])**2 for iter in range(min(len(voltage),len(voltage_noise)))])/min(len(voltage),len(voltage_noise))
+        self.assertLess(MSE, 0.02) # Pretty good approx            
+        self.assertNotEqual(MSE, 0)
+
+        self.assertAlmostEqual(surrogate_results.times[-1], surrogate_noise_results.times[-1], delta=20)
+        for i in range(min(len(surrogate_results.times), len(surrogate_noise_results.times))):
+            self.assertListEqual(list(surrogate_noise_results.inputs[i].keys()), list(surrogate_noise_results.inputs[i].keys()))
+            self.assertAlmostEqual(surrogate_noise_results.states[i]['tb'], surrogate_results.states[i]['tb'], delta=10)
+            self.assertAlmostEqual(surrogate_noise_results.states[i]['Vo'], surrogate_results.states[i]['Vo'], delta=0.1)
+            self.assertAlmostEqual(surrogate_noise_results.states[i]['Vsn'], surrogate_results.states[i]['Vsn'], delta = 0.5)
+            self.assertAlmostEqual(surrogate_noise_results.states[i]['Vsp'], surrogate_results.states[i]['Vsp'], delta = 0.1)
+            self.assertAlmostEqual(surrogate_noise_results.states[i]['qnB'], surrogate_results.states[i]['qnB'], delta=15)
+            self.assertAlmostEqual(surrogate_noise_results.states[i]['qnS'], surrogate_results.states[i]['qnS'], delta=3)
+            self.assertAlmostEqual(surrogate_noise_results.states[i]['qpB'], surrogate_results.states[i]['qpB'], delta=15)
+            self.assertAlmostEqual(surrogate_noise_results.states[i]['qpS'], surrogate_results.states[i]['qpS'], delta=3)
+            self.assertAlmostEqual(surrogate_noise_results.states[i]['v'], surrogate_results.states[i]['v'], delta = 0.3)
+            self.assertAlmostEqual(surrogate_noise_results.states[i]['EOD'], surrogate_results.states[i]['EOD'],delta=0.1)
+            self.assertEqual(surrogate_noise_results.states[i]['v'], surrogate_noise_results.outputs[i]['v'])
+            self.assertEqual(surrogate_noise_results.states[i]['EOD'], surrogate_noise_results.event_states[i]['EOD'])
+            self.assertAlmostEqual(surrogate_noise_results.states[i]['v'], surrogate_results.outputs[i]['v'], delta=0.3)
+            self.assertAlmostEqual(surrogate_noise_results.states[i]['EOD'], surrogate_results.event_states[i]['EOD'], delta=0.1)
+            if i > 0:
+                self.assertNotEqual(surrogate_noise_results.states[i]['tb'] - surrogate_results.states[i]['tb'], 0)
+                self.assertNotEqual(surrogate_noise_results.states[i]['Vo'] - surrogate_results.states[i]['Vo'], 0)
+                self.assertNotEqual(surrogate_noise_results.states[i]['Vsn'] - surrogate_results.states[i]['Vsn'], 0)
+                self.assertNotEqual(surrogate_noise_results.states[i]['Vsp'] - surrogate_results.states[i]['Vsp'], 0)
+                self.assertNotEqual(surrogate_noise_results.states[i]['qnB'] - surrogate_results.states[i]['qnB'], 0)
+                self.assertNotEqual(surrogate_noise_results.states[i]['qnS'] - surrogate_results.states[i]['qnS'], 0)
+                self.assertNotEqual(surrogate_noise_results.states[i]['qpB'] - surrogate_results.states[i]['qpB'], 0)
+                self.assertNotEqual(surrogate_noise_results.states[i]['qpS'] - surrogate_results.states[i]['qpS'], 0)
+                self.assertNotEqual(surrogate_noise_results.states[i]['v'] - surrogate_results.states[i]['v'], 0)
+                self.assertNotEqual(surrogate_noise_results.states[i]['EOD'] - surrogate_results.states[i]['EOD'], 0)
+                self.assertNotEqual(surrogate_noise_results.states[i]['v'] - surrogate_results.outputs[i]['v'], 0)
+                self.assertNotEqual(surrogate_noise_results.states[i]['EOD'] - surrogate_results.event_states[i]['EOD'], 0)
+    
     def test_surrogate_options(self):
         m = ThrownObject()
         def load_eqn(t = None, x = None):
@@ -267,14 +445,14 @@ def load_eqn(t = None, x = None):
         warnings.filterwarnings("error")
 
         try:
-            surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25, threshold_keys = 'impact', states = ['v'])
+            surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25, threshold_keys = 'impact', state_keys = ['v'], training_noise = 0)
             self.fail('warning not raised')
         except Warning:
             pass
 
         try:
             # Set sufficiently large failure tolerance
-            surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25, threshold_keys = 'impact', states = ['v'], stability_tol=1e99)
+            surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25, threshold_keys = 'impact', state_keys = ['v'], stability_tol=1e99)
         except Warning:
             self.fail('Warning raised')
         
@@ -286,7 +464,7 @@ def test_surrogate_use_error_cases(self):
         def load_eqn(t = None, x = None):
             return m.InputContainer({})
         
-        surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25, threshold_keys = 'impact')
+        surrogate = m.generate_surrogate([load_eqn], dt = 0.1, save_freq = 0.25, threshold_keys = 'impact', training_noise = 0)
 
         with self.assertWarns(Warning):
             surrogate.simulate_to_threshold(load_eqn, dt = 0.05)
diff --git a/tests/test_tutorials.py b/tests/test_tutorials.py
index 71bae61db..25fcd7556 100644
--- a/tests/test_tutorials.py
+++ b/tests/test_tutorials.py
@@ -1,12 +1,18 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
 
+import importlib.util
 import unittest
-from testbook import testbook
+import warnings
+
 
 class TestTutorials(unittest.TestCase):
     def test_tutorial_ipynb(self):
-        with testbook('./tutorial.ipynb', execute=True) as tb:
-            self.assertEqual(tb.__class__.__name__, "TestbookNotebookClient")
+        if importlib.util.find_spec('testbook') is None:
+            warnings.warn('testbook not installed')
+        else:
+            from testbook import testbook
+            with testbook('./tutorial.ipynb', execute=True) as tb:
+                self.assertEqual(tb.__class__.__name__, "TestbookNotebookClient")
 
 def main():
     l = unittest.TestLoader()
@@ -15,7 +21,7 @@ def main():
     result = runner.run(l.loadTestsFromTestCase(TestTutorials)).wasSuccessful()
 
     if not result:
-        raise Exception("Failed test")
+        raise Exception("Failed test") 
 
 if __name__ == '__main__':
     main()