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.
@@ -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