pybounds 0.0.6__py3-none-any.whl → 0.0.7__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 CHANGED
@@ -6,6 +6,8 @@ from .observability import SlidingEmpiricalObservabilityMatrix
6
6
  from .observability import FisherObservability
7
7
  from .observability import SlidingFisherObservability
8
8
  from .observability import ObservabilityMatrixImage
9
+ from .observability import transform_states
10
+ from .observability import SymbolicJacobian
9
11
 
10
12
  from .util import colorline
11
13
 
pybounds/jacobian.py ADDED
@@ -0,0 +1,46 @@
1
+ import sympy as sp
2
+ import numpy as np
3
+
4
+
5
+ class SymbolicJacobian:
6
+ def __init__(self, func, state_vars):
7
+ """
8
+ Initialize the Jacobian calculator with a function or symbolic expression for f(x).
9
+
10
+ :param func: A Python function or sympy Matrix representing f(x).
11
+ If func is a Python function, it should take in x and u (e.g., f(x))
12
+ and return a sympy Matrix for xdot.
13
+ If func is a sympy Matrix, it directly represents xdot.
14
+ :param state_vars: List of sympy symbols for variables (e.g., [x1, x2]).
15
+ """
16
+
17
+ self.state_vars = sp.Matrix(state_vars) # state variables as sympy Matrix
18
+
19
+ # Detect if func is symbolic (sympy.Matrix) or a Python function
20
+ if isinstance(func, sp.Matrix):
21
+ # If func is a symbolic Matrix, use it directly as xdot
22
+ self.z_sym = func
23
+ else:
24
+ # If func is a Python function, call it with symbolic state and control variables
25
+ x_sym = self.state_vars
26
+ self.z_sym = func(x_sym) # get the symbolic expression from the function
27
+
28
+ # Calculate the Jacobian of xdot_sym with respect to the state variables
29
+ self.jacobian_symbolic = self.z_sym.jacobian(self.state_vars)
30
+ self.jacobian_symbolic = sp.simplify(self.jacobian_symbolic)
31
+
32
+ def get_jacobian_function(self):
33
+ """
34
+ Returns a numerical function to evaluate the Jacobian at specific values of x and u.
35
+
36
+ :return: A function that calculates the Jacobian matrix given values of x and u.
37
+ The function takes numpy arrays x and u as inputs.
38
+ """
39
+ # Convert the symbolic Jacobian to a numerical function using lambdify
40
+ jacobian_func = sp.lambdify(self.state_vars, self.jacobian_symbolic, 'numpy')
41
+
42
+ # Return a wrapper function that accepts x as numpy array
43
+ def jacobian_numerical(x):
44
+ return jacobian_func(*np.array(x))
45
+
46
+ return jacobian_numerical
pybounds/observability.py CHANGED
@@ -9,10 +9,12 @@ import matplotlib.pyplot as plt
9
9
  from mpl_toolkits.axes_grid1.inset_locator import inset_axes
10
10
  import sympy as sp
11
11
  from .util import LatexStates
12
+ from jacobian import SymbolicJacobian
12
13
 
13
14
 
14
15
  class EmpiricalObservabilityMatrix:
15
- def __init__(self, simulator, x0, u, eps=1e-5, parallel=False):
16
+ def __init__(self, simulator, x0, u, eps=1e-5, parallel=False,
17
+ z_function=None, z_state_names=None):
16
18
  """ Construct an empirical observability matrix O.
17
19
 
18
20
  :param callable simulator: simulator object that has a method y = simulator.simulate(x0, u, **kwargs)
@@ -21,6 +23,12 @@ class EmpiricalObservabilityMatrix:
21
23
  :param dict/np.array u: inputs array
22
24
  :param float eps: epsilon value for perturbations to construct O, should be small number
23
25
  :param bool parallel: if True, run the perturbations in parallel
26
+ :param callable z_function: function that transforms coordinates from original to new states
27
+ must be of the form z = z_function(x), where x & z are the same size
28
+ should use sympy functions wherever possible
29
+ leave as None to maintain original coordinates
30
+ :param list | tuple z_state_names: (optional) names of states in new coordinates.
31
+ will only have an effect if O is a data-frame & z_function is not None
24
32
  """
25
33
 
26
34
  # Store inputs
@@ -81,9 +89,22 @@ class EmpiricalObservabilityMatrix:
81
89
 
82
90
  self.time_labels = np.hstack(self.time_labels)
83
91
 
84
- # Run
92
+ # Run simulations to construct O
85
93
  self.run()
86
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
+
87
108
  def run(self, parallel=None):
88
109
  """ Construct empirical observability matrix.
89
110
  """
@@ -150,7 +171,8 @@ class EmpiricalObservabilityMatrix:
150
171
 
151
172
  class SlidingEmpiricalObservabilityMatrix:
152
173
  def __init__(self, simulator, t_sim, x_sim, u_sim, w=None, eps=1e-5,
153
- parallel_sliding=False, parallel_perturbation=False):
174
+ parallel_sliding=False, parallel_perturbation=False,
175
+ z_function=None, z_state_names=None):
154
176
  """ Construct empirical observability matrix O in sliding windows along a trajectory.
155
177
 
156
178
  :param callable simulator: Simulator object : y = simulator(x0, u, **kwargs)
@@ -160,7 +182,6 @@ class SlidingEmpiricalObservabilityMatrix:
160
182
  :param np.array u_sim: input array (N, m), can also be dict
161
183
  :param np.array w: window size for O calculations, will automatically set how many windows to compute
162
184
  :params float eps: tolerance for sliding windows
163
- :param dict/np.array u: inputs array
164
185
  :param float eps: epsilon value for perturbations to construct O's, should be small number
165
186
  :param bool parallel_sliding: if True, run the sliding windows in parallel
166
187
  :param bool parallel_perturbation: if True, run the perturbations in parallel
@@ -170,6 +191,8 @@ class SlidingEmpiricalObservabilityMatrix:
170
191
  self.eps = eps
171
192
  self.parallel_sliding = parallel_sliding
172
193
  self.parallel_perturbation = parallel_perturbation
194
+ self.z_function = z_function
195
+ self.z_state_names = z_state_names
173
196
 
174
197
  # Set time vector
175
198
  self.t_sim = np.array(t_sim)
@@ -267,12 +290,14 @@ class SlidingEmpiricalObservabilityMatrix:
267
290
 
268
291
  # Pull out time & control inputs in window
269
292
  t_win = self.t_sim[win] # time in window
270
- t_win0 = t_win - t_win[0] # start at 0
293
+ # t_win0 = t_win - t_win[0] # start at 0
271
294
  u_win = self.u_sim[win, :] # inputs in window
272
295
 
273
296
  # Calculate O for window
274
297
  EOM = EmpiricalObservabilityMatrix(self.simulator, x0, u_win, eps=self.eps,
275
- parallel=self.parallel_perturbation)
298
+ parallel=self.parallel_perturbation,
299
+ z_function=self.z_function,
300
+ z_state_names=self.z_state_names)
276
301
  self.EOM = EOM
277
302
 
278
303
  # Store data
@@ -292,18 +317,18 @@ class SlidingEmpiricalObservabilityMatrix:
292
317
 
293
318
 
294
319
  class FisherObservability:
295
- def __init__(self, O, R=None, sensor_noise_dict=None, lam=None):
320
+ def __init__(self, O, R=None, lam=None):
296
321
  """ Evaluate the observability of a state variable(s) using the Fisher Information Matrix.
297
322
 
298
323
  :param np.array O: observability matrix (w*p, n)
299
324
  w is the number of time-steps, p is the number of measurements, and n in the number of states
300
325
  can also be set as pd.DataFrame where columns set the state names & a multilevel index sets the
301
326
  measurement names: O.index names must be ('sensor', 'time_step')
302
- :param np.array R: measurement noise covariance matrix (w*p x w*p)
327
+ :param None | np.array | float| dict R: measurement noise covariance matrix (w*p x w*p)
303
328
  can also be set as pd.DataFrame where R.index = R.columns = O.index
304
- can also be a scaler where R = R * I
305
- :param dict sensor_noise_dict: constructs R by setting the noise levels for each sensor across time-steps
306
- keys must correspond to the 'sensor' index in O data-frame, can only be set if R is None
329
+ can also be a scaler where R = R * I_(nxn)
330
+ can also be dict where keys must correspond to the 'sensor' index in O data-frame
331
+ if None, then R = I_(nxn)
307
332
  :param float lam: lamda parameter, if lam='limit' compute F^-1 symbolically, otherwise use Chernoff inverse
308
333
  """
309
334
 
@@ -315,7 +340,7 @@ class FisherObservability:
315
340
  self.sensor_names = tuple(O.index.get_level_values('sensor'))
316
341
  self.state_names = tuple(O.columns)
317
342
  elif isinstance(O, np.ndarray): # array given
318
- self.sensor_names = tuple(['y' for n in range(self.pw)])
343
+ self.sensor_names = tuple(['y' for _ in range(self.pw)])
319
344
  self.state_names = tuple(['x_' + str(n) for n in range(self.n)])
320
345
  self.O = pd.DataFrame(O, index=self.sensor_names, columns=self.state_names)
321
346
  else:
@@ -324,7 +349,7 @@ class FisherObservability:
324
349
  # Set measurement noise covariance matrix
325
350
  self.R = pd.DataFrame(np.eye(self.pw), index=self.O.index, columns=self.O.index)
326
351
  self.R_inv = pd.DataFrame(np.eye(self.pw), index=self.O.index, columns=self.O.index)
327
- self.set_noise_covariance(R=R, sensor_noise_dict=sensor_noise_dict)
352
+ self.set_noise_covariance(R=R)
328
353
 
329
354
  # Calculate Fisher Information Matrix
330
355
  self.F = self.O.values.T @ self.R_inv.values @ self.O.values
@@ -353,7 +378,7 @@ class FisherObservability:
353
378
  # Pull out diagonal elements
354
379
  self.error_variance = pd.DataFrame(np.diag(self.F_inv), index=self.O.columns).T
355
380
 
356
- def set_noise_covariance(self, R=None, sensor_noise_dict=None):
381
+ def set_noise_covariance(self, R=None):
357
382
  """ Set the measurement noise covariance matrix.
358
383
  """
359
384
 
@@ -361,19 +386,15 @@ class FisherObservability:
361
386
  self.R = pd.DataFrame(np.eye(self.pw), index=self.O.index, columns=self.O.index)
362
387
 
363
388
  # Set R based on values in dict
364
- if sensor_noise_dict is not None: # set each distinct sensor's noise level
365
- if R is not None:
366
- raise Exception('R can not be set directly if sensor_noise_dict is set')
367
- else:
368
- # for s in self.R.index.levels[0]:
369
- for s in pd.unique(self.R.index.get_level_values('sensor')):
370
- R_sensor = self.R.loc[[s], [s]]
371
- for r in range(R_sensor.shape[0]):
372
- R_sensor.iloc[r, r] = sensor_noise_dict[s]
389
+ if isinstance(R, dict): # set each distinct sensor's noise level
390
+ for s in pd.unique(self.R.index.get_level_values('sensor')):
391
+ R_sensor = self.R.loc[[s], [s]]
392
+ for r in range(R_sensor.shape[0]):
393
+ R_sensor.iloc[r, r] = R[s]
373
394
 
374
- self.R.loc[[s], [s]] = R_sensor.values
395
+ self.R.loc[[s], [s]] = R_sensor.values
375
396
  else:
376
- if R is None:
397
+ if R is None: # set R as identity matrix
377
398
  warnings.warn('R not set, defaulting to identity matrix')
378
399
  else: # set R directly
379
400
  if np.atleast_1d(R).shape[0] == 1: # given scalar
@@ -382,8 +403,10 @@ class FisherObservability:
382
403
  self.R = R.copy()
383
404
  elif isinstance(R, np.ndarray): # matrix in array
384
405
  self.R = pd.DataFrame(R, index=self.R.index, columns=self.R.columns)
406
+ elif isinstance(R, float) or isinstance(R, int): # set as scalar multiplied by identity matrix
407
+ self.R = R * self.R
385
408
  else:
386
- raise Exception('R must be a numpy array, pandas data-frame, or scalar value')
409
+ raise Exception('R must be a dict, numpy array, pandas data-frame, or scalar value')
387
410
 
388
411
  # Inverse of R
389
412
  R_diagonal = np.diag(self.R.values)
@@ -400,22 +423,23 @@ class FisherObservability:
400
423
 
401
424
 
402
425
  class SlidingFisherObservability:
403
- def __init__(self, O_list, time=None, lam=1e6, R=None, sensor_noise_dict=None,
426
+ def __init__(self, O_list, time=None, lam=1e6, R=None,
404
427
  states=None, sensors=None, time_steps=None, w=None):
405
428
 
406
429
  """ Compute the Fisher information matrix & inverse in sliding windows and pull put the minimum error variance.
407
430
  :param list O_list: list of observability matrices O (stored as pd.DataFrame)
408
- :param np.array time: time vector the same size as O_list
409
- :param np.array lam: lamda parameter, if lam='limit' compute F^-1 symbolically, otherwise use Chernoff inverse
410
- :param np.array R: measurement noise covariance matrix (w*p x w*p)
431
+ :param None | np.array time: time vector the same size as O_list
432
+ :param float | np.array lam: lamda parameter, if lam='limit' compute F^-1 symbolically, otherwise use Chernoff inverse
433
+ :param None | np.array | float| dict R: measurement noise covariance matrix (w*p x w*p)
411
434
  can also be set as pd.DataFrame where R.index = R.columns = O.index
412
- can also be a scaler where R = R * I
413
- :param dict sensor_noise_dict: constructs R by setting the noise levels for each sensor across time-steps
414
- keys must correspond to the 'sensor' index in O data-frame, can only be set if R is None
415
- :param list states: list of states to use from O's. ex: ['g', 'd']
416
- :param list sensors: list of sensors to use from O's, ex: ['r']
417
- :param np.array time_steps: array of time steps to use from O's, ex: np.array([0, 1, 2])
418
- :param np.array w: window size to use from O's, if None then just grab it from O
435
+ can also be a scaler where R = R * I_(nxn)
436
+ can also be dict where keys must correspond to the 'sensor' index in O data-frame
437
+ if None, then R = I_(nxn)
438
+ :param None | tuple | list states: list of states to use from O's. ex: ['g', 'd']
439
+ :param None | tuple | list sensors: list of sensors to use from O's, ex: ['r']
440
+ :param None | tuple | list | np.array time_steps: array of time steps to use from O's, ex: np.array([0, 1, 2])
441
+ :param None | tuple | list | np.array w: window size to use from O's,
442
+ if None then just grab it from O as the maximum window size
419
443
  """
420
444
 
421
445
  self.O_list = O_list
@@ -427,7 +451,14 @@ class SlidingFisherObservability:
427
451
  else:
428
452
  self.time = np.array(time)
429
453
 
430
- self.dt = np.mean(np.diff(self.time))
454
+ # Set time-step
455
+ if time is not None:
456
+ if self.n_window > 1: # compute time-step from vector
457
+ self.dt = np.mean(np.diff(self.time))
458
+ else:
459
+ self.dt = 0.0
460
+ else: # default is time-step of 1
461
+ self.dt = 1
431
462
 
432
463
  # Get single O
433
464
  O = O_list[0]
@@ -457,10 +488,8 @@ class SlidingFisherObservability:
457
488
  self.time_steps = np.array(time_steps)
458
489
 
459
490
  # Compute Fisher information matrix & inverse for each sliding window
460
- self.EV = [] # collect error variance data for each state over time
461
- self.FO = []
462
- self.shift_index = int(np.round((1 / 2) * self.w))
463
- self.shift_time = self.shift_index * self.dt # shift the time forward by half the window size
491
+ self.EV = [] # collect error variance data for each state over windows
492
+ self.FO = [] # collect FisherObservability objects over windows
464
493
  for k in range(self.n_window): # each window
465
494
  # Get full O
466
495
  O = self.O_list[k]
@@ -469,7 +498,7 @@ class SlidingFisherObservability:
469
498
  O_subset = O.loc[(self.sensors, self.time_steps), self.states].sort_values(['time_step', 'sensor'])
470
499
 
471
500
  # Compute Fisher information & inverse
472
- FO = FisherObservability(O_subset, sensor_noise_dict=sensor_noise_dict, R=R, lam=lam)
501
+ FO = FisherObservability(O_subset, R=R, lam=lam)
473
502
  self.FO.append(FO)
474
503
 
475
504
  # Collect error variance data
@@ -478,15 +507,75 @@ class SlidingFisherObservability:
478
507
  self.EV.append(ev)
479
508
 
480
509
  # Concatenate error variance & make same size as simulation data
510
+ self.shift_index = int(np.round((1 / 2) * self.w))
511
+ self.shift_time = self.shift_index * self.dt # shift the time forward by half the window size
481
512
  self.EV = pd.concat(self.EV, axis=0, ignore_index=True)
482
- self.EV.index = np.arange(self.shift_index, self.EV.shape[0] + self.shift_index, step=1, dtype=int)
483
- time_df = pd.DataFrame(np.atleast_2d(self.time).T, columns=['time'])
484
- self.EV_aligned = pd.concat((time_df, self.EV), axis=1)
513
+ if self.n_window > 1: # more than 1 window
514
+ self.EV.index = np.arange(self.shift_index, self.EV.shape[0] + self.shift_index, step=1, dtype=int)
515
+ time_df = pd.DataFrame(np.atleast_2d(self.time).T, columns=['time'])
516
+ self.EV_aligned = pd.concat((time_df, self.EV), axis=1)
517
+ else:
518
+ self.EV_aligned = self.EV.copy()
485
519
 
486
520
  def get_minimum_error_variance(self):
487
521
  return self.EV_aligned.copy()
488
522
 
489
523
 
524
+ def transform_states(O=None, square_flag=False, z_function=None, x0=None, z_state_names=None):
525
+ """ Transform the coordinates of an observability matrix (O) or Fisher information matrix (F)
526
+ from the original coordinates (x) to new user defined coordinates (z).
527
+
528
+ :param O: observability matrix or Fisher information matrix
529
+ :param boolean square_flag: whether to square the transform Jacobian or not
530
+ should be set to False if passing squared an observability matrix as O
531
+ should be set to True if passing squared a Fisher information matrix as O
532
+ :param callable z_function: function that transforms coordinates from original to new states
533
+ must be of the form z = z_function(x), where x & z are the same size
534
+ should use sympy functions wherever possible
535
+ :param np.array x0: initial state in original coordinates
536
+ :param list | tuple z_state_names: (optional) names of states in new coordinates.
537
+ will only have an effect if O or F is a data-frame
538
+
539
+ :return:
540
+ Z: observability matrix or Fisher information matrix in transformed coordinates
541
+ dzdx: numerical Jacobian dz/dx (inverse of dx/dz) evaluated at x0
542
+ dxdz_sym: symbolic Jacobian dx/dz
543
+ """
544
+
545
+ # Symbolic vector of original states
546
+ x_sym = sp.symbols('x_0:%d' % O.shape[1])
547
+
548
+ # Initialize the Jacobian calculator with a Python function
549
+ jacobian_calculator_func = SymbolicJacobian(func=z_function, state_vars=x_sym)
550
+
551
+ # Get the symbolic Jacobian dx/dz
552
+ dxdz_sym = jacobian_calculator_func.jacobian_symbolic
553
+
554
+ # Get the Jacobian calculator function
555
+ dxdz_function = jacobian_calculator_func.get_jacobian_function()
556
+
557
+ # Evaluate the Jacobian at x0
558
+ dxdz = dxdz_function(np.array(x0))
559
+
560
+ # Take the inverse
561
+ dzdx = np.linalg.inv(dxdz)
562
+
563
+ # Compute the new O or F
564
+ if square_flag: # F
565
+ O_z = dzdx.T @ O @ dzdx
566
+ else: # O
567
+ O_z = O @ dzdx
568
+
569
+ # Set column/index names if data-frame was passed
570
+ if isinstance(O_z, pd.DataFrame):
571
+ if z_state_names is not None:
572
+ O_z.columns = z_state_names
573
+ if square_flag:
574
+ O_z.index = z_state_names
575
+
576
+ return O_z, dzdx, dxdz_sym
577
+
578
+
490
579
  class ObservabilityMatrixImage:
491
580
  def __init__(self, O, state_names=None, sensor_names=None, vmax_percentile=100, vmin_ratio=1.0, cmap='bwr'):
492
581
  """ Display an image of an observability matrix.
@@ -638,4 +727,4 @@ class ObservabilityMatrixImage:
638
727
  # Store figure & axis
639
728
  self.fig = fig
640
729
  self.ax = ax
641
- self.cbar = cbar
730
+ self.cbar = cbar
@@ -0,0 +1,11 @@
1
+
2
+ # from .observability import EmpiricalObservabilityMatrix
3
+ #
4
+ #
5
+ # class DroneSimulator(EmpiricalObservabilityMatrix):
6
+ # def __init__(self, dt=0.1, mpc_horizon=10, r_u=1e-2, input_mode='direct', control_mode='velocity_body_level'):
7
+ # self.dynamics = DroneModel()
8
+ # super().__init__(self.dynamics.f, self.dynamics.h, dt=dt, mpc_horizon=mpc_horizon,
9
+ # state_names=self.dynamics.state_names,
10
+ # input_names=self.dynamics.input_names,
11
+ # measurement_names=self.dynamics.measurement_names)
pybounds/simulator.py CHANGED
@@ -374,7 +374,7 @@ class Simulator(object):
374
374
  return y_array
375
375
 
376
376
  def get_time_states_inputs_measurements(self):
377
- return self.time.copy(), self.x.copy(), self.u.copy(), self.u.copy()
377
+ return self.time.copy(), self.x.copy(), self.u.copy(), self.y.copy()
378
378
 
379
379
  def plot(self, name='x', dpi=150, plot_kwargs=None):
380
380
  """ Plot states, inputs.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pybounds
3
- Version: 0.0.6
3
+ Version: 0.0.7
4
4
  Summary: Bounding Observability for Uncertain Nonlinear Dynamics Systems (BOUNDS)
5
5
  Home-page: https://pypi.org/project/pybounds/
6
6
  Author: Ben Cellini, Burak Boyacioglu, Floris van Breugel
@@ -0,0 +1,11 @@
1
+ pybounds/__init__.py,sha256=0MZnwxcaT4ERtEUgcKpkwZVwQ8vVkSIQbYEqSnEQw6Y,438
2
+ pybounds/jacobian.py,sha256=hqDOwwqZMdnlTECz0Rx6txCd4VuZ4iZHPaj62PTkKvA,2057
3
+ pybounds/observability.py,sha256=F3tKpOgf-Ltx_jmNsrdSw5YL99OOkjZCwMZdnj7n9II,31675
4
+ pybounds/observability_transform.py,sha256=YibApe7OzwrZT44BmlZwetJ1JLHOIgMkpVYggWz5Myo,585
5
+ pybounds/simulator.py,sha256=MhK7NjYHdRMToGzwVEuwEnu5Aa6MguF1KE7fAbWCiUA,16194
6
+ pybounds/util.py,sha256=Gs0UgqgLXTJI9FZww90iJhqU02iJ31bXBURjGiq3YzM,7401
7
+ pybounds-0.0.7.dist-info/LICENSE,sha256=kqeyRXtRGgBVZdXYeIX4zR9l2KZ2rqIBVEiPMTjxjcI,1093
8
+ pybounds-0.0.7.dist-info/METADATA,sha256=zi7w4FqcUvDxvYqcVrcdWK3Yxsozc2F6pNibEBDcz4Q,2155
9
+ pybounds-0.0.7.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
10
+ pybounds-0.0.7.dist-info/top_level.txt,sha256=V-ofnWE3m_UkXTXJwNRD07n14m5R6sc6l4NadaCCP_A,9
11
+ pybounds-0.0.7.dist-info/RECORD,,
@@ -1,422 +0,0 @@
1
-
2
- import warnings
3
- import numpy as np
4
- import matplotlib.pyplot as plt
5
- import do_mpc
6
- from .util import FixedKeysDict, SetDict
7
-
8
-
9
- class Simulator(object):
10
- def __init__(self, f, h, dt=0.01, n=None, m=None,
11
- state_names=None, input_names=None, measurement_names=None,
12
- params_simulator=None, mpc_horizon=10):
13
-
14
- """ Simulator.
15
-
16
- :param callable f: dynamics function f(X, U, t)
17
- :param callable h: measurement function h(X, U, t)
18
- :param float dt: sampling time in seconds
19
- :param int n: number of states, optional but cannot be set if state_names is set
20
- :param int m: number of inputs, optional but cannot be set if input_names is set
21
- :param list state_names: names of states, optional but cannot be set if n is set
22
- :param list input_names: names of inputs, optional but cannot be set if m is set
23
- :param list measurement_names: names of measurements, optional
24
- :param dict params_simulator: simulation parameters, optional
25
- """
26
-
27
- self.f = f
28
- self.h = h
29
- self.dt = dt
30
-
31
- # Set state names
32
- if state_names is None: # default state names
33
- if n is None:
34
- raise ValueError('must set state_names or n')
35
- else:
36
- self.n = int(n)
37
-
38
- self.state_names = ['x_' + str(n) for n in range(self.n)]
39
- else: # state names given
40
- if n is not None:
41
- raise ValueError('cannot set state_names and n')
42
-
43
- self.state_names = list(state_names)
44
- self.n = len(self.state_names)
45
- # if len(self.state_names) != self.n:
46
- # raise ValueError('state_names must have length equal to x0')
47
-
48
- # Set input names
49
- if input_names is None: # default input names
50
- if m is None:
51
- raise ValueError('must set input_names or m')
52
- else:
53
- self.m = int(m)
54
-
55
- self.input_names = ['u_' + str(m) for m in range(self.m)]
56
- else: # input names given
57
- if m is not None:
58
- raise ValueError('cannot set in and n')
59
-
60
- self.input_names = list(input_names)
61
- self.m = len(self.input_names)
62
- # if len(self.input_names) != self.m:
63
- # raise ValueError('input_names must have length equal to u0')
64
-
65
- # Run measurement function to get measurement size
66
- x0 = np.ones(self.n)
67
- u0 = np.ones(self.m)
68
- y = self.h(x0, u0)
69
- self.p = len(y) # number of measurements
70
-
71
- # Set measurement names
72
- if measurement_names is None: # default measurement names
73
- self.measurement_names = ['y_' + str(p) for p in range(self.p)]
74
- else:
75
- self.measurement_names = measurement_names
76
- if len(self.measurement_names) != self.p:
77
- raise ValueError('measurement_names must have length equal to y')
78
-
79
- # Initialize time vector
80
- self.w = 11 # initialize for w time-steps, but this can change later
81
- self.time = np.arange(0, self.w * self.dt, step=self.dt) # time vector
82
-
83
- # Define initial states & initialize state time-series
84
- self.x0 = {}
85
- self.x = {}
86
- for n, state_name in enumerate(self.state_names):
87
- self.x0[state_name] = x0[n]
88
- self.x[state_name] = x0[n] * np.ones(self.w)
89
-
90
- self.x0 = FixedKeysDict(self.x0)
91
- self.x = FixedKeysDict(self.x)
92
-
93
- # Initialize input time-series
94
- self.u = {}
95
- for m, input_name in enumerate(self.input_names):
96
- self.u[input_name] = u0[m] * np.ones(self.w)
97
-
98
- self.u = FixedKeysDict(self.u)
99
-
100
- # Initialize measurement time-series
101
- self.y = {}
102
- for p, measurement_name in enumerate(self.measurement_names):
103
- self.y[measurement_name] = 0.0 * np.ones(self.w)
104
-
105
- self.y = FixedKeysDict(self.y)
106
-
107
- # Initialize state set-points
108
- self.setpoint = {}
109
- for n, state_name in enumerate(self.state_names):
110
- self.setpoint[state_name] = 0.0 * np.ones(self.w)
111
-
112
- self.setpoint = FixedKeysDict(self.setpoint)
113
-
114
- # Define continuous-time MPC model
115
- self.model = do_mpc.model.Model('continuous')
116
-
117
- # Define state variables
118
- X = []
119
- for n, state_name in enumerate(self.state_names):
120
- x = self.model.set_variable(var_type='_x', var_name=state_name, shape=(1, 1))
121
- X.append(x)
122
-
123
- # Define input variables
124
- U = []
125
- for m, input_name in enumerate(self.input_names):
126
- u = self.model.set_variable(var_type='_u', var_name=input_name, shape=(1, 1))
127
- U.append(u)
128
-
129
- # Define dynamics
130
- Xdot = self.f(X, U)
131
- for n, state_name in enumerate(self.state_names):
132
- self.model.set_rhs(state_name, Xdot[n])
133
-
134
- # Add time-varying set-point variables for later use with MPC
135
- for n, state_name in enumerate(self.state_names):
136
- x = self.model.set_variable(var_type='_tvp', var_name=state_name + str('_set'), shape=(1, 1))
137
-
138
- # Build model
139
- self.model.setup()
140
-
141
- # Define simulator & simulator parameters
142
- self.simulator = do_mpc.simulator.Simulator(self.model)
143
-
144
- # Set simulation parameters
145
- if params_simulator is None:
146
- self.params_simulator = {
147
- 'integration_tool': 'idas', # cvodes, idas
148
- 'abstol': 1e-8,
149
- 'reltol': 1e-8,
150
- 't_step': self.dt
151
- }
152
- else:
153
- self.params_simulator = params_simulator
154
-
155
- self.simulator.set_param(**self.params_simulator)
156
-
157
- # Setup MPC
158
- self.mpc = do_mpc.controller.MPC(self.model)
159
- self.mpc_horizon = mpc_horizon
160
- setup_mpc = {
161
- 'n_horizon': self.mpc_horizon,
162
- 'n_robust': 0,
163
- 'open_loop': 0,
164
- 't_step': self.dt,
165
- 'state_discretization': 'collocation',
166
- 'collocation_type': 'radau',
167
- 'collocation_deg': 2,
168
- 'collocation_ni': 1,
169
- 'store_full_solution': False,
170
-
171
- # Use MA27 linear solver in ipopt for faster calculations:
172
- 'nlpsol_opts': {'ipopt.linear_solver': 'mumps', # mumps, MA27
173
- 'ipopt.print_level': 0,
174
- 'ipopt.sb': 'yes',
175
- 'print_time': 0,
176
- }
177
- }
178
-
179
- self.mpc.set_param(**setup_mpc)
180
-
181
- # Get template's for MPC time-varying parameters
182
- self.mpc_tvp_template = self.mpc.get_tvp_template()
183
- self.simulator_tvp_template = self.simulator.get_tvp_template()
184
-
185
- # Set time-varying set-point functions
186
- self.mpc.set_tvp_fun(self.mpc_tvp_function)
187
- self.simulator.set_tvp_fun(self.simulator_tvp_function)
188
-
189
- # Setup simulator
190
- self.simulator.setup()
191
-
192
- def simulator_tvp_function(self, t):
193
- """ Set the set-point function for MPC simulator.
194
- :param t: current time
195
- """
196
-
197
- mpc_horizon = self.mpc._settings.n_horizon
198
-
199
- # Set current step index
200
- k_step = int(np.round(t / self.dt))
201
- if k_step >= mpc_horizon: # point is beyond end of input data
202
- k_step = mpc_horizon - 1 # set point beyond input data to last point
203
-
204
- # Update current set-point
205
- for n, state_name in enumerate(self.state_names):
206
- self.simulator_tvp_template[state_name + '_set'] = self.setpoint[state_name][k_step]
207
-
208
- return self.simulator_tvp_template
209
-
210
- def mpc_tvp_function(self, t):
211
- """ Set the set-point function for MPC optimizer.
212
- """
213
-
214
- mpc_horizon = self.mpc._settings.n_horizon
215
-
216
- # Set current step index
217
- k_step = int(np.round(t / self.dt))
218
-
219
- # Update set-point time horizon
220
- for k in range(mpc_horizon + 1):
221
- k_set = k_step + k
222
- if k_set >= self.w: # horizon is beyond end of input data
223
- k_set = self.w - 1 # set part of horizon beyond input data to last point
224
-
225
- # Update each set-point over time horizon
226
- for n, state_name in enumerate(self.state_names):
227
- self.mpc_tvp_template['_tvp', k, state_name + '_set'] = self.setpoint[state_name][k_set]
228
-
229
- return self.mpc_tvp_template
230
-
231
- def set_initial_state(self, x0):
232
- """ Update the initial state.
233
- """
234
-
235
- if x0 is not None: # initial state given
236
- if isinstance(x0, dict): # in dict format
237
- SetDict().set_dict_with_overwrite(self.x0, x0) # update only the states in the dict given
238
- elif isinstance(x0, list) or isinstance(x0, tuple) or (
239
- x0, np.ndarray): # list, tuple, or numpy array format
240
- x0 = np.array(x0).squeeze()
241
- for n, key in enumerate(self.x0.keys()): # each state
242
- self.x0[key] = x0[n]
243
- else:
244
- raise Exception('x0 must be either a dict, tuple, list, or numpy array')
245
-
246
- def update_dict(self, data=None, name=None):
247
- """ Update.
248
- """
249
-
250
- update = getattr(self, name)
251
-
252
- if data is not None: # data given
253
- if isinstance(data, dict): # in dict format
254
- SetDict().set_dict_with_overwrite(update, data) # update only the inputs in the dict given
255
- elif isinstance(data, list) or isinstance(data, tuple): # list or tuple format, each input vector in each element
256
- for n, k in enumerate(update.keys()): # each state
257
- update[k] = data[n]
258
- elif isinstance(data, np.ndarray): # numpy array format given as matrix where columns are the different inputs
259
- if len(data.shape) <= 1: # given as 1d array, so convert to column vector
260
- data = np.atleast_2d(data).T
261
-
262
- for n, key in enumerate(update.keys()): # each input
263
- update[key] = data[:, n]
264
-
265
- else:
266
- raise Exception(name + ' must be either a dict, tuple, list, or numpy array')
267
-
268
- # Make sure inputs are the same size
269
- points = np.array([update[key].shape[0] for key in update.keys()])
270
- points_check = points == points[0]
271
- if not np.all(points_check):
272
- raise Exception(name + ' not the same size')
273
-
274
- def simulate(self, x0=None, u=None, mpc=False, return_full_output=False):
275
- """
276
- Simulate the system.
277
-
278
- :params x0: initial state dict or array
279
- :params u: input dict or array
280
- :params return_full_output: boolean to run (time, x, u, y) instead of y
281
- """
282
-
283
- if (mpc is True) and (u is not None):
284
- raise Exception('u must be None if running MPC')
285
-
286
- if (mpc is False) and (u is None):
287
- warnings.warn('not running MPC or setting u directly')
288
-
289
- # Update the initial state
290
- if x0 is None:
291
- if mpc: # set the initial state to start at set-point if running MPC
292
- x0 = {}
293
- for state_name in self.state_names:
294
- x0[state_name] = self.setpoint[state_name][0]
295
-
296
- self.set_initial_state(x0=x0)
297
- else:
298
- self.set_initial_state(x0=x0)
299
-
300
- # Update the inputs
301
- self.update_dict(u, name='u')
302
-
303
- # Concatenate the inputs, where rows are individual inputs and columns are time-steps
304
- if mpc:
305
- self.w = np.vstack(list(self.setpoint.values())).shape[1]
306
- u_sim = np.zeros((self.w, self.m)) # preallocate input array
307
- else:
308
- self.w = np.vstack(list(self.u.values())).shape[1]
309
- u_sim = np.vstack(list(self.u.values())).T
310
-
311
- # Update time vector
312
- T = (self.w - 1) * self.dt
313
- self.time = np.linspace(0, T, num=self.w)
314
-
315
- # Set array to store simulated states, where rows are individual states and columns are time-steps
316
- x_step = np.array(list(self.x0.values())) # initialize state
317
- x = np.nan * np.zeros((self.w, self.n))
318
- x[0, :] = x_step.copy()
319
-
320
- # Initialize the simulator
321
- self.simulator.t0 = self.time[0]
322
- self.simulator.x0 = x_step.copy()
323
- self.simulator.set_initial_guess()
324
-
325
- # Initialize MPC
326
- if mpc:
327
- self.mpc.setup()
328
- self.mpc.t0 = self.time[0]
329
- self.mpc.x0 = x_step.copy()
330
- self.mpc.u0 = np.zeros((self.m, 1))
331
- self.mpc.set_initial_guess()
332
-
333
- # Run simulation
334
- for k in range(1, self.w):
335
- # Set input
336
- if mpc: # run MPC step
337
- u_step = self.mpc.make_step(x_step)
338
- else: # use inputs directly
339
- u_step = u_sim[k - 1:k, :].T
340
-
341
- # Store inputs
342
- u_sim[k - 1, :] = u_step.squeeze()
343
-
344
- # Simulate one time step given current inputs
345
- x_step = self.simulator.make_step(u_step)
346
-
347
- # Store new states
348
- x[k, :] = x_step.squeeze()
349
-
350
- # Last input has no effect, so keep it the same as previous time-step
351
- if mpc:
352
- u_sim[-1, :] = u_sim[-2, :]
353
-
354
- # Update the inputs
355
- self.update_dict(u_sim, name='u')
356
-
357
- # Update state trajectory
358
- self.update_dict(x, name='x')
359
-
360
- # Calculate measurements
361
- x_list = list(self.x.values())
362
- u_list = list(self.u.values())
363
- y = self.h(x_list, u_list)
364
-
365
- # Set measurements
366
- self.update_dict(y, name='y')
367
-
368
- # Return the measurements in array format
369
- y_array = np.vstack(list(self.y.values())).T
370
-
371
- if return_full_output:
372
- return self.time.copy(), self.x.copy(), self.u.copy(), self.u.copy()
373
- else:
374
- return y_array
375
-
376
- def get_time_states_inputs_measurements(self):
377
- return self.time.copy(), self.x.copy(), self.u.copy(), self.u.copy()
378
-
379
- def plot(self, name='x', dpi=150, plot_kwargs=None):
380
- """ Plot states, inputs.
381
- """
382
-
383
- if plot_kwargs is None:
384
- plot_kwargs = {
385
- 'color': 'black',
386
- 'linewidth': 2.0,
387
- 'linestyle': '-',
388
- 'marker': '.',
389
- 'markersize': 0
390
- }
391
-
392
- if name == 'x':
393
- plot_kwargs['color'] = 'firebrick'
394
- elif name == 'u':
395
- plot_kwargs['color'] = 'royalblue'
396
- elif name == 'y':
397
- plot_kwargs['color'] = 'seagreen'
398
- elif name == 'setpoint':
399
- plot_kwargs['color'] = 'gray'
400
-
401
- plot_dict = getattr(self, name)
402
- plot_data = np.array(list(plot_dict.values()))
403
- n = plot_data.shape[0]
404
-
405
- fig, ax = plt.subplots(n, 1, figsize=(4, n * 1.5), dpi=dpi, sharex=True)
406
- ax = np.atleast_1d(ax)
407
-
408
- for n, key in enumerate(plot_dict.keys()):
409
- ax[n].plot(self.time, plot_dict[key], label='set-point', **plot_kwargs)
410
- ax[n].set_ylabel(key, fontsize=7)
411
-
412
- # Also plot the states if plotting setpoint
413
- if name == 'setpoint':
414
- ax[n].plot(self.time, self.x[key], label=key, color='firebrick', linestyle='-', linewidth=0.5)
415
- ax[n].legend(fontsize=6)
416
-
417
- ax[-1].set_xlabel('time', fontsize=7)
418
- ax[0].set_title(name, fontsize=8, fontweight='bold')
419
-
420
-
421
- for a in ax.flat:
422
- a.tick_params(axis='both', labelsize=6)
@@ -1,10 +0,0 @@
1
- pybounds/__init__.py,sha256=so9LuRNw2V8MGsDs-RPstGGgJtBFp2YGolQbS0rVfhw,348
2
- pybounds/drone_simulator.py,sha256=-CrQVpNfiBDFECd6H7FU5has4sYGW1gyS2RhOgXUqZg,15858
3
- pybounds/observability.py,sha256=4tdK6AK678zoorbkQ2psvzMRLY32CIj2QwVb-0w-GXk,27541
4
- pybounds/simulator.py,sha256=ReaCRHA-DjiE1EbmCStw2Top9EyJeg41S_lO-iqnjv4,16194
5
- pybounds/util.py,sha256=Gs0UgqgLXTJI9FZww90iJhqU02iJ31bXBURjGiq3YzM,7401
6
- pybounds-0.0.6.dist-info/LICENSE,sha256=kqeyRXtRGgBVZdXYeIX4zR9l2KZ2rqIBVEiPMTjxjcI,1093
7
- pybounds-0.0.6.dist-info/METADATA,sha256=MAp33xdjWLOfB17N32E-GdKRZnaCHtIwBc65XmFQXb4,2155
8
- pybounds-0.0.6.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
9
- pybounds-0.0.6.dist-info/top_level.txt,sha256=V-ofnWE3m_UkXTXJwNRD07n14m5R6sc6l4NadaCCP_A,9
10
- pybounds-0.0.6.dist-info/RECORD,,