11import numpy as np
22from warnings import warn
3+ from scipy .linalg import expm
34
4- ####################
5- # Helper functions #
6- ####################
7- def _kalman_forward_filter (xhat0 , P0 , y , A , C , Q , R , u = None , B = None ):
8- """Run the forward pass of a Kalman filter
5+
6+ def kalman_filter (y , _t , xhat0 , P0 , A , Q , C , R , B = None , u = None , save_P = True ):
7+ """Run the forward pass of a Kalman filter, with regular or irregular step size.
8+
9+ :param np.array y: series of measurements, stacked along axis 0.
10+ :param float or array[float] _t: This function supports variable step size. This parameter is either the constant
11+ dt if given as a single float, or data locations if given as an array of same length as :code:`x`.
912 :param np.array xhat0: initial estimate of state (often 0) one step before the first measurement
1013 :param np.array P0: initial guess of state covariance (often identity matrix) before the first measurement
11- :param np.array y: series of measurements, stacked along axis 0. In this case just the raw noisy data (fully observable)
12- :param np.array A: discrete- time state transition matrix
14+ :param np.array A: state transition matrix, in discrete time if constant dt, in continuous time if variable dt
15+ :param np.array Q: noise covariance matrix, in discrete time if constant dt, in continuous time if variable dt
1316 :param np.array C: measurement matrix
14- :param np.array Q: discrete-time process noise covariance
1517 :param np.array R: measurement noise covariance
18+ :param np.array B: optional control matrix, in discrete time if constant dt, in continuous time if variable dt
1619 :param np.array u: optional control input
17- :param np.array B: optional control matrix
20+ :param bool save_P: whether to save history of error covariance and a priori state estimates, used with rts
21+ smoothing but nonstandard to compute for ordinary filtering
22+
1823 :return: tuple[np.array, np.array, np.array, np.array] of\n
1924 - **xhat_pre** -- a priori estimates of xhat, with axis=0 the batch dimension, so xhat[n] gets the nth step
2025 - **xhat_post** -- a posteriori estimates of xhat
2126 - **P_pre** -- a priori estimates of P
2227 - **P_post** -- a posteriori estimates of P
28+ if :code:`save_P` is :code:`True` else only **xhat_post** to save memory
2329 """
24- if B is None : B = np .zeros ((A .shape [0 ], 1 ))
25- if u is None : u = np .zeros (B .shape [1 ])
30+ # _pre variables are a priori predictions based on only past information
31+ # _post variables are a posteriori combinations of all information available at the current step
32+ xhat_post = []
33+ if save_P : xhat_pre = []; P_pre = []; P_post = []
2634 xhat = xhat0
2735 P = P0
2836
29- # _pre variables are a priori predictions based on only past information
30- # _post variables are a posteriori combinations of all information available at the current step
31- xhat_pre = []; xhat_post = []; P_pre = []; P_post = []
32-
37+ equispaced = isinstance (_t , (float , int )) # I'd rather not call isinstance in the loop
38+ control = isinstance (B , np .ndarray ) and isinstance (B , np .ndarray ) # whether there is a control input
3339 for n in range (y .shape [0 ]):
34- xhat_ = A @ xhat + B @ u # ending underscores denote an a priori prediction
35- P_ = A @ P @ A .T + Q
36- xhat_pre .append (xhat_ ) # the [n]th index holds _{n|n-1} info
37- P_pre .append (P_ )
38-
39- xhat = xhat_ .copy () # copies very important here. See #122
40+ if not equispaced :
41+ M = expm (np .block ([[A , Q ],[numpy .zeros (A .shape ), - A .T ]]) * (_t [n ] - _t [n - 1 ])) # form discrete-time matrices TODO doesn't work at n=0
42+ An = M [:order + 1 ,:order + 1 ] # upper left block
43+ Qn = M [:order + 1 ,order + 1 :] @ An .T # upper right block
44+ if control :
45+ M = expm (np .block ([[A , B ], [np .zeros ((A .shape [0 ], 2 * A .shape [1 ]))]]))
46+ Bn = M [:order + 1 ,order + 1 :] # upper right block
47+ else : # matrices should already be discrete time
48+ An , Qn , Bn = A , Q , B
49+
50+ xhat_ = An @ xhat + Bn @ u if control else An @ xhat # ending underscores denote an a priori prediction
51+ P_ = An @ P @ An .T + Qn # the dense matrix multiplies here are the most expensive step
52+ xhat = xhat_ .copy () # copies, lest modifications to these variables change the a priori estimates. See #122
4053 P = P_ .copy ()
4154 if not np .isnan (y [n ]): # handle missing data
4255 K = P_ @ C .T @ np .linalg .inv (C @ P_ @ C .T + R )
4356 xhat += K @ (y [n ] - C @ xhat_ )
4457 P -= K @ C @ P_
45- xhat_post .append (xhat ) # the [n]th index holds _{n|n} info
46- P_post .append (P )
47-
48- return np .stack (xhat_pre , axis = 0 ), np .stack (xhat_post , axis = 0 ), np .stack (P_pre , axis = 0 ), np .stack (P_post , axis = 0 )
49-
50-
51- def _kalman_backward_smooth (A , xhat_pre , xhat_post , P_pre , P_post ):
52- """Do the additional Rauch-Tung-Striebel smoothing step
53- :param A: discrete-time state transition matrix
54- :param xhat_pre: a priori estimates of xhat
55- :param xhat_post: a posteriori estimates of xhat
56- :param P_pre: a priori estimates of P
57- :param P_post: a posteriori estimates of P
58+ # the [n]th index of pre variables holds _{n|n-1} info; the [n]th index of post variables holds _{n|n} info
59+ xhat_post .append (xhat )
60+ if save_P : xhat_pre .append (xhat_ ); P_pre .append (P_ ); P_post .append (P )
61+
62+ xhat_post = np .stack (xhat_post , axis = 0 )
63+ return xhat_post if not save_P else (np .stack (xhat_pre , axis = 0 ), xhat_post ,
64+ np .stack (P_pre , axis = 0 ), np .stack (P_post , axis = 0 ))
65+
66+
67+ def rts_smooth (_t , A , xhat_pre , xhat_post , P_pre , P_post , compute_P_smooth = False ):
68+ """Perform Rauch-Tung-Striebel smoothing, using information from forward Kalman filtering.
69+
70+ :param float or array[float] _t: This function supports variable step size. This parameter is either the constant
71+ dt if given as a single float, or data locations if given as an array of same length as :code:`x`.
72+ :param np.array A: state transition matrix, in discrete time if constant dt, in continuous time if variable dt
73+ :param list[np.array] xhat_pre: a priori estimates of xhat from a kalman_filter forward pass
74+ :param list[np.array] xhat_post: a posteriori estimates of xhat from a kalman_filter forward pass
75+ :param list[np.array] P_pre: a priori estimates of P from a kalman_filter forward pass
76+ :param list[np.array] P_post: a posteriori estimates of P from a kalman_filter forward pass
77+ :param bool compute_P_smooth: it's extra work if you don't need it
78+
5879 :return: tuple[np.array, np.array] of\n
5980 - **xhat_smooth** -- RTS smoothed xhat
6081 - **P_smooth** -- RTS smoothed P
82+ if :code:`compute_P_smooth` is :code:`True` else only **xhat_smooth**
6183 """
62- xhat_smooth = [xhat_post [- 1 ]]
63- P_smooth = [P_post [- 1 ]]
84+ xhat_smooth = np .empty (xhat_post .shape ); xhat_smooth [- 1 ] = xhat_post [- 1 ] # preallocate arrays
85+ if compute_P_smooth : P_smooth = np .empty (P_post .shape ); P_smooth [- 1 ] = P_post [- 1 ]
86+
87+ equispaced = isinstance (_t , (int , float )) # I'd rather not call isinstance in a loop when it's avoidable
6488 for n in range (xhat_pre .shape [0 ]- 2 , - 1 , - 1 ):
65- C_RTS = P_post [n ] @ A .T @ np .linalg .inv (P_pre [n + 1 ]) # the [n+1]th index holds _{n+1|n} info
66- xhat_smooth .append (xhat_post [n ] + C_RTS @ (xhat_smooth [- 1 ] - xhat_pre [n + 1 ])) # The original authors use C, not to
67- P_smooth .append (P_post [n ] + C_RTS @ (P_smooth [- 1 ] - P_pre [n + 1 ]) @ C_RTS .T ) # be confused with the measurement matrix
89+ An = A if equispaced else expm (A * (_t [n + 1 ] - _t [n ])) # state transition from n to n+1, per the paper
90+ C_RTS = P_post [n ] @ An .T @ np .linalg .inv (P_pre [n + 1 ]) # the [n+1]th index holds _{n+1|n} info
91+ xhat_smooth [n ] = xhat_post [n ] + C_RTS @ (xhat_smooth [n + 1 ] - xhat_pre [n + 1 ]) # The original authors use C, not to be confused
92+ if compute_P_smooth : P_smooth [n ] = P_post [n ] + C_RTS @ (P_smooth [n + 1 ] - P_pre [n + 1 ]) @ C_RTS .T # with the measurement matrix
6893
69- return np . stack ( xhat_smooth [:: - 1 ], axis = 0 ), np . stack ( P_smooth [:: - 1 ], axis = 0 ) # reverse lists
94+ return xhat_smooth if not compute_P_smooth else ( xhat_smooth , P_smooth )
7095
7196
72- def _RTS_smooth (xhat0 , P0 , y , A , C , Q , R , u = None , B = None ):
73- """forward-backward Kalman/Rauch-Tung-Striebel smoother. For params see the helper functions.
74- """
75- xhat_pre , xhat_post , P_pre , P_post = _kalman_forward_filter (xhat0 , P0 , y , A , C , Q , R ) # noisy x are the "y" in Kalman-land
76- xhat_smooth , _ = _kalman_backward_smooth (A , xhat_pre , xhat_post , P_pre , P_post ) # not doing anything with P_smooth
77- return xhat_smooth
78-
79-
80- #########################################
81- # Constant 1st, 2nd, and 3rd derivative #
82- #########################################
83- def rtsdiff (x , dt , order , qr_ratio , forwardbackward ):
97+ def rtsdiff (x , _t , order , qr_ratio , forwardbackward ):
8498 """Perform Rauch-Tung-Striebel smoothing with a naive constant derivative model. Other constant derivative
8599 methods in this module call this function.
86100
87101 :param np.array[float] x: data series to differentiate
88- :param float dt: step size
102+ :param float or array[float] _t: This function supports variable step size. This parameter is either the constant
103+ dt if given as a single float, or data locations if given as an array of same length as :code:`x`.
89104 :param int order: which derivative to stabilize in the constant-derivative model
90105 :param qr_ratio: the process noise level of the divided by the measurement noise level, because,
91106 per `our analysis <https://github.com/florisvb/PyNumDiff/issues/139>`_, the mean result is
@@ -97,49 +112,49 @@ def rtsdiff(x, dt, order, qr_ratio, forwardbackward):
97112 - **x_hat** -- estimated (smoothed) x
98113 - **dxdt_hat** -- estimated derivative of x
99114 """
115+ if isinstance (_t , (np .ndarray , list )) and len (x ) != len (_t ):
116+ raise ValueError ("If `_t` is given as array-like, must have same length as `x`." )
117+
100118 q = 10 ** int (np .log10 (qr_ratio )/ 2 ) # even-ish split of the powers across 0
101119 r = q / qr_ratio
102120
103- if order == 1 :
104- A = np .array ([[1 , dt ], [0 , 1 ]]) # states are x, x'. This basically does an Euler update.
105- C = np .array ([[1 , 0 ]]) # we measure only y = noisy x
106- R = np .array ([[r ]])
107- Q = np .array ([[1e-16 , 0 ], [0 , q ]]) # uncertainty is around the velocity
108- P0 = np .array (100 * np .eye (2 )) # See #110 for why this choice of P0
109- elif order == 2 :
110- A = np .array ([[1 , dt , (dt ** 2 )/ 2 ], # states are x, x', x"
111- [0 , 1 , dt ],
112- [0 , 0 , 1 ]])
113- C = np .array ([[1 , 0 , 0 ]]) # we measure only y = noisy x
114- R = np .array ([[r ]])
115- Q = np .array ([[1e-16 , 0 , 0 ],
116- [0 , 1e-16 , 0 ],
117- [0 , 0 , q ]]) # uncertainty is around the acceleration
118- P0 = np .array (100 * np .eye (3 )) # See #110 for why this choice of P0
119- elif order == 3 :
120- A = np .array ([[1 , dt , (dt ** 2 )/ 2 , (dt ** 3 )/ 6 ], # states are x, x', x", x'"
121- [0 , 1 , dt , (dt ** 2 )/ 2 ],
122- [0 , 0 , 1 , dt ],
123- [0 , 0 , 0 , 1 ]])
124- C = np .array ([[1 , 0 , 0 , 0 ]]) # we measure only y = noisy x
125- R = np .array ([[r ]])
126- Q = np .array ([[1e-16 , 0 , 0 , 0 ],
127- [0 , 1e-16 , 0 , 0 ],
128- [0 , 0 , 1e-16 , 0 ],
129- [0 , 0 , 0 , q ]]) # uncertainty is around the jerk
130- P0 = np .array (100 * np .eye (4 )) # See #110 for why this choice of P0
131-
121+ A = np .diag (np .ones (order ), 1 ) # continuous-time A just has 1s on the first diagonal (where 0th is main diagonal)
122+ Q = np .zeros (A .shape ); Q [- 1 ,- 1 ] = q # continuous-time uncertainty around the last derivative
123+ C = np .zeros ((1 , order + 1 )); C [0 ,0 ] = 1 # we measure only y = noisy x
124+ R = np .array ([[r ]]) # 1 observed state, so this is 1x1
125+ P0 = 100 * np .eye (order + 1 ) # See #110 for why this choice of P0
132126 xhat0 = np .zeros (A .shape [0 ]); xhat0 [0 ] = x [0 ] # The first estimate is the first seen state. See #110
133- xhat_smooth = _RTS_smooth (xhat0 , P0 , x , A , C , Q , R ) # noisy x are the "y" in Kalman-land
127+
128+ if isinstance (_t , (float , int )):
129+ M = expm (np .block ([[A , Q ],[numpy .zeros (A .shape ), - A .T ]]) * _t ) # form discrete-time versions
130+ A = M [:order + 1 ,:order + 1 ]
131+ Q = M [:order + 1 ,order + 1 :] @ A .T
132+
133+ xhat_pre , xhat_post , P_pre , P_post = kalman_filter (x , _t , xhat0 , P0 , A , Q , C , R ) # noisy x are the "y" in Kalman-land
134+ xhat_smooth = rts_smooth (_t , A , xhat_pre , xhat_post , P_pre , P_post ) # not doing anything with P_smooth
134135 x_hat_forward = xhat_smooth [:, 0 ] # first dimension is time, so slice first element at all times
135136 dxdt_hat_forward = xhat_smooth [:, 1 ]
136137
137138 if not forwardbackward : # bounce out here if not doing the same in reverse and then combining
138139 return x_hat_forward , dxdt_hat_forward
139140
141+ # for backward case, if discrete time invert the matrix, and for continuous time reverse _t, because then dts will go negative
140142 xhat0 [0 ] = x [- 1 ] # starting from the other end of the signal
141- Ainv = np .linalg .inv (A ) # dynamics are inverted
142- xhat_smooth = _RTS_smooth (xhat0 , P0 , x [::- 1 ], Ainv , C , Q , R ) # noisy x are the "y" in Kalman-land
143+
144+ ### TODO fix between here and next hashes
145+ if isinstance (_t , (float , int )):
146+ M = expm (np .block ([[A , Q ],[numpy .zeros (A .shape ), - A .T ]]) * - _t ) # form discrete-time versions
147+ A = M [:order + 1 ,:order + 1 ] # inverse of A
148+ Q = M [:order + 1 ,order + 1 :] @ A .T #
149+
150+ Ainv = np .linalg .inv (A_c ) # dynamics are inverted, same as expm() with negative dt
151+ _t_rev = _t [::- 1 ] _t
152+
153+ xhat_pre , xhat_post , P_pre , P_post = _kalman_forward_filter (x [::- 1 ], _t [::- 1 ], xhat0 , P0 , A , Q , C , R ) if \
154+ isinstance (_t , (np .ndarray , list )) else _kalman_forward_filter (x [::- 1 ], _t , xhat0 , P0 , np .linalg .inv (A ), Q , C , R )
155+ xhat_smooth = _kalman_backward_smooth (_t_rev , A_c , xhat_pre , xhat_post , P_pre , P_post )
156+ ###
157+
143158 x_hat_backward = xhat_smooth [:, 0 ][::- 1 ] # the result is backwards still, so reverse it
144159 dxdt_hat_backward = xhat_smooth [:, 1 ][::- 1 ]
145160
@@ -235,41 +250,3 @@ def constant_jerk(x, dt, params=None, options=None, r=None, q=None, forwardbackw
235250 raise ValueError ("`q` and `r` must be given." )
236251
237252 return rtsdiff (x , dt , 3 , q / r , forwardbackward )
238-
239-
240- def known_dynamics (x , params , u = None , options = None , xhat0 = None , P0 = None , A = None ,
241- B = 0 , C = None , Q = None , R = None , smooth = True ):
242- """Run a forward RTS Kalman smoother given known dynamics.
243-
244- :param np.array[float] x: data series of noisy measurements
245- :param list params: (**deprecated**, prefer :code:`xhat0`, :code:`P0`, :code:`A`,
246- :code:`B`, :code:`C`, :code:`R`, and :code:`Q`), a list in the order here (note flip of Q and R)
247- :param np.array[float] u: series of control inputs
248- :param options: (**deprecated**, prefer :code:`smooth`)
249- a dictionary consisting of {'smooth': (bool)}
250- :param np.array xhat0: inital condition, length N = number of states
251- :param np.array P0: initial covariance matrix of NxN
252- :param np.array A: dynamics matrix, NxN
253- :param np.array B: control input matrix, NxM, M = number of measurements
254- :param np.array C: measurement dynamics, MxN
255- :param np.array Q: covariance matrix for the model, NxN
256- :param np.array R: covariance matrix for the measurements, MxM
257- :param bool smooth: whether to run the RTS smoother step
258-
259- :return: np.array **x_hat** -- estimated (smoothed) x
260- """ # Why not also returning derivative here?
261- if params != None :
262- warn ("`params` and `options` parameters will be removed in a future version. Use `xhat0`, " +
263- "`P0`, `A`, `B`, `C`, `Q`, `R`, and `smooth` instead." , DeprecationWarning )
264- xhat0 , P0 , A , B , C , R , Q = params
265- if options != None :
266- if 'smooth' in options : smooth = options ['smooth' ]
267- elif None in [xhat0 , P0 , A , C , R , Q ]:
268- raise ValueError ("`xhat0`, `P0`, `A`, `C`, `Q`, and `R` must be given." )
269-
270- xhat_pre , xhat_post , P_pre , P_post = _kalman_forward_filter (xhat0 , P0 , x , A , C , Q , R , u , B ) # noisy x are the "y" in Kalman-land
271- if not smooth :
272- return xhat_post
273-
274- xhat_smooth , _ = _kalman_backward_smooth (A , xhat_pre , xhat_post , P_pre , P_post )
275- return xhat_smooth # We're not calculating a derivative here. Why not?
0 commit comments