pybounds 0.0.1__tar.gz
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-0.0.1/LICENSE +21 -0
- pybounds-0.0.1/PKG-INFO +15 -0
- pybounds-0.0.1/README.md +1 -0
- pybounds-0.0.1/pybounds/__init__.py +11 -0
- pybounds-0.0.1/pybounds/observability.py +579 -0
- pybounds-0.0.1/pybounds/simulator.py +241 -0
- pybounds-0.0.1/pybounds/util.py +162 -0
- pybounds-0.0.1/pybounds.egg-info/PKG-INFO +15 -0
- pybounds-0.0.1/pybounds.egg-info/SOURCES.txt +11 -0
- pybounds-0.0.1/pybounds.egg-info/dependency_links.txt +1 -0
- pybounds-0.0.1/pybounds.egg-info/top_level.txt +1 -0
- pybounds-0.0.1/setup.cfg +4 -0
- pybounds-0.0.1/setup.py +22 -0
pybounds-0.0.1/LICENSE
ADDED
|
@@ -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.
|
pybounds-0.0.1/PKG-INFO
ADDED
|
@@ -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
|
pybounds-0.0.1/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# pybounds
|
|
@@ -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
|
|
@@ -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
|
+
|
|
@@ -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,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,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
setup.py
|
|
4
|
+
pybounds/__init__.py
|
|
5
|
+
pybounds/observability.py
|
|
6
|
+
pybounds/simulator.py
|
|
7
|
+
pybounds/util.py
|
|
8
|
+
pybounds.egg-info/PKG-INFO
|
|
9
|
+
pybounds.egg-info/SOURCES.txt
|
|
10
|
+
pybounds.egg-info/dependency_links.txt
|
|
11
|
+
pybounds.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pybounds
|
pybounds-0.0.1/setup.cfg
ADDED
pybounds-0.0.1/setup.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import setuptools
|
|
2
|
+
|
|
3
|
+
with open("README.md", "r") as fh:
|
|
4
|
+
long_description = fh.read()
|
|
5
|
+
|
|
6
|
+
setuptools.setup(
|
|
7
|
+
name="pybounds", # Replace with your own username
|
|
8
|
+
version="0.0.1",
|
|
9
|
+
author="Ben Cellini, Burak Boyacioglu, Floris van Breugel",
|
|
10
|
+
author_email="bcellini00@gmail.com",
|
|
11
|
+
description="Bounding Observability for Uncertain Nonlinear Dynamics Systems (BOUNDS)",
|
|
12
|
+
long_description=long_description,
|
|
13
|
+
long_description_content_type="text/markdown",
|
|
14
|
+
url="https://github.com/vanbreugel-lab/pybounds",
|
|
15
|
+
packages=setuptools.find_packages(),
|
|
16
|
+
classifiers=[
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
],
|
|
21
|
+
python_requires='>=3.9',
|
|
22
|
+
)
|