pybounds 0.0.1__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.

Potentially problematic release.


This version of pybounds might be problematic. Click here for more details.

pybounds/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+
2
+ from .simulator import Simulator
3
+
4
+ from .observability import EmpiricalObservabilityMatrix
5
+ from .observability import SlidingEmpiricalObservabilityMatrix
6
+ from .observability import FisherObservability
7
+ from .observability import SlidingFisherObservability
8
+ from .observability import ObservabilityMatrixImage
9
+
10
+ from .util import colorline
11
+
@@ -0,0 +1,579 @@
1
+
2
+ import numpy as np
3
+ import pandas as pd
4
+ from multiprocessing import Pool
5
+ import warnings
6
+ import matplotlib as mpl
7
+ import matplotlib.pyplot as plt
8
+ from mpl_toolkits.axes_grid1.inset_locator import inset_axes
9
+ import sympy as sp
10
+ from util import LatexStates
11
+
12
+
13
+ class EmpiricalObservabilityMatrix:
14
+ def __init__(self, simulator, x0, t_sim, u_sim, eps=1e-5, parallel=False):
15
+ """ Construct an empirical observability matrix O.
16
+
17
+ Inputs
18
+ simulator: simulator object: y = simulator(x0, t_sim, u_sim)
19
+ x0: initial state
20
+ t_sim: simulation time
21
+ u_sim: simulation inputs
22
+ eps: amount to perturb initial states
23
+ """
24
+
25
+ # Store inputs
26
+ self.simulator = simulator
27
+ self.t_sim = t_sim.copy()
28
+ self.eps = eps
29
+ self.parallel = parallel
30
+
31
+ if isinstance(x0, dict):
32
+ self.x0 = np.array(list(x0.values()))
33
+ else:
34
+ self.x0 = np.array(x0).squeeze()
35
+
36
+ if isinstance(u_sim, dict):
37
+ self.u_sim = np.vstack(list(u_sim.values())).T
38
+ else:
39
+ self.u_sim = np.array(u_sim)
40
+
41
+ # Simulate once for nominal trajectory
42
+ self.y_nominal = self.simulator.simulate(self.x0, self.u_sim)
43
+ self.x_nominal = self.simulator.x.copy()
44
+ self.u_nominal = self.simulator.u.copy()
45
+ # self.sim_data_nominal = self.simulator.sim_data.copy()
46
+
47
+ # Perturbation amounts
48
+ self.w = len(t_sim) # of points in time window
49
+ self.delta_x = eps * np.eye(self.simulator.n) # perturbation amount for each state
50
+ self.delta_y = np.zeros((self.simulator.p, self.simulator.n, self.w)) # preallocate delta_y
51
+ self.y_plus = np.zeros((self.w, self.simulator.n, self.simulator.p))
52
+ self.y_minus = np.zeros((self.w, self.simulator.n, self.simulator.p))
53
+
54
+ # Observability matrix
55
+ self.O = np.nan * np.zeros((self.simulator.p * self.w, self.simulator.n))
56
+ self.O_df = pd.DataFrame(self.O)
57
+
58
+ # Set measurement names
59
+ self.measurement_labels = []
60
+ self.time_labels = []
61
+ for w in range(self.w):
62
+ tl = (w * np.ones(self.simulator.p)).astype(int)
63
+ self.time_labels.append(tl)
64
+ self.measurement_labels = self.measurement_labels + list(self.simulator.output_mode)
65
+
66
+ self.time_labels = np.hstack(self.time_labels)
67
+
68
+ # Run
69
+ self.run()
70
+
71
+ def run(self, parallel=None):
72
+ """ Construct empirical observability matrix.
73
+ """
74
+
75
+ if parallel is not None:
76
+ self.parallel = parallel
77
+
78
+ # Run simulations for perturbed initial conditions
79
+ state_index = np.arange(0, self.simulator.n).tolist()
80
+ if self.parallel: # multiprocessing
81
+ with Pool(4) as pool:
82
+ results = pool.map(self.simulate, state_index)
83
+
84
+ for n, r in enumerate(results):
85
+ delta_y, y_plus, y_minus = r
86
+ self.delta_y[:, n, :] = delta_y
87
+ self.y_plus[:, n, :] = y_plus
88
+ self.y_minus[:, n, :] = y_minus
89
+
90
+ else: # sequential
91
+ for n in state_index:
92
+ delta_y, y_plus, y_minus = self.simulate(n)
93
+ self.delta_y[:, n, :] = delta_y
94
+ self.y_plus[:, n, :] = y_plus
95
+ self.y_minus[:, n, :] = y_minus
96
+
97
+ # Construct O by stacking the 3rd dimension of delta_y along the 1st dimension, O is a (p*w x n) matrix
98
+ self.O = np.zeros((self.simulator.p * self.w, self.simulator.n))
99
+ for w in range(self.w):
100
+ if w == 0:
101
+ start_index = 0
102
+ else:
103
+ start_index = int(w * self.simulator.p)
104
+
105
+ end_index = start_index + self.simulator.p
106
+ self.O[start_index:end_index] = self.delta_y[:, :, w]
107
+
108
+ # Make O into a data-frame for interpretability
109
+ self.O_df = pd.DataFrame(self.O, columns=self.simulator.state_names, index=self.measurement_labels)
110
+ self.O_df['time_step'] = self.time_labels
111
+ self.O_df = self.O_df.set_index('time_step', append=True)
112
+ self.O_df.index.names = ['sensor', 'time_step']
113
+
114
+ def simulate(self, n):
115
+ """ Run the simulator for specified state index (n).
116
+ """
117
+
118
+ # Perturb initial condition in both directions
119
+ x0_plus = self.x0 + self.delta_x[:, n]
120
+ x0_minus = self.x0 - self.delta_x[:, n]
121
+
122
+ # Simulate measurements from perturbed initial conditions
123
+ y_plus = self.simulator.simulate(x0=x0_plus, u=self.u_sim)
124
+ y_minus = self.simulator.simulate(x0=x0_minus, u=self.u_sim)
125
+
126
+ # Calculate the numerical Jacobian & normalize by 2x the perturbation amount
127
+ delta_y = np.array(y_plus - y_minus).T / (2 * self.eps)
128
+
129
+ return delta_y, y_plus, y_minus
130
+
131
+
132
+ class SlidingEmpiricalObservabilityMatrix:
133
+ def __init__(self, simulator, t_sim, x_sim, u_sim, w=None, eps=1e-5, parallel=False):
134
+ """ Construct an empirical observability matrix O in sliding windows along a trajectory.
135
+
136
+ Inputs
137
+ simulator: simulator object
138
+ t_sim: simulation time along trajectory
139
+ u_sim: simulation inputs along trajectory
140
+ x_sim: state trajectory
141
+ w: simulation window size for each calculation of O
142
+ eps: amount to perturb initial state
143
+ """
144
+
145
+ self.simulator = simulator
146
+ self.t_sim = t_sim.copy()
147
+ self.eps = eps
148
+ self.parallel = parallel
149
+
150
+ if isinstance(x_sim, dict):
151
+ self.x_sim = np.vstack((list(x_sim.values()))).T
152
+ else:
153
+ self.x_sim = np.array(x_sim).squeeze()
154
+
155
+ if isinstance(u_sim, dict):
156
+ self.u_sim = np.vstack(list(u_sim.values())).T
157
+ else:
158
+ self.u_sim = np.array(u_sim).squeeze()
159
+
160
+ # self.dt = np.round(np.mean(np.diff(self.t_sim)), 6)
161
+ self.N = self.t_sim.shape[0]
162
+
163
+ if w is None: # set window size to full time-series size
164
+ self.w = self.N
165
+ else:
166
+ self.w = w
167
+
168
+ if self.w > self.N:
169
+ raise ValueError('Window size must be smaller than trajectory length')
170
+
171
+ # All the indices to calculate O
172
+ self.O_index = np.arange(0, self.N - self.w + 1, step=1) # indices to compute O
173
+ self.O_time = self.t_sim[self.O_index] # times to compute O
174
+ self.n_point = len(self.O_index) # # of times to calculate O
175
+
176
+ # Where to store sliding window trajectory data & O's
177
+ self.window_data = {'t': [], 'u': [], 'x': [], 'y': [], 'y_plus': [], 'y_minus': []}
178
+ self.O_sliding = []
179
+ self.O_df_sliding = []
180
+
181
+ # Run
182
+ self.EOM = None
183
+ self.run()
184
+
185
+ def run(self, parallel=None):
186
+ """ Run.
187
+ """
188
+
189
+ if parallel is not None:
190
+ self.parallel = parallel
191
+
192
+ # Where to store sliding window trajectory data & O's
193
+ self.window_data = {'t': [], 'u': [], 'x': [], 'y': [], 'y_plus': [], 'y_minus': []}
194
+ self.O_sliding = []
195
+ self.O_df_sliding = []
196
+
197
+ # Construct O's
198
+ n_point_range = np.arange(0, self.n_point).astype(int)
199
+ if self.parallel: # multiprocessing
200
+ with Pool(4) as pool:
201
+ results = pool.map(self.construct, n_point_range)
202
+ for r in results:
203
+ self.O_sliding.append(r[0])
204
+ self.O_df_sliding.append(r[1])
205
+ for k in self.window_data.keys():
206
+ self.window_data[k].append(r[2][k])
207
+
208
+ else:
209
+ for n in n_point_range: # each point on trajectory
210
+ O_sliding, O_df_sliding, window_data = self.construct(n)
211
+ self.O_sliding.append(O_sliding)
212
+ self.O_df_sliding.append(O_df_sliding)
213
+ for k in self.window_data.keys():
214
+ self.window_data[k].append(window_data[k])
215
+
216
+ def construct(self, n):
217
+ # Start simulation at point along nominal trajectory
218
+ x0 = np.squeeze(self.x_sim[self.O_index[n], :]) # get state on trajectory & set it as the initial condition
219
+
220
+ # Get the range to pull out time & input data for simulation
221
+ win = np.arange(self.O_index[n], self.O_index[n] + self.w, step=1) # index range
222
+
223
+ # Remove part of window if it is past the end of the nominal trajectory
224
+ within_win = win < self.N
225
+ win = win[within_win]
226
+
227
+ # Pull out time & control inputs in window
228
+ t_win = self.t_sim[win] # time in window
229
+ t_win0 = t_win - t_win[0] # start at 0
230
+ u_win = self.u_sim[win, :] # inputs in window
231
+
232
+ # Calculate O for window
233
+ EOM = EmpiricalObservabilityMatrix(self.simulator, x0, t_win0, u_win, eps=self.eps, parallel=False)
234
+ self.EOM = EOM
235
+
236
+ # Store data
237
+ O_sliding = EOM.O.copy()
238
+ O_df_sliding = EOM.O_df.copy()
239
+
240
+ window_data = {'t': t_win.copy(),
241
+ 'u': u_win.copy(),
242
+ 'x': EOM.x_nominal.copy(),
243
+ 'y': EOM.y_nominal.copy(),
244
+ 'y_plus': EOM.y_plus.copy(),
245
+ 'y_minus': EOM.y_minus.copy()}
246
+
247
+ return O_sliding, O_df_sliding, window_data
248
+
249
+
250
+ class FisherObservability:
251
+ def __init__(self, O, R=None, sensor_noise_dict=None, sigma=None):
252
+ """ Evaluate the observability of a state variable(s) using the Fisher Information Matrix.
253
+
254
+ Inputs
255
+ O: observability matrix. Can be numpy array or pandas data-frame (pxw x n)
256
+ R: measurement noise covariance matrix (p x p)
257
+ beta: reconstruction error bound for binary observability
258
+ epsilon: F = F + epsilon*I if not None
259
+ """
260
+
261
+ # Make O a data-frame
262
+ self.pw = O.shape[0] # number of sensors * time-steps
263
+ self.n = O.shape[1] # number of states
264
+ if isinstance(O, pd.DataFrame): # data-frame given
265
+ self.O = O.copy()
266
+ self.sensor_names = tuple(O.index.get_level_values('sensor'))
267
+ self.state_names = tuple(O.columns)
268
+ elif isinstance(O, np.ndarray): # array given
269
+ self.sensor_names = tuple(['y' for n in range(self.pw)])
270
+ self.state_names = tuple(['x_' + str(n) for n in range(self.n)])
271
+ self.O = pd.DataFrame(O, index=self.sensor_names, columns=self.state_names)
272
+ else:
273
+ raise TypeError('O is not a pandas data-frame or numpy array')
274
+
275
+ # Set measurement noise covariance matrix
276
+ self.R = pd.DataFrame(np.eye(self.pw), index=self.O.index, columns=self.O.index)
277
+ self.R_inv = pd.DataFrame(np.eye(self.pw), index=self.O.index, columns=self.O.index)
278
+ self.set_noise_covariance(R=R, sensor_noise_dict=sensor_noise_dict)
279
+
280
+ # Calculate Fisher Information Matrix
281
+ self.F = self.O.values.T @ self.R_inv.values @ self.O.values
282
+ self.F = pd.DataFrame(self.F, index=O.columns, columns=O.columns)
283
+
284
+ # Set sigma
285
+ if sigma is None:
286
+ # np.linalg.eig(self.F)
287
+ self.sigma = 0.0
288
+ else:
289
+ self.sigma = sigma
290
+
291
+ # Invert F
292
+ if self.sigma == 'limit': # calculate limit with symbolic sigma
293
+ sigma_sym = sp.symbols('sigma')
294
+ F_hat = self.F.values + sp.Matrix(sigma_sym * np.eye(self.n))
295
+ F_hat_inv = F_hat.inv()
296
+ F_hat_inv_limit = F_hat_inv.applyfunc(lambda elem: sp.limit(elem, sigma_sym, 0))
297
+ self.F_inv = np.array(F_hat_inv_limit, dtype=np.float64)
298
+ else: # numeric sigma
299
+ F_epsilon = self.F.values + (self.sigma * np.eye(self.n))
300
+ self.F_inv = np.linalg.inv(F_epsilon)
301
+
302
+ self.F_inv = pd.DataFrame(self.F_inv, index=O.columns, columns=O.columns)
303
+
304
+ # Pull out diagonal elements
305
+ self.error_variance = pd.DataFrame(np.diag(self.F_inv), index=self.O.columns).T
306
+
307
+ def set_noise_covariance(self, R=None, sensor_noise_dict=None):
308
+ """ Set the measurement noise covariance matrix.
309
+ """
310
+
311
+ # Preallocate the noise covariance matrix R
312
+ self.R = pd.DataFrame(np.eye(self.pw), index=self.O.index, columns=self.O.index)
313
+
314
+ # Set R based on values in dict
315
+ if sensor_noise_dict is not None: # set each distinct sensor's noise level
316
+ if R is not None:
317
+ raise Exception('R can not be set directly if sensor_noise_dict is set')
318
+ else:
319
+ # for s in self.R.index.levels[0]:
320
+ for s in pd.unique(self.R.index.get_level_values('sensor')):
321
+ R_sensor = self.R.loc[[s], [s]]
322
+ for r in range(R_sensor.shape[0]):
323
+ R_sensor.iloc[r, r] = sensor_noise_dict[s]
324
+
325
+ self.R.loc[[s], [s]] = R_sensor.values
326
+ else:
327
+ if R is None:
328
+ warnings.warn('R not set, defaulting to identity matrix')
329
+ else: # set R directly
330
+ if np.atleast_1d(R).shape[0] == 1: # given scalar
331
+ self.R = R * self.R
332
+ elif isinstance(R, pd.DataFrame): # matrix R in data-frame
333
+ self.R = R.copy()
334
+ elif isinstance(R, np.ndarray): # matrix in array
335
+ self.R = pd.DataFrame(R, index=self.R.index, columns=self.R.columns)
336
+ else:
337
+ raise Exception('R must be a numpy array, pandas data-frame, or scalar value')
338
+
339
+ # Inverse of R
340
+ R_diagonal = np.diag(self.R.values)
341
+ is_diagonal = np.all(self.R.values == np.diag(R_diagonal))
342
+ if is_diagonal:
343
+ self.R_inv = np.diag(1 / R_diagonal)
344
+ else:
345
+ self.R_inv = np.linalg.inv(self.R.values)
346
+
347
+ self.R_inv = pd.DataFrame(self.R_inv, index=self.R.index, columns=self.R.index)
348
+
349
+
350
+ class SlidingFisherObservability:
351
+ def __init__(self, O_list, time=None, sigma=1e6, R=None, sensor_noise_dict=None,
352
+ states=None, sensors=None, time_steps=None, w=None):
353
+
354
+ """ Compute the Fisher information matrix & inverse in sliding windows and pull put the minimum error variance.
355
+ :param O_list: list of observability matrices O
356
+ :param time: time vector the same size as O_list
357
+ :param states: list of states to use from O's
358
+ :param sensors: list of sensors to use from O's
359
+ :param time_steps: list of time steps to use from O's
360
+ """
361
+
362
+ self.O_list = O_list
363
+ self.n_window = len(O_list)
364
+
365
+ # Set time & time-step
366
+ if time is None:
367
+ self.time = np.arange(0, self.n_window, step=1)
368
+ else:
369
+ self.time = time
370
+
371
+ self.dt = np.mean(np.diff(self.time))
372
+
373
+ # Get single O
374
+ O = O_list[0]
375
+
376
+ # Set window size
377
+ if w is None: # set automatically
378
+ self.w = np.max(np.array(O.index.get_level_values('time_step'))) + 1
379
+ else:
380
+ self.w = w
381
+
382
+ # Set the states to use
383
+ if states is None:
384
+ self.states = O.columns
385
+ else:
386
+ self.states = states
387
+
388
+ # Set the sensors to use
389
+ if sensors is None:
390
+ self.sensors = O.index.get_level_values('sensor')
391
+ else:
392
+ self.sensors = sensors
393
+
394
+ # Set the time-steps to use
395
+ if time_steps is None:
396
+ self.time_steps = O.index.get_level_values('time_step')
397
+ else:
398
+ self.time_steps = time_steps
399
+
400
+ # Compute Fisher information matrix & inverse for each sliding window
401
+ self.EV = [] # collect error variance data for each state over time
402
+ self.FO = []
403
+ self.shift_index = int(np.round((1 / 2) * self.w))
404
+ self.shift_time = self.shift_index * self.dt # shift the time forward by half the window size
405
+ for k in range(self.n_window): # each window
406
+ # Get full O
407
+ O = self.O_list[k]
408
+
409
+ # Get subset of O
410
+ O_subset = O.loc[(self.sensors, self.time_steps), self.states].sort_values(['time_step', 'sensor'])
411
+
412
+ # Compute Fisher information & inverse
413
+ FO = FisherObservability(O_subset, sensor_noise_dict=sensor_noise_dict, R=R, sigma=sigma)
414
+ self.FO.append(FO)
415
+
416
+ # Collect error variance data
417
+ ev = FO.error_variance.copy()
418
+ ev.insert(0, 'time_initial', self.time[k])
419
+ self.EV.append(ev)
420
+
421
+ # Concatenate error variance & make same size as simulation data
422
+ self.EV = pd.concat(self.EV, axis=0, ignore_index=True)
423
+ self.EV.index = np.arange(self.shift_index, self.EV.shape[0] + self.shift_index, step=1, dtype=int)
424
+ time_df = pd.DataFrame(np.atleast_2d(self.time).T, columns=['time'])
425
+ self.EV_aligned = pd.concat((time_df, self.EV), axis=1)
426
+
427
+
428
+ class ObservabilityMatrixImage:
429
+ def __init__(self, O, state_names=None, sensor_names=None, vmax_percentile=100, vmin_ratio=1.0, cmap='bwr'):
430
+ """ Display an image of an observability matrix.
431
+ """
432
+
433
+ # Plotting parameters
434
+ self.vmax_percentile = vmax_percentile
435
+ self.vmin_ratio = vmin_ratio
436
+ self.cmap = cmap
437
+ self.crange = None
438
+ self.fig = None
439
+ self.ax = None
440
+ self.cbar = None
441
+
442
+ # Get O
443
+ self.pw, self.n = O.shape
444
+ if isinstance(O, pd.DataFrame): # data-frame
445
+ self.O = O.copy() # O in matrix form
446
+
447
+ # Default state names based on data-frame columns
448
+ self.state_names_default = list(O.columns)
449
+
450
+ # Default sensor names based on data-frame 'sensor' index
451
+ sensor_names_all = list(np.unique(O.index.get_level_values('sensor')))
452
+ self.sensor_names_default = list(O.index.get_level_values('sensor')[0:len(sensor_names_all)])
453
+
454
+ else: # numpy matrix
455
+ raise TypeError('n-sensor must be an integer value when O is given as a numpy matrix')
456
+
457
+ self.n_sensor = len(self.sensor_names_default) # number of sensors
458
+ self.n_time_step = int(self.pw / self.n_sensor) # number of time-steps
459
+
460
+ # Set state names
461
+ if state_names is not None:
462
+ if len(state_names) == self.n:
463
+ self.state_names = state_names.copy()
464
+ elif len(state_names) == 1:
465
+ self.state_names = ['$' + state_names[0] + '_{' + str(n) + '}$' for n in range(1, self.n + 1)]
466
+ else:
467
+ raise TypeError('state_names must be of length n or length 1')
468
+ else:
469
+ self.state_names = self.state_names_default.copy()
470
+
471
+ # Convert to Latex
472
+ LatexConverter = LatexStates()
473
+ self.state_names = LatexConverter.convert_to_latex(self.state_names)
474
+
475
+ # Set sensor & measurement names
476
+ if sensor_names is not None:
477
+ if len(sensor_names) == self.n_sensor:
478
+ self.sensor_names = sensor_names.copy()
479
+ self.sensor_names = LatexConverter.convert_to_latex(self.sensor_names, remove_dollar_signs=True)
480
+ self.measurement_names = []
481
+ for w in range(self.n_time_step):
482
+ for p in range(self.n_sensor):
483
+ m = '$' + self.sensor_names[p] + ',_{' + 'k=' + str(w) + '}$'
484
+ self.measurement_names.append(m)
485
+
486
+ elif len(sensor_names) == 1:
487
+ self.sensor_names = [sensor_names[0] + '_{' + str(n) + '}$' for n in range(1, self.n_sensor + 1)]
488
+ self.sensor_names = LatexConverter.convert_to_latex(self.sensor_names, remove_dollar_signs=True)
489
+ self.measurement_names = []
490
+ for w in range(self.n_time_step):
491
+ for p in range(self.n_sensor):
492
+ m = '$' + sensor_names[0] + '_{' + str(p) + ',k=' + str(w) + '}$'
493
+ self.measurement_names.append(m)
494
+ else:
495
+ raise TypeError('sensor_names must be of length p or length 1')
496
+
497
+ else:
498
+ self.sensor_names = self.sensor_names_default.copy()
499
+ self.sensor_names = LatexConverter.convert_to_latex(self.sensor_names, remove_dollar_signs=True)
500
+ self.measurement_names = []
501
+ for w in range(self.n_time_step):
502
+ for p in range(self.n_sensor):
503
+ m = '$' + self.sensor_names[p] + '_{' + ',k=' + str(w) + '}$'
504
+ self.measurement_names.append(m)
505
+
506
+ def plot(self, vmax_percentile=100, vmin_ratio=0.0, vmax_override=None, cmap='bwr', grid=True, scale=1.0, dpi=150,
507
+ ax=None):
508
+ """ Plot the observability matrix.
509
+ """
510
+
511
+ # Plot properties
512
+ self.vmax_percentile = vmax_percentile
513
+ self.vmin_ratio = vmin_ratio
514
+ self.cmap = cmap
515
+
516
+ if vmax_override is None:
517
+ self.crange = np.percentile(np.abs(self.O), self.vmax_percentile)
518
+ else:
519
+ self.crange = vmax_override
520
+
521
+ # Display O
522
+ O_disp = self.O.values
523
+ # O_disp = np.nan_to_num(np.sign(O_disp) * np.log(np.abs(O_disp)), nan=0.0)
524
+ for n in range(self.n):
525
+ for m in range(self.pw):
526
+ oval = O_disp[m, n]
527
+ if (np.abs(oval) < (self.vmin_ratio * self.crange)) and (np.abs(oval) > 1e-6):
528
+ O_disp[m, n] = self.vmin_ratio * self.crange * np.sign(oval)
529
+
530
+ # Plot
531
+ if ax is None:
532
+ fig, ax = plt.subplots(1, 1, figsize=(0.3 * self.n * scale, 0.3 * self.pw * scale),
533
+ dpi=dpi)
534
+ else:
535
+ fig = None
536
+
537
+ O_data = ax.imshow(O_disp, vmin=-self.crange, vmax=self.crange, cmap=self.cmap)
538
+ ax.grid(visible=False)
539
+
540
+ ax.set_xlim(-0.5, self.n - 0.5)
541
+ ax.set_ylim(self.pw - 0.5, -0.5)
542
+
543
+ ax.set_xticks(np.arange(0, self.n))
544
+ ax.set_yticks(np.arange(0, self.pw))
545
+
546
+ ax.set_xlabel('States', fontsize=10, fontweight='bold')
547
+ ax.set_ylabel('Measurements', fontsize=10, fontweight='bold')
548
+
549
+ ax.set_xticklabels(self.state_names)
550
+ ax.set_yticklabels(self.measurement_names)
551
+
552
+ ax.tick_params(axis='x', which='major', labelsize=7, pad=-1.0)
553
+ ax.tick_params(axis='y', which='major', labelsize=7, pad=-0.0, left=False)
554
+ ax.tick_params(axis='x', which='both', top=False, labeltop=True, bottom=False, labelbottom=False)
555
+ ax.xaxis.set_label_position('top')
556
+ plt.setp(ax.xaxis.get_majorticklabels(), rotation=0, ha='center')
557
+
558
+ # Draw grid
559
+ if grid:
560
+ grid_color = [0.8, 0.8, 0.8, 1.0]
561
+ grid_lw = 1.0
562
+ for n in np.arange(-0.5, self.pw + 1.5):
563
+ ax.axhline(y=n, color=grid_color, linewidth=grid_lw)
564
+ for n in np.arange(-0.5, self.n + 1.5):
565
+ ax.axvline(x=n, color=grid_color, linewidth=grid_lw)
566
+
567
+ # Make colorbar
568
+ axins = inset_axes(ax, width='100%', height=0.1, loc='lower left',
569
+ bbox_to_anchor=(0.0, -1.0 * (1.0 / self.pw), 1, 1), bbox_transform=ax.transAxes,
570
+ borderpad=0)
571
+
572
+ cbar = plt.colorbar(O_data, cax=axins, orientation='horizontal')
573
+ cbar.ax.tick_params(labelsize=8)
574
+ cbar.set_label('matrix values', fontsize=9, fontweight='bold', rotation=0)
575
+
576
+ # Store figure & axis
577
+ self.fig = fig
578
+ self.ax = ax
579
+ self.cbar = cbar
pybounds/simulator.py ADDED
@@ -0,0 +1,241 @@
1
+
2
+ import numpy as np
3
+ import do_mpc
4
+ from util import FixedKeysDict, SetDict
5
+
6
+ # from typing import Callable
7
+
8
+
9
+ class Simulator(object):
10
+ def __init__(self, f, h, x0, u0, dt=0.01,
11
+ state_names=None, input_names=None, measurement_names=None,
12
+ params_simulator=None):
13
+ """ Simulator.
14
+ :param float f: callable dynamics function f(X, U, t)
15
+ :param float h: callable measurement function h(X, U, t)
16
+ :param float dt: sampling time in seconds
17
+ """
18
+
19
+ self.f = f
20
+ self.h = h
21
+ self.dt = dt
22
+
23
+ # Set state & input sizes
24
+ self.n = len(x0) # number of states
25
+ self.m = len(u0) # number of inputs
26
+
27
+ # Run measurement function to get measurement size
28
+ y = self.h(x0, u0, 0)
29
+ self.p = len(y) # number of measurements
30
+
31
+ # Set state names
32
+ if state_names is None: # default state names
33
+ self.state_names = ['x_' + str(n) for n in range(self.n)]
34
+ else:
35
+ self.state_names = state_names
36
+ if len(self.state_names) != self.n:
37
+ raise ValueError('state_names must have length equal to x0')
38
+
39
+ # Set input names
40
+ if input_names is None: # default measurement names
41
+ self.input_names = ['u_' + str(m) for m in range(self.m)]
42
+ else:
43
+ self.input_names = input_names
44
+ if len(self.input_names) != self.m:
45
+ raise ValueError('input_names must have length equal to u0')
46
+
47
+ # Set measurement names
48
+ if measurement_names is None: # default measurement names
49
+ self.measurement_names = ['y_' + str(p) for p in range(self.p)]
50
+ else:
51
+ self.measurement_names = measurement_names
52
+ if len(self.measurement_names) != self.p:
53
+ raise ValueError('measurement_names must have length equal to y')
54
+
55
+ self.output_mode = self.measurement_names
56
+
57
+ # Initialize time vector
58
+ w = 10 # initialize for w time-steps, but this can change later
59
+ self.time = np.arange(0, w * self.dt + self.dt / 2, step=self.dt) # time vector
60
+
61
+ # Define initial states & initialize state time-series
62
+ self.x0 = {}
63
+ self.x = {}
64
+ for n, state_name in enumerate(self.state_names):
65
+ self.x0[state_name] = x0[n]
66
+ self.x[state_name] = x0[n] * np.ones(w)
67
+
68
+ self.x0 = FixedKeysDict(self.x0)
69
+
70
+ # Initialize input time-series
71
+ self.u = {}
72
+ for m, input_name in enumerate(self.input_names):
73
+ self.u[input_name] = u0[m] * np.ones(w)
74
+
75
+ self.u = FixedKeysDict(self.u)
76
+
77
+ # Initialize measurement time-series
78
+ self.y = {}
79
+ for p, measurement_name in enumerate(self.measurement_names):
80
+ self.y[measurement_name] = 0.0 * np.ones(w)
81
+
82
+ self.y = FixedKeysDict(self.y)
83
+
84
+ # Define continuous-time MPC model
85
+ self.model = do_mpc.model.Model('continuous')
86
+
87
+ # Define state variables
88
+ X = []
89
+ for n, state_name in enumerate(self.state_names):
90
+ x = self.model.set_variable(var_type='_x', var_name=state_name, shape=(1, 1))
91
+ X.append(x)
92
+
93
+ # Define input variables
94
+ U = []
95
+ for m, input_name in enumerate(self.input_names):
96
+ u = self.model.set_variable(var_type='_u', var_name=input_name, shape=(1, 1))
97
+ U.append(u)
98
+
99
+ # Define dynamics
100
+ Xdot = self.f(X, U, 0)
101
+ for n, state_name in enumerate(self.state_names):
102
+ self.model.set_rhs(state_name, Xdot[n])
103
+
104
+ # Build model
105
+ self.model.setup()
106
+
107
+ # Define simulator & simulator parameters
108
+ self.simulator = do_mpc.simulator.Simulator(self.model)
109
+
110
+ # Set simulation parameters
111
+ if params_simulator is None:
112
+ self.params_simulator = {
113
+ 'integration_tool': 'idas', # cvodes, idas
114
+ 'abstol': 1e-8,
115
+ 'reltol': 1e-8,
116
+ 't_step': self.dt
117
+ }
118
+ else:
119
+ self.params_simulator = params_simulator
120
+
121
+ self.simulator.set_param(**self.params_simulator)
122
+ self.simulator.setup()
123
+
124
+ def set_initial_state(self, x0):
125
+ """ Update the initial state.
126
+ """
127
+
128
+ if x0 is not None: # initial state given
129
+ if isinstance(x0, dict): # in dict format
130
+ SetDict().set_dict_with_overwrite(self.x0, x0) # update only the states in the dict given
131
+ elif isinstance(x0, list) or isinstance(x0, tuple) or (
132
+ x0, np.ndarray): # list, tuple, or numpy array format
133
+ x0 = np.array(x0).squeeze()
134
+ for n, key in enumerate(self.x0.keys()): # each state
135
+ self.x0[key] = x0[n]
136
+ else:
137
+ raise Exception('x0 must be either a dict, tuple, list, or numpy array')
138
+
139
+ def set_inputs(self, u):
140
+ """ Update the inputs.
141
+ """
142
+
143
+ if u is not None: # inputs given
144
+ if isinstance(u, dict): # in dict format
145
+ SetDict().set_dict_with_overwrite(self.u, u) # update only the inputs in the dict given
146
+ elif isinstance(u, list) or isinstance(u, tuple): # list or tuple format, each input vector in each element
147
+ for n, k in enumerate(self.u.keys()): # each input
148
+ self.u[k] = u[n]
149
+ elif isinstance(u, np.ndarray): # numpy array format given as matrix where columns are the different inputs
150
+ if len(u.shape) <= 1: # given as 1d array, so convert to column vector
151
+ u = np.atleast_2d(u).T
152
+
153
+ for m, key in enumerate(self.u.keys()): # each input
154
+ self.u[key] = u[:, m]
155
+
156
+ else:
157
+ raise Exception('u must be either a dict, tuple, list, or numpy array')
158
+
159
+ # Make sure inputs are the same size
160
+ points = np.array([self.u[key].shape[0] for key in self.u.keys()])
161
+ points_check = points == points[0]
162
+ if not np.all(points_check):
163
+ raise Exception('inputs are not the same size')
164
+
165
+ def simulate(self, x0=None, u=None, return_full_output=False):
166
+ """
167
+ Simulate the system.
168
+
169
+ :params x0: initial state dict or array
170
+ :params u: input dict or array
171
+ :params output_mode: array of strings containing variable names of outputs
172
+ :params run_mpc: boolean to run MPC controller
173
+ """
174
+
175
+ # Update the initial state
176
+ self.set_initial_state(x0=x0.copy())
177
+
178
+ # Update the inputs
179
+ self.set_inputs(u=u)
180
+
181
+ # Concatenate the inputs, where rows are individual inputs and columns are time-steps
182
+ u_sim = np.vstack(list(self.u.values())).T
183
+ n_point = u_sim.shape[0]
184
+
185
+ # Update time vector
186
+ T = (n_point - 1) * self.dt
187
+ self.time = np.linspace(0, T, num=n_point)
188
+
189
+ # Set array to store simulated states, where rows are individual states and columns are time-steps
190
+ x_step = np.array(list(self.x0.values())) # initialize state
191
+ x_sim = np.nan * np.zeros((n_point, self.n))
192
+ x_sim[0, :] = x_step.copy()
193
+
194
+ # Initialize the simulator
195
+ self.simulator.t0 = self.time[0]
196
+ self.simulator.x0 = x_step.copy()
197
+ self.simulator.set_initial_guess()
198
+
199
+ # Run simulation
200
+ for k in range(1, n_point):
201
+ # Set input
202
+ u_step = u_sim[k - 1:k, :].T
203
+
204
+ # Store inputs
205
+ u_sim[k - 1, :] = u_step.squeeze()
206
+
207
+ # Simulate one time step given current inputs
208
+ x_step = self.simulator.make_step(u_step)
209
+
210
+ # Store new states
211
+ x_sim[k, :] = x_step.squeeze()
212
+
213
+ # Update the inputs
214
+ self.set_inputs(u=u_sim)
215
+
216
+ # Update state trajectory
217
+ for n, key in enumerate(self.x.keys()):
218
+ self.x[key] = x_sim[:, n]
219
+
220
+ # Calculate measurements
221
+ x_list = list(self.x.values())
222
+ u_list = list(self.u.values())
223
+ y = self.h(x_list, u_list, 0)
224
+
225
+ # Set outputs
226
+ self.y = {}
227
+ for p, measurement_name in enumerate(self.measurement_names):
228
+ self.y[measurement_name] = y[p]
229
+
230
+ # Return the outputs in array format
231
+ y_array = np.vstack(list(self.y.values())).T
232
+
233
+ if return_full_output:
234
+ return self.time.copy(), self.x.copy(), self.u.copy(), self.u.copy()
235
+ else:
236
+ return y_array
237
+
238
+ def get_time_states_input_measurements(self):
239
+ return self.time.copy(), self.x.copy(), self.u.copy(), self.u.copy()
240
+
241
+
pybounds/util.py ADDED
@@ -0,0 +1,162 @@
1
+
2
+ import numpy as np
3
+ import matplotlib.pyplot as plt
4
+ import matplotlib.collections as mcoll
5
+ import matplotlib.patheffects as path_effects
6
+
7
+ class FixedKeysDict(dict):
8
+ def __init__(self, *args, **kwargs):
9
+ super(FixedKeysDict, self).__init__(*args, **kwargs)
10
+ self._frozen_keys = set(self.keys()) # Capture initial keys
11
+
12
+ def __setitem__(self, key, value):
13
+ if key not in self._frozen_keys:
14
+ raise KeyError(f"Key '{key}' cannot be added.")
15
+ super(FixedKeysDict, self).__setitem__(key, value)
16
+
17
+ def __delitem__(self, key):
18
+ raise KeyError(f"Key '{key}' cannot be deleted.")
19
+
20
+ def pop(self, key, default=None):
21
+ raise KeyError(f"Key '{key}' cannot be popped.")
22
+
23
+ def popitem(self):
24
+ raise KeyError("Cannot pop item from FixedKeysDict.")
25
+
26
+ def clear(self):
27
+ raise KeyError("Cannot clear FixedKeysDict.")
28
+
29
+ def update(self, *args, **kwargs):
30
+ for key in dict(*args, **kwargs):
31
+ if key not in self._frozen_keys:
32
+ raise KeyError(f"Key '{key}' cannot be added.")
33
+ super(FixedKeysDict, self).update(*args, **kwargs)
34
+
35
+
36
+ class SetDict(object):
37
+ # set_dict(self, dTarget, dSource, bPreserve)
38
+ # Takes a target dictionary, and enters values from the source dictionary, overwriting or not, as asked.
39
+ # For example,
40
+ # dT={'a':1, 'b':2}
41
+ # dS={'a':0, 'c':0}
42
+ # Set(dT, dS, True)
43
+ # dT is {'a':1, 'b':2, 'c':0}
44
+ #
45
+ # dT={'a':1, 'b':2}
46
+ # dS={'a':0, 'c':0}
47
+ # Set(dT, dS, False)
48
+ # dT is {'a':0, 'b':2, 'c':0}
49
+ #
50
+ def set_dict(self, dTarget, dSource, bPreserve):
51
+ for k, v in dSource.items():
52
+ bKeyExists = (k in dTarget)
53
+ if (not bKeyExists) and type(v) == type({}):
54
+ dTarget[k] = {}
55
+ if ((not bKeyExists) or not bPreserve) and (type(v) != type({})):
56
+ dTarget[k] = v
57
+
58
+ if type(v) == type({}):
59
+ self.set_dict(dTarget[k], v, bPreserve)
60
+
61
+ def set_dict_with_preserve(self, dTarget, dSource):
62
+ self.set_dict(dTarget, dSource, True)
63
+
64
+ def set_dict_with_overwrite(self, dTarget, dSource):
65
+ self.set_dict(dTarget, dSource, False)
66
+
67
+
68
+ class LatexStates:
69
+ """ Holds LaTex format corresponding to set symbolic variables.
70
+ """
71
+
72
+ def __init__(self, dict=None):
73
+ self.dict = {'v_para': r'$v_{\parallel}$',
74
+ 'v_perp': r'$v_{\perp}$',
75
+ 'phi': r'$\phi$',
76
+ 'phidot': r'$\dot{\phi}$',
77
+ 'phiddot': r'$\ddot{\phi}$',
78
+ 'w': r'$w$',
79
+ 'zeta': r'$\zeta$',
80
+ 'I': r'$I$',
81
+ 'm': r'$m$',
82
+ 'C_para': r'$C_{\parallel}$',
83
+ 'C_perp': r'$C_{\perp}$',
84
+ 'C_phi': r'$C_{\phi}$',
85
+ 'km1': r'$k_{m_1}$',
86
+ 'km2': r'$k_{m_2}$',
87
+ 'km3': r'$k_{m_3}$',
88
+ 'km4': r'$k_{m_4}$',
89
+ 'd': r'$d$',
90
+ 'psi': r'$\psi$',
91
+ 'gamma': r'$\gamma$',
92
+ 'alpha': r'$\alpha$',
93
+ 'of': r'$\frac{g}{d}$',
94
+ 'gdot': r'$\dot{g}$',
95
+ 'v_para_dot': r'$\dot{v_{\parallel}}$',
96
+ 'v_perp_dot': r'$\dot{v_{\perp}}$',
97
+ 'v_para_dot_ratio': r'$\frac{\Delta v_{\parallel}}{v_{\parallel}}$',
98
+ 'sigma': r'$\sigma$',
99
+ 'rho': r'$\rho$',
100
+ 'beta': r'$\beta$'
101
+ }
102
+
103
+ if dict is not None:
104
+ SetDict().set_dict_with_overwrite(self.dict, dict)
105
+
106
+ def convert_to_latex(self, list_of_strings, remove_dollar_signs=False):
107
+ """ Loop through list of strings and if any match the dict, then swap in LaTex symbol.
108
+ """
109
+
110
+ if isinstance(list_of_strings, str): # if single string is given instead of list
111
+ list_of_strings = [list_of_strings]
112
+ string_flag = True
113
+ else:
114
+ string_flag = False
115
+
116
+ list_of_strings = list_of_strings.copy()
117
+ for n, s in enumerate(list_of_strings): # each string in list
118
+ for k in self.dict.keys(): # check each key in Latex dict
119
+ if s == k: # string contains key
120
+ # print(s, ',', self.dict[k])
121
+ list_of_strings[n] = self.dict[k] # replace string with LaTex
122
+ if remove_dollar_signs:
123
+ list_of_strings[n] = list_of_strings[n].replace('$', '')
124
+
125
+ if string_flag:
126
+ list_of_strings = list_of_strings[0]
127
+
128
+ return list_of_strings
129
+
130
+
131
+ def make_segments(x, y):
132
+ points = np.array([x, y]).T.reshape(-1, 1, 2)
133
+ segments = np.concatenate([points[:-1], points[1:]], axis=1)
134
+ return segments
135
+
136
+
137
+ def colorline(x, y, z, ax=None, cmap=plt.get_cmap('copper'), norm=None, linewidth=1.5, alpha=1.0):
138
+ # Special case if a single number:
139
+ if not hasattr(z, "__iter__"): # to check for numerical input -- this is a hack
140
+ z = np.array([z])
141
+
142
+ z = np.asarray(z)
143
+
144
+ # Set normalization
145
+ if norm is None:
146
+ norm = plt.Normalize(np.min(z), np.max(z))
147
+
148
+ print(norm)
149
+
150
+ # Make segments
151
+ segments = make_segments(x, y)
152
+ lc = mcoll.LineCollection(segments, array=z, cmap=cmap, norm=norm,
153
+ linewidth=linewidth, alpha=alpha,
154
+ path_effects=[path_effects.Stroke(capstyle="round")])
155
+
156
+ # Plot
157
+ if ax is None:
158
+ ax = plt.gca()
159
+
160
+ ax.add_collection(lc)
161
+
162
+ return lc
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 van Breugel lab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.1
2
+ Name: pybounds
3
+ Version: 0.0.1
4
+ Summary: Bounding Observability for Uncertain Nonlinear Dynamics Systems (BOUNDS)
5
+ Home-page: https://github.com/vanbreugel-lab/pybounds
6
+ Author: Ben Cellini, Burak Boyacioglu, Floris van Breugel
7
+ Author-email: bcellini00@gmail.com
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+
15
+ # pybounds
@@ -0,0 +1,9 @@
1
+ pybounds/__init__.py,sha256=so9LuRNw2V8MGsDs-RPstGGgJtBFp2YGolQbS0rVfhw,348
2
+ pybounds/observability.py,sha256=tiJdR5tS72SGn1df8Vnz303nDPzhOFaQWSYJoY1o8Qg,24055
3
+ pybounds/simulator.py,sha256=-XpPKQUcwNubw40wNfoqSfqmohbGpBjSQSNWctyfRkQ,8837
4
+ pybounds/util.py,sha256=xxmXmpLR3yK923X6wAPKp_5w814cO3m9OqF1XIt9S8c,5818
5
+ pybounds-0.0.1.dist-info/LICENSE,sha256=kqeyRXtRGgBVZdXYeIX4zR9l2KZ2rqIBVEiPMTjxjcI,1093
6
+ pybounds-0.0.1.dist-info/METADATA,sha256=Il4j0WbOUWga2nq69YD-fjh3OtElrZ-XA4TBSKMUUMs,539
7
+ pybounds-0.0.1.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
8
+ pybounds-0.0.1.dist-info/top_level.txt,sha256=V-ofnWE3m_UkXTXJwNRD07n14m5R6sc6l4NadaCCP_A,9
9
+ pybounds-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.1.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ pybounds