pybounds 0.0.14__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pybounds/__init__.py +13 -0
- pybounds/jacobian.py +46 -0
- pybounds/observability.py +764 -0
- pybounds/simulator.py +477 -0
- pybounds/util.py +279 -0
- pybounds-0.0.14.dist-info/METADATA +65 -0
- pybounds-0.0.14.dist-info/RECORD +10 -0
- pybounds-0.0.14.dist-info/WHEEL +5 -0
- pybounds-0.0.14.dist-info/licenses/LICENSE +21 -0
- pybounds-0.0.14.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import sympy as sp
|
|
4
|
+
import warnings
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
from mpl_toolkits.axes_grid1.inset_locator import inset_axes
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
|
|
9
|
+
from .util import LatexStates
|
|
10
|
+
from .jacobian import SymbolicJacobian
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EmpiricalObservabilityMatrix:
|
|
14
|
+
def __init__(self, simulator, x0, u, aux=None, eps=1e-5, parallel=False,
|
|
15
|
+
z_function=None, z_state_names=None):
|
|
16
|
+
""" Construct an empirical observability matrix O.
|
|
17
|
+
|
|
18
|
+
:param callable simulator: simulator object that has a method y = simulator.simulate(x0, u, **kwargs)
|
|
19
|
+
y is (w x p) array. w is the number of time-steps and p is the number of measurements
|
|
20
|
+
:param dict/list/np.array x0: initial state for Simulator
|
|
21
|
+
:param dict/np.array u: inputs array
|
|
22
|
+
:param aux: auxiliary input that can be passed to Simulator class
|
|
23
|
+
:param float eps: epsilon value for perturbations to construct O, should be small number
|
|
24
|
+
:param bool parallel: if True, run the perturbations in parallel
|
|
25
|
+
:param callable z_function: function that transforms coordinates from original to new states
|
|
26
|
+
must be of the form z = z_function(x), where x & z are the same size
|
|
27
|
+
should use sympy functions wherever possible
|
|
28
|
+
leave as None to maintain original coordinates
|
|
29
|
+
:param list | tuple | None z_state_names: (optional) names of states in new coordinates.
|
|
30
|
+
will only have an effect if O is a data-frame & z_function is not None
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
# Store inputs
|
|
34
|
+
self.simulator = simulator
|
|
35
|
+
self.aux = aux
|
|
36
|
+
self.eps = eps
|
|
37
|
+
self.parallel = parallel
|
|
38
|
+
|
|
39
|
+
if isinstance(x0, dict):
|
|
40
|
+
self.x0 = np.array(list(x0.values()))
|
|
41
|
+
else:
|
|
42
|
+
self.x0 = np.array(x0).squeeze()
|
|
43
|
+
|
|
44
|
+
if isinstance(u, dict):
|
|
45
|
+
self.u = np.vstack(list(u.values())).T
|
|
46
|
+
else:
|
|
47
|
+
self.u = np.array(u)
|
|
48
|
+
|
|
49
|
+
# Number of states
|
|
50
|
+
self.n = self.x0.shape[0]
|
|
51
|
+
|
|
52
|
+
# Simulate once for nominal trajectory
|
|
53
|
+
self.y_nominal = self.simulator.simulate(x0=self.x0, u=self.u, aux=self.aux)
|
|
54
|
+
|
|
55
|
+
# Number of outputs
|
|
56
|
+
self.p = self.y_nominal.shape[1]
|
|
57
|
+
|
|
58
|
+
# Number of time-steps
|
|
59
|
+
self.w = self.y_nominal.shape[0] # of points in time window
|
|
60
|
+
|
|
61
|
+
# Check for state/measurement names
|
|
62
|
+
if hasattr(self.simulator, 'state_names'):
|
|
63
|
+
self.state_names = self.simulator.state_names
|
|
64
|
+
else:
|
|
65
|
+
self.state_names = ['x_' + str(n) for n in range(self.n)]
|
|
66
|
+
|
|
67
|
+
if hasattr(self.simulator, 'measurement_names'):
|
|
68
|
+
self.measurement_names = self.simulator.measurement_names
|
|
69
|
+
else:
|
|
70
|
+
self.measurement_names = ['y_' + str(p) for p in range(self.p)]
|
|
71
|
+
|
|
72
|
+
# Perturbation amounts
|
|
73
|
+
self.delta_x = eps * np.eye(self.n) # perturbation amount for each state
|
|
74
|
+
self.delta_y = np.zeros((self.p, self.n, self.w)) # preallocate delta_y
|
|
75
|
+
self.y_plus = np.zeros((self.w, self.n, self.p))
|
|
76
|
+
self.y_minus = np.zeros((self.w, self.n, self.p))
|
|
77
|
+
|
|
78
|
+
# Observability matrix
|
|
79
|
+
self.O = np.nan * np.zeros((self.p * self.w, self.n))
|
|
80
|
+
self.O_df = pd.DataFrame(self.O)
|
|
81
|
+
|
|
82
|
+
# Set measurement names
|
|
83
|
+
self.measurement_labels = []
|
|
84
|
+
self.time_labels = []
|
|
85
|
+
for w in range(self.w):
|
|
86
|
+
tl = (w * np.ones(self.p)).astype(int)
|
|
87
|
+
self.time_labels.append(tl)
|
|
88
|
+
self.measurement_labels = self.measurement_labels + list(self.measurement_names)
|
|
89
|
+
|
|
90
|
+
self.time_labels = np.hstack(self.time_labels)
|
|
91
|
+
|
|
92
|
+
# Run simulations to construct O
|
|
93
|
+
self.run()
|
|
94
|
+
|
|
95
|
+
# Perform coordinate transformation on O, if specified
|
|
96
|
+
if z_function is not None:
|
|
97
|
+
self.O_df, self.dzdx, self.dxdz_sym = transform_states(O=self.O_df,
|
|
98
|
+
square_flag=False,
|
|
99
|
+
z_function=z_function,
|
|
100
|
+
x0=self.x0,
|
|
101
|
+
z_state_names=z_state_names)
|
|
102
|
+
self.state_names = tuple(self.O_df.columns)
|
|
103
|
+
self.O = self.O_df.values
|
|
104
|
+
else:
|
|
105
|
+
self.dzdx = None
|
|
106
|
+
self.dxdz_sym = None
|
|
107
|
+
|
|
108
|
+
def run(self, parallel=None):
|
|
109
|
+
""" Construct empirical observability matrix.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
if parallel is not None:
|
|
113
|
+
self.parallel = parallel
|
|
114
|
+
|
|
115
|
+
# Run simulations for perturbed initial conditions
|
|
116
|
+
state_index = np.arange(0, self.n).tolist()
|
|
117
|
+
if self.parallel: # multiprocessing
|
|
118
|
+
# with Pool(4) as pool:
|
|
119
|
+
# results = pool.map(self.simulate, state_index)
|
|
120
|
+
|
|
121
|
+
with ThreadPoolExecutor() as executor:
|
|
122
|
+
results = list(executor.map(self.simulate, state_index))
|
|
123
|
+
|
|
124
|
+
for n, r in enumerate(results):
|
|
125
|
+
delta_y, y_plus, y_minus = r
|
|
126
|
+
self.delta_y[:, n, :] = delta_y
|
|
127
|
+
self.y_plus[:, n, :] = y_plus
|
|
128
|
+
self.y_minus[:, n, :] = y_minus
|
|
129
|
+
|
|
130
|
+
else: # sequential
|
|
131
|
+
for n in state_index:
|
|
132
|
+
delta_y, y_plus, y_minus = self.simulate(n)
|
|
133
|
+
self.delta_y[:, n, :] = delta_y
|
|
134
|
+
self.y_plus[:, n, :] = y_plus
|
|
135
|
+
self.y_minus[:, n, :] = y_minus
|
|
136
|
+
|
|
137
|
+
# Construct O by stacking the 3rd dimension of delta_y along the 1st dimension, O is a (p*w x n) matrix
|
|
138
|
+
self.O = np.zeros((self.p * self.w, self.n))
|
|
139
|
+
for w in range(self.w):
|
|
140
|
+
if w == 0:
|
|
141
|
+
start_index = 0
|
|
142
|
+
else:
|
|
143
|
+
start_index = int(w * self.p)
|
|
144
|
+
|
|
145
|
+
end_index = start_index + self.p
|
|
146
|
+
self.O[start_index:end_index] = self.delta_y[:, :, w]
|
|
147
|
+
|
|
148
|
+
# Make O into a data-frame for interpretability
|
|
149
|
+
self.O_df = pd.DataFrame(self.O, columns=self.state_names, index=self.measurement_labels)
|
|
150
|
+
self.O_df['time_step'] = self.time_labels
|
|
151
|
+
self.O_df = self.O_df.set_index('time_step', append=True)
|
|
152
|
+
self.O_df.index.names = ['sensor', 'time_step']
|
|
153
|
+
|
|
154
|
+
def simulate(self, n):
|
|
155
|
+
""" Run the simulator for specified state index (n).
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
# Perturb initial condition in both directions
|
|
159
|
+
x0_plus = self.x0 + self.delta_x[:, n]
|
|
160
|
+
x0_minus = self.x0 - self.delta_x[:, n]
|
|
161
|
+
|
|
162
|
+
# Simulate measurements from perturbed initial conditions
|
|
163
|
+
y_plus = self.simulator.simulate(x0=x0_plus, u=self.u, aux=self.aux)
|
|
164
|
+
y_minus = self.simulator.simulate(x0=x0_minus, u=self.u, aux=self.aux)
|
|
165
|
+
|
|
166
|
+
# Calculate the numerical Jacobian & normalize by 2x the perturbation amount
|
|
167
|
+
delta_y = np.array(y_plus - y_minus).T / (2 * self.eps)
|
|
168
|
+
|
|
169
|
+
return delta_y, y_plus, y_minus
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class SlidingEmpiricalObservabilityMatrix:
|
|
173
|
+
def __init__(self, simulator, t_sim, x_sim, u_sim, aux_list=None, w=None, eps=1e-5,
|
|
174
|
+
parallel_sliding=False, parallel_perturbation=False,
|
|
175
|
+
z_function=None, z_state_names=None):
|
|
176
|
+
""" Construct empirical observability matrix O in sliding windows along a trajectory.
|
|
177
|
+
|
|
178
|
+
:param callable simulator: Simulator object : y = simulator(x0, u, **kwargs)
|
|
179
|
+
y is (w x p) array. w is the number of time-steps and p is the number of measurements
|
|
180
|
+
:param np.array t_sim: time values along state trajectory array (N, 1)
|
|
181
|
+
:param np.array x_sim: state trajectory array (N, n), can also be dict
|
|
182
|
+
:param np.array u_sim: input array (N, m), can also be dict
|
|
183
|
+
:param aux_list: auxiliary input that can be passed to Simulator class
|
|
184
|
+
:param np.array w: window size for O calculations, will automatically set how many windows to compute
|
|
185
|
+
:params float eps: tolerance for sliding windows
|
|
186
|
+
:param float eps: epsilon value for perturbations to construct O's, should be small number
|
|
187
|
+
:param bool parallel_sliding: if True, run the sliding windows in parallel
|
|
188
|
+
:param bool parallel_perturbation: if True, run the perturbations in parallel
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
self.simulator = simulator
|
|
192
|
+
self.eps = eps
|
|
193
|
+
self.parallel_sliding = parallel_sliding
|
|
194
|
+
self.parallel_perturbation = parallel_perturbation
|
|
195
|
+
self.z_function = z_function
|
|
196
|
+
self.z_state_names = z_state_names
|
|
197
|
+
|
|
198
|
+
# Set time vector
|
|
199
|
+
self.t_sim = np.array(t_sim)
|
|
200
|
+
|
|
201
|
+
# Number of points
|
|
202
|
+
self.N = self.t_sim.shape[0]
|
|
203
|
+
|
|
204
|
+
# Make x_sim & u_sim arrays
|
|
205
|
+
if isinstance(x_sim, dict):
|
|
206
|
+
self.x_sim = np.vstack((list(x_sim.values()))).T
|
|
207
|
+
else:
|
|
208
|
+
self.x_sim = np.array(x_sim).squeeze()
|
|
209
|
+
|
|
210
|
+
if isinstance(u_sim, dict):
|
|
211
|
+
self.u_sim = np.vstack(list(u_sim.values())).T
|
|
212
|
+
else:
|
|
213
|
+
self.u_sim = np.array(u_sim)
|
|
214
|
+
|
|
215
|
+
# Check sizes
|
|
216
|
+
if self.N != self.x_sim.shape[0]:
|
|
217
|
+
raise ValueError('t_sim & x_sim must have same number of rows')
|
|
218
|
+
elif self.N != self.u_sim.shape[0]:
|
|
219
|
+
raise ValueError('t_sim & u_sim must have same number of rows')
|
|
220
|
+
elif self.x_sim.shape[0] != self.u_sim.shape[0]:
|
|
221
|
+
raise ValueError('x_sim & u_sim must have same number of rows')
|
|
222
|
+
|
|
223
|
+
# Set aux inputs
|
|
224
|
+
if aux_list is None:
|
|
225
|
+
self.aux_list = [None for k in range(self.N)]
|
|
226
|
+
else:
|
|
227
|
+
self.aux_list = aux_list
|
|
228
|
+
|
|
229
|
+
if len(self.aux_list) != self.N:
|
|
230
|
+
raise ValueError('aux_list must have same number of elements as t_sim')
|
|
231
|
+
|
|
232
|
+
# Set time-window to calculate O's
|
|
233
|
+
if w is None: # set window size to full time-series size
|
|
234
|
+
self.w = self.N
|
|
235
|
+
else:
|
|
236
|
+
self.w = w
|
|
237
|
+
|
|
238
|
+
if self.w > self.N:
|
|
239
|
+
raise ValueError('window size must be smaller than trajectory length')
|
|
240
|
+
|
|
241
|
+
# All the indices to calculate O
|
|
242
|
+
self.O_index = np.arange(0, self.N - self.w + 1, step=1) # indices to compute O
|
|
243
|
+
self.O_time = self.t_sim[self.O_index] # times to compute O
|
|
244
|
+
self.n_point = len(self.O_index) # # of times to calculate O
|
|
245
|
+
|
|
246
|
+
# Where to store sliding window trajectory data & O's
|
|
247
|
+
self.window_data = {}
|
|
248
|
+
self.O_sliding = []
|
|
249
|
+
self.O_df_sliding = []
|
|
250
|
+
|
|
251
|
+
# Run
|
|
252
|
+
self.EOM = None
|
|
253
|
+
self.run()
|
|
254
|
+
|
|
255
|
+
def run(self, parallel_sliding=None):
|
|
256
|
+
""" Run.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
if parallel_sliding is not None:
|
|
260
|
+
self.parallel_sliding = parallel_sliding
|
|
261
|
+
|
|
262
|
+
# Where to store sliding window trajectory data & O's
|
|
263
|
+
self.window_data = {'t': [], 'u': [], 'y': [], 'y_plus': [], 'y_minus': []}
|
|
264
|
+
self.O_sliding = []
|
|
265
|
+
self.O_df_sliding = []
|
|
266
|
+
|
|
267
|
+
# Construct O's
|
|
268
|
+
n_point_range = np.arange(0, self.n_point).astype(int)
|
|
269
|
+
if self.parallel_sliding: # multiprocessing
|
|
270
|
+
# with Pool(4) as pool:
|
|
271
|
+
# results = pool.map(self.construct, n_point_range)
|
|
272
|
+
|
|
273
|
+
with ThreadPoolExecutor(max_workers=12) as executor:
|
|
274
|
+
results = list(executor.map(self.construct, n_point_range))
|
|
275
|
+
|
|
276
|
+
for r in results:
|
|
277
|
+
self.O_sliding.append(r[0])
|
|
278
|
+
self.O_df_sliding.append(r[1])
|
|
279
|
+
for k in self.window_data.keys():
|
|
280
|
+
self.window_data[k].append(r[2][k])
|
|
281
|
+
|
|
282
|
+
else:
|
|
283
|
+
for n in n_point_range: # each point on trajectory
|
|
284
|
+
O_sliding, O_df_sliding, window_data = self.construct(n)
|
|
285
|
+
self.O_sliding.append(O_sliding)
|
|
286
|
+
self.O_df_sliding.append(O_df_sliding)
|
|
287
|
+
for k in self.window_data.keys():
|
|
288
|
+
self.window_data[k].append(window_data[k])
|
|
289
|
+
|
|
290
|
+
def construct(self, n):
|
|
291
|
+
# Start simulation at point along nominal trajectory
|
|
292
|
+
x0 = np.squeeze(self.x_sim[self.O_index[n], :]) # get state on trajectory & set it as the initial condition
|
|
293
|
+
|
|
294
|
+
# Get the range to pull out time & input data for simulation
|
|
295
|
+
win = np.arange(self.O_index[n], self.O_index[n] + self.w, step=1) # index range
|
|
296
|
+
|
|
297
|
+
# Remove part of window if it is past the end of the nominal trajectory
|
|
298
|
+
within_win = win < self.N
|
|
299
|
+
win = win[within_win]
|
|
300
|
+
|
|
301
|
+
# Pull out time & control inputs in window
|
|
302
|
+
t_win = self.t_sim[win] # time in window
|
|
303
|
+
# t_win0 = t_win - t_win[0] # start at 0
|
|
304
|
+
u_win = self.u_sim[win, :] # inputs in window
|
|
305
|
+
|
|
306
|
+
# Calculate O for window
|
|
307
|
+
EOM = EmpiricalObservabilityMatrix(self.simulator, x0, u_win,
|
|
308
|
+
aux=self.aux_list[n],
|
|
309
|
+
eps=self.eps,
|
|
310
|
+
parallel=self.parallel_perturbation,
|
|
311
|
+
z_function=self.z_function,
|
|
312
|
+
z_state_names=self.z_state_names)
|
|
313
|
+
self.EOM = EOM
|
|
314
|
+
|
|
315
|
+
# Store data
|
|
316
|
+
O_sliding = EOM.O.copy()
|
|
317
|
+
O_df_sliding = EOM.O_df.copy()
|
|
318
|
+
|
|
319
|
+
window_data = {'t': t_win.copy(),
|
|
320
|
+
'u': u_win.copy(),
|
|
321
|
+
'y': EOM.y_nominal.copy(),
|
|
322
|
+
'y_plus': EOM.y_plus.copy(),
|
|
323
|
+
'y_minus': EOM.y_minus.copy()}
|
|
324
|
+
|
|
325
|
+
return O_sliding, O_df_sliding, window_data
|
|
326
|
+
|
|
327
|
+
def get_observability_matrix(self):
|
|
328
|
+
return self.O_df_sliding.copy()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class FisherObservability:
|
|
332
|
+
def __init__(self, O, R=None, lam=None, force_R_scalar=False,
|
|
333
|
+
states=None, sensors=None, time_steps=None, w=None):
|
|
334
|
+
""" Evaluate the observability of a state variable(s) using the Fisher Information Matrix.
|
|
335
|
+
|
|
336
|
+
:param np.array O: observability matrix (w*p, n)
|
|
337
|
+
w is the number of time-steps, p is the number of measurements, and n in the number of states
|
|
338
|
+
can also be set as pd.DataFrame where columns set the state names & a multilevel index sets the
|
|
339
|
+
measurement names: O.index names must be ('sensor', 'time_step')
|
|
340
|
+
:param None | np.array | float | dict R: measurement noise covariance matrix (w*p x w*p)
|
|
341
|
+
can also be set as pd.DataFrame where R.index = R.columns = O.index
|
|
342
|
+
can also be a scaler where R = R * I_(nxn)
|
|
343
|
+
can also be dict where keys must correspond to the 'sensor' index in O data-frame
|
|
344
|
+
if None, then R = I_(nxn)
|
|
345
|
+
:param float lam: lamda parameter, if lam='limit' compute F^-1 symbolically, otherwise use Chernoff inverse
|
|
346
|
+
:param bool force_R_scalar: force R to be a scalar, useful when the resulting R matrix is too big to fit in memory
|
|
347
|
+
:param None | tuple | list states: list of states to use from O's. ex: ['g', 'd']
|
|
348
|
+
:param None | tuple | list sensors: list of sensors to use from O's, ex: ['r']
|
|
349
|
+
:param None | tuple | list | np.array time_steps: array of time steps to use from O's, ex: np.array([0, 1, 2])
|
|
350
|
+
:param None | tuple | list | np.array w: window size to use from O's,
|
|
351
|
+
if None then just grab it from O as the maximum window size """
|
|
352
|
+
|
|
353
|
+
# Make O a data-frame
|
|
354
|
+
self.pw = O.shape[0] # number of sensors * time-steps
|
|
355
|
+
self.n = O.shape[1] # number of states
|
|
356
|
+
if isinstance(O, pd.DataFrame): # data-frame given
|
|
357
|
+
self.O = O.copy()
|
|
358
|
+
self.sensor_names = tuple(O.index.get_level_values('sensor'))
|
|
359
|
+
self.state_names = tuple(O.columns)
|
|
360
|
+
elif isinstance(O, np.ndarray): # array given
|
|
361
|
+
self.sensor_names = tuple(['y' for _ in range(self.pw)])
|
|
362
|
+
self.state_names = tuple(['x_' + str(n) for n in range(self.n)])
|
|
363
|
+
self.O = pd.DataFrame(O, index=self.sensor_names, columns=self.state_names)
|
|
364
|
+
else:
|
|
365
|
+
raise TypeError('O is not a pandas data-frame or numpy array')
|
|
366
|
+
|
|
367
|
+
# Set window size
|
|
368
|
+
if w is None: # set automatically
|
|
369
|
+
self.w = np.max(np.array(self.O.index.get_level_values('time_step'))) + 1
|
|
370
|
+
else:
|
|
371
|
+
self.w = w
|
|
372
|
+
|
|
373
|
+
# Set the states to use
|
|
374
|
+
if states is None:
|
|
375
|
+
self.states = self.O.columns
|
|
376
|
+
else:
|
|
377
|
+
self.states = states
|
|
378
|
+
|
|
379
|
+
# Set the sensors to use
|
|
380
|
+
if sensors is None:
|
|
381
|
+
self.sensors = self.O.index.get_level_values('sensor')
|
|
382
|
+
else:
|
|
383
|
+
self.sensors = sensors
|
|
384
|
+
|
|
385
|
+
# Set the time-steps to use
|
|
386
|
+
if time_steps is None:
|
|
387
|
+
self.time_steps = self.O.index.get_level_values('time_step')
|
|
388
|
+
else:
|
|
389
|
+
self.time_steps = np.array(time_steps)
|
|
390
|
+
|
|
391
|
+
# Get subset of O
|
|
392
|
+
self.O = O.loc[(self.sensors, self.time_steps), self.states].sort_values(['time_step', 'sensor'])
|
|
393
|
+
|
|
394
|
+
# Reset the size of O
|
|
395
|
+
self.pw = self.O.shape[0] # number of sensors * time-steps
|
|
396
|
+
self.n = self.O.shape[1] # number of states
|
|
397
|
+
|
|
398
|
+
# Set measurement noise covariance matrix & calculate Fisher Information Matrix
|
|
399
|
+
if force_R_scalar and np.isscalar(R): # scalar R
|
|
400
|
+
self.R = pd.DataFrame({'R': {'index': float(R)}})
|
|
401
|
+
self.R_inv = pd.DataFrame({'R_inv': {'index': 1 / self.R.values.squeeze()}})
|
|
402
|
+
|
|
403
|
+
# Calculate Fisher Information Matrix for scalar R
|
|
404
|
+
self.F = self.R_inv.values.squeeze() * (self.O.values.T @ self.O.values)
|
|
405
|
+
|
|
406
|
+
elif force_R_scalar and not np.isscalar(R):
|
|
407
|
+
raise Exception('R must be a scalar')
|
|
408
|
+
|
|
409
|
+
else: # non-scalar R
|
|
410
|
+
self.R = pd.DataFrame(np.eye(self.pw), index=self.O.index, columns=self.O.index)
|
|
411
|
+
self.R_inv = pd.DataFrame(np.eye(self.pw), index=self.O.index, columns=self.O.index)
|
|
412
|
+
self.set_noise_covariance(R=R)
|
|
413
|
+
|
|
414
|
+
# Calculate Fisher Information Matrix for non-scalar R
|
|
415
|
+
self.F = self.O.values.T @ self.R_inv.values.squeeze() @ self.O.values
|
|
416
|
+
|
|
417
|
+
self.F = pd.DataFrame(self.F, index=self.O.columns, columns=self.O.columns)
|
|
418
|
+
|
|
419
|
+
# Set sigma
|
|
420
|
+
if lam is None:
|
|
421
|
+
# np.linalg.eig(self.F)
|
|
422
|
+
self.lam = 0.0
|
|
423
|
+
else:
|
|
424
|
+
self.lam = lam
|
|
425
|
+
|
|
426
|
+
# Invert F
|
|
427
|
+
if self.lam == 'limit': # calculate limit with symbolic sigma
|
|
428
|
+
sigma_sym = sp.symbols('sigma')
|
|
429
|
+
F_hat = self.F.values + sp.Matrix(sigma_sym * np.eye(self.n))
|
|
430
|
+
F_hat_inv = F_hat.inv()
|
|
431
|
+
F_hat_inv_limit = F_hat_inv.applyfunc(lambda elem: sp.limit(elem, sigma_sym, 0))
|
|
432
|
+
self.F_inv = np.array(F_hat_inv_limit, dtype=np.float64)
|
|
433
|
+
else: # numeric sigma
|
|
434
|
+
F_epsilon = self.F.values + (self.lam * np.eye(self.n))
|
|
435
|
+
self.F_inv = np.linalg.inv(F_epsilon)
|
|
436
|
+
|
|
437
|
+
self.F_inv = pd.DataFrame(self.F_inv, index=self.O.columns, columns=self.O.columns)
|
|
438
|
+
|
|
439
|
+
# Pull out diagonal elements
|
|
440
|
+
self.error_variance = pd.DataFrame(np.diag(self.F_inv), index=self.O.columns).T
|
|
441
|
+
|
|
442
|
+
def set_noise_covariance(self, R=None):
|
|
443
|
+
""" Set the measurement noise covariance matrix.
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
# Preallocate the noise covariance matrix R
|
|
447
|
+
self.R = pd.DataFrame(np.eye(self.pw), index=self.O.index, columns=self.O.index)
|
|
448
|
+
|
|
449
|
+
# Set R based on values in dict
|
|
450
|
+
if isinstance(R, dict): # set each distinct sensor's noise level
|
|
451
|
+
for s in pd.unique(self.R.index.get_level_values('sensor')):
|
|
452
|
+
R_sensor = self.R.loc[[s], [s]]
|
|
453
|
+
for r in range(R_sensor.shape[0]):
|
|
454
|
+
R_sensor.iloc[r, r] = R[s]
|
|
455
|
+
|
|
456
|
+
self.R.loc[[s], [s]] = R_sensor.values
|
|
457
|
+
else:
|
|
458
|
+
if R is None: # set R as identity matrix
|
|
459
|
+
warnings.warn('R not set, defaulting to identity matrix')
|
|
460
|
+
else: # set R directly
|
|
461
|
+
if np.atleast_1d(R).shape[0] == 1: # given scalar
|
|
462
|
+
self.R = R * self.R
|
|
463
|
+
elif isinstance(R, pd.DataFrame): # matrix R in data-frame
|
|
464
|
+
self.R = R.copy()
|
|
465
|
+
elif isinstance(R, np.ndarray): # matrix in array
|
|
466
|
+
self.R = pd.DataFrame(R, index=self.R.index, columns=self.R.columns)
|
|
467
|
+
elif isinstance(R, float) or isinstance(R, int): # set as scalar multiplied by identity matrix
|
|
468
|
+
self.R = R * self.R
|
|
469
|
+
else:
|
|
470
|
+
raise Exception('R must be a dict, numpy array, pandas data-frame, or scalar value')
|
|
471
|
+
|
|
472
|
+
# Inverse of R
|
|
473
|
+
R_diagonal = np.diag(self.R.values)
|
|
474
|
+
is_diagonal = np.all(self.R.values == np.diag(R_diagonal))
|
|
475
|
+
if is_diagonal:
|
|
476
|
+
self.R_inv = np.diag(1 / R_diagonal)
|
|
477
|
+
else:
|
|
478
|
+
self.R_inv = np.linalg.inv(self.R.values)
|
|
479
|
+
|
|
480
|
+
self.R_inv = pd.DataFrame(self.R_inv, index=self.R.index, columns=self.R.index)
|
|
481
|
+
|
|
482
|
+
def get_fisher_information(self):
|
|
483
|
+
return self.F.copy(), self.F_inv.copy(), self.R.copy()
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
class SlidingFisherObservability:
|
|
487
|
+
def __init__(self, O_list, R=None, lam=1e6, time=None,
|
|
488
|
+
states=None, sensors=None, time_steps=None, w=None):
|
|
489
|
+
|
|
490
|
+
""" Compute the Fisher information matrix & inverse in sliding windows and pull put the minimum error variance.
|
|
491
|
+
|
|
492
|
+
:param list O_list: list of observability matrices O (stored as pd.DataFrame)
|
|
493
|
+
:param None | np.array | float| dict R: measurement noise covariance matrix (w*p x w*p)
|
|
494
|
+
can also be set as pd.DataFrame where R.index = R.columns = O.index
|
|
495
|
+
can also be a scaler where R = R * I_(nxn)
|
|
496
|
+
can also be dict where keys must correspond to the 'sensor' index in O data-frame
|
|
497
|
+
if None, then R = I_(nxn)
|
|
498
|
+
:param float | np.array lam: lamda parameter, if lam='limit' compute F^-1 symbolically, otherwise use Chernoff inverse
|
|
499
|
+
:param None | np.array time: time vector the same size as O_list
|
|
500
|
+
:param None | tuple | list states: list of states to use from O's. ex: ['g', 'd']
|
|
501
|
+
:param None | tuple | list sensors: list of sensors to use from O's, ex: ['r']
|
|
502
|
+
:param None | tuple | list | np.array time_steps: array of time steps to use from O's, ex: np.array([0, 1, 2])
|
|
503
|
+
:param None | tuple | list | np.array w: window size to use from O's,
|
|
504
|
+
if None then just grab it from O as the maximum window size
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
self.O_list = O_list
|
|
508
|
+
self.n_window = len(O_list)
|
|
509
|
+
|
|
510
|
+
# Set time & time-step
|
|
511
|
+
if time is None:
|
|
512
|
+
self.time = np.arange(0, self.n_window, step=1)
|
|
513
|
+
else:
|
|
514
|
+
self.time = np.array(time)
|
|
515
|
+
|
|
516
|
+
# Set time-step
|
|
517
|
+
if time is not None:
|
|
518
|
+
if self.n_window > 1: # compute time-step from vector
|
|
519
|
+
self.dt = np.mean(np.diff(self.time))
|
|
520
|
+
else:
|
|
521
|
+
self.dt = 0.0
|
|
522
|
+
else: # default is time-step of 1
|
|
523
|
+
self.dt = 1
|
|
524
|
+
|
|
525
|
+
# Compute Fisher information matrix & inverse for each sliding window
|
|
526
|
+
self.EV = [] # collect error variance data for each state over windows
|
|
527
|
+
self.FO = [] # collect FisherObservability objects over windows
|
|
528
|
+
for k in range(self.n_window): # each window
|
|
529
|
+
# Get full O
|
|
530
|
+
O = self.O_list[k]
|
|
531
|
+
|
|
532
|
+
# Compute Fisher information & inverse
|
|
533
|
+
FO = FisherObservability(O, R=R, lam=lam, states=states, sensors=sensors, time_steps=time_steps, w=w)
|
|
534
|
+
self.FO.append(FO)
|
|
535
|
+
|
|
536
|
+
# Collect error variance data
|
|
537
|
+
ev = FO.error_variance.copy()
|
|
538
|
+
ev.insert(0, 'time_initial', self.time[k])
|
|
539
|
+
self.EV.append(ev)
|
|
540
|
+
|
|
541
|
+
# Concatenate error variance & make same size as simulation data
|
|
542
|
+
self.shift_index = int(np.round((1 / 2) * float(FO.w)))
|
|
543
|
+
self.shift_time = self.shift_index * self.dt # shift the time forward by half the window size
|
|
544
|
+
self.EV = pd.concat(self.EV, axis=0, ignore_index=True)
|
|
545
|
+
if self.n_window > 1: # more than 1 window
|
|
546
|
+
self.EV.index = np.arange(self.shift_index, self.EV.shape[0] + self.shift_index, step=1, dtype=int)
|
|
547
|
+
time_df = pd.DataFrame(np.atleast_2d(self.time).T, columns=['time'])
|
|
548
|
+
self.EV_aligned = pd.concat((time_df, self.EV), axis=1)
|
|
549
|
+
else:
|
|
550
|
+
self.EV_aligned = self.EV.copy()
|
|
551
|
+
|
|
552
|
+
def get_minimum_error_variance(self):
|
|
553
|
+
return self.EV_aligned.copy()
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def transform_states(O=None, square_flag=False, z_function=None, x0=None, z_state_names=None):
|
|
557
|
+
""" Transform the coordinates of an observability matrix (O) or Fisher information matrix (F)
|
|
558
|
+
from the original coordinates (x) to new user defined coordinates (z).
|
|
559
|
+
|
|
560
|
+
:param O: observability matrix or Fisher information matrix
|
|
561
|
+
:param boolean square_flag: whether to square the transform Jacobian or not
|
|
562
|
+
should be set to False if passing squared an observability matrix as O
|
|
563
|
+
should be set to True if passing squared a Fisher information matrix as O
|
|
564
|
+
:param callable z_function: function that transforms coordinates from original to new states
|
|
565
|
+
must be of the form z = z_function(x), where x & z are the same size
|
|
566
|
+
should use sympy functions wherever possible
|
|
567
|
+
:param np.array x0: initial state in original coordinates
|
|
568
|
+
:param list | tuple z_state_names: (optional) names of states in new coordinates.
|
|
569
|
+
will only have an effect if O or F is a data-frame
|
|
570
|
+
|
|
571
|
+
:return:
|
|
572
|
+
Z: observability matrix or Fisher information matrix in transformed coordinates
|
|
573
|
+
dzdx: numerical Jacobian dz/dx (inverse of dx/dz) evaluated at x0
|
|
574
|
+
dxdz_sym: symbolic Jacobian dx/dz
|
|
575
|
+
"""
|
|
576
|
+
|
|
577
|
+
# Symbolic vector of original states
|
|
578
|
+
x_sym = sp.symbols('x_0:%d' % O.shape[1])
|
|
579
|
+
|
|
580
|
+
# Initialize the Jacobian calculator with a Python function
|
|
581
|
+
jacobian_calculator_func = SymbolicJacobian(func=z_function, state_vars=x_sym)
|
|
582
|
+
|
|
583
|
+
# Get the symbolic Jacobian dx/dz
|
|
584
|
+
dxdz_sym = jacobian_calculator_func.jacobian_symbolic
|
|
585
|
+
|
|
586
|
+
# Get the Jacobian calculator function
|
|
587
|
+
dxdz_function = jacobian_calculator_func.get_jacobian_function()
|
|
588
|
+
|
|
589
|
+
# Evaluate the Jacobian at x0
|
|
590
|
+
dxdz = dxdz_function(np.array(x0))
|
|
591
|
+
|
|
592
|
+
# Take the inverse
|
|
593
|
+
dzdx = np.linalg.inv(dxdz)
|
|
594
|
+
|
|
595
|
+
# Compute the new O or F
|
|
596
|
+
if square_flag: # F
|
|
597
|
+
O_z = dzdx.T @ O @ dzdx
|
|
598
|
+
else: # O
|
|
599
|
+
O_z = O @ dzdx
|
|
600
|
+
|
|
601
|
+
# Set column/index names if data-frame was passed
|
|
602
|
+
if isinstance(O_z, pd.DataFrame):
|
|
603
|
+
if z_state_names is not None:
|
|
604
|
+
O_z.columns = z_state_names
|
|
605
|
+
if square_flag:
|
|
606
|
+
O_z.index = z_state_names
|
|
607
|
+
|
|
608
|
+
return O_z, dzdx, dxdz_sym
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
class ObservabilityMatrixImage:
|
|
612
|
+
def __init__(self, O, state_names=None, sensor_names=None, vmax_percentile=100, vmin_ratio=1.0, cmap='bwr'):
|
|
613
|
+
""" Display an image of an observability matrix.
|
|
614
|
+
"""
|
|
615
|
+
|
|
616
|
+
# Plotting parameters
|
|
617
|
+
self.vmax_percentile = vmax_percentile
|
|
618
|
+
self.vmin_ratio = vmin_ratio
|
|
619
|
+
self.cmap = cmap
|
|
620
|
+
self.crange = None
|
|
621
|
+
self.fig = None
|
|
622
|
+
self.ax = None
|
|
623
|
+
self.cbar = None
|
|
624
|
+
|
|
625
|
+
# Get O
|
|
626
|
+
self.pw, self.n = O.shape
|
|
627
|
+
if isinstance(O, pd.DataFrame): # data-frame
|
|
628
|
+
self.O = O.copy() # O in matrix form
|
|
629
|
+
|
|
630
|
+
# Default state names based on data-frame columns
|
|
631
|
+
self.state_names_default = list(O.columns)
|
|
632
|
+
|
|
633
|
+
# Default sensor names based on data-frame 'sensor' index
|
|
634
|
+
sensor_names_all = list(np.unique(O.index.get_level_values('sensor')))
|
|
635
|
+
self.sensors = list(O.index.get_level_values('sensor'))
|
|
636
|
+
self.time_steps = np.array(O.index.get_level_values('time_step'))
|
|
637
|
+
self.sensor_names_default = self.sensors[0:len(sensor_names_all)]
|
|
638
|
+
self.time_steps_default = np.unique(self.time_steps)
|
|
639
|
+
else: # numpy matrix
|
|
640
|
+
raise TypeError('n-sensor must be an integer value when O is given as a numpy matrix')
|
|
641
|
+
|
|
642
|
+
self.n_sensor = len(self.sensor_names_default) # number of sensors
|
|
643
|
+
self.n_time_step = int(self.pw / self.n_sensor) # number of time-steps
|
|
644
|
+
|
|
645
|
+
# Set state names
|
|
646
|
+
if state_names is not None:
|
|
647
|
+
if len(state_names) == self.n:
|
|
648
|
+
self.state_names = state_names.copy()
|
|
649
|
+
elif len(state_names) == 1:
|
|
650
|
+
self.state_names = ['$' + state_names[0] + '_{' + str(n) + '}$' for n in range(1, self.n + 1)]
|
|
651
|
+
else:
|
|
652
|
+
raise TypeError('state_names must be of length n or length 1')
|
|
653
|
+
else:
|
|
654
|
+
self.state_names = self.state_names_default.copy()
|
|
655
|
+
|
|
656
|
+
# Convert to Latex
|
|
657
|
+
LatexConverter = LatexStates()
|
|
658
|
+
self.state_names = LatexConverter.convert_to_latex(self.state_names)
|
|
659
|
+
|
|
660
|
+
# Set sensor & measurement names
|
|
661
|
+
if sensor_names is not None:
|
|
662
|
+
if len(sensor_names) == self.n_sensor:
|
|
663
|
+
self.sensor_names = sensor_names.copy()
|
|
664
|
+
self.sensor_names = LatexConverter.convert_to_latex(self.sensor_names, remove_dollar_signs=True)
|
|
665
|
+
self.measurement_names = []
|
|
666
|
+
for w in range(self.n_time_step):
|
|
667
|
+
for p in range(self.n_sensor):
|
|
668
|
+
m = '$' + self.sensor_names[p] + ',_{' + 'k=' + str(self.time_steps_default[w]) + '}$'
|
|
669
|
+
self.measurement_names.append(m)
|
|
670
|
+
|
|
671
|
+
elif len(sensor_names) == 1:
|
|
672
|
+
self.sensor_names = [sensor_names[0] + '_{' + str(n) + '}$' for n in range(1, self.n_sensor + 1)]
|
|
673
|
+
self.sensor_names = LatexConverter.convert_to_latex(self.sensor_names, remove_dollar_signs=True)
|
|
674
|
+
self.measurement_names = []
|
|
675
|
+
for w in range(self.n_time_step):
|
|
676
|
+
for p in range(self.n_sensor):
|
|
677
|
+
m = '$' + sensor_names[0] + '_{' + str(p) + ',k=' + str(self.time_steps_default[w]) + '}$'
|
|
678
|
+
self.measurement_names.append(m)
|
|
679
|
+
else:
|
|
680
|
+
raise TypeError('sensor_names must be of length p or length 1')
|
|
681
|
+
|
|
682
|
+
else:
|
|
683
|
+
self.sensor_names = self.sensor_names_default.copy()
|
|
684
|
+
self.sensor_names = LatexConverter.convert_to_latex(self.sensor_names, remove_dollar_signs=True)
|
|
685
|
+
self.measurement_names = []
|
|
686
|
+
for w in range(self.n_time_step):
|
|
687
|
+
for p in range(self.n_sensor):
|
|
688
|
+
m = '$' + self.sensor_names[p] + '_{' + ',k=' + str(self.time_steps_default[w]) + '}$'
|
|
689
|
+
self.measurement_names.append(m)
|
|
690
|
+
|
|
691
|
+
def plot(self, vmax_percentile=100, vmin_ratio=0.0, vmax_override=None, cmap='bwr', grid=True, scale=1.0, dpi=150,
|
|
692
|
+
ax=None):
|
|
693
|
+
""" Plot the observability matrix.
|
|
694
|
+
"""
|
|
695
|
+
|
|
696
|
+
# Plot properties
|
|
697
|
+
self.vmax_percentile = vmax_percentile
|
|
698
|
+
self.vmin_ratio = vmin_ratio
|
|
699
|
+
self.cmap = cmap
|
|
700
|
+
|
|
701
|
+
if vmax_override is None:
|
|
702
|
+
self.crange = np.percentile(np.abs(self.O), self.vmax_percentile)
|
|
703
|
+
else:
|
|
704
|
+
self.crange = vmax_override
|
|
705
|
+
|
|
706
|
+
# Display O
|
|
707
|
+
O_disp = self.O.values
|
|
708
|
+
# O_disp = np.nan_to_num(np.sign(O_disp) * np.log(np.abs(O_disp)), nan=0.0)
|
|
709
|
+
for n in range(self.n):
|
|
710
|
+
for m in range(self.pw):
|
|
711
|
+
oval = O_disp[m, n]
|
|
712
|
+
if (np.abs(oval) < (self.vmin_ratio * self.crange)) and (np.abs(oval) > 1e-6):
|
|
713
|
+
O_disp[m, n] = self.vmin_ratio * self.crange * np.sign(oval)
|
|
714
|
+
|
|
715
|
+
# Plot
|
|
716
|
+
if ax is None:
|
|
717
|
+
fig, ax = plt.subplots(1, 1, figsize=(0.3 * self.n * scale, 0.3 * self.pw * scale),
|
|
718
|
+
dpi=dpi)
|
|
719
|
+
else:
|
|
720
|
+
fig = None
|
|
721
|
+
|
|
722
|
+
O_data = ax.imshow(O_disp, vmin=-self.crange, vmax=self.crange, cmap=self.cmap)
|
|
723
|
+
ax.grid(visible=False)
|
|
724
|
+
|
|
725
|
+
ax.set_xlim(-0.5, self.n - 0.5)
|
|
726
|
+
ax.set_ylim(self.pw - 0.5, -0.5)
|
|
727
|
+
|
|
728
|
+
ax.set_xticks(np.arange(0, self.n))
|
|
729
|
+
ax.set_yticks(np.arange(0, self.pw))
|
|
730
|
+
|
|
731
|
+
ax.set_xlabel('States', fontsize=10, fontweight='bold')
|
|
732
|
+
ax.set_ylabel('Measurements', fontsize=10, fontweight='bold')
|
|
733
|
+
|
|
734
|
+
ax.set_xticklabels(self.state_names)
|
|
735
|
+
ax.set_yticklabels(self.measurement_names)
|
|
736
|
+
|
|
737
|
+
ax.tick_params(axis='x', which='major', labelsize=7, pad=-1.0)
|
|
738
|
+
ax.tick_params(axis='y', which='major', labelsize=7, pad=-0.0, left=False)
|
|
739
|
+
ax.tick_params(axis='x', which='both', top=False, labeltop=True, bottom=False, labelbottom=False)
|
|
740
|
+
ax.xaxis.set_label_position('top')
|
|
741
|
+
plt.setp(ax.xaxis.get_majorticklabels(), rotation=0, ha='center')
|
|
742
|
+
|
|
743
|
+
# Draw grid
|
|
744
|
+
if grid:
|
|
745
|
+
grid_color = [0.8, 0.8, 0.8, 1.0]
|
|
746
|
+
grid_lw = 1.0
|
|
747
|
+
for n in np.arange(-0.5, self.pw + 1.5):
|
|
748
|
+
ax.axhline(y=n, color=grid_color, linewidth=grid_lw)
|
|
749
|
+
for n in np.arange(-0.5, self.n + 1.5):
|
|
750
|
+
ax.axvline(x=n, color=grid_color, linewidth=grid_lw)
|
|
751
|
+
|
|
752
|
+
# Make colorbar
|
|
753
|
+
axins = inset_axes(ax, width='100%', height=0.1, loc='lower left',
|
|
754
|
+
bbox_to_anchor=(0.0, -1.0 * (1.0 / self.pw), 1, 1), bbox_transform=ax.transAxes,
|
|
755
|
+
borderpad=0)
|
|
756
|
+
|
|
757
|
+
cbar = plt.colorbar(O_data, cax=axins, orientation='horizontal')
|
|
758
|
+
cbar.ax.tick_params(labelsize=8)
|
|
759
|
+
cbar.set_label('matrix values', fontsize=9, fontweight='bold', rotation=0)
|
|
760
|
+
|
|
761
|
+
# Store figure & axis
|
|
762
|
+
self.fig = fig
|
|
763
|
+
self.ax = ax
|
|
764
|
+
self.cbar = cbar
|