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,562 @@
1
+ # ruff: noqa: D100, D101, D102, D103, D104, D105, D106, D107, D200, D203, D400, D401
2
+
3
+ """Reimplementation of strikepy from.
4
+
5
+ StrikePy: https://github.com/afvillaverde/StrikePy
6
+ STRIKE-GOLDD: https://github.com/afvillaverde/strike-goldd
7
+
8
+ FIXME:
9
+ - no handling of derived variables
10
+ - performance issues of generic_rank
11
+ """
12
+
13
+ import textwrap
14
+ from concurrent.futures import ProcessPoolExecutor
15
+ from dataclasses import dataclass, field
16
+ from functools import partial
17
+ from math import ceil, inf
18
+ from time import time
19
+ from typing import cast
20
+
21
+ import numpy as np
22
+ import numpy.typing as npt
23
+ import symbtools as st
24
+ import sympy
25
+ import sympy as sym
26
+ import tqdm
27
+ from sympy import Matrix
28
+ from sympy.matrices import zeros
29
+
30
+ __all__ = ["Model", "Options", "Result", "strike_goldd"]
31
+
32
+
33
+ @dataclass
34
+ class Options:
35
+ check_observability: bool = True
36
+ max_lie_time: float = inf
37
+ non_zero_known_input_derivatives: list[int] = field(default_factory=lambda: [100])
38
+ non_zero_unknown_input_derivatives: list[int] = field(default_factory=lambda: [100])
39
+ prev_ident_pars: set[sympy.Symbol] = field(default_factory=set)
40
+
41
+
42
+ @dataclass
43
+ class Model:
44
+ states: list[sym.Symbol]
45
+ pars: list[sym.Symbol]
46
+ eqs: list[sym.Expr]
47
+ outputs: list[sym.Symbol]
48
+ known_inputs: list[sym.Symbol] = field(default_factory=list)
49
+ unknown_inputs: list[sym.Symbol] = field(default_factory=list)
50
+
51
+
52
+ @dataclass
53
+ class Result:
54
+ rank: int
55
+ model: Model
56
+ is_fispo: bool
57
+ par_ident: list
58
+ par_unident: list
59
+ state_obs: list
60
+ state_unobs: list
61
+ input_obs: list
62
+ input_unobs: list
63
+
64
+ def all_inputs_observable(self) -> bool:
65
+ return bool(
66
+ len(self.par_ident) == len(self.model.pars)
67
+ and len(self.model.unknown_inputs) > 0
68
+ )
69
+
70
+ def summary(self) -> str:
71
+ return textwrap.dedent(f"""\
72
+ Summary
73
+ =======
74
+ The model {"is" if self.is_fispo else "is not"} FISPO.
75
+ Identifiable parameters: {self.par_ident}
76
+ Unidentifiable parameters: {self.par_unident}
77
+ Identifiable variables: {self.state_obs}
78
+ Unidentifiable variables: {self.state_unobs}
79
+ Identifiable inputs: {self.input_obs}
80
+ Unidentifiable inputs: {self.input_unobs}
81
+ """)
82
+
83
+
84
+ def _rationalize_all_numbers(expr: sym.Matrix) -> sym.Matrix:
85
+ """Convert all numbers in expr to sympy.Rational-objects."""
86
+ numbers_atoms = list(expr.atoms(sym.Number))
87
+ rationalized_number_tpls = [(n, sym.Rational(n)) for n in numbers_atoms]
88
+ return expr.subs(rationalized_number_tpls)
89
+
90
+
91
+ def _calculate_num_rank(inp: tuple[int, list[int]], onx: Matrix) -> tuple[int, int]:
92
+ idx, indices = inp
93
+ return idx, st.generic_rank(onx.col(indices))
94
+
95
+
96
+ def _elim_and_recalc(
97
+ *,
98
+ model: Model,
99
+ res: Result,
100
+ options: Options,
101
+ unmeas_xred_indices: list[int],
102
+ onx: sym.Matrix,
103
+ unidflag: bool,
104
+ w1: list[sym.Symbol],
105
+ ) -> None:
106
+ onx = _rationalize_all_numbers(onx)
107
+ par_ident = res.par_ident
108
+ state_obs = res.state_obs
109
+ input_obs = res.input_obs
110
+
111
+ r = sym.shape(onx)[1]
112
+ new_ident_pars = par_ident
113
+ new_nonid_pars = []
114
+ new_obs_states = state_obs
115
+ new_unobs_states = []
116
+ new_obs_in = input_obs
117
+ new_unobs_in = []
118
+
119
+ all_indices: list[tuple[int, list[int]]] = []
120
+ for idx in range(len(model.pars)):
121
+ if model.pars[idx] not in par_ident:
122
+ indices = list(range(r))
123
+ indices.pop(len(model.states) + idx)
124
+ all_indices.append((idx, indices))
125
+ with ProcessPoolExecutor() as ppe:
126
+ num_ranks = list(ppe.map(partial(_calculate_num_rank, onx=onx), all_indices))
127
+ for idx, num_rank in num_ranks:
128
+ if num_rank == res.rank:
129
+ if unidflag:
130
+ new_nonid_pars.append(model.pars[idx])
131
+ else:
132
+ new_ident_pars.append(model.pars[idx])
133
+
134
+ # At each iteration we try removing a different state from 'xred':
135
+ if options.check_observability:
136
+ all_indices = []
137
+ for idx in range(len(unmeas_xred_indices)):
138
+ orig_idx = unmeas_xred_indices[idx]
139
+ if model.states[orig_idx] not in state_obs:
140
+ indices = list(range(r))
141
+ indices.pop(orig_idx)
142
+ all_indices.append((orig_idx, indices))
143
+ with ProcessPoolExecutor() as ppe:
144
+ num_ranks = list(
145
+ ppe.map(partial(_calculate_num_rank, onx=onx), all_indices)
146
+ )
147
+ for orig_idx, num_rank in num_ranks:
148
+ if num_rank == res.rank:
149
+ if unidflag:
150
+ new_unobs_states.append(model.states[orig_idx])
151
+ else:
152
+ new_obs_states.append(model.states[orig_idx])
153
+
154
+ # At each iteration we try removing a different column from onx:
155
+ all_indices = []
156
+ for idx in range(len(w1)):
157
+ if w1[idx] not in input_obs:
158
+ indices = list(range(r))
159
+ indices.pop(len(model.states) + len(model.pars) + idx)
160
+ all_indices.append((idx, indices))
161
+ with ProcessPoolExecutor() as ppe:
162
+ num_ranks = list(ppe.map(partial(_calculate_num_rank, onx=onx), all_indices))
163
+ for idx, num_rank in num_ranks:
164
+ if num_rank == res.rank:
165
+ if unidflag:
166
+ new_unobs_in.append(w1[idx])
167
+ else:
168
+ new_obs_in.append(w1[idx])
169
+
170
+ res.par_ident = new_ident_pars
171
+ res.par_unident = new_nonid_pars
172
+ res.state_obs = new_obs_states
173
+ res.state_unobs = new_unobs_states
174
+ res.input_obs = new_obs_in
175
+ res.input_unobs = new_unobs_in
176
+
177
+
178
+ def _remove_identified_parameters(model: Model, options: Options) -> None:
179
+ if len(options.prev_ident_pars) != 0:
180
+ model.pars = [i for i in model.pars if i not in options.prev_ident_pars]
181
+
182
+
183
+ def _get_measured_states(model: Model) -> tuple[list[sym.Symbol], list[int]]:
184
+ # Check which states are directly measured, if any.
185
+ # Basically it is checked if any state is directly on the output,
186
+ # then that state is directly measurable.
187
+ is_measured: list[bool] = [False for i in range(len(model.states))]
188
+ for i, state in enumerate(model.states):
189
+ if state in model.outputs:
190
+ is_measured[i] = True
191
+
192
+ measured_state_idxs: list[int] = [i for i, j in enumerate(is_measured) if j]
193
+ unmeasured_state_idxs = [i for i, j in enumerate(is_measured) if not j]
194
+ measured_state_names = [model.states[i] for i in measured_state_idxs]
195
+ return measured_state_names, unmeasured_state_idxs
196
+
197
+
198
+ def _create_derivatives(
199
+ elements: list[sym.Symbol], n_min_lie_derivatives: int, n_derivatives: list[int]
200
+ ) -> list[list[sym.Symbol]]:
201
+ derivatives: list[list[float | sym.Symbol]] = []
202
+ for ind_u, element in enumerate(elements):
203
+ auxiliar: list[float | sym.Symbol] = [sym.Symbol(f"{element}")]
204
+ for k in range(n_min_lie_derivatives):
205
+ auxiliar.append(sym.Symbol(f"{element}_d{k + 1}")) # noqa: PERF401
206
+ derivatives.append(auxiliar)
207
+
208
+ if len(derivatives[0]) >= n_derivatives[ind_u] + 1:
209
+ for i in range(len(derivatives[0][(n_derivatives[ind_u] + 1) :])):
210
+ derivatives[ind_u][(n_derivatives[ind_u] + 1) + i] = 0
211
+ return derivatives # type: ignore
212
+
213
+
214
+ def _create_w1_vector(
215
+ model: Model, w_der: list[list[sym.Symbol]]
216
+ ) -> tuple[list[sym.Symbol], list[sym.Symbol]]:
217
+ w1vector = []
218
+ w1vector_dot = []
219
+
220
+ if len(model.unknown_inputs) == 0:
221
+ return w1vector, w1vector_dot
222
+
223
+ w1vector.extend(w_der[:-1])
224
+ w1vector_dot.extend(w_der[1:])
225
+
226
+ # -- Include as states only nonzero inputs / derivatives:
227
+ nzi = []
228
+ nzj = []
229
+ nz_w1vec = []
230
+ for fila in range(len(w1vector)):
231
+ if w1vector[fila][0] != 0:
232
+ nzi.append([fila])
233
+ nzj.append([1])
234
+ nz_w1vec.append(w1vector[fila])
235
+
236
+ w1vector = nz_w1vec
237
+ w1vector_dot = w1vector_dot[0 : len(nzi)]
238
+ return w1vector, w1vector_dot
239
+
240
+
241
+ def _create_xaug_faug(
242
+ model: Model,
243
+ w1vector: list[sym.Symbol],
244
+ w1vector_dot: list[sym.Symbol],
245
+ ) -> tuple[npt.NDArray, npt.NDArray]:
246
+ xaug = np.array(model.states)
247
+ xaug = np.append(xaug, model.pars, axis=0) # type: ignore
248
+ if len(w1vector) != 0:
249
+ xaug = np.append(xaug, w1vector, axis=0) # type: ignore
250
+
251
+ faug = np.atleast_2d(np.array(model.eqs, dtype=object)).T
252
+ faug = np.append(faug, zeros(len(model.pars), 1), axis=0)
253
+ if len(w1vector) != 0:
254
+ faug = np.append(faug, w1vector_dot, axis=0) # type: ignore
255
+ return xaug, faug
256
+
257
+
258
+ def _compute_extra_term(
259
+ extra_term: npt.NDArray,
260
+ ind: int,
261
+ past_lie: sym.Matrix,
262
+ input_der: list[list[sym.Symbol]],
263
+ zero_input_der_dummy_name: sym.Symbol,
264
+ ) -> npt.NDArray:
265
+ for i in range(ind):
266
+ column = len(input_der) - 1
267
+ if i < column:
268
+ lo_u_der = input_der[i]
269
+ if lo_u_der == 0:
270
+ lo_u_der = zero_input_der_dummy_name
271
+ lo_u_der = np.array([lo_u_der])
272
+ hi_u_der = input_der[i + 1]
273
+ hi_u_der = Matrix([hi_u_der])
274
+ intermedio = past_lie.jacobian(lo_u_der) * hi_u_der
275
+ extra_term = extra_term + intermedio if extra_term else intermedio
276
+ return extra_term
277
+
278
+
279
+ def _compute_n_min_lie_derivatives(model: Model) -> int:
280
+ n_outputs = len(model.outputs)
281
+ n_states = len(model.states)
282
+ n_unknown_pars = len(model.pars)
283
+ n_unknown_inp = len(model.unknown_inputs)
284
+ n_vars_to_observe = n_states + n_unknown_pars + n_unknown_inp
285
+ return ceil((n_vars_to_observe - n_outputs) / n_outputs)
286
+
287
+
288
+ def _test_fispo(
289
+ model: Model,
290
+ res: Result,
291
+ measured_state_names: list[sym.Symbol],
292
+ ) -> bool:
293
+ if len(res.par_ident) == len(model.pars) and (
294
+ len(res.state_obs) + len(measured_state_names)
295
+ ) == len(model.states):
296
+ res.is_fispo = True
297
+ res.state_obs = model.states
298
+ res.input_obs = model.unknown_inputs
299
+ res.par_ident = model.pars
300
+
301
+ return res.is_fispo
302
+
303
+
304
+ def _create_onx(
305
+ model: Model,
306
+ n_min_lie_derivatives: int,
307
+ options: Options,
308
+ w1vector: list[sym.Symbol],
309
+ xaug: npt.ArrayLike,
310
+ faug: npt.ArrayLike,
311
+ input_der: list[list[sym.Symbol]],
312
+ zero_input_der_dummy_name: sym.Symbol,
313
+ ) -> tuple[npt.NDArray, sym.Matrix]:
314
+ onx = np.array(
315
+ zeros(
316
+ len(model.outputs) * (1 + n_min_lie_derivatives),
317
+ len(model.states) + len(model.pars) + len(w1vector),
318
+ )
319
+ )
320
+ jacobian = sym.Matrix(model.outputs).jacobian(xaug)
321
+
322
+ # first row(s) of onx (derivative of the output with respect to the vector states+unknown parameters).
323
+ onx[0 : len(model.outputs)] = np.array(jacobian)
324
+
325
+ past_Lie = sym.Matrix(model.outputs)
326
+ extra_term = np.array(0)
327
+
328
+ # loop as long as I don't complete the preset Lie derivatives or go over the maximum time
329
+ t_start = time()
330
+
331
+ onx[(len(model.outputs)) : 2 * len(model.outputs)] = past_Lie.jacobian(xaug)
332
+ for ind in range(1, n_min_lie_derivatives):
333
+ if (time() - t_start) > options.max_lie_time:
334
+ msg = "More Lie derivatives would be needed to analyse the model."
335
+ raise TimeoutError(msg)
336
+
337
+ lie_derivatives = Matrix(
338
+ (onx[(ind * len(model.outputs)) : (ind + 1) * len(model.outputs)][:]).dot(
339
+ faug
340
+ )
341
+ )
342
+ extra_term = _compute_extra_term(
343
+ extra_term,
344
+ ind=ind,
345
+ past_lie=past_Lie,
346
+ input_der=input_der,
347
+ zero_input_der_dummy_name=zero_input_der_dummy_name,
348
+ )
349
+
350
+ ext_Lie = lie_derivatives + extra_term if extra_term else lie_derivatives
351
+ past_Lie = ext_Lie
352
+ onx[((ind + 1) * len(model.outputs)) : (ind + 2) * len(model.outputs)] = (
353
+ sym.Matrix(ext_Lie).jacobian(xaug)
354
+ )
355
+ return onx, cast(sym.Matrix, past_Lie)
356
+
357
+
358
+ def strike_goldd(model: Model, options: Options | None = None) -> Result:
359
+ options = Options() if options is None else options
360
+
361
+ # Check if the size of nnzDerU and nnzDerW are appropriate
362
+ if len(model.known_inputs) > len(options.non_zero_known_input_derivatives):
363
+ msg = (
364
+ "The number of known inputs is higher than the size of nnzDerU "
365
+ "and must have the same size."
366
+ )
367
+ raise ValueError(msg)
368
+ if len(model.unknown_inputs) > len(options.non_zero_unknown_input_derivatives):
369
+ msg = (
370
+ "The number of unknown inputs is higher than the size of nnzDerW "
371
+ "and must have the same size."
372
+ )
373
+ raise ValueError(msg)
374
+
375
+ _remove_identified_parameters(model, options)
376
+
377
+ res = Result(
378
+ rank=0,
379
+ is_fispo=False,
380
+ model=model,
381
+ par_ident=[],
382
+ par_unident=[],
383
+ state_obs=[],
384
+ state_unobs=[],
385
+ input_obs=[],
386
+ input_unobs=[],
387
+ )
388
+
389
+ lastrank = None
390
+ unidflag = False
391
+ skip_elim: bool = False
392
+
393
+ n_min_lie_derivatives = _compute_n_min_lie_derivatives(model)
394
+ measured_state_names, unmeasured_state_idxs = _get_measured_states(model)
395
+
396
+ input_der = _create_derivatives(
397
+ model.known_inputs,
398
+ n_min_lie_derivatives=n_min_lie_derivatives,
399
+ n_derivatives=options.non_zero_known_input_derivatives,
400
+ )
401
+ zero_input_der_dummy_name = sym.Symbol("zero_input_der_dummy_name")
402
+
403
+ w_der: list[list[sym.Symbol]] = _create_derivatives(
404
+ model.unknown_inputs,
405
+ n_min_lie_derivatives=n_min_lie_derivatives,
406
+ n_derivatives=options.non_zero_unknown_input_derivatives,
407
+ )
408
+
409
+ w1vector, w1vector_dot = _create_w1_vector(
410
+ model,
411
+ w_der=w_der,
412
+ )
413
+
414
+ xaug, faug = _create_xaug_faug(
415
+ model,
416
+ w1vector=w1vector,
417
+ w1vector_dot=w1vector_dot,
418
+ )
419
+
420
+ onx, past_Lie = _create_onx(
421
+ model,
422
+ n_min_lie_derivatives=n_min_lie_derivatives,
423
+ options=options,
424
+ w1vector=w1vector,
425
+ xaug=xaug,
426
+ faug=faug,
427
+ input_der=input_der,
428
+ zero_input_der_dummy_name=zero_input_der_dummy_name,
429
+ )
430
+
431
+ t_start = time()
432
+
433
+ pbar = tqdm.tqdm(desc="Main loop")
434
+ while True:
435
+ pbar.update(1)
436
+ if time() - t_start > options.max_lie_time:
437
+ msg = "More Lie derivatives would be needed to see if the model is structurally unidentifiable as a whole."
438
+ raise TimeoutError(msg)
439
+
440
+ # FIXME: For some problems this starts to be really slow
441
+ # can't directly be fixed by using numpy.linalg.matrix_rank because
442
+ # that can't handle the symbolic stuff
443
+ res.rank = st.generic_rank(_rationalize_all_numbers(Matrix(onx)))
444
+
445
+ # If the onx matrix already has full rank... all is observable and identifiable
446
+ if res.rank == len(xaug):
447
+ res.state_obs = model.states
448
+ res.input_obs = model.unknown_inputs
449
+ res.par_ident = model.pars
450
+ break
451
+
452
+ # If there are unknown inputs, we may want to check id/obs of (x,p,w) and not of dw/dt:
453
+ if len(model.unknown_inputs) > 0:
454
+ _elim_and_recalc(
455
+ model=model,
456
+ res=res,
457
+ options=options,
458
+ unmeas_xred_indices=unmeasured_state_idxs,
459
+ onx=Matrix(onx),
460
+ unidflag=unidflag,
461
+ w1=w1vector,
462
+ )
463
+
464
+ if _test_fispo(
465
+ model=model, res=res, measured_state_names=measured_state_names
466
+ ):
467
+ break
468
+
469
+ # If possible (& necessary), calculate one more Lie derivative and retry:
470
+ if n_min_lie_derivatives < len(xaug) and res.rank != lastrank:
471
+ ind = n_min_lie_derivatives
472
+ n_min_lie_derivatives = (
473
+ n_min_lie_derivatives + 1
474
+ ) # One is added to the number of derivatives already made
475
+ extra_term = np.array(0) # reset for each new Lie derivative
476
+ # - Known input derivatives: ----------------------------------
477
+ # Extra terms of extended Lie derivatives
478
+ # may have to add extra input derivatives (note that 'nd' has grown):
479
+ if len(model.known_inputs) > 0:
480
+ input_der = _create_derivatives(
481
+ model.known_inputs,
482
+ n_min_lie_derivatives=n_min_lie_derivatives,
483
+ n_derivatives=options.non_zero_known_input_derivatives,
484
+ )
485
+ extra_term = _compute_extra_term(
486
+ extra_term,
487
+ ind=ind,
488
+ past_lie=past_Lie,
489
+ input_der=input_der,
490
+ zero_input_der_dummy_name=zero_input_der_dummy_name,
491
+ )
492
+
493
+ # add new derivatives, if they are not zero
494
+ if len(model.unknown_inputs) > 0:
495
+ prev_size = len(w1vector)
496
+ w_der = _create_derivatives(
497
+ model.unknown_inputs,
498
+ n_min_lie_derivatives=n_min_lie_derivatives + 1,
499
+ n_derivatives=options.non_zero_unknown_input_derivatives,
500
+ )
501
+ w1vector, w1vector_dot = _create_w1_vector(
502
+ model,
503
+ w_der=w_der,
504
+ )
505
+ xaug, faug = _create_xaug_faug(
506
+ model, w1vector=w1vector, w1vector_dot=w1vector_dot
507
+ )
508
+
509
+ # Augment size of the Obs-Id matrix if needed
510
+ new_size = len(w1vector)
511
+ onx = np.append(
512
+ onx,
513
+ zeros((ind + 1) * len(model.outputs), new_size - prev_size),
514
+ axis=1,
515
+ )
516
+
517
+ newLie = Matrix(
518
+ (
519
+ onx[(ind * len(model.outputs)) : (ind + 1) * len(model.outputs)][:] # type: ignore
520
+ ).dot(faug) # type: ignore
521
+ )
522
+ past_Lie = newLie + extra_term if extra_term else newLie
523
+ newOnx = sym.Matrix(past_Lie).jacobian(xaug)
524
+ onx = np.append(onx, newOnx, axis=0)
525
+ lastrank = res.rank
526
+
527
+ # If that is not possible, there are several possible causes:
528
+ # This is the case when you have onx with all possible derivatives done
529
+ # and it is not full rank, the maximum time for the next derivative has passed
530
+ # or the matrix no longer increases in rank as derivatives are increased.
531
+ else:
532
+ # The maximum number of Lie derivatives has been reached
533
+ if n_min_lie_derivatives >= len(xaug):
534
+ unidflag = True
535
+ elif res.rank == lastrank:
536
+ onx = onx[0 : (-1 - (len(model.outputs) - 1))] # type: ignore
537
+ # It is indicated that the number of derivatives needed was
538
+ # one less than the number of derivatives made
539
+ n_min_lie_derivatives = n_min_lie_derivatives - 1
540
+ unidflag = True
541
+
542
+ if not skip_elim and not res.is_fispo:
543
+ # Eliminate columns one by one to check identifiability
544
+ # of the associated parameters
545
+ _elim_and_recalc(
546
+ model=model,
547
+ res=res,
548
+ options=options,
549
+ unmeas_xred_indices=unmeasured_state_idxs,
550
+ onx=Matrix(onx),
551
+ unidflag=unidflag,
552
+ w1=w1vector,
553
+ )
554
+
555
+ if _test_fispo(
556
+ model=model, res=res, measured_state_names=measured_state_names
557
+ ):
558
+ break
559
+
560
+ break
561
+ pbar.close()
562
+ return res