modelbase2 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1017 @@
1
+ # ruff: noqa: D100, D101, D102, D103, D104, D105, D106, D107, D200, D203, D400, D401, T201
2
+
3
+ __all__ = [
4
+ "Model",
5
+ "Options",
6
+ "ScanResult",
7
+ "elim_and_recalc",
8
+ "rationalize_all_numbers",
9
+ "strike_goldd",
10
+ ]
11
+
12
+
13
+ from dataclasses import dataclass, field
14
+ from datetime import datetime
15
+ from math import ceil, inf
16
+ from pathlib import Path
17
+ from time import time
18
+ from typing import cast
19
+
20
+ import numpy as np
21
+ import symbtools as st
22
+ import sympy as sp
23
+ from sympy.matrices import zeros
24
+
25
+
26
+ @dataclass
27
+ class Model:
28
+ x: list[list[sp.Symbol]] # known variables
29
+ p: list[list[sp.Symbol]] # unknown parameters
30
+ w: list # unknown symbols
31
+ u: list # known symbols
32
+ f: list # dynamic equations
33
+ h: list # outputs
34
+
35
+
36
+ @dataclass
37
+ class Options:
38
+ name: str
39
+ check_obser = 1
40
+ max_lie_time = inf
41
+ nnz_der_u: list[float] = field(default_factory=lambda: [inf])
42
+ nnz_der_w: list[float] = field(default_factory=lambda: [inf])
43
+ prev_ident_pars: list = field(default_factory=list)
44
+
45
+
46
+ @dataclass
47
+ class ScanResult: ...
48
+
49
+
50
+ def rationalize_all_numbers(expr: sp.Matrix) -> sp.Matrix:
51
+ numbers_atoms = list(expr.atoms(sp.Number))
52
+ rationalized_number_tpls = [(n, sp.Rational(n)) for n in numbers_atoms]
53
+ return cast(sp.Matrix, expr.subs(rationalized_number_tpls))
54
+
55
+
56
+ def elim_and_recalc(
57
+ unmeas_xred_indices,
58
+ rangoinicial,
59
+ numonx,
60
+ p,
61
+ x,
62
+ unidflag,
63
+ w1vector,
64
+ *args,
65
+ ):
66
+ numonx = rationalize_all_numbers(sp.Matrix(numonx))
67
+ # Depending on the number of arguments you pass to the function, there are two cases:
68
+
69
+ # called when there is no 'w'
70
+ if len(args) == 0:
71
+ pred = p
72
+ xred = x
73
+ wred = w1vector
74
+ identifiables = []
75
+ obs_states = []
76
+ obs_inputs = []
77
+ q = len(pred)
78
+ n = len(xred)
79
+ nw = len(wred)
80
+
81
+ # called when there are 'w'
82
+ if len(args) == 3:
83
+ pred = p
84
+ xred = x
85
+ wred = w1vector
86
+ identifiables = args[0]
87
+ obs_states = args[1]
88
+ obs_inputs = args[2]
89
+ q = len(pred)
90
+ n = len(xred)
91
+ nw = len(wred)
92
+
93
+ # before: q+n+nw; but with unknown inputs there may also be derivatives
94
+ r = sp.shape(sp.Matrix(numonx))[1]
95
+ new_ident_pars = identifiables
96
+ new_nonid_pars = []
97
+ new_obs_states = obs_states
98
+ new_unobs_states = []
99
+ new_obs_in = obs_inputs
100
+ new_unobs_in = []
101
+
102
+ # ========================================================================
103
+ # ELIMINATE A PARAMETER:
104
+ # ========================================================================
105
+ # At each iteration we remove a different column (= parameter) from onx:
106
+ for ind in range(q): # for each parameter of p...
107
+ if q <= 1: # check if the parameter has already been marked as identifiable
108
+ isidentifiable = pred[ind] in identifiables
109
+ else:
110
+ isidentifiable = any(pred[ind] in arr for arr in identifiables)
111
+ if isidentifiable:
112
+ print(
113
+ f"\n Parameter {pred[ind]} has already been classified as identifiable."
114
+ )
115
+ else:
116
+ indices = []
117
+ for i in range(r):
118
+ indices.append(i)
119
+ indices.pop(n + ind)
120
+ column_del_numonx = sp.Matrix(numonx).col(indices) # one column is removed
121
+ num_rank = st.generic_rank(
122
+ sp.Matrix(column_del_numonx)
123
+ ) # the range is calculated without that column
124
+ if num_rank == rangoinicial:
125
+ if unidflag == 1:
126
+ print(
127
+ f"\n => Parameter {pred[ind]} is structurally unidentifiable"
128
+ )
129
+ new_nonid_pars.append(pred[ind])
130
+ else:
131
+ print(
132
+ f"\n => We cannot decide about parameter {pred[ind]} at the moment"
133
+ )
134
+ else:
135
+ print(f"\n => Parameter {pred[ind]} is structurally identifiable")
136
+ new_ident_pars.append(pred[ind])
137
+
138
+ # ========================================================================
139
+ # ELIMINATE A STATE:
140
+ # ========================================================================
141
+ # At each iteration we try removing a different state from 'xred':
142
+ if options.checkObser == 1:
143
+ for ind in range(len(unmeas_xred_indices)): # for each unmeasured state
144
+ original_index = unmeas_xred_indices[ind]
145
+ if len(obs_states) <= 1:
146
+ isobservable = xred[original_index] in obs_states
147
+ else:
148
+ isobservable = any(xred[original_index] in arr for arr in obs_states)
149
+ if isobservable:
150
+ print("\n State %s has already been classified as observable.".format())
151
+ else:
152
+ indices = []
153
+ for i in range(r):
154
+ indices.append(i)
155
+ indices.pop(original_index) # remove the column that we want to check
156
+ column_del_numonx = sp.Matrix(numonx).col(indices)
157
+ num_rank = st.generic_rank(sp.Matrix(column_del_numonx))
158
+ if num_rank == rangoinicial:
159
+ if unidflag == 1:
160
+ print(f"\n => State {xred[original_index]} is unobservable")
161
+ new_unobs_states.append(xred[original_index])
162
+ else: # if this function was called because the necessary number of derivatives was not calculated...
163
+ print(
164
+ f"\n => We cannot decide about state {xred[original_index]} at the moment"
165
+ )
166
+ else:
167
+ print(f"\n => State {xred[original_index]} is observable")
168
+ new_obs_states.append(xred[original_index])
169
+
170
+ # ========================================================================
171
+ # ELIMINATE AN UNKNOWN INPUT:
172
+ # ========================================================================
173
+ # At each iteration we try removing a different column from onx:
174
+ for ind in range(nw): # for each unknown input...
175
+ if (
176
+ len(obs_inputs) <= 1
177
+ ): # check if the unknown input has already been marked as observable
178
+ isobservable = wred[ind] in obs_inputs
179
+ else:
180
+ isobservable = any(wred[ind] in arr for arr in obs_inputs)
181
+ if isobservable:
182
+ print("\n Input %s has already been classified as observable.".format())
183
+ else:
184
+ indices = []
185
+ for i in range(r):
186
+ indices.append(i)
187
+ indices.pop(n + q + ind) # remove the column that we want to check
188
+ column_del_numonx = sp.Matrix(numonx).col(indices)
189
+ num_rank = st.generic_rank(sp.Matrix(column_del_numonx))
190
+ if num_rank == rangoinicial:
191
+ if unidflag == 1:
192
+ print(f"\n => Input {wred[ind]} is unobservable")
193
+ new_unobs_in.append(wred[ind])
194
+ else:
195
+ print(
196
+ f"\n => We cannot decide about input {wred[ind]} at the moment"
197
+ )
198
+ else:
199
+ print(f"\n => Input {wred[ind]} is observable")
200
+ new_obs_in.append(wred[ind])
201
+ return (
202
+ new_ident_pars,
203
+ new_nonid_pars,
204
+ new_obs_states,
205
+ new_unobs_states,
206
+ new_obs_in,
207
+ new_unobs_in,
208
+ )
209
+
210
+
211
+ def strike_goldd(model: Model, options: Options) -> ScanResult:
212
+ results_dir = Path("results")
213
+ results_dir.mkdir(parents=True, exist_ok=True)
214
+
215
+ # Initialize variables:
216
+ identifiables = [] # identifiable parameters.
217
+ nonidentif = [] # unidentifiable parameters.
218
+ obs_states = [] # observable states.
219
+ unobs_states = [] # unobservable states.
220
+ obs_inputs = [] # observable inputs.
221
+ unobs_inputs = [] # unobservable inputs.
222
+ lastrank = None
223
+ unidflag = 0
224
+ skip_elim = 0
225
+ is_fispo = 0
226
+
227
+ # Dimensions of the problem:
228
+ m = len(model.h) # number of outputs
229
+ n = len(model.x) # number of states
230
+ q = len(model.p) # number of unknown parameters
231
+ nw = len(model.w)
232
+ r = n + q + nw # number of unknown variables to observe / identify
233
+ nd = ceil((r - m) / m) # minimum number of Lie derivatives for Oi to have full rank
234
+
235
+ # Check which states are directly measured, if any.
236
+ # Basically it is checked if any state is directly on the output,
237
+ # then that state is directly measurable.
238
+ saidas = model.h if m == 1 else [model.h[i] for i in range(m)]
239
+ estados = model.x if n == 1 else [model.x[i][0] for i in range(n)]
240
+ ismeasured = [0 for i in range(n)]
241
+
242
+ if len(saidas) == 1:
243
+ for i in range(n):
244
+ if estados[i] in saidas:
245
+ ismeasured[i] = 1
246
+ else:
247
+ for i in range(n):
248
+ if any(estados[i] in arr for arr in saidas):
249
+ ismeasured[i] = 1
250
+
251
+ measured_states_idx = [i for i in range(n) if ismeasured[i] == 1]
252
+ unmeasured_states_idx = [i for i in range(n) if ismeasured[i] == 0]
253
+
254
+ # names of the measured states
255
+ meas_x = []
256
+ if len(measured_states_idx) == 1 and n == 1:
257
+ meas_x = estados
258
+ if len(measured_states_idx) == 1 and n != 1:
259
+ meas_x.append(estados[measured_states_idx[0]])
260
+ if len(measured_states_idx) > 1:
261
+ for i in range(len(measured_states_idx)):
262
+ meas_x.append([estados[measured_states_idx[i]]])
263
+
264
+ print(
265
+ f"Building the observability-identifiability matrix requires at least {nd} Lie derivatives"
266
+ )
267
+ print("Calculating derivatives: ")
268
+
269
+ ########################################################################
270
+ # Check if the size of nnzDerU and nnzDerW are appropriate
271
+ if len(model.u) > len(options.nnz_der_u):
272
+ msg = """ The number of known inputs is higher than the size of nnzDerU and must have the same size.
273
+ Go to the options file and modify it.
274
+ For more information about the error see point 7 of the StrikePy instruction manual."""
275
+ raise ValueError(msg)
276
+ if len(model.w) > len(options.nnz_der_w):
277
+ msg = """ The number of unknown inputs is higher than the size of nnzDerW and must have the same size.
278
+ Go to the options file and modify it.
279
+ For more information about the error see point 7 of the StrikePy instruction manual. """
280
+ raise ValueError(msg)
281
+
282
+ ########################################################################
283
+ # Input derivates:
284
+
285
+ # Create array of known inputs and set certain derivatives to zero:
286
+ input_der = []
287
+ if len(model.u) > 0:
288
+ for ind_u in range(len(model.u)): # create array of derivatives of the inputs
289
+ if len(model.u) == 1:
290
+ locals()[f"{model.u[ind_u]}"] = sp.Symbol(
291
+ f"{model.u[ind_u]}"
292
+ ) # the first element is the underived input
293
+ auxiliar = [locals()[f"{model.u[ind_u]}"]]
294
+ else:
295
+ locals()[f"{model.u[ind_u][0]}"] = sp.Symbol(
296
+ f"{model.u[ind_u][0]}"
297
+ ) # the first element is the underived input
298
+ auxiliar = [locals()[f"{model.u[ind_u][0]}"]]
299
+ for k in range(nd):
300
+ if len(model.u) == 1:
301
+ locals()[f"{model.u[ind_u]}_d{k + 1}"] = sp.Symbol(
302
+ f"{model.u[ind_u]}_d{k + 1}"
303
+ )
304
+ auxiliar.append(locals()[f"{model.u[ind_u]}_d{k + 1}"])
305
+ else:
306
+ locals()[f"{model.u[ind_u][0]}_d{k + 1}"] = sp.Symbol(
307
+ f"{model.u[ind_u][0]}_d{k + 1}"
308
+ )
309
+ auxiliar.append(locals()[f"{model.u[ind_u][0]}_d{k + 1}"])
310
+ if len(model.u) == 1:
311
+ input_der = auxiliar
312
+ if len(input_der) >= options.nnz_der_u[0] + 1:
313
+ for i in range(len(input_der[(options.nnz_der_u[0] + 1) :])):
314
+ input_der[(options.nnz_der_u[0] + 1) + i] = 0
315
+ else:
316
+ input_der.append(auxiliar)
317
+ if len(input_der[0]) >= options.nnz_der_u[ind_u] + 1:
318
+ for i in range(len(input_der[0][(options.nnz_der_u[ind_u] + 1) :])):
319
+ input_der[ind_u][(options.nnz_der_u[ind_u] + 1) + i] = 0
320
+ zero_input_der_dummy_name = sp.Symbol("zero_input_der_dummy_name")
321
+
322
+ # Create array of unknown inputs and set certain derivatives to zero:
323
+ w_der = []
324
+ if len(model.w) > 0:
325
+ for ind_w in range(len(model.w)): # create array of derivatives of the inputs
326
+ if len(model.w) == 1:
327
+ locals()[f"{model.w[ind_w]}"] = sp.Symbol(
328
+ f"{model.w[ind_w]}"
329
+ ) # the first element is the underived input
330
+ auxiliar = [locals()[f"{model.w[ind_w]}"]]
331
+ else:
332
+ locals()[f"{model.w[ind_w][0]}"] = sp.Symbol(
333
+ f"{model.w[ind_w][0]}"
334
+ ) # the first element is the underived input
335
+ auxiliar = [locals()[f"{model.w[ind_w][0]}"]]
336
+ for k in range(nd + 1):
337
+ if len(model.w) == 1:
338
+ locals()[f"{model.w[ind_w]}_d{k + 1}"] = sp.Symbol(
339
+ f"{model.w[ind_w]}_d{k + 1}"
340
+ )
341
+ auxiliar.append(locals()[f"{model.w[ind_w]}_d{k + 1}"])
342
+ else:
343
+ locals()[f"{model.w[ind_w][0]}_d{k + 1}"] = sp.Symbol(
344
+ f"{model.w[ind_w][0]}_d{k + 1}"
345
+ )
346
+ auxiliar.append(locals()[f"{model.w[ind_w][0]}_d{k + 1}"])
347
+ if len(model.w) == 1:
348
+ w_der = auxiliar
349
+ if len(w_der) >= options.nnz_der_w[0] + 1:
350
+ for i in range(len(w_der[(options.nnz_der_w[0] + 1) :])):
351
+ w_der[(options.nnz_der_w[0] + 1) + i] = 0
352
+ else:
353
+ w_der.append(auxiliar)
354
+ if len(w_der[0]) >= options.nnz_der_w[ind_w] + 1:
355
+ for i in range(len(w_der[0][(options.nnz_der_w[ind_w] + 1) :])):
356
+ w_der[ind_w][(options.nnzDerW[ind_w] + 1) + i] = 0
357
+
358
+ if sp.shape(sp.Matrix(w_der).T)[0] == 1:
359
+ w1vector = [[w_der[i]] for i in range(len(w_der) - 1)]
360
+ w1vector_dot = [[w_der[i]] for i in range(1, len(w_der))]
361
+
362
+ else:
363
+ w1vector = []
364
+ for k in range(sp.shape(sp.Matrix(w_der))[1] - 1):
365
+ for i in w_der:
366
+ w1vector.append([i[k]])
367
+ w1vector_dot = []
368
+ for k in range(sp.shape(sp.Matrix(w_der))[1]):
369
+ for i in w_der:
370
+ if k != 0:
371
+ w1vector_dot.append([i[k]])
372
+
373
+ # -- Include as states only nonzero inputs / derivatives:
374
+ nzi = [[fila] for fila in range(len(w1vector)) if w1vector[fila][0] != 0]
375
+ nzj = [[1] for fila in range(len(w1vector)) if w1vector[fila][0] != 0]
376
+ nz_w1vec = [
377
+ w1vector[fila] for fila in range(len(w1vector)) if w1vector[fila][0] != 0
378
+ ]
379
+ w1vector = nz_w1vec
380
+ w1vector_dot = w1vector_dot[0 : len(nzi)]
381
+
382
+ else:
383
+ w1vector = []
384
+ w1vector_dot = []
385
+
386
+ ########################################################################
387
+ # Augment state vector, dynamics:
388
+ if len(model.x) == 1:
389
+ xaug = []
390
+ xaug.append(model.x)
391
+ xaug = np.append(xaug, model.p, axis=0)
392
+ if len(w1vector) != 0:
393
+ xaug = np.append(xaug, w1vector, axis=0)
394
+
395
+ faug = []
396
+ faug.append(model.f)
397
+ faug = np.append(faug, zeros(len(model.p), 1), axis=0)
398
+ if len(w1vector) != 0:
399
+ faug = np.append(faug, w1vector_dot, axis=0)
400
+
401
+ else:
402
+ xaug = model.x
403
+ xaug = np.append(xaug, model.p, axis=0)
404
+ if len(w1vector) != 0:
405
+ xaug = np.append(xaug, w1vector, axis=0)
406
+
407
+ faug = model.f
408
+ faug = np.append(faug, zeros(len(model.p), 1), axis=0)
409
+ if len(w1vector) != 0:
410
+ faug = np.append(faug, w1vector_dot, axis=0)
411
+ ########################################################################
412
+ # Build Oi:
413
+ onx = np.array(zeros(m * (1 + nd), n + q + len(w1vector)))
414
+ jacobiano = sp.Matrix(model.h).jacobian(xaug)
415
+ onx[0 : len(model.h)] = np.array(
416
+ jacobiano
417
+ ) # first row(s) of onx (derivative of the output with respect to the vector states+unknown parameters).
418
+ ind = 0 # Lie derivative index (sometimes called 'k')
419
+
420
+ ########################################################################
421
+ past_Lie = model.h
422
+ extra_term = np.array(0)
423
+
424
+ # loop as long as I don't complete the preset Lie derivatives or go over the maximum time set for each derivative
425
+ while ind < nd:
426
+ Lieh = sp.Matrix((onx[(ind * m) : (ind + 1) * m][:]).dot(faug))
427
+ if ind > 0 and len(model.u) > 0:
428
+ for i in range(ind):
429
+ if len(model.u) == 1:
430
+ column = len(input_der) - 1
431
+ if i < column:
432
+ lo_u_der = input_der[i]
433
+ if lo_u_der == 0:
434
+ lo_u_der = zero_input_der_dummy_name
435
+ lo_u_der = np.array([lo_u_der])
436
+ hi_u_der = input_der[i + 1]
437
+ hi_u_der = sp.Matrix([hi_u_der])
438
+
439
+ intermedio = sp.Matrix([past_Lie]).jacobian(lo_u_der) * hi_u_der
440
+ if extra_term:
441
+ extra_term = extra_term + intermedio
442
+ else:
443
+ extra_term = intermedio
444
+ else:
445
+ column = len(input_der[0]) - 1
446
+ if i < column:
447
+ lo_u_der = []
448
+ hi_u_der = []
449
+ for fila in input_der:
450
+ lo_u_der.append(fila[i])
451
+ hi_u_der.append(fila[i + 1])
452
+ for i in range(len(lo_u_der)):
453
+ if lo_u_der[i] == 0:
454
+ lo_u_der[i] = zero_input_der_dummy_name
455
+ lo_u_der = np.array(lo_u_der)
456
+ hi_u_der = sp.Matrix(hi_u_der)
457
+ intermedio = sp.Matrix([past_Lie]).jacobian(lo_u_der) * hi_u_der
458
+ if extra_term:
459
+ extra_term = extra_term + intermedio
460
+ else:
461
+ extra_term = intermedio
462
+ ext_Lie = Lieh + extra_term if extra_term else Lieh
463
+ past_Lie = ext_Lie
464
+ onx[((ind + 1) * m) : (ind + 2) * m] = sp.Matrix(ext_Lie).jacobian(xaug)
465
+
466
+ ind = ind + 1
467
+ print(end=f" {ind}")
468
+
469
+ if (
470
+ ind == nd
471
+ ): # If I have done all the minimum derivatives to build onx (I have not exceeded the time limit)....
472
+ increaseLie = 1
473
+ while (
474
+ increaseLie == 1
475
+ ): # while increaseLie is 1 I will increase the size of onx
476
+ print(
477
+ f"\n >>> Observability-Identifiability matrix built with {nd} Lie derivatives"
478
+ )
479
+ # =============================================================================================
480
+ # The observability/identifiability matrix is saved in a .txt file
481
+
482
+ with (
483
+ results_dir / f"obs_ident_matrix_{options.name}_{nd}_Lie_deriv.txt"
484
+ ).open("w") as file:
485
+ file.write(f"onx = {onx.tolist()!s}")
486
+
487
+ # =============================================================================================
488
+ # Check identifiability by calculating rank:
489
+ print(
490
+ f" >>> Calculating rank of matrix with size {sp.shape(sp.Matrix(onx))[0]}x{sp.shape(sp.Matrix(onx))[1]}..."
491
+ )
492
+ rational_onx = rationalize_all_numbers(sp.Matrix(onx))
493
+ rango = st.generic_rank(sp.Matrix(rational_onx))
494
+ print(f" Rank = {rango} (calculated in {toc} seconds)")
495
+ if (
496
+ rango == len(xaug)
497
+ ): # If the onx matrix already has full rank... all is observable and identifiable
498
+ obs_states = model.x
499
+ obs_inputs = model.w
500
+ identifiables = model.p
501
+ increaseLie = (
502
+ 0 # stop increasing the number of onx rows with derivatives
503
+ )
504
+
505
+ else: # With that number of Lie derivatives the array is not full rank.
506
+ # ----------------------------------------------------------
507
+ # If there are unknown inputs, we may want to check id/obs of (x,p,w) and not of dw/dt:
508
+ if len(model.w) > 0:
509
+ [
510
+ identifiables,
511
+ nonidentif,
512
+ obs_states,
513
+ unobs_states,
514
+ obs_inputs,
515
+ unobs_inputs,
516
+ ] = elim_and_recalc(
517
+ unmeasured_states_idx,
518
+ rango,
519
+ onx,
520
+ model.p,
521
+ model.x,
522
+ unidflag,
523
+ w1vector,
524
+ identifiables,
525
+ obs_states,
526
+ obs_inputs,
527
+ )
528
+
529
+ # Check which unknown inputs are observable:
530
+ obs_in_no_der = []
531
+ if len(model.w) == 1 and len(obs_inputs) > 0:
532
+ if model.w == obs_inputs:
533
+ obs_in_no_der = model.w
534
+ if len(model.w) > 1 and len(obs_inputs) > 0:
535
+ for elemento in model.w:
536
+ if len(obs_inputs) == 1:
537
+ if elemento == obs_inputs:
538
+ obs_in_no_der = elemento
539
+ else:
540
+ for input_ in obs_inputs:
541
+ if elemento == input_:
542
+ obs_in_no_der.append(elemento[0])
543
+ if (
544
+ len(identifiables) == len(model.p)
545
+ and len(obs_states) + len(meas_x) == len(model.x)
546
+ and len(obs_in_no_der) == len(model.w)
547
+ ):
548
+ obs_states = model.x
549
+ obs_inputs = obs_in_no_der
550
+ identifiables = model.p
551
+ increaseLie = 0 # -> with this we skip the next 'if' block and jump to the end of the algorithm
552
+ is_fispo = 1
553
+ # ----------------------------------------------------------
554
+ # If possible (& necessary), calculate one more Lie derivative and retry:
555
+ if (
556
+ nd < len(xaug)
557
+ and lasttime < options.max_lie_time
558
+ and rango != lastrank
559
+ and increaseLie == 1
560
+ ):
561
+ ind = nd
562
+ nd = (
563
+ nd + 1
564
+ ) # One is added to the number of derivatives already made
565
+ extra_term = np.array(0) # reset for each new Lie derivative
566
+ # - Known input derivatives: ----------------------------------
567
+ if len(model.u) > 0: # Extra terms of extended Lie derivatives
568
+ # may have to add extra input derivatives (note that 'nd' has grown):
569
+ input_der = []
570
+ for ind_u in range(
571
+ len(model.u)
572
+ ): # create array of derivatives of the inputs
573
+ if len(model.u) == 1:
574
+ locals()[f"{model.u[ind_u]}"] = sp.Symbol(
575
+ f"{model.u[ind_u]}"
576
+ ) # the first element is the underived input
577
+ auxiliar = [locals()[f"{model.u[ind_u]}"]]
578
+ else:
579
+ locals()[f"{model.u[ind_u][0]}"] = sp.Symbol(
580
+ f"{model.u[ind_u][0]}"
581
+ ) # the first element is the underived input
582
+ auxiliar = [locals()[f"{model.u[ind_u][0]}"]]
583
+ for k in range(nd):
584
+ if len(model.u) == 1:
585
+ locals()[f"{model.u[ind_u]}_d{k + 1}"] = sp.Symbol(
586
+ f"{model.u[ind_u]}_d{k + 1}"
587
+ )
588
+ auxiliar.append(
589
+ locals()[f"{model.u[ind_u]}_d{k + 1}"]
590
+ )
591
+ else:
592
+ locals()[f"{model.u[ind_u][0]}_d{k + 1}"] = (
593
+ sp.Symbol(f"{model.u[ind_u][0]}_d{k + 1}")
594
+ )
595
+ auxiliar.append(
596
+ locals()[f"{model.u[ind_u][0]}_d{k + 1}"]
597
+ )
598
+ if len(model.u) == 1:
599
+ input_der = auxiliar
600
+ if len(input_der) >= options.nnz_der_u[0] + 1:
601
+ for i in range(
602
+ len(input_der[(options.nnz_der_u[0] + 1) :])
603
+ ):
604
+ input_der[(options.nnz_der_u[0] + 1) + i] = 0
605
+ else:
606
+ input_der.append(auxiliar)
607
+ if len(input_der[0]) >= options.nnz_der_u[ind_u] + 1:
608
+ for i in range(
609
+ len(
610
+ input_der[0][
611
+ (options.nnz_der_u[ind_u] + 1) :
612
+ ]
613
+ )
614
+ ):
615
+ input_der[ind_u][
616
+ (options.nnzDerU[ind_u] + 1) + i
617
+ ] = 0
618
+
619
+ for i in range(ind):
620
+ if len(model.u) == 1:
621
+ column = len(input_der) - 1
622
+ if i < column:
623
+ lo_u_der = input_der[i]
624
+ if lo_u_der == 0:
625
+ lo_u_der = zero_input_der_dummy_name
626
+ lo_u_der = np.array([lo_u_der])
627
+ hi_u_der = input_der[i + 1]
628
+ hi_u_der = sp.Matrix([hi_u_der])
629
+
630
+ intermedio = (
631
+ sp.Matrix([past_Lie]).jacobian(lo_u_der)
632
+ * hi_u_der
633
+ )
634
+ if extra_term:
635
+ extra_term = extra_term + intermedio
636
+ else:
637
+ extra_term = intermedio
638
+ else:
639
+ column = len(input_der[0]) - 1
640
+ if i < column:
641
+ lo_u_der = []
642
+ hi_u_der = []
643
+ for fila in input_der:
644
+ lo_u_der.append(fila[i])
645
+ hi_u_der.append(fila[i + 1])
646
+ for i in range(len(lo_u_der)):
647
+ if lo_u_der[i] == 0:
648
+ lo_u_der[i] = zero_input_der_dummy_name
649
+ lo_u_der = np.array(lo_u_der)
650
+ hi_u_der = sp.Matrix(hi_u_der)
651
+ intermedio = (
652
+ sp.Matrix([past_Lie]).jacobian(lo_u_der)
653
+ * hi_u_der
654
+ )
655
+ if extra_term:
656
+ extra_term = extra_term + intermedio
657
+ else:
658
+ extra_term = intermedio
659
+
660
+ # - Unknown input derivatives:----------------
661
+ # add new derivatives, if they are not zero:
662
+ if len(model.w) > 0:
663
+ prev_size = len(w1vector)
664
+ w_der = []
665
+ for ind_w in range(
666
+ len(model.w)
667
+ ): # create array of derivatives of the inputs
668
+ if len(model.w) == 1:
669
+ locals()[f"{model.w[ind_w]}"] = sp.Symbol(
670
+ f"{model.w[ind_w]}"
671
+ ) # the first element is the underived input
672
+ auxiliar = [locals()[f"{model.w[ind_w]}"]]
673
+ else:
674
+ locals()[f"{model.w[ind_w][0]}"] = sp.Symbol(
675
+ f"{model.w[ind_w][0]}"
676
+ ) # the first element is the underived input
677
+ auxiliar = [locals()[f"{model.w[ind_w][0]}"]]
678
+ for k in range(nd + 1):
679
+ if len(model.w) == 1:
680
+ locals()[f"{model.w[ind_w]}_d{k + 1}"] = sp.Symbol(
681
+ f"{model.w[ind_w]}_d{k + 1}"
682
+ )
683
+ auxiliar.append(
684
+ locals()[f"{model.w[ind_w]}_d{k + 1}"]
685
+ )
686
+ else:
687
+ locals()[f"{model.w[ind_w][0]}_d{k + 1}"] = (
688
+ sp.Symbol(f"{model.w[ind_w][0]}_d{k + 1}")
689
+ )
690
+ auxiliar.append(
691
+ locals()[f"{model.w[ind_w][0]}_d{k + 1}"]
692
+ )
693
+ if len(model.w) == 1:
694
+ w_der = auxiliar
695
+ if len(w_der) >= options.nnz_der_w[0] + 1:
696
+ for i in range(
697
+ len(w_der[(options.nnz_der_w[0] + 1) :])
698
+ ):
699
+ w_der[(options.nnz_der_w[0] + 1) + i] = 0
700
+ else:
701
+ w_der.append(auxiliar)
702
+ if len(w_der[0]) >= options.nnz_der_w[ind_w] + 1:
703
+ for i in range(
704
+ len(w_der[0][(options.nnz_der_w[ind_w] + 1) :])
705
+ ):
706
+ w_der[ind_w][
707
+ (options.nnzDerW[ind_w] + 1) + i
708
+ ] = 0
709
+
710
+ if sp.shape(sp.Matrix(w_der).T)[0] == 1:
711
+ w1vector = []
712
+ for i in range(len(w_der) - 1):
713
+ w1vector.append([w_der[i]])
714
+ w1vector_dot = []
715
+ for i in range(len(w_der)):
716
+ if i != 0:
717
+ w1vector_dot.append([w_der[i]])
718
+
719
+ else:
720
+ w1vector = []
721
+ for k in range(sp.shape(sp.Matrix(w_der))[1] - 1):
722
+ for i in w_der:
723
+ w1vector.append([i[k]])
724
+ w1vector_dot = []
725
+ for k in range(sp.shape(sp.Matrix(w_der))[1]):
726
+ for i in w_der:
727
+ if k != 0:
728
+ w1vector_dot.append([i[k]])
729
+
730
+ # -- Include as states only nonzero inputs / derivatives:
731
+ nzi = []
732
+ for fila in range(len(w1vector)):
733
+ if w1vector[fila][0] != 0:
734
+ nzi.append([fila])
735
+ nzj = []
736
+ for fila in range(len(w1vector)):
737
+ if w1vector[fila][0] != 0:
738
+ nzj.append([1])
739
+ nz_w1vec = []
740
+ for fila in range(len(w1vector)):
741
+ if w1vector[fila][0] != 0:
742
+ nz_w1vec.append(w1vector[fila])
743
+ w1vector = nz_w1vec
744
+ w1vector_dot = w1vector_dot[0 : len(nzi)]
745
+
746
+ ########################################################################
747
+ # Augment state vector, dynamics:
748
+ if len(model.x) == 1:
749
+ xaug = []
750
+ xaug.append(model.x)
751
+ xaug = np.append(xaug, model.p, axis=0)
752
+ if len(w1vector) != 0:
753
+ xaug = np.append(xaug, w1vector, axis=0)
754
+
755
+ faug = []
756
+ faug.append(model.f)
757
+ faug = np.append(faug, zeros(len(model.p), 1), axis=0)
758
+ if len(w1vector) != 0:
759
+ faug = np.append(faug, w1vector_dot, axis=0)
760
+
761
+ else:
762
+ xaug = model.x
763
+ xaug = np.append(xaug, model.p, axis=0)
764
+ if len(w1vector) != 0:
765
+ xaug = np.append(xaug, w1vector, axis=0)
766
+
767
+ faug = model.f
768
+ faug = np.append(faug, zeros(len(model.p), 1), axis=0)
769
+ if len(w1vector) != 0:
770
+ faug = np.append(faug, w1vector_dot, axis=0)
771
+ ########################################################################
772
+ # -- Augment size of the Obs-Id matrix if needed:
773
+ new_size = len(w1vector)
774
+ onx = np.append(
775
+ onx, zeros((ind + 1) * m, new_size - prev_size), axis=1
776
+ )
777
+ ########################################################################
778
+ newLie = sp.Matrix((onx[(ind * m) : (ind + 1) * m][:]).dot(faug))
779
+ past_Lie = newLie + extra_term if extra_term else newLie
780
+ newOnx = sp.Matrix(past_Lie).jacobian(xaug)
781
+ onx = np.append(onx, newOnx, axis=0)
782
+
783
+ lastrank = rango
784
+
785
+ # If that is not possible, there are several possible causes:
786
+ # This is the case when you have onx with all possible derivatives done and it is not full rank, the maximum time for the next derivative has passed
787
+ # or the matrix no longer increases in rank as derivatives are increased.
788
+ else:
789
+ if nd >= len(
790
+ xaug
791
+ ): # The maximum number of Lie derivatives has been reached
792
+ unidflag = 1
793
+ print(
794
+ "\n >>> The model is structurally unidentifiable as a whole"
795
+ )
796
+ elif rango == lastrank:
797
+ onx = onx[0 : (-1 - (m - 1))]
798
+ nd = (
799
+ nd - 1
800
+ ) # It is indicated that the number of derivatives needed was one less than the number of derivatives made
801
+ unidflag = 1
802
+ elif lasttime >= options.max_lie_time:
803
+ print(
804
+ "\n => More Lie derivatives would be needed to see if the model is structurally unidentifiable as a whole."
805
+ )
806
+ print(
807
+ " However, the maximum computation time allowed for calculating each of them has been reached."
808
+ )
809
+ print(
810
+ f" You can increase it by changing <<maxLietime>> in options (currently maxLietime = {options.max_lie_time})"
811
+ )
812
+ unidflag = 0
813
+ if skip_elim == 0 and is_fispo == 0:
814
+ # Eliminate columns one by one to check identifiability of the associated parameters:
815
+ [
816
+ identifiables,
817
+ nonidentif,
818
+ obs_states,
819
+ unobs_states,
820
+ obs_inputs,
821
+ unobs_inputs,
822
+ ] = elim_and_recalc(
823
+ unmeasured_states_idx,
824
+ rango,
825
+ onx,
826
+ model.p,
827
+ model.x,
828
+ unidflag,
829
+ w1vector,
830
+ identifiables,
831
+ obs_states,
832
+ obs_inputs,
833
+ )
834
+
835
+ # Check which unknown inputs are observable:
836
+ obs_in_no_der = []
837
+ if (
838
+ len(model.w) == 1
839
+ and len(obs_inputs) > 0
840
+ and model.w == obs_inputs
841
+ ):
842
+ obs_in_no_der = model.w
843
+ if len(model.w) > 1 and len(obs_inputs) > 0:
844
+ for elemento in model.w: # for each unknown input
845
+ if len(obs_inputs) == 1:
846
+ if elemento == obs_inputs:
847
+ obs_in_no_der = elemento
848
+ else:
849
+ for input in obs_inputs:
850
+ if elemento == input:
851
+ obs_in_no_der.append(elemento[0])
852
+
853
+ if (
854
+ len(identifiables) == len(model.p)
855
+ and (len(obs_states) + len(meas_x)) == len(model.x)
856
+ and len(obs_in_no_der) == len(model.w)
857
+ ):
858
+ obs_states = model.x
859
+ obs_inputs = obs_in_no_der
860
+ identifiables = model.p
861
+ increaseLie = 0 # -> with this we skip the next 'if' block and jump to the end of the algorithm
862
+ is_fispo = 1
863
+ increaseLie = 0
864
+
865
+ else: # If the maxLietime has been reached, but the minimum of Lie derivatives has not been calculated:
866
+ print("\n => More Lie derivatives would be needed to analyse the model.")
867
+ print(
868
+ " However, the maximum computation time allowed for calculating each of them has been reached."
869
+ )
870
+ print(
871
+ f" You can increase it by changing <<maxLietime>> in options (currently maxLietime = {options.max_lie_time})"
872
+ )
873
+ print(
874
+ f"\n >>> Calculating rank of matrix with size {sp.shape(sp.Matrix(onx))[0]}x{sp.shape(sp.Matrix(onx))[1]}..."
875
+ )
876
+ # =============================================================================================
877
+ # The observability/identifiability matrix is saved in a .txt file
878
+ file_path = results_dir / f"obs_ident_matrix_{options.name}_{nd}_Lie_deriv.txt"
879
+ with file_path.open("w") as file:
880
+ file.write(f"onx = {onx.tolist()!s}")
881
+
882
+ # =============================================================================================
883
+ rational_onx = rationalize_all_numbers(sp.Matrix(onx))
884
+ rango = st.generic_rank(sp.Matrix(rational_onx))
885
+
886
+ print(f"\n Rank = {rango}")
887
+ (
888
+ identifiables,
889
+ nonidentif,
890
+ obs_states,
891
+ unobs_states,
892
+ obs_inputs,
893
+ unobs_inputs,
894
+ ) = elim_and_recalc(
895
+ unmeasured_states_idx, rango, onx, identifiables, obs_states, obs_inputs
896
+ )
897
+ # ======================================================================================
898
+ # Build the vectors of identifiable / unidentifiable parameters, and of observable / unobservable states and inputs:
899
+ if len(identifiables) != 0:
900
+ p_id = sp.Matrix(identifiables).T
901
+ p_id = np.array(p_id).tolist()[0]
902
+ else:
903
+ p_id = []
904
+
905
+ if len(nonidentif) != 0:
906
+ p_un = sp.Matrix(nonidentif).T
907
+ p_un = np.array(p_un).tolist()[0]
908
+ else:
909
+ p_un = []
910
+
911
+ if len(obs_states) != 0:
912
+ obs_states = sp.Matrix(obs_states).T
913
+ obs_states = np.array(obs_states).tolist()[0]
914
+
915
+ if len(unobs_states) != 0:
916
+ unobs_states = sp.Matrix(unobs_states).T
917
+ unobs_states = np.array(unobs_states).tolist()[0]
918
+
919
+ if len(obs_inputs) != 0:
920
+ obs_inputs = sp.Matrix(obs_inputs).T
921
+ obs_inputs = np.array(obs_inputs).tolist()[0]
922
+
923
+ if len(unobs_inputs) != 0:
924
+ unobs_inputs = sp.Matrix(unobs_inputs).T
925
+ unobs_inputs = np.array(unobs_inputs).tolist()[0]
926
+ # ========================================================================================
927
+ # The observability/identifiability matrix is saved in a .txt file
928
+
929
+ file_path = results_dir / f"obs_ident_matrix_{options.name}_{nd}_Lie_deriv.txt"
930
+ with file_path.open("w") as file:
931
+ file.write(f"onx = {onx.tolist()!s}")
932
+
933
+ # The summary of the results is saved in a .txt file
934
+ file_path = (
935
+ results_dir
936
+ / f"id_results_{options.name}_{datetime.today().strftime('%d-%m-%Y')}.txt"
937
+ )
938
+ with file_path.open("w") as file:
939
+ file.write("\n RESULTS SUMMARY:")
940
+
941
+ # Report results:
942
+ # result
943
+ # fispo: bool
944
+
945
+ print("\n ------------------------ ")
946
+ print(" RESULTS SUMMARY:")
947
+ print(" ------------------------ ")
948
+ if (
949
+ len(p_id) == len(model.p)
950
+ and len(obs_states) == len(model.x)
951
+ and len(obs_inputs) == len(model.w)
952
+ ):
953
+ print("\n >>> The model is Fully Input-State-Parameter Observable (FISPO):")
954
+ if len(model.w) > 0:
955
+ print("\n All its unknown inputs are observable.")
956
+ file.write("\n All its unknown inputs are observable.")
957
+ print("\n All its states are observable.")
958
+ print("\n All its parameters are locally structurally identifiable.")
959
+ else:
960
+ if len(p_id) == len(model.p):
961
+ print("\n >>> The model is structurally identifiable:")
962
+ print("\n All its parameters are structurally identifiable.")
963
+ file.write(
964
+ "\n >>> The model is structurally identifiable:\n All its parameters are structurally identifiable."
965
+ )
966
+ elif unidflag:
967
+ print("\n >>> The model is structurally unidentifiable.")
968
+ print(f"\n >>> These parameters are identifiable:\n {p_id} ")
969
+ print(f"\n >>> These parameters are unidentifiable:\n {p_un}")
970
+ file.write(
971
+ f"\n >>> The model is structurally unidentifiable.\n >>> These parameters are identifiable:\n {p_id}\n >>> These parameters are unidentifiable:\n {p_un}"
972
+ )
973
+ else:
974
+ print(f"\n >>> These parameters are identifiable:\n {p_id}")
975
+ file.write(f"\n >>> These parameters are identifiable:\n {p_id}")
976
+
977
+ if len(obs_states) > 0:
978
+ print(
979
+ f"\n >>> These states are observable (and their initial conditions, if unknown, are identifiable):\n {obs_states}"
980
+ )
981
+ file.write(
982
+ f"\n >>> These states are observable (and their initial conditions, if unknown, are identifiable):\n {obs_states}"
983
+ )
984
+ if len(unobs_states) > 0:
985
+ print(
986
+ f"\n >>> These states are unobservable (and their initial conditions, if unknown, are unidentifiable):\n {unobs_states}"
987
+ )
988
+ file.write(
989
+ f"\n >>> These states are unobservable (and their initial conditions, if unknown, are unidentifiable):\n {unobs_states}"
990
+ )
991
+
992
+ if len(meas_x) != 0: # para mostrarlo en una fila, como el resto
993
+ meas_x = sp.Matrix(meas_x).T
994
+ meas_x = np.array(meas_x).tolist()[0]
995
+ else:
996
+ meas_x = []
997
+
998
+ if len(meas_x) > 0:
999
+ print(f"\n >>> These states are directly measured:\n {meas_x}")
1000
+ file.write(f"\n >>> These states are directly measured:\n {meas_x}")
1001
+ if len(obs_inputs) > 0:
1002
+ print(f"\n >>> These unmeasured inputs are observable:\n {obs_inputs}")
1003
+ file.write(
1004
+ f"\n >>> These unmeasured inputs are observable:\n {obs_inputs}"
1005
+ )
1006
+ if len(unobs_inputs) > 0:
1007
+ print(
1008
+ f"\n >>> These unmeasured inputs are unobservable:\n {unobs_inputs}"
1009
+ )
1010
+ file.write(
1011
+ f"\n >>> These unmeasured inputs are unobservable:\n {unobs_inputs}"
1012
+ )
1013
+ if len(model.u) > 0:
1014
+ print(f"\n >>> These inputs are known:\n {model.u}")
1015
+ file.write(f"\n >>> These inputs are known:\n {model.u}")
1016
+
1017
+ return ScanResult()