Repository: nasa/prog_algs (language: python)

Issue:
dt changes
DT changes from the release check list.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index 79136947..e6d2b476 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -14,7 +14,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: ['3.7', '3.8', '3.9', '3.10']
+        python-version: ['3.7']
     steps:
     - uses: actions/checkout@v2
     - name: Set up Python ${{ matrix.python-version }}
@@ -36,7 +36,7 @@ jobs:
   #   runs-on: ubuntu-latest
   #   strategy:
   #     matrix:
-  #       python-version: ['3.9']
+  #       python-version: ['3.7']
   #   steps:
   #   - uses: actions/checkout@v2
   #   - name: Set up Python ${{ matrix.python-version }}
@@ -55,7 +55,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: ['3.9']
+        python-version: ['3.10']
     steps:
     - uses: actions/checkout@v2
     - name: Set up Python ${{ matrix.python-version }}
@@ -69,7 +69,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.9]
+        python-version: ['3.10']
     steps:
     - uses: actions/checkout@v2
     - name: Set up Python ${{ matrix.python-version }}
diff --git a/.gitignore b/.gitignore
index 3c50e3f2..688f5636 100644
--- a/.gitignore
+++ b/.gitignore
@@ -102,3 +102,5 @@ cython_debug/
 .DS_Store
 data_test.pkl
 predictor_test.pkl
+
+.VSCodeCounter/
diff --git a/README.md b/README.md
index 099ad908..196c0530 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,6 @@ See documentation [here](https://nasa.github.io/progpy/prog_algs_guide.html)
 ## Repository Directory Structure 
 
 `src/prog_algs/` - The prognostics algorithm python package<br />
-`docs/` - Project documentation (see also [github.io](https://nasa.github.io/prog_algs/))<br />
 `examples/` - Example Python scripts using prog_algs<br />
 `tests/` - Tests for prog_models<br />
 `README.md` - The readme (this file)<br />
@@ -26,23 +25,23 @@ See documentation [here](https://nasa.github.io/progpy/prog_algs_guide.html)
 Use the following to cite this repository:
 
 ```
-@misc{2022_nasa_prog_algs,
+@misc{2023_nasa_prog_algs,
     author    = {Christopher Teubert and Matteo Corbetta and Chetan Kulkarni},
     title     = {Prognostics Algorithm Python Package},
-    month     = Dec,
-    year      = 2022,
-    version   = {1.4.3},
+    month     = May,
+    year      = 2023,
+    version   = {1.5},
     url       = {https://github.com/nasa/prog\_algs}
     }
 ```
 
 The corresponding reference should look like this:
 
-C. Teubert, M. Corbetta, C. Kulkarni, Prognostics Algorithm Python Package, v1.4.3, Dec 2022. URL https://github.com/nasa/prog_algs.
+C. Teubert, M. Corbetta, C. Kulkarni, Prognostics Algorithm Python Package, v1.5, May 2023. URL https://github.com/nasa/prog_algs.
 
 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.4, December 2022. URL https://nasa.github.io/progpy.
+C. Teubert, C. Kulkarni, M. Corbetta, K. Jarvis, M. Daigle, ProgPy Prognostics Python Packages, v1.5, May 2023. URL https://nasa.github.io/progpy.
 
 ## Acknowledgements
 The structure and algorithms of this package are strongly inspired by the [MATLAB Prognostics Algorithm Library](https://github.com/nasa/PrognosticsAlgorithmLibrary) and the [MATLAB Prognostics Metrics Library](https://github.com/nasa/PrognosticsMetricsLibrary). We would like to recognize Matthew Daigle, Shankar Sankararaman 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_algs
diff --git a/Python Prognostic Algorithm Library NOSA_2021.pdf b/license.pdf
similarity index 100%
rename from Python Prognostic Algorithm Library NOSA_2021.pdf
rename to license.pdf
diff --git a/predictor_template.py b/predictor_template.py
index 595b3b6b..df000dfc 100644
--- a/predictor_template.py
+++ b/predictor_template.py
@@ -13,7 +13,7 @@ class TemplatePredictor(Predictor):
     """
 
     # REPLACE THE FOLLOWING LIST WITH CONFIGURED PARAMETERS
-    default_parameters = { # Default Parameters, used as config for UKF
+    default_parameters = {  # Default Parameters, used as config for UKF
         'Example Parameter': 0.0
     } 
 
@@ -76,10 +76,10 @@ def predict(self, state, future_loading_eqn, **kwargs):
         # time_of_event.final_state = {'event1': ScalarData({'state1': 10, 'state2': 20}), 'event2': ScalarData({'state1': 12, 'state2': 18})}
 
         return PredictionResults(
-            times, 
-            inputs, 
-            states, 
-            outputs, 
-            event_states, 
+            times,
+            inputs,
+            states,
+            outputs,
+            event_states,
             time_of_event
         )
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 1df65bb8..00000000
--- a/requirements.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-filterpy>=1.4.5
-matplotlib>=3.5.2
-scipy>=1.7.3
-prog_models>=1.4.0
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index e8d010aa..00000000
--- a/setup.cfg
+++ /dev/null
@@ -1,3 +0,0 @@
-[metadata]
-license_files = license.pdf
-description-file = README.md
\ No newline at end of file
diff --git a/setup.py b/setup.py
index e96b09f0..e0af8ac4 100644
--- a/setup.py
+++ b/setup.py
@@ -10,7 +10,7 @@
 
 setup(
     name = 'prog_algs',
-    version = '1.4.3',
+    version = '1.5.0.pre',
     description = "The NASA Prognostics Algorithm Package is a framework for model-based prognostics (computation of remaining useful life) of engineering systems. It includes algorithms for state estimation and prediction, including uncertainty propagation. The algorithms use prognostic models (see prog_models) to perform estimation and prediction. The package enables rapid development of prognostics solutions for given models of components and systems. Algorithms can be swapped for comparative studies and evaluations",
     long_description=long_description,
     long_description_content_type='text/markdown',
@@ -36,13 +36,13 @@
     keywords = ['prognostics', 'diagnostics', 'fault detection', 'fdir', 'prognostics and health management', 'PHM', 'health management', 'ivhm'],
     package_dir = {"":"src"},
     packages = find_packages(where = 'src'),
-    python_requires='>=3.7, <3.11',
+    python_requires='>=3.7, <3.12',
     install_requires = [
         'numpy',
         'scipy',
         'filterpy',
         'matplotlib',
-        'prog_models>=1.4.0'
+        'prog_models>=1.5.0.pre'
     ],
     license = 'NOSA',
     project_urls={  # Optional
diff --git a/src/prog_algs/__init__.py b/src/prog_algs/__init__.py
index 680a542a..293ae246 100644
--- a/src/prog_algs/__init__.py
+++ b/src/prog_algs/__init__.py
@@ -5,7 +5,7 @@
 
 import warnings
 
-__version__ = '1.4.3'
+__version__ = '1.5.0.pre'
 
 def run_prog_playback(obs, pred, future_loading, output_measurements, **kwargs):
     warnings.warn("Depreciated in 1.2.0, will be removed in a future release.", DeprecationWarning)
diff --git a/src/prog_algs/exceptions.py b/src/prog_algs/exceptions.py
deleted file mode 100644
index 0563e340..00000000
--- a/src/prog_algs/exceptions.py
+++ /dev/null
@@ -1,19 +0,0 @@
-# Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration. All Rights Reserved.
-
-
-class ProgAlgException(Exception):
-    """
-    Base Prognostics Model Exception
-    """
-
-
-class ProgAlgInputException(ProgAlgException):
-    """
-    Prognostics Input Exception - indicates the method input parameters were incorrect
-    """
-
-
-class ProgAlgTypeError(ProgAlgException, TypeError):
-    """
-    Prognostics Type Error - indicates the model could not be constructed
-    """
diff --git a/src/prog_algs/metrics/samples.py b/src/prog_algs/metrics/samples.py
index d72d3e1d..bd276cd8 100644
--- a/src/prog_algs/metrics/samples.py
+++ b/src/prog_algs/metrics/samples.py
@@ -8,7 +8,7 @@
 from .toe_metrics import prob_success
 from .toe_profile_metrics import alpha_lambda
 
-def mean_square_error(values : list, ground_truth : float) -> float:
+def mean_square_error(values: list, ground_truth: float) -> float:
     """Mean Square Error
     Args:
         values (List[float]): Times of Event (ToE) for a single event, output from predictor
@@ -28,7 +28,7 @@ def root_mean_square_error(values, ground_truth):
     """
     return sqrt(sum([(mean(x) - ground_truth)**2 for x in values])/len(values))
 
-def percentage_in_bounds(toe : list, bounds : tuple) -> float:
+def percentage_in_bounds(toe: list, bounds: tuple) -> float:
     """Calculate percentage of ToE dist is within specified bounds
 
     Args:
diff --git a/src/prog_algs/metrics/toe_metrics.py b/src/prog_algs/metrics/toe_metrics.py
index ec0e9603..836b70f7 100644
--- a/src/prog_algs/metrics/toe_metrics.py
+++ b/src/prog_algs/metrics/toe_metrics.py
@@ -8,7 +8,7 @@
 
 from ..uncertain_data import UncertainData, UnweightedSamples
 
-def prob_success(toe : UncertainData, time : float, **kwargs) -> float:
+def prob_success(toe: UncertainData, time: float, **kwargs) -> float:
     """Calculate probability of success - i.e., probability that event will not occur within a given time (i.e., success)
 
     Args:
diff --git a/src/prog_algs/metrics/toe_profile_metrics.py b/src/prog_algs/metrics/toe_profile_metrics.py
index 3f61ee28..b39439e3 100644
--- a/src/prog_algs/metrics/toe_profile_metrics.py
+++ b/src/prog_algs/metrics/toe_profile_metrics.py
@@ -9,7 +9,7 @@
 
 from ..predictors import ToEPredictionProfile
 
-def alpha_lambda(toe_profile : ToEPredictionProfile, ground_truth : dict, lambda_value : float, alpha : float, beta : float, **kwargs) -> dict: 
+def alpha_lambda(toe_profile: ToEPredictionProfile, ground_truth: dict, lambda_value: float, alpha: float, beta: float, **kwargs) -> dict: 
     """
     Compute alpha lambda metric, a common metric in prognostics. Alpha-Lambda is met if alpha % of the Time to Event (TtE) distribution is within beta % of the ground truth at prediction time lambda.
 
@@ -45,7 +45,7 @@ def alpha_lambda(toe_profile : ToEPredictionProfile, ground_truth : dict, lambda
                     print('\tBounds: [{} - {}]({}%)'.format(bounds[key][0], bounds[key][1], pib[key]))
             return result
 
-def prognostic_horizon(toe_profile : ToEPredictionProfile, criteria_eqn : Callable, ground_truth : dict, **kwargs) -> dict:
+def prognostic_horizon(toe_profile: ToEPredictionProfile, criteria_eqn: Callable, ground_truth: dict, **kwargs) -> dict:
     """
     Compute prognostic horizon metric, defined as the difference between a time ti, when the predictions meet specified performance criteria, and the time corresponding to the true Time of Event (ToE), for each event.
     PH = ToE - ti
@@ -69,7 +69,7 @@ def prognostic_horizon(toe_profile : ToEPredictionProfile, criteria_eqn : Callab
     }
     params.update(kwargs)
 
-    ph_result = {k:None for k in ground_truth.keys()} # False means not yet met; will be either a numerical value or None if met
+    ph_result = {k:None for k in ground_truth.keys()}  # False means not yet met; will be either a numerical value or None if met
     for (t_prediction, toe) in toe_profile.items():
         # Convert to TtE for toe and ground_truth
         tte = toe - t_prediction
@@ -81,14 +81,14 @@ def prognostic_horizon(toe_profile : ToEPredictionProfile, criteria_eqn : Callab
             if v and (ph_result[k] == None):
                 ph_calc = ground_truth[k] - t_prediction
                 if ph_calc > 0:
-                    ph_result[k] = ph_calc # PH = EOL - ti # ground truth is a dictionary {'EOD': 3005.2} should be ph_result[k] = g_truth[key] - t_prediction
+                    ph_result[k] = ph_calc  # PH = EOL - ti # ground truth is a dictionary {'EOD': 3005.2} should be ph_result[k] = g_truth[key] - t_prediction
                 if (all(v != None for v in ph_result.values())):
                     # Return PH once all criteria are met
                     return ph_result
     # Return PH when criteria not met for at least one event key
     return ph_result
 
-def cumulative_relative_accuracy(toe_profile : ToEPredictionProfile, ground_truth : dict, **kwargs) -> Dict[str, float]:
+def cumulative_relative_accuracy(toe_profile: ToEPredictionProfile, ground_truth: dict, **kwargs) -> Dict[str, float]:
     """
     Compute cumulative relative accuracy for a given profile, defined as the normalized sum of relative prediction accuracies at specific time instances.
     
@@ -108,7 +108,7 @@ def cumulative_relative_accuracy(toe_profile : ToEPredictionProfile, ground_trut
             ra_sums[event] += value
     return {event:ra_sum/len(toe_profile) for event, ra_sum in ra_sums.items()}
 
-def monotonicity(toe_profile : ToEPredictionProfile, **kwargs) -> Dict[str, float]:
+def monotonicity(toe_profile: ToEPredictionProfile, **kwargs) -> Dict[str, float]:
         """Calculate monotonicty for a prediction profile. 
         Given a prediction profile, for each prediction: go through all predicted events and compare those to the next one.
         Calculates monotonicity for each prediction key using its associated mean value in UncertainData.
diff --git a/src/prog_algs/metrics/uncertain_data_metrics.py b/src/prog_algs/metrics/uncertain_data_metrics.py
index 948b854c..c85938ab 100644
--- a/src/prog_algs/metrics/uncertain_data_metrics.py
+++ b/src/prog_algs/metrics/uncertain_data_metrics.py
@@ -10,7 +10,7 @@
 
 from ..uncertain_data import UncertainData, UnweightedSamples
 
-def calc_metrics(data : UncertainData, ground_truth : Union[float, dict] = None, **kwargs) -> dict:
+def calc_metrics(data: UncertainData, ground_truth: Union[float, dict] = None, **kwargs) -> dict:
     """Calculate all time of event metrics
 
     Args:
diff --git a/src/prog_algs/predictors/monte_carlo.py b/src/prog_algs/predictors/monte_carlo.py
index 8111089f..8a1bd125 100644
--- a/src/prog_algs/predictors/monte_carlo.py
+++ b/src/prog_algs/predictors/monte_carlo.py
@@ -39,7 +39,7 @@ class MonteCarlo(Predictor):
         'n_samples': 100  # Default number of samples to use, if none specified
     }
 
-    def predict(self, state : UncertainData, future_loading_eqn : Callable, **kwargs) -> PredictionResults:
+    def predict(self, state: UncertainData, future_loading_eqn: Callable, **kwargs) -> PredictionResults:
         if isinstance(state, dict) or isinstance(state, self.model.StateContainer):
             from prog_algs.uncertain_data import ScalarData
             state = ScalarData(state, _type = self.model.StateContainer)
diff --git a/src/prog_algs/predictors/prediction.py b/src/prog_algs/predictors/prediction.py
index 2c124b7b..6fb55697 100644
--- a/src/prog_algs/predictors/prediction.py
+++ b/src/prog_algs/predictors/prediction.py
@@ -25,7 +25,7 @@ def __init__(self, times : list, data : list):
         self.times = times
         self.data = data
 
-    def __eq__(self, other : "Prediction") -> bool:
+    def __eq__(self, other: "Prediction") -> bool:
         """Compare 2 Predictions
 
         Args:
@@ -36,7 +36,7 @@ def __eq__(self, other : "Prediction") -> bool:
         """
         return self.times == other.times and self.data == other.data
 
-    def snapshot(self, time_index : int) -> UncertainData:
+    def snapshot(self, time_index: int) -> UncertainData:
         """Get all samples from a specific timestep
 
         Args:
@@ -63,7 +63,7 @@ def mean(self) -> List[dict]:
         """
         return [dist.mean for dist in self.data]
 
-    def time(self, index : int):
+    def time(self, index: int):
         warn("Deprecated. Please use prediction.times[index] instead.")
         return self.times[index]
 
@@ -110,7 +110,7 @@ class UnweightedSamplesPrediction(Prediction, UserList):
             Data points where data[n] is a SimResult for sample n
     """
 
-    def __init__(self, times : list, data : list):
+    def __init__(self, times: list, data: list):
         super(UnweightedSamplesPrediction, self).__init__(times, data)
         self.__transformed = False  # If transform has been calculated
 
@@ -133,11 +133,11 @@ def mean(self) -> list:
             self.__calculate_tranform()
         return [dist.mean for dist in self.__transform]
 
-    def sample(self, sample_id : int):
+    def sample(self, sample_id: int):
         warn("Deprecated. Please use prediction[sample_id] instead.")
         return self[sample_id]
 
-    def snapshot(self, time_index : int) -> UnweightedSamples:
+    def snapshot(self, time_index: int) -> UnweightedSamples:
         """Get all samples from a specific timestep
 
         Args:
diff --git a/src/prog_algs/predictors/predictor.py b/src/prog_algs/predictors/predictor.py
index 4696dc81..2fc06c5a 100644
--- a/src/prog_algs/predictors/predictor.py
+++ b/src/prog_algs/predictors/predictor.py
@@ -5,7 +5,6 @@
 from typing import Callable
 
 from prog_algs.predictors.prediction import PredictionResults
-from ..exceptions import ProgAlgTypeError
 from ..uncertain_data import UncertainData
 
 
@@ -26,17 +25,17 @@ class Predictor(ABC):
 
     def __init__(self, model, **kwargs):
         if not hasattr(model, 'output'):
-            raise ProgAlgTypeError("model must have `output` method")
+            raise NotImplementedError("model must have `output` method")
         if not hasattr(model, 'next_state'):
-            raise ProgAlgTypeError("model must have `next_state` method")
+            raise NotImplementedError("model must have `next_state` method")
         if not hasattr(model, 'inputs'):
-            raise ProgAlgTypeError("model must have `inputs` property")
+            raise NotImplementedError("model must have `inputs` property")
         if not hasattr(model, 'outputs'):
-            raise ProgAlgTypeError("model must have `outputs` property")
+            raise NotImplementedError("model must have `outputs` property")
         if not hasattr(model, 'states'):
-            raise ProgAlgTypeError("model must have `states` property")
+            raise NotImplementedError("model must have `states` property")
         if not hasattr(model, 'simulate_to_threshold'):
-            raise ProgAlgTypeError("model must have `simulate_to_threshold` property")
+            raise NotImplementedError("model must have `simulate_to_threshold` property")
         self.model = model
 
         self.parameters = deepcopy(self.default_parameters)
@@ -44,7 +43,7 @@ def __init__(self, model, **kwargs):
         self.parameters.update(kwargs)
 
     @abstractmethod
-    def predict(self, state : UncertainData, future_loading_eqn : Callable, **kwargs) -> PredictionResults:
+    def predict(self, state: UncertainData, future_loading_eqn: Callable, **kwargs) -> PredictionResults:
         """
         Perform a single prediction
 
@@ -55,27 +54,14 @@ def predict(self, state : UncertainData, future_loading_eqn : Callable, **kwargs
         future_loading_eqn : function (t, x) -> z
             Function to generate an estimate of loading at future time t, and state x
 
-        Keyword Arguments
-        -------------------
-        dt : float
-            Simulation step size (s), e.g., 0.1
-        events : list[str]
-            Events to predict (subset of model.events) e.g., ['event1', 'event2']
-        horizon : float
-            Prediction horizon (s)
-        save_freq : float
-            Frequency at which results are saved (s)
-        save_pts : list[float]
-            Any additional savepoints (s) e.g., [10.1, 22.5]
-
         Return
         ----------
-        NamedTuple
-            * times (list[float]): Times for each savepoint such that inputs.snapshot(i), states.snapshot(i), outputs.snapshot(i), and event_states.snapshot(i) are all at times[i]            
-            * inputs (:py:class:`Prediction`): Inputs at each savepoint such that inputs.snapshot(i) is the input distribution (type UncertainData) at times[i]
-            * states (:py:class:`Prediction`): States at each savepoint such that states.snapshot(i) is the state distribution (type UncertainData) at times[i]
-            * outputs (:py:class:`Prediction`): Outputs at each savepoint such that outputs.snapshot(i) is the output distribution (type UncertainData) at times[i]
-            * event_states (:py:class:`Prediction`): Event states at each savepoint such that event_states.snapshot(i) is the event state distribution (type UncertainData) at times[i]
-            * time_of_event (:py:class:`prog_algs.uncertain_data.UncertainData`): Distribution of predicted Time of Event (ToE) for each predicted event, represented by some subclass of UncertaintData (e.g., :py:class:`MultivariateNormalDist`)
+        result from prediction, including: NameTuple
+            * times (List[float]): Times for each savepoint such that inputs.snapshot(i), states.snapshot(i), outputs.snapshot(i), and event_states.snapshot(i) are all at times[i]            
+            * inputs (Prediction): Inputs at each savepoint such that inputs.snapshot(i) is the input distribution (type UncertainData) at times[i]
+            * states (Prediction): States at each savepoint such that states.snapshot(i) is the state distribution (type UncertainData) at times[i]
+            * outputs (Prediction): Outputs at each savepoint such that outputs.snapshot(i) is the output distribution (type UncertainData) at times[i]
+            * event_states (Prediction): Event states at each savepoint such that event_states.snapshot(i) is the event state distribution (type UncertainData) at times[i]
+            * time_of_event (UncertainData): Distribution of predicted Time of Event (ToE) for each predicted event, represented by some subclass of UncertaintData (e.g., MultivariateNormalDist)
         """
         pass
diff --git a/src/prog_algs/predictors/toe_prediction_profile.py b/src/prog_algs/predictors/toe_prediction_profile.py
index cfbbb2ea..43106978 100644
--- a/src/prog_algs/predictors/toe_prediction_profile.py
+++ b/src/prog_algs/predictors/toe_prediction_profile.py
@@ -41,7 +41,7 @@ def values(self):
         """
         return [self[k] for k in self.keys()]
 
-    def alpha_lambda(self, ground_truth : Dict[str, float], lambda_value : float, alpha : float, beta : float, **kwargs) -> Dict[str, bool]:
+    def alpha_lambda(self, ground_truth: Dict[str, float], lambda_value: float, alpha: float, beta: float, **kwargs) -> Dict[str, bool]:
         """Calculate Alpha lambda metric for the prediction profile
 
         Args:
@@ -134,7 +134,7 @@ def monotonicity(self, **kwargs) -> Dict[str, float]:
         from ..metrics import monotonicity
         return monotonicity(self, **kwargs)
     
-    def plot(self, ground_truth : dict = None , alpha : float = None, show : bool = True) -> dict: # use ground truth, alpha if given,
+    def plot(self, ground_truth: dict = None , alpha: float = None, show: bool = True) -> dict: # use ground truth, alpha if given,
         """Produce an alpha-beta plot depicting the TtE distribution by time of prediction for each event.
 
         Args:
diff --git a/src/prog_algs/predictors/unscented_transform.py b/src/prog_algs/predictors/unscented_transform.py
index 91116baf..b1b52be6 100644
--- a/src/prog_algs/predictors/unscented_transform.py
+++ b/src/prog_algs/predictors/unscented_transform.py
@@ -123,7 +123,7 @@ def state_transition(x, dt):
         self.filter = kalman.UnscentedKalmanFilter(num_states, num_measurements, self.parameters['dt'], measure, state_transition, self.sigma_points)
         self.filter.Q = self.parameters['Q']
 
-    def predict(self, state, future_loading_eqn : Callable, **kwargs) -> PredictionResults:
+    def predict(self, state, future_loading_eqn: Callable, **kwargs) -> PredictionResults:
         """
         Perform a single prediction
 
diff --git a/src/prog_algs/state_estimators/kalman_filter.py b/src/prog_algs/state_estimators/kalman_filter.py
index 3284888c..417ab45c 100644
--- a/src/prog_algs/state_estimators/kalman_filter.py
+++ b/src/prog_algs/state_estimators/kalman_filter.py
@@ -32,7 +32,8 @@ class KalmanFilter(state_estimator.StateEstimator):
         t0 (float, optional):
             Starting time (s)
         dt (float, optional):
-            time step (s)
+            Maximum timestep for prediction in seconds. By default, the timestep dt is the difference between the last and current call of .estimate(). Some models are unstable at larger dt. Setting a smaller dt will force the model to take smaller steps; resulting in multiple prediction steps for each estimate step. Default is the parameters['dt']
+            e.g., dt = 1e-2
         Q (list[list[float]], optional):
             Kalman Process Noise Matrix 
         R (list[list[float]], optional):
@@ -96,7 +97,7 @@ def __init__(self, model, x0, **kwargs):
         self.filter.F = F
         self.filter.B = B
 
-    def estimate(self, t : float, u, z):
+    def estimate(self, t: float, u, z, **kwargs):
         """
         Perform one state estimation step (i.e., update the state estimate)
 
@@ -111,10 +112,16 @@ def estimate(self, t : float, u, z):
         z : dict
             Measured outputs, with keys defined by model.outputs.
             e.g., z = {'t':12.4, 'v':3.3} given inputs = ['t', 'v']
+
+        Keyword Arguments
+        -----------------
+        dt : float, optional
+            Maximum timestep for prediction in seconds. By default, the timestep dt is the difference between the last and current call of .estimate(). Some models are unstable at larger dt. Setting a smaller dt will force the model to take smaller steps; resulting in multiple prediction steps for each estimate step. Default is the parameters['dt']
+            e.g., dt = 1e-2
         """
         assert t > self.t, "New time must be greater than previous"
-
-        dt = t - self.t
+        dt = kwargs.get('dt', self.parameters['dt'])
+        dt = min(t - self.t, dt)  # Ensure dt is not larger than the maximum time step
         # Create u array, ensuring order of model.inputs. And reshaping to (n,1), n can be 0.
         inputs = np.array([u[key] for key in self.model.inputs]).reshape((-1,1))
 
@@ -124,8 +131,6 @@ def estimate(self, t : float, u, z):
         else:
             inputs = np.append(inputs, [[1]], 0)
 
-        self.t = t
-
         # Update equations
         # prog_models is dx = Ax + Bu + E
         # kalman_models is x' = Fx + Bu, where x' is the next state
@@ -135,7 +140,9 @@ def estimate(self, t : float, u, z):
         F = np.multiply(self.filter.F, dt) + np.diag([1]* self.model.n_states)
 
         # Predict
-        self.filter.predict(u = inputs, B = B, F = F)
+        while self.t < t :
+            self.filter.predict(u = inputs, B = B, F = F)
+            self.t += dt
 
         # Create z array, ensuring order of model.outputs
         outputs = np.array([z[key] for key in self.model.outputs])
diff --git a/src/prog_algs/state_estimators/particle_filter.py b/src/prog_algs/state_estimators/particle_filter.py
index 93dd31d9..d85ad773 100644
--- a/src/prog_algs/state_estimators/particle_filter.py
+++ b/src/prog_algs/state_estimators/particle_filter.py
@@ -1,7 +1,8 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
 
 from filterpy.monte_carlo import residual_resample
-from numpy import array, empty, take, exp, max, take
+import numpy as np
+from numpy import array, empty, take, exp, max, take, float64
 from scipy.stats import norm
 from warnings import warn
 
@@ -9,7 +10,6 @@
 
 from . import state_estimator
 from ..uncertain_data import UnweightedSamples, ScalarData, UncertainData
-from ..exceptions import ProgAlgTypeError
 
 
 class ParticleFilter(state_estimator.StateEstimator):
@@ -32,7 +32,8 @@ class ParticleFilter(state_estimator.StateEstimator):
         t0 (float, optional):
             Starting time (s)
         dt (float, optional): 
-            time step (s)
+            Maximum timestep for prediction in seconds. By default, the timestep dt is the difference between the last and current call of .estimate(). Some models are unstable at larger dt. Setting a smaller dt will force the model to take smaller steps; resulting in multiple prediction steps for each estimate step. Default is the parameters['dt']
+            e.g., dt = 1e-2
         num_particles (int, optional):
             Number of particles in particle filter
         resample_fcn (function, optional):
@@ -40,7 +41,7 @@ class ParticleFilter(state_estimator.StateEstimator):
     """
     default_parameters = {
             't0': -1e-99,  # practically 0, but allowing for a 0 first estimate
-            'num_particles': 20, 
+            'num_particles': None, 
             'resample_fcn': residual_resample,
         }
 
@@ -53,27 +54,62 @@ def __init__(self, model, x0, **kwargs):
         if isinstance(x0, DictLikeMatrixWrapper) or isinstance(x0, dict):
             x0 = ScalarData(x0)
         elif not isinstance(x0, UncertainData):
-            raise ProgAlgTypeError(f"ProgAlgTypeError: x0 must be of type UncertainData or StateContainer, was {type(x0)}.")
+            raise TypeError(f"x0 must be of type UncertainData or StateContainer, was {type(x0)}.")
 
-        sample_gen = x0.sample(self.parameters['num_particles'])
-        samples = [array(sample_gen.key(k)) for k in x0.keys()]
+        if self.parameters['num_particles'] is None and isinstance(x0, UnweightedSamples):
+            sample_gen = x0  # Directly use samples passed in
+            self.parameters['num_particles'] = len(x0)
+        else:
+            if self.parameters['num_particles'] is None:
+                # Default to 100 particles
+                self.parameters['num_particles'] = 100
+            else:
+                # Added to avoid float/int issues
+                self.parameters['num_particles'] = int(self.parameters['num_particles'])
+            sample_gen = x0.sample(self.parameters['num_particles'])
+        samples = [array(sample_gen.key(k), dtype=float64) for k in x0.keys()]
         
-        self.particles = model.StateContainer(array(samples))
+        self.particles = model.StateContainer(array(samples, dtype=float64))
 
         if 'R' in self.parameters:
             # For backwards compatibility
             warn("'R' is deprecated. Use 'measurement_noise' instead.", DeprecationWarning)
             self.parameters['measurement_noise'] = self.parameters['R']
         elif 'measurement_noise' not in self.parameters:
-            self.parameters['measurement_noise'] = {key: 0.0 for key in x0.keys()}
+            self.parameters['measurement_noise'] = {key: 0.0 for key in model.outputs}
     
     def __str__(self):
         return "{} State Estimator".format(self.__class__)
         
-    def estimate(self, t : float, u, z):
+    def estimate(self, t : float, u, z, dt = None):
+        """
+        Perform one state estimation step (i.e., update the state estimate, filt.x)
+
+        Args
+        ----------
+        t : float
+            Current timestamp in seconds (≥ 0.0)
+            e.g., t = 3.4
+        u : InputContainer
+            Measured inputs, with keys defined by model.inputs.
+            e.g., u = m.InputContainer({'i':3.2}) given inputs = ['i']
+        z : OutputContainer
+            Measured outputs, with keys defined by model.outputs.
+            e.g., z = m.OutputContainer({'t':12.4, 'v':3.3}) given outputs = ['t', 'v']
+            
+        Keyword Args
+        ------------
+        dt : float, optional
+            Maximum timestep for prediction in seconds. By default, the timestep dt is the difference between the last and current call of .estimate(). Some models are unstable at larger dt. Setting a smaller dt will force the model to take smaller steps; resulting in multiple prediction steps for each estimate step. Default is the parameters['dt']
+            e.g., dt = 1e-2
+
+        Note
+        ----
+        This method updates the state estimate stored in filt.x, but doesn't return the updated estimate. Call filt.x to get the updated estimate.
+        """
         assert t > self.t, "New time must be greater than previous"
-        dt = t - self.t
-        self.t = t
+        if dt is None:
+            dt = min(t - self.t, self.parameters['dt'])
 
         # Check Types
         if isinstance(u, dict):
@@ -85,9 +121,10 @@ def estimate(self, t : float, u, z):
         particles = self.particles
         next_state = self.model.next_state
         apply_process_noise = self.model.apply_process_noise
+        apply_limits = self.model.apply_limits
         output = self._measure
         # apply_measurement_noise = self.model.apply_measurement_noise
-        noise_params = self.model.parameters['measurement_noise']
+        noise_params = self.parameters['measurement_noise']
         num_particles = self.parameters['num_particles']
         # Check which output keys are present (i.e., output of measurement function)
         measurement_keys = output(self.model.StateContainer({key: particles[key][0] for key in particles.keys()})).keys()
@@ -95,21 +132,31 @@ def estimate(self, t : float, u, z):
 
         if self.model.is_vectorized:
             # Propagate particles state
-            self.particles = apply_process_noise(next_state(particles, u, dt), dt)
+            while self.t < t:
+                dt_i = min(dt, t-self.t)
+                particles = apply_process_noise(next_state(particles, u, dt_i), dt_i)
+                self.particles = apply_limits(particles)
+                self.t += dt_i
 
             # Get particle measurements
             zPredicted = output(self.particles)
         else:
-            # Propogate and calculate weights
+            # Propagate and calculate weights
             for i in range(num_particles):
+                t_i = self.t  # Used to mark time for each particle
                 x = self.model.StateContainer({key: particles[key][i] for key in particles.keys()})
-                x = next_state(x, u, dt) 
-                x = apply_process_noise(x, dt)
+                while t_i < t:
+                    dt_i = min(dt, t-t_i)
+                    x = next_state(x, u, dt_i) 
+                    x = apply_process_noise(x, dt_i)
+                    x = apply_limits(x)
+                    t_i += dt_i
                 for key in particles.keys():
                     self.particles[key][i] = x[key]
                 z = output(x)
                 for key in measurement_keys:
                     zPredicted[key][i] = z[key]
+            self.t = t
 
         # Calculate pdf values
         pdfs = array([norm(zPredicted[key], noise_params[key]).logpdf(z[key])
diff --git a/src/prog_algs/state_estimators/state_estimator.py b/src/prog_algs/state_estimators/state_estimator.py
index fe65bc54..75cac51f 100644
--- a/src/prog_algs/state_estimators/state_estimator.py
+++ b/src/prog_algs/state_estimators/state_estimator.py
@@ -4,14 +4,13 @@
 from copy import deepcopy
 
 from ..uncertain_data import UncertainData
-from ..exceptions import ProgAlgTypeError
 
 
 class StateEstimator(ABC):
     """
     Interface class for state estimators
 
-    Abstract base class for creating state estimators that perform state estimation. Subclasses must implement this interface. Equivilant to "Observers" in NASA's Matlab Prognostics Algorithm Library
+    Abstract base class for creating state estimators that perform state estimation. Subclasses must implement this interface. Equivalent to "Observers" in NASA's Matlab Prognostics Algorithm Library
 
     Args:
         model (PrognosticsModel):
@@ -20,43 +19,64 @@ class StateEstimator(ABC):
         x0 (UncertainData, model.StateContainer, or dict):
             Initial (starting) state, with keys defined by model.states \n
             e.g., x = ScalarData({'abc': 332.1, 'def': 221.003}) given states = ['abc', 'def']
-    
-    See state-estimator specific documentation for speicfic keyword arguments.
+
+    Keyword Args:
+        t0 (float):
+            Initial time at which prediction begins, e.g., 0
+        dt (float):
+            Maximum timestep for prediction in seconds. By default, the timestep dt is the difference between the last and current call of .estimate(). Some models are unstable at larger dt. Setting a smaller dt will force the model to take smaller steps; resulting in multiple prediction steps for each estimate step. Default is the parameters['dt']
+            e.g., dt = 1e-2
+        **kwargs: 
+            See state-estimator specific documentation for specific keyword arguments.
     """
 
     default_parameters = {
-        't0': 0
+        't0': -1e-10,
+        'dt': float('inf')
     }
 
     def __init__(self, model, x0, **kwargs):
         # Check model
         if not hasattr(model, 'output'):
-            raise ProgAlgTypeError("model must have `output` method")
+            raise NotImplementedError("model must have `output` method")
         if not hasattr(model, 'next_state'):
-            raise ProgAlgTypeError("model must have `next_state` method")
+            raise NotImplementedError("model must have `next_state` method")
         if not hasattr(model, 'outputs'):
-            raise ProgAlgTypeError("model must have `outputs` property")
+            raise NotImplementedError("model must have `outputs` property")
         if not hasattr(model, 'states'):
-            raise ProgAlgTypeError("model must have `states` property")
+            raise NotImplementedError("model must have `states` property")
         self.model = model
 
         # Check x0
         for key in model.states:
             if key not in x0:
-                raise ProgAlgTypeError("x0 missing state `{}`".format(key))
+                raise KeyError("x0 missing state `{}`".format(key))
         
         # Process kwargs (configuration)
-        self.parameters = deepcopy(self.default_parameters)
+        self.parameters = deepcopy(StateEstimator.default_parameters)
+        self.parameters.update(self.default_parameters)
         self.parameters.update(kwargs)
 
+        if isinstance(self.parameters['t0'], int):
+            self.parameters['t0'] = float(self.parameters['t0'])
+        if isinstance(self.parameters['dt'], int):
+            self.parameters['dt'] = float(self.parameters['dt'])
+
+        if not isinstance(self.parameters['t0'], float):
+            raise TypeError(f"t0 must be float, was {type(self.parameters['t0'])}")
+        if not isinstance(self.parameters['dt'], float):
+            raise TypeError(f"dt must be float, was {type(self.parameters['dt'])}")
+        if self.parameters['dt'] <= 0:
+            raise ValueError(f"dt must be positive, was {self.parameters['dt']}")
+
         self.t = self.parameters['t0']  # Initial Time
 
     @abstractmethod
-    def estimate(self, t : float, u, z, **kwargs) -> None:
+    def estimate(self, t: float, u, z, **kwargs) -> None:
         """
         Perform one state estimation step (i.e., update the state estimate, filt.x)
 
-        Parameters
+        Args
         ----------
         t : float
             Current timestamp in seconds (≥ 0.0)
@@ -68,6 +88,14 @@ def estimate(self, t : float, u, z, **kwargs) -> None:
             Measured outputs, with keys defined by model.outputs.
             e.g., z = m.OutputContainer({'t':12.4, 'v':3.3}) given outputs = ['t', 'v']
 
+        Keyword Args
+        -------------
+            dt : float, optional
+                Maximum timestep for prediction in seconds. By default, the timestep dt is the difference between the last and current call of .estimate(). Some models are unstable at larger dt. Setting a smaller dt will force the model to take smaller steps; resulting in multiple prediction steps for each estimate step. Default is the parameters['dt']
+                e.g., dt = 1e-2
+            **kwargs: 
+                See state-estimator specific documentation for specific keyword arguments.
+
         Note
         ----
         This method updates the state estimate stored in filt.x, but doesn't return the updated estimate. Call filt.x to get the updated estimate.
diff --git a/src/prog_algs/state_estimators/unscented_kalman_filter.py b/src/prog_algs/state_estimators/unscented_kalman_filter.py
index 6118782b..38c34eea 100644
--- a/src/prog_algs/state_estimators/unscented_kalman_filter.py
+++ b/src/prog_algs/state_estimators/unscented_kalman_filter.py
@@ -4,8 +4,8 @@
 from numpy import diag, array
 from warnings import warn
 
-from . import state_estimator
-from ..uncertain_data import MultivariateNormalDist, UncertainData
+from prog_algs.state_estimators import state_estimator
+from prog_algs.uncertain_data import MultivariateNormalDist, UncertainData
 
 class UnscentedKalmanFilter(state_estimator.StateEstimator):
     """
@@ -33,7 +33,8 @@ class UnscentedKalmanFilter(state_estimator.StateEstimator):
         t0 (float, optional):
             Starting time (s)
         dt (float, optional):
-            time step (s)
+            Maximum timestep for prediction in seconds. By default, the timestep dt is the difference between the last and current call of .estimate(). Some models are unstable at larger dt. Setting a smaller dt will force the model to take smaller steps; resulting in multiple prediction steps for each estimate step. Default is the parameters['dt']
+            e.g., dt = 1e-2
         Q (list[list[float]], optional):
             Process Noise Matrix 
         R (list[list[float]], optional):
@@ -43,8 +44,6 @@ class UnscentedKalmanFilter(state_estimator.StateEstimator):
         'alpha': 1, 
         'beta': 0, 
         'kappa': -1,
-        't0': -1e-10,
-        'dt': 1
     } 
 
     def __init__(self, model, x0, **kwargs):
@@ -63,7 +62,7 @@ def measure(x):
             return array(list(z.values())).ravel()
 
         if 'Q' not in self.parameters:
-            self.parameters['Q'] = diag([1.0e-3 for i in x0.keys()])
+            self.parameters['Q'] = diag([1.0e-3 for _ in x0.keys()])
 
         def state_transition(x, dt):
             x = model.StateContainer({key: value for (key, value) in zip(x0.keys(), x)})
@@ -95,7 +94,7 @@ def state_transition(x, dt):
         self.filter.Q = self.parameters['Q']
         self.filter.R = self.parameters['R']
 
-    def estimate(self, t : float, u, z):
+    def estimate(self, t: float, u, z, **kwargs):
         """
         Perform one state estimation step (i.e., update the state estimate)
 
@@ -110,12 +109,20 @@ def estimate(self, t : float, u, z):
         z : dict
             Measured outputs, with keys defined by model.outputs.
             e.g., z = {'t':12.4, 'v':3.3} given inputs = ['t', 'v']
+
+        Keyword Args
+        ------------
+        dt : float, optional
+            Maximum timestep for prediction in seconds. By default, the timestep dt is the difference between the last and current call of .estimate(). Some models are unstable at larger dt. Setting a smaller dt will force the model to take smaller steps; resulting in multiple prediction steps for each estimate step. Default is the parameters['dt']
+            e.g., dt = 1e-2
         """
         assert t > self.t, "New time must be greater than previous"
-        dt = t - self.t
+        dt = kwargs.get('dt', self.parameters['dt'])
+        dt = min(t - self.t, dt)
         self.__input = u
-        self.t = t
-        self.filter.predict(dt=dt)
+        while self.t < t:
+            self.filter.predict(dt=dt)
+            self.t += dt
         self.filter.update(array(list(z.values())))
     
     @property
@@ -127,4 +134,4 @@ def x(self) -> MultivariateNormalDist:
         -------
         state = observer.x
         """
-        return MultivariateNormalDist(self.x0.keys(), self.filter.x, self.filter.P, _type = self.model.StateContainer)
+        return MultivariateNormalDist(self.x0.keys(), self.filter.x, self.filter.P, _type=self.model.StateContainer)
diff --git a/src/prog_algs/uncertain_data/multivariate_normal_dist.py b/src/prog_algs/uncertain_data/multivariate_normal_dist.py
index 2f8b40c7..793ea111 100644
--- a/src/prog_algs/uncertain_data/multivariate_normal_dist.py
+++ b/src/prog_algs/uncertain_data/multivariate_normal_dist.py
@@ -24,40 +24,40 @@ def __init__(self, labels, mean: array, covar : array, _type = dict):
     def __reduce__(self):
         return (MultivariateNormalDist, (self.__labels, self.__mean, self.__covar))
 
-    def __eq__(self, other : "MultivariateNormalDist") -> bool:
+    def __eq__(self, other: "MultivariateNormalDist") -> bool:
         return isinstance(other, MultivariateNormalDist) and self.keys() == other.keys() and self.mean == other.mean and (self.cov == other.cov).all()
 
-    def __add__(self, other : int) -> "UncertainData":
+    def __add__(self, other: int) -> "UncertainData":
         if other == 0:
             return self
         return MultivariateNormalDist(self.__labels, array([i+other for i in self.__mean]), self.__covar)
 
-    def __radd__(self, other : int) -> "UncertainData":
+    def __radd__(self, other: int) -> "UncertainData":
         return self.__add__(other)
 
-    def __iadd__(self, other : int) -> "UncertainData":
+    def __iadd__(self, other: int) -> "UncertainData":
         if not isinstance(other, (int, float)):
             raise TypeError(f" unsupported operand type(s) for +: '{type(other)}' and '{type(self.__mean[0])}'")
         if other != 0:
             self.__mean = array([i+other for i in self.__mean])
         return self
 
-    def __sub__(self, other : int) -> "UncertainData":
+    def __sub__(self, other: int) -> "UncertainData":
         if other == 0:
             return self
         return MultivariateNormalDist(self.__labels, array([i-other for i in self.__mean]), self.__covar)
 
-    def __rsub__(self, other : int) -> "UncertainData":
+    def __rsub__(self, other: int) -> "UncertainData":
         return self.__sub__(other)
 
-    def __isub__(self, other : int) -> "UncertainData":
+    def __isub__(self, other: int) -> "UncertainData":
         if not isinstance(other, (int, float)):
             raise TypeError(f" unsupported operand type(s) for -: '{type(other)}' and '{type(self.__mean[0])}'")
         if other != 0:
             self.__mean = array([i-other for i in self.__mean])
         return self
 
-    def sample(self, num_samples : int = 1) -> UnweightedSamples:
+    def sample(self, num_samples: int = 1) -> UnweightedSamples:
         if len(self.__mean) != len(self.__labels):
             raise Exception("labels must be provided for each value")
     
diff --git a/src/prog_algs/uncertain_data/scalar_data.py b/src/prog_algs/uncertain_data/scalar_data.py
index 28fe8926..6dfb207d 100644
--- a/src/prog_algs/uncertain_data/scalar_data.py
+++ b/src/prog_algs/uncertain_data/scalar_data.py
@@ -13,17 +13,17 @@ class ScalarData(UncertainData):
     Args:
         state (dict or Container): Single state in the form of dict or model.*Container (InputContainer, OutputContainer, Statecontainer) representing states and respective values.
     """
-    def __init__(self, state, _type = dict): 
+    def __init__(self, state, _type=dict): 
         self.__state = state
         super().__init__(_type)
     
     def __reduce__(self):
         return (ScalarData, (self.__state, ))
 
-    def __eq__(self, other : "ScalarData") -> bool:
+    def __eq__(self, other: "ScalarData") -> bool:
         return isinstance(other, ScalarData) and other.mean == self.__state
 
-    def __add__(self, other : int) -> "UncertainData":
+    def __add__(self, other: int) -> "UncertainData":
         if other == 0:
             return self
         new_state = dict()
@@ -31,25 +31,25 @@ def __add__(self, other : int) -> "UncertainData":
             new_state[k] = v + other
         return ScalarData(new_state)
 
-    def __radd__(self, other : int) -> "UncertainData":
+    def __radd__(self, other: int) -> "UncertainData":
         return self.__add__(other)
 
-    def __iadd__(self, other : int) -> "UncertainData":
+    def __iadd__(self, other: int) -> "UncertainData":
         if other != 0:
             for k in self.__state.keys():
                 self.__state[k] += other
         return self
 
-    def __sub__(self, other : int) -> "UncertainData":
+    def __sub__(self, other: int) -> "UncertainData":
         new_state = dict()
         for k,v in self.__state.items():
             new_state[k] = v - other
         return ScalarData(new_state)
 
-    def __rsub__(self, other : int) -> "UncertainData":
+    def __rsub__(self, other: int) -> "UncertainData":
         return self.__sub__(other)
 
-    def __isub__(self, other : int) -> "UncertainData":
+    def __isub__(self, other: int) -> "UncertainData":
         if other != 0:
             for k in self.__state.keys():
                 self.__state[k] -= other
@@ -76,7 +76,7 @@ def sample(self, num_samples : int = 1) -> UnweightedSamples:
     def __str__(self) -> str:
         return 'ScalarData({})'.format(self.__state)
 
-    def percentage_in_bounds(self, bounds : Union[list, dict]) -> dict:
+    def percentage_in_bounds(self, bounds: Union[list, dict]) -> dict:
         if isinstance(bounds, list):
             bounds = {key: bounds for key in self.keys()}
         if not isinstance(bounds, dict) and all([isinstance(b, list) for b in bounds]):
diff --git a/src/prog_algs/uncertain_data/uncertain_data.py b/src/prog_algs/uncertain_data/uncertain_data.py
index 7641511a..baea862d 100644
--- a/src/prog_algs/uncertain_data/uncertain_data.py
+++ b/src/prog_algs/uncertain_data/uncertain_data.py
@@ -15,11 +15,11 @@ class UncertainData(ABC):
     Abstract base class for data with uncertainty. Any new uncertainty type must implement this class
     """
 
-    def __init__(self, _type = dict):
+    def __init__(self, _type=dict):
         self._type = _type
     
     @abstractmethod
-    def sample(self, nSamples : int = 1):
+    def sample(self, nSamples: int = 1):
         """Generate samples from data
 
         Args:
@@ -76,7 +76,7 @@ def cov(self) -> array:
                 covariance_matrix = data.cov
         """
 
-    def relative_accuracy(self, ground_truth : dict) -> dict:
+    def relative_accuracy(self, ground_truth: dict) -> dict:
         """The relative accuracy is how close the mean of the distribution is to the ground truth, on relative terms
         
         :math:`RA = 1 - \dfrac{\| r-p \|}{r}`
@@ -117,7 +117,7 @@ def keys(self):
     def __contains__(self, key):
         return key in self.keys()
 
-    def percentage_in_bounds(self, bounds : tuple, keys : list = None, n_samples = 1000) -> dict:
+    def percentage_in_bounds(self, bounds: tuple, keys: list = None, n_samples: int = 1000) -> dict:
         """Calculate percentage of dist is within specified bounds
 
         Args:
diff --git a/src/prog_algs/uncertain_data/unweighted_samples.py b/src/prog_algs/uncertain_data/unweighted_samples.py
index da9130f8..353d7267 100644
--- a/src/prog_algs/uncertain_data/unweighted_samples.py
+++ b/src/prog_algs/uncertain_data/unweighted_samples.py
@@ -20,7 +20,7 @@ class UnweightedSamples(UncertainData, UserList):
             If list, must be of the form of [{key: value, ...}, ...]\n
             If InputContainer, OutputContainer, or StateContainer, must be of the form of *Container({'key': value, ...})
     """
-    def __init__(self, samples : list = [], _type = dict):
+    def __init__(self, samples: list = [], _type=dict):
         super().__init__(_type)
         if isinstance(samples, dict) or isinstance(samples, DictLikeMatrixWrapper):
             # Is in form of {key: [value, ...], ...}
@@ -39,11 +39,11 @@ def __init__(self, samples : list = [], _type = dict):
     def __eq__(self, other):
         return isinstance(other, UnweightedSamples) and self.data == other.data
 
-    def __getitem__(self,n):
+    def __getitem__(self, n):
         datem = self.data[n]
         return self._type(datem) if datem is not None else None
 
-    def __add__(self, other : int) -> "UncertainData":
+    def __add__(self, other: int) -> "UncertainData":
         if other == 0:
             return self
         result = []
@@ -54,17 +54,17 @@ def __add__(self, other : int) -> "UncertainData":
             result.append(new_dict)
         return UnweightedSamples(result)
 
-    def __radd__(self, other : int) -> "UncertainData":
+    def __radd__(self, other: int) -> "UncertainData":
         return self.__add__(other)
 
-    def __iadd__(self, other : int) -> "UncertainData":
+    def __iadd__(self, other: int) -> "UncertainData":
         if other != 0:
             for i in range(len(self.data)):
                 for k,v in self.data[i].items():
                     self.data[i][k] += other
         return self
 
-    def __sub__(self, other : int) -> "UncertainData":
+    def __sub__(self, other: int) -> "UncertainData":
         if other == 0:
             return self
         result = []
@@ -75,10 +75,10 @@ def __sub__(self, other : int) -> "UncertainData":
             result.append(new_dict)
         return UnweightedSamples(result)
 
-    def __rsub__(self, other : int) -> "UncertainData":
+    def __rsub__(self, other: int) -> "UncertainData":
         return self.__sub__(other)
 
-    def __isub__(self, other : int) -> "UncertainData":
+    def __isub__(self, other: int) -> "UncertainData":
         if other != 0:
             for i in range(len(self.data)):
                 for k,v in self.data[i].items():
@@ -88,10 +88,10 @@ def __isub__(self, other : int) -> "UncertainData":
     def __reduce__(self):
         return (UnweightedSamples, (self.data, ))
 
-    def sample(self, num_samples : int = 1, replace = True) -> "UnweightedSamples":
+    def sample(self, num_samples: int = 1, replace: bool = True) -> "UnweightedSamples":
         # Completely random resample
-        indices = random.choice(len(self.data), num_samples, replace = replace)
-        return UnweightedSamples([self.data[i] for i in indices], _type = self._type)
+        indices = random.choice(len(self.data), int(num_samples), replace=replace)
+        return UnweightedSamples([self.data[i] for i in indices], _type=self._type)
 
     def keys(self) -> list:
         if len(self.data) == 0:
@@ -163,7 +163,7 @@ def size(self) -> int:
         """
         return len(self)
 
-    def percentage_in_bounds(self, bounds, keys : list = None) -> dict:
+    def percentage_in_bounds(self, bounds, keys: list = None) -> dict:
         if not keys:
             keys = self.keys()
         if isinstance(keys, str):
diff --git a/tutorial.ipynb b/tutorial.ipynb
index cb91125b..b9934b17 100644
--- a/tutorial.ipynb
+++ b/tutorial.ipynb
@@ -54,7 +54,7 @@
     "* `prog_algs.uncertain_data.ScalarData` - Data without uncertainty, a single value\n",
     "* `prog_algs.uncertain_data.UnweightedSamples` - Data represented by a set of unweighted samples. Objects of this class can be treated like a list where samples[n] returns the nth sample (Dict)\n",
     "\n",
-    "To begin using `UncertainData`, import the type that best portrays the data. In this simple demonstration, we import the `UnweightedSamples` data type. See <https://nasa.github.io/prog_algs> for full details on the available `UncertainData` types."
+    "To begin using `UncertainData`, import the type that best portrays the data. In this simple demonstration, we import the `UnweightedSamples` data type. See <https://nasa.github.io/progpy/api_ref/prog_algs/UncertainData.html> for full details on the available `UncertainData` types."
    ]
   },
   {
@@ -192,7 +192,7 @@
     "\n",
     "In `prog_algs` a State Estimator is used to estimate the system state. \n",
     "\n",
-    "To start, import the needed packages. Here we will import the `BatteryCircuit` model and the `UnscentedKalmanFilter` state estimator. See <https://nasa.github.io/progpy/guide> for more details on the available state estimators.\n"
+    "To start, import the needed packages. Here we will import the `BatteryCircuit` model and the `UnscentedKalmanFilter` state estimator. See <https://nasa.github.io/progpy/api_ref/prog_algs/StateEstimator.html> for more details on the available state estimators.\n"
    ]
   },
   {


Test patch:
diff --git a/.github/workflows/release-tests.yml b/.github/workflows/release-tests.yml
new file mode 100644
index 00000000..51bd60e6
--- /dev/null
+++ b/.github/workflows/release-tests.yml
@@ -0,0 +1,54 @@
+name: Python package
+
+on:
+  push:
+    branches:
+      - 'release/**'
+  pull_request:
+    branches:
+      - 'release/**'
+    paths:
+      - prog_algs
+
+jobs:
+  test-prog_models-dev:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: ['3.8', '3.9', '3.10']
+    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 prog_models
+      run: |
+        python -m pip install git+https://github.com/nasa/prog_models.git@dev
+    - name: Install
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install -e .
+        python -m pip install notebook
+        python -m pip install testbook
+    - name: Run tests
+      run: python -m tests
+  test-prog_models-released:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: ['3.8', '3.9', '3.10']
+    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
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install -e .
+        python -m pip install notebook
+        python -m pip install testbook
+    - name: Run tests
+      run: python -m tests
diff --git a/test_requirements.txt b/test_requirements.txt
new file mode 100644
index 00000000..e1396156
--- /dev/null
+++ b/test_requirements.txt
@@ -0,0 +1,4 @@
+ipython>=8.10.0
+ipykernel>=6.21.2
+testbook>=0.4.2
+.
\ No newline at end of file
diff --git a/tests/test_examples.py b/tests/test_examples.py
index 00144003..87fd03f5 100644
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -1,16 +1,17 @@
 # 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 matplotlib import pyplot as plt
 from os.path import dirname, join
-sys.path.append(join(dirname(__file__), ".."))
-
+import pkgutil
+import sys
 import unittest
-from io import StringIO 
-from examples import *
 from unittest.mock import patch
-import pkgutil
-from importlib import import_module
+
+sys.path.append(join(dirname(__file__), ".."))  # puts examples in path
+from examples import *
 
 skipped_examples = ['playback']
 
@@ -49,7 +50,10 @@ def main():
     l = unittest.TestLoader()
     runner = unittest.TextTestRunner()
     print("\n\nTesting Examples")
-    result = runner.run(l.loadTestsFromTestCase(TestExamples)).wasSuccessful()
+    with patch('matplotlib.pyplot.show'):
+        result = runner.run(l.loadTestsFromTestCase(TestExamples)).wasSuccessful()
+
+    plt.close('all')
 
     if not result:
         raise Exception("Failed test")
diff --git a/tests/test_manual.py b/tests/test_manual.py
index f98722a6..e5f5a23a 100644
--- a/tests/test_manual.py
+++ b/tests/test_manual.py
@@ -1,7 +1,9 @@
 # Copyright © 2021 United States Government as represented by the Administrator of the National Aeronautics and Space Administration.  All Rights Reserved.
 
+from matplotlib import pyplot as plt
 import sys
 import unittest
+from unittest.mock import patch
 
 """
 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.
@@ -25,7 +27,10 @@ def main():
     l = unittest.TestLoader()
     runner = unittest.TextTestRunner()
     print("\n\nTesting Manual")
-    result = runner.run(l.loadTestsFromTestCase(TestManual)).wasSuccessful()
+    
+    with patch('matplotlib.pyplot.show'):
+        result = runner.run(l.loadTestsFromTestCase(TestManual)).wasSuccessful()
+    plt.close('all')
 
     if not result:
         raise Exception("Failed test")
diff --git a/tests/test_state_estimators.py b/tests/test_state_estimators.py
index 1fc2600a..a9f18d9a 100644
--- a/tests/test_state_estimators.py
+++ b/tests/test_state_estimators.py
@@ -4,9 +4,8 @@
 import random
 
 from prog_models import PrognosticsModel, LinearModel
-from prog_models.models import ThrownObject, BatteryElectroChem
+from prog_models.models import ThrownObject, BatteryElectroChem, PneumaticValveBase
 from prog_algs.state_estimators import ParticleFilter, KalmanFilter, UnscentedKalmanFilter
-from prog_algs.exceptions import ProgAlgTypeError
 from prog_algs.uncertain_data import ScalarData, MultivariateNormalDist, UnweightedSamples
 
 def equal_cov(pair1, pair2):
@@ -83,22 +82,29 @@ def test_state_est_template(self):
         se = TemplateStateEstimator(self._m_mock, {'a': 0.0, 'b': 0.0, 'c': 0.0, 't':0.0})
 
     def __test_state_est(self, filt, m):
-        x_guess = m.StateContainer(filt.x.mean)  # Might be new
         x = m.initialize()
 
         self.assertTrue(all(key in filt.x.mean for key in m.states))
 
         # run for a while
-        dt = 0.01
+        dt = 0.2
         u = m.InputContainer({})
-        for i in range(1250):
+        last_time = 0
+        for i in range(500):
             # Get simulated output (would be measured in a real application)
             x = m.next_state(x, u, dt)
-            x_guess = m.next_state(x_guess, u, dt)
             z = m.output(x)
 
-            # Estimate New State
-            filt.estimate((i+1)*dt, u, z)
+            # Estimate New State every few steps
+            if i % 8 == 0:
+                # This is to test dt
+                # Without dt, this would fail
+                last_time = (i+1)*dt
+                filt.estimate((i+1)*dt, u, z, dt=dt)
+
+        if last_time != (i+1)*dt:
+            # Final estimate
+            filt.estimate((i+1)*dt, u, z, dt=dt)
 
         # Check results - make sure it converged
         x_est = filt.x.mean
@@ -185,7 +191,8 @@ def output(self):
                 pass
         m = IncompleteModel()
         x0 = {'a': 0, 'c': 2}
-        with self.assertRaises(ProgAlgTypeError):
+        # Missing Key 'b'
+        with self.assertRaises(KeyError):
             filter(m, x0)
 
         class IncompleteModel:
@@ -196,7 +203,7 @@ def output(self):
                 pass
         m = IncompleteModel()
         x0 = {'a': 0, 'b': 2}
-        with self.assertRaises(ProgAlgTypeError):
+        with self.assertRaises(NotImplementedError):
             filter(m, x0)
 
         class IncompleteModel:
@@ -207,7 +214,7 @@ def output(self):
                 pass
         m = IncompleteModel()
         x0 = {'a': 0, 'b': 2}
-        with self.assertRaises(ProgAlgTypeError):
+        with self.assertRaises(NotImplementedError):
             filter(m, x0)
 
         class IncompleteModel:
@@ -217,7 +224,7 @@ def output(self):
                 pass
         m = IncompleteModel()
         x0 = {'a': 0, 'b': 2}
-        with self.assertRaises(ProgAlgTypeError):
+        with self.assertRaises(NotImplementedError):
             filter(m, x0)
         class IncompleteModel:
             outputs = []
@@ -226,17 +233,90 @@ def next_state(self):
                 pass
         m = IncompleteModel()
         x0 = {'a': 0, 'b': 2}
-        with self.assertRaises(ProgAlgTypeError):
+        with self.assertRaises(NotImplementedError):
             filter(m, x0)
 
     def test_UKF_incorrect_input(self):
         self.__incorrect_input_tests(UnscentedKalmanFilter)
 
+    def test_PF_limit_check(self):
+        class OneInputOneOutputOneEventLM(LinearModel):
+            inputs = ['u1']
+            states = ['x1']
+            outputs = ['x1+1']
+            events = ['x1 == 10']
+
+            A = np.array([[0]])
+            B = np.array([[1]])
+            C = np.array([[1]])
+            D = np.array([[1]])
+            F = np.array([[-0.1]])
+            G = np.array([[1]])
+
+            default_parameters = {
+                'process_noise': 0.1,
+                'measurement_noise': 0.1,
+                'x0': {
+                    'x1': 0
+                }
+            }
+
+        m = OneInputOneOutputOneEventLM()
+        pf = ParticleFilter(m, {'x1': 10}, num_particles = 5)
+
+        # Without state limits
+        pf.estimate(1, {'u1': 1}, {'x1+1': 12})
+        self.assertAlmostEqual(pf.x.mean['x1'], 11, delta=0.2)
+
+        # With state limits
+        OneInputOneOutputOneEventLM.state_limits = {
+            'x1': (0, 10)
+        }
+        pf.estimate(2, {'u1': 1}, {'x1+1': 13})
+        self.assertLessEqual(pf.x.mean['x1'], 10)  # Limited to 10 now
+    
+    def test_PF_step(self):
+        m = PneumaticValveBase()
+
+        # Generate data
+        cycle_time = 20
+        def future_loading(t, x=None):
+            t = t % cycle_time
+            if t < cycle_time/2:
+                return m.InputContainer({
+                    'pL': 3.5e5,
+                    'pR': 2.0e5,
+                    # Open Valve
+                    'uTop': False,
+                    'uBot': True
+                })
+            return m.InputContainer({
+                'pL': 3.5e5,
+                'pR': 2.0e5,
+                # Close Valve
+                'uTop': True,
+                'uBot': False
+            })
+
+        config = {
+                'dt': 0.01,
+                'save_freq': 1,
+            }
+        simulated_results = m.simulate_to(10, future_loading, **config)
+
+        # Setup PF
+        x0 = m.initialize(future_loading(0))
+        filt = ParticleFilter(m, x0, num_particles = 100)
+        t=0
+        for u, z in zip(simulated_results.inputs, simulated_results.outputs):
+            filt.estimate(t, u, z, dt = 3)
+            t += config['save_freq']
+
     def test_PF(self):
         m = ThrownObject(process_noise={'x': 0.75, 'v': 0.75}, measurement_noise=1)
         x_guess = {'x': 1.75, 'v': 38.5} # Guess of initial state, actual is {'x': 1.83, 'v': 40}
 
-        filt = ParticleFilter(m, x_guess, num_particles = 1000)
+        filt = ParticleFilter(m, x_guess, num_particles = 1000, measurement_noise = {'x': 1})
         self.__test_state_est(filt, m)
 
         # Test ParticleFilter ScalarData
@@ -266,7 +346,7 @@ def test_PF(self):
         x_us = UnweightedSamples(uw_input)
         filt_us = ParticleFilter(m, x_us, num_particles = 100000)
         for k, v in filt_us.x.mean.items():
-            self.assertAlmostEqual(v, x_us.mean[k], delta=0.02)
+            self.assertAlmostEqual(v, x_us.mean[k], delta=0.025)
         for i in range(len(filt_us.x.cov)):
             for j in range(len(filt_us.x.cov[i])):
                 self.assertAlmostEqual(filt_us.x.cov[i][j], x_us.cov[i][j], delta=0.1)