pynamicalsys 1.2.2__py3-none-any.whl → 1.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,10 +29,10 @@ from pynamicalsys.discrete_time.trajectory_analysis import (
29
29
  iterate_mapping,
30
30
  generate_trajectory,
31
31
  )
32
- from pynamicalsys.common.utils import qr, householder_qr, fit_poly
32
+ from pynamicalsys.common.utils import qr, householder_qr, fit_poly, wedge_norm
33
33
 
34
34
 
35
- @njit(cache=True)
35
+ @njit
36
36
  def lyapunov_1D(
37
37
  u: NDArray[np.float64],
38
38
  parameters: NDArray[np.float64],
@@ -41,8 +41,9 @@ def lyapunov_1D(
41
41
  derivative_mapping: Callable[
42
42
  [NDArray[np.float64], NDArray[np.float64], Callable], NDArray[np.float64]
43
43
  ],
44
+ num_exponents: int, # Added just to match signature
45
+ sample_times: Union[NDArray[np.int32], NDArray[np.int64]],
44
46
  return_history: bool = False,
45
- sample_times: Optional[NDArray[np.int32]] = None,
46
47
  transient_time: Optional[int] = None,
47
48
  log_base: float = np.e,
48
49
  ) -> Union[NDArray[np.float64], float]:
@@ -64,10 +65,10 @@ def lyapunov_1D(
64
65
  Function defining the system's evolution: `u_next = mapping(u, parameters)`.
65
66
  derivative_mapping : Callable[[NDArray, NDArray, Callable], NDArray]
66
67
  Function returning the derivative of `mapping` (Jacobian for 1D systems).
68
+ sample_times: Union[NDArray[np.int32], NDArray[np.int64]],
69
+ Specific time steps to record the exponent (if `return_history=True`).
67
70
  return_history : bool, optional
68
71
  If True, returns the Lyapunov exponent estimate at each step (default: False).
69
- sample_times : Optional[NDArray[np.int32]], optional
70
- Specific time steps to record the exponent (if `return_history=True`).
71
72
  transient_time : Optional[int], optional
72
73
  Number of initial iterations to discard as transient (default: None).
73
74
  log_base : float, optional
@@ -96,33 +97,30 @@ def lyapunov_1D(
96
97
  sample_size = total_time
97
98
 
98
99
  # Initialize history tracking
99
- exponent = 0.0
100
100
  if return_history:
101
- if sample_times is not None:
102
- if sample_times.max() > sample_size:
103
- raise ValueError("sample_times must be ≤ total_time")
104
- history = np.zeros(len(sample_times))
105
- count = 0
106
- else:
107
- history = np.zeros(sample_size)
101
+ if sample_times.max() > sample_size:
102
+ raise ValueError("sample_times must be ≤ total_time - transient_time")
103
+ history = np.zeros(len(sample_times))
108
104
 
109
- # Main computation loop
110
- for i in range(1, sample_size + 1):
111
- u = mapping(u, parameters)
112
- du = derivative_mapping(u, parameters, mapping)
113
- exponent += np.log(np.abs(du[0, 0])) / np.log(log_base)
105
+ sample_idx = 0
106
+ exponent = 0.0
107
+ prev_i = 0
108
+ for st in sample_times:
109
+ steps = st - prev_i
110
+ for _ in range(steps):
111
+ u = mapping(u, parameters)
112
+ du = derivative_mapping(u, parameters, mapping)
113
+ exponent += np.log(np.abs(du[0, 0])) / np.log(log_base)
114
114
 
115
115
  if return_history:
116
- if sample_times is None:
117
- history[i - 1] = exponent / i
118
- elif i in sample_times:
119
- history[count] = exponent / i
120
- count += 1
116
+ history[sample_idx] = exponent / st
117
+ sample_idx += 1
118
+ prev_i = st
121
119
 
122
120
  return history if return_history else np.array([exponent / sample_size])
123
121
 
124
122
 
125
- @njit(cache=True)
123
+ @njit
126
124
  def lyapunov_er(
127
125
  u: NDArray[np.float64],
128
126
  parameters: NDArray[np.float64],
@@ -131,8 +129,9 @@ def lyapunov_er(
131
129
  jacobian: Callable[
132
130
  [NDArray[np.float64], NDArray[np.float64], Callable], NDArray[np.float64]
133
131
  ],
132
+ num_exponents: int, # Added just to match signature
133
+ sample_times: Union[NDArray[np.int32], NDArray[np.int64]],
134
134
  return_history: bool = False,
135
- sample_times: Optional[NDArray[np.int32]] = None,
136
135
  transient_time: Optional[int] = None,
137
136
  log_base: float = np.e,
138
137
  ) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
@@ -154,10 +153,10 @@ def lyapunov_er(
154
153
  System evolution function: `u_next = mapping(u, parameters)`.
155
154
  jacobian : Callable[[NDArray, NDArray, Callable], NDArray]
156
155
  Function returning the Jacobian matrix (shape: `(2, 2)`).
156
+ sample_times: Union[NDArray[np.int32], NDArray[np.int64]],
157
+ Specific time steps to record exponents (if `return_history=True`).
157
158
  return_history : bool, optional
158
159
  If True, returns exponent convergence history (default: False).
159
- sample_times : Optional[NDArray[np.int32]], optional
160
- Specific time steps to record exponents (if `return_history=True`).
161
160
  transient_time : Optional[int], optional
162
161
  Number of initial iterations to discard as transient (default: None).
163
162
  log_base : float, optional
@@ -202,44 +201,41 @@ def lyapunov_er(
202
201
 
203
202
  # Initialize history tracking
204
203
  if return_history:
205
- if sample_times is not None:
206
- if sample_times.max() > sample_size:
207
- raise ValueError("sample_times must be ≤ total_time")
208
- history = np.zeros((len(sample_times), neq))
209
- count = 0
210
- else:
211
- history = np.zeros((sample_size, neq))
204
+ if sample_times.max() > sample_size:
205
+ raise ValueError("sample_times must be ≤ total_time - transient_time")
206
+ history = np.zeros((len(sample_times), neq))
212
207
 
208
+ sample_idx = 0
213
209
  eigvals = np.zeros(neq)
214
- # Main computation loop
215
- for i in range(1, sample_size + 1):
216
- u_contig = mapping(u_contig, parameters)
217
- J = jacobian(u_contig, parameters, mapping)
218
-
219
- # Compute new rotation angle
220
- cb0, sb0 = np.cos(beta0), np.sin(beta0)
221
- beta = np.arctan2(-J[1, 0] * cb0 + J[1, 1] * sb0, J[0, 0] * cb0 - J[0, 1] * sb0)
222
-
223
- # Transformation matrix elements
224
- cb, sb = np.cos(beta), np.sin(beta)
225
- eigvals[0] = (J[0, 0] * cb - J[1, 0] * sb) * cb0 - (
226
- J[0, 1] * cb - J[1, 1] * sb
227
- ) * sb0
228
- eigvals[1] = (J[0, 0] * sb + J[1, 0] * cb) * sb0 + (
229
- J[0, 1] * sb + J[1, 1] * cb
230
- ) * cb0
231
-
232
- exponents += np.log(np.abs(eigvals)) / np.log(log_base)
233
-
234
- # Record history if requested
235
- if return_history:
236
- if sample_times is None:
237
- history[i - 1] = exponents / i
238
- elif i in sample_times:
239
- history[count] = exponents / i
240
- count += 1
210
+ log_base_inv = 1.0 / np.log(log_base)
211
+ prev_i = 0
212
+ for st in sample_times:
213
+ steps = st - prev_i
214
+ for _ in range(steps):
215
+ u_contig = mapping(u_contig, parameters)
216
+ J = jacobian(u_contig, parameters, mapping)
241
217
 
242
- beta0 = beta # Update angle for next iteration
218
+ cb0, sb0 = np.cos(beta0), np.sin(beta0)
219
+ beta = np.arctan2(
220
+ -J[1, 0] * cb0 + J[1, 1] * sb0, J[0, 0] * cb0 - J[0, 1] * sb0
221
+ )
222
+
223
+ cb, sb = np.cos(beta), np.sin(beta)
224
+ eigvals[0] = (J[0, 0] * cb - J[1, 0] * sb) * cb0 - (
225
+ J[0, 1] * cb - J[1, 1] * sb
226
+ ) * sb0
227
+ eigvals[1] = (J[0, 0] * sb + J[1, 0] * cb) * sb0 + (
228
+ J[0, 1] * sb + J[1, 1] * cb
229
+ ) * cb0
230
+
231
+ exponents += np.log(np.abs(eigvals)) * log_base_inv
232
+
233
+ beta0 = beta
234
+
235
+ if return_history:
236
+ history[sample_idx] = exponents / st
237
+ sample_idx += 1
238
+ prev_i = st
243
239
 
244
240
  # Format output
245
241
  if return_history:
@@ -250,7 +246,128 @@ def lyapunov_er(
250
246
  return aux_exponents, u_contig
251
247
 
252
248
 
253
- @njit(cache=True)
249
+ @njit
250
+ def maximum_lyapunov_er(
251
+ u: NDArray[np.float64],
252
+ parameters: NDArray[np.float64],
253
+ total_time: int,
254
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
255
+ jacobian: Callable[
256
+ [NDArray[np.float64], NDArray[np.float64], Callable], NDArray[np.float64]
257
+ ],
258
+ num_exponents: int, # Added just to match signature
259
+ sample_times: Union[NDArray[np.int32], NDArray[np.int64]],
260
+ return_history: bool = False,
261
+ transient_time: Optional[int] = None,
262
+ log_base: float = np.e,
263
+ ) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
264
+ """
265
+ Compute the maximum Lyapunov exponent using the Eckmann-Ruelle (ER) method for 2D systems.
266
+
267
+ This method tracks the evolution of perturbations via continuous QR decomposition
268
+ using rotational angles, providing numerically stable exponent estimates.
269
+
270
+ Parameters
271
+ ----------
272
+ u : NDArray[np.float64]
273
+ Initial state vector (shape: `(2,)` for 2D systems).
274
+ parameters : NDArray[np.float64]
275
+ System parameters passed to `mapping` and `jacobian`.
276
+ total_time : int
277
+ Total number of iterations (time steps) to compute.
278
+ mapping : Callable[[NDArray, NDArray], NDArray]
279
+ System evolution function: `u_next = mapping(u, parameters)`.
280
+ jacobian : Callable[[NDArray, NDArray, Callable], NDArray]
281
+ Function returning the Jacobian matrix (shape: `(2, 2)`).
282
+ sample_times: Union[NDArray[np.int32], NDArray[np.int64]],
283
+ Specific time steps to record exponents (if `return_history=True`).
284
+ return_history : bool, optional
285
+ If True, returns exponent convergence history (default: False).
286
+ transient_time : Optional[int], optional
287
+ Number of initial iterations to discard as transient (default: None).
288
+ log_base : float, optional
289
+ Logarithm base for exponent calculation (default: e).
290
+
291
+ Returns
292
+ -------
293
+ Tuple[NDArray[np.float64], NDArray[np.float64]]
294
+ - If `return_history=True`:
295
+ - `history`: Array of exponent estimates (shape: `(sample_size, 2)` or `(len(sample_times), 2)`)
296
+ - `final_state`: System state at termination (shape: `(2,)`)
297
+ - If `return_history=False`:
298
+ - `exponents`: Final Lyapunov exponents (shape: `(2, 1)`)
299
+ - `final_state`: System state at termination (shape: `(2,)`)
300
+
301
+ Notes
302
+ -----
303
+ - **Method**: Uses rotation angles for continuous QR decomposition [1].
304
+ - **Stability**: More robust than Gram-Schmidt for 2D systems.
305
+ - **Limitation**: Designed specifically for 2D maps (`neq=2`).
306
+ - **Numerics**: Exponents are averaged as:
307
+ λ_i = (1/N) Σ log|T_ii|, where T is the transformation matrix.
308
+
309
+ References
310
+ ----------
311
+ [1] J. Eckmann & D. Ruelle, "Ergodic theory of chaos and strange attractors",
312
+ Rev. Mod. Phys. 57, 617 (1985).
313
+ """
314
+
315
+ neq = len(u)
316
+ exponent = 0.0
317
+ beta0 = 0.0 # Initial rotation angle
318
+ u_contig = np.ascontiguousarray(u)
319
+
320
+ # Handle transient time
321
+ if transient_time is not None:
322
+ sample_size = total_time - transient_time
323
+ for _ in range(transient_time):
324
+ u_contig = mapping(u_contig, parameters)
325
+ else:
326
+ sample_size = total_time
327
+
328
+ # Initialize history tracking
329
+ if return_history:
330
+ if sample_times.max() > sample_size:
331
+ raise ValueError("sample_times must be ≤ total_time - transient_time")
332
+ history = np.zeros(len(sample_times))
333
+
334
+ sample_idx = 0
335
+ eigval = 0.0
336
+ log_base_inv = 1.0 / np.log(log_base)
337
+ prev_i = 0
338
+ for st in sample_times:
339
+ steps = st - prev_i
340
+ for _ in range(steps):
341
+ u_contig = mapping(u_contig, parameters)
342
+ J = jacobian(u_contig, parameters, mapping)
343
+
344
+ cb0, sb0 = np.cos(beta0), np.sin(beta0)
345
+ beta = np.arctan2(
346
+ -J[1, 0] * cb0 + J[1, 1] * sb0, J[0, 0] * cb0 - J[0, 1] * sb0
347
+ )
348
+
349
+ cb, sb = np.cos(beta), np.sin(beta)
350
+ eigval = (J[0, 0] * cb - J[1, 0] * sb) * cb0 - (
351
+ J[0, 1] * cb - J[1, 1] * sb
352
+ ) * sb0
353
+
354
+ exponent += np.log(np.abs(eigval)) * log_base_inv
355
+
356
+ beta0 = beta
357
+
358
+ if return_history:
359
+ history[sample_idx] = exponent / st
360
+ sample_idx += 1
361
+ prev_i = st
362
+
363
+ # Format output
364
+ if return_history:
365
+ return history, u_contig
366
+ else:
367
+ return np.array([exponent / sample_size]), u_contig
368
+
369
+
370
+ @njit
254
371
  def lyapunov_qr(
255
372
  u: NDArray[np.float64],
256
373
  parameters: NDArray[np.float64],
@@ -259,13 +376,15 @@ def lyapunov_qr(
259
376
  jacobian: Callable[
260
377
  [NDArray[np.float64], NDArray[np.float64], Callable], NDArray[np.float64]
261
378
  ],
379
+ num_exponents: int,
380
+ sample_times: Union[NDArray[np.int32], NDArray[np.int64]],
262
381
  QR: Callable[
263
382
  [NDArray[np.float64]], Tuple[NDArray[np.float64], NDArray[np.float64]]
264
383
  ] = qr,
265
384
  return_history: bool = False,
266
- sample_times: Optional[NDArray[np.int32]] = None,
267
385
  transient_time: Optional[int] = None,
268
386
  log_base: float = np.e,
387
+ seed: int = 13,
269
388
  ) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
270
389
  """
271
390
  Compute Lyapunov exponents using QR decomposition (Gram-Schmidt) for N-dimensional systems.
@@ -320,11 +439,11 @@ def lyapunov_qr(
320
439
  Physica D 16D, 285-317 (1985).
321
440
  """
322
441
 
442
+ np.random.seed(seed)
323
443
  neq = len(u)
324
- v = np.ascontiguousarray(np.random.rand(neq, neq))
325
- v = np.ascontiguousarray(np.eye(neq))
444
+ v = np.ascontiguousarray(np.random.rand(neq, num_exponents))
326
445
  v, _ = qr(v) # Initialize orthonormal vectors
327
- exponents = np.zeros(neq)
446
+ exponents = np.zeros(num_exponents)
328
447
  u_contig = np.ascontiguousarray(u.copy())
329
448
 
330
449
  # Handle transient time
@@ -337,38 +456,34 @@ def lyapunov_qr(
337
456
 
338
457
  # Initialize history tracking
339
458
  if return_history:
340
- if sample_times is not None:
341
- if sample_times.max() > sample_size:
342
- raise ValueError("sample_times must be ≤ total_time")
343
- history = np.zeros((len(sample_times), neq))
344
- count = 0
345
- else:
346
- history = np.zeros((sample_size, neq))
347
-
348
- # Main computation loop
349
- for j in range(1, sample_size + 1):
350
- u_contig = mapping(u_contig, parameters)
351
- J = np.ascontiguousarray(jacobian(u_contig, parameters, mapping))
352
-
353
- # Evolve and orthogonalize vectors
354
- for i in range(neq):
355
- v[:, i] = np.ascontiguousarray(J) @ np.ascontiguousarray(v[:, i])
356
- v, R = QR(v)
357
- exponents += np.log(np.abs(np.diag(R))) / np.log(log_base)
459
+ if sample_times.max() > sample_size:
460
+ raise ValueError("sample_times must be ≤ total_time - transient_time")
461
+ history = np.zeros((len(sample_times), num_exponents))
462
+
463
+ sample_idx = 0
464
+ log_base_inv = 1.0 / np.log(log_base)
465
+ prev_i = 0
466
+ for st in sample_times:
467
+ steps = st - prev_i
468
+ for _ in range(steps):
469
+ u_contig = mapping(u_contig, parameters)
470
+ J = np.ascontiguousarray(jacobian(u_contig, parameters, mapping))
471
+ # Evolve and orthogonalize vectors
472
+ for i in range(num_exponents):
473
+ v[:, i] = np.ascontiguousarray(J) @ np.ascontiguousarray(v[:, i])
474
+ v, R = QR(v)
475
+ exponents += np.log(np.abs(np.diag(R))) * log_base_inv
358
476
 
359
- # Record history if requested
360
477
  if return_history:
361
- if sample_times is None:
362
- history[j - 1] = exponents / j
363
- elif j in sample_times:
364
- history[count] = exponents / j
365
- count += 1
478
+ history[sample_idx] = exponents / st
479
+ sample_idx += 1
480
+ prev_i = st
366
481
 
367
482
  # Format output
368
483
  if return_history:
369
484
  return history, u_contig
370
485
  else:
371
- aux_exponents = np.zeros((neq, 1))
486
+ aux_exponents = np.zeros((num_exponents, 1))
372
487
  aux_exponents[:, 0] = exponents / sample_size
373
488
  return aux_exponents, u_contig
374
489
 
@@ -382,6 +497,7 @@ def finite_time_lyapunov(
382
497
  jacobian: Callable[
383
498
  [NDArray[np.float64], NDArray[np.float64], Callable], NDArray[np.float64]
384
499
  ],
500
+ num_exponents: int,
385
501
  method: str = "QR",
386
502
  transient_time: Optional[int] = None,
387
503
  log_base: float = np.e,
@@ -451,18 +567,43 @@ def finite_time_lyapunov(
451
567
 
452
568
  neq = len(u)
453
569
  num_windows = sample_size // finite_time
454
- exponents = np.zeros((num_windows, neq))
570
+ exponents = np.zeros((num_windows, num_exponents))
455
571
  phase_space_points = np.zeros((num_windows, neq))
456
-
572
+ sample_times = np.arange(finite_time)
457
573
  # Compute exponents for each window
458
574
  for i in range(num_windows):
459
- if method == "ER":
575
+ if num_exponents == 1 and method == "ER":
576
+ window_exponents, u_new = maximum_lyapunov_er(
577
+ u,
578
+ parameters,
579
+ finite_time,
580
+ mapping,
581
+ jacobian,
582
+ num_exponents,
583
+ sample_times,
584
+ log_base=log_base,
585
+ )
586
+ elif num_exponents > 1 and method == "ER":
460
587
  window_exponents, u_new = lyapunov_er(
461
- u, parameters, finite_time, mapping, jacobian, log_base=log_base
588
+ u,
589
+ parameters,
590
+ finite_time,
591
+ mapping,
592
+ jacobian,
593
+ num_exponents,
594
+ sample_times,
595
+ log_base=log_base,
462
596
  )
463
597
  elif method == "QR":
464
598
  window_exponents, u_new = lyapunov_qr(
465
- u, parameters, finite_time, mapping, jacobian, log_base=log_base
599
+ u,
600
+ parameters,
601
+ finite_time,
602
+ mapping,
603
+ jacobian,
604
+ num_exponents,
605
+ sample_times,
606
+ log_base=log_base,
466
607
  )
467
608
  elif method == "QR_HH":
468
609
  window_exponents, u_new = lyapunov_qr(
@@ -471,6 +612,8 @@ def finite_time_lyapunov(
471
612
  finite_time,
472
613
  mapping,
473
614
  jacobian,
615
+ num_exponents,
616
+ sample_times,
474
617
  QR=householder_qr,
475
618
  log_base=log_base,
476
619
  )
@@ -558,7 +701,7 @@ def dig(
558
701
  return -np.log10(abs(WB0 - WB1))
559
702
 
560
703
 
561
- @njit(cache=True)
704
+ @njit
562
705
  def SALI(
563
706
  u: NDArray[np.float64],
564
707
  parameters: NDArray[np.float64],
@@ -567,8 +710,8 @@ def SALI(
567
710
  jacobian: Callable[
568
711
  [NDArray[np.float64], NDArray[np.float64], Callable], NDArray[np.float64]
569
712
  ],
713
+ sample_times: Union[NDArray[np.int32], NDArray[np.int64]],
570
714
  return_history: bool = False,
571
- sample_times: Optional[NDArray[np.int32]] = None,
572
715
  tol: float = 1e-16,
573
716
  transient_time: Optional[int] = None,
574
717
  seed: int = 13,
@@ -591,10 +734,10 @@ def SALI(
591
734
  Function representing the system's time evolution: `u_next = mapping(u, parameters)`.
592
735
  jacobian : Callable[[NDArray, NDArray, Callable], NDArray]
593
736
  Function computing the Jacobian matrix of `mapping` at state `u`.
737
+ sample_times: Union[NDArray[np.int32], NDArray[np.int64]],
738
+ Specific time steps at which to record SALI (if `return_history=True`). Must be sorted.
594
739
  return_history : bool, optional
595
740
  If True, return SALI values at each time step (or `sample_times`). Default: False.
596
- sample_times : Optional[NDArray[np.int32]], optional
597
- Specific time steps at which to record SALI (if `return_history=True`). Must be sorted.
598
741
  tol : float, optional
599
742
  Tolerance for early stopping if SALI < `tol` (default: 1e-16).
600
743
  transient_time : Optional[int], optional
@@ -618,7 +761,7 @@ def SALI(
618
761
  - Uses QR decomposition to initialize orthonormal deviation vectors.
619
762
  - Computes both Parallel (PAI) and Antiparallel (AAI) Alignment Indices.
620
763
  - Early termination occurs if SALI < `tol` (indicating chaotic behavior).
621
- - Optimized with `@njit(cache=True)` for performance.
764
+ - Optimized with `@njit` for performance.
622
765
  """
623
766
 
624
767
  np.random.seed(seed) # For reproducibility
@@ -639,48 +782,158 @@ def SALI(
639
782
 
640
783
  # Initialize history tracking
641
784
  if return_history:
642
- if sample_times is not None:
643
- if sample_times.max() > sample_size:
644
- raise ValueError("Maximum sample time must be ≤ total_time.")
645
- history = np.zeros(len(sample_times))
646
- count = 0
647
- else:
648
- history = np.zeros(sample_size)
649
-
650
- # Main evolution loop
651
- for j in range(sample_size):
652
- u = mapping(u, parameters)
653
- J = np.ascontiguousarray(jacobian(u, parameters, mapping))
785
+ if sample_times.max() > sample_size:
786
+ raise ValueError("sample_times must be ≤ total_time - transient_time")
787
+ history = np.zeros(len(sample_times))
788
+
789
+ sample_idx = 0
790
+ prev_i = 0
791
+ for st in sample_times:
792
+ steps = st - prev_i
793
+ for _ in range(steps):
794
+ u = mapping(u, parameters)
795
+ J = np.ascontiguousarray(jacobian(u, parameters, mapping))
654
796
 
655
- # Update deviation vectors
656
- for i in range(2):
657
- v[:, i] = np.ascontiguousarray(J) @ np.ascontiguousarray(v[:, i])
658
- v[:, i] /= np.linalg.norm(v[:, i])
797
+ for i in range(2):
798
+ v[:, i] = np.ascontiguousarray(J) @ np.ascontiguousarray(v[:, i])
799
+ v[:, i] /= np.linalg.norm(v[:, i])
659
800
 
660
- # Compute SALI
661
- PAI = np.linalg.norm(v[:, 0] + v[:, 1])
662
- AAI = np.linalg.norm(v[:, 0] - v[:, 1])
663
- sali_val = min(PAI, AAI)
801
+ # Compute SALI
802
+ PAI = np.linalg.norm(v[:, 0] + v[:, 1])
803
+ AAI = np.linalg.norm(v[:, 0] - v[:, 1])
804
+ sali_val = min(PAI, AAI)
664
805
 
665
- # Record history if requested
666
806
  if return_history:
667
- if sample_times is None:
668
- history[j] = sali_val
669
- elif (j + 1) in sample_times:
670
- history[count] = sali_val
671
- count += 1
807
+ history[sample_idx] = sali_val
808
+ sample_idx += 1
809
+ prev_i = st
672
810
 
673
- # Early termination
674
811
  if sali_val < tol:
675
812
  break
676
813
 
677
814
  return history if return_history else np.array([sali_val])
678
815
 
679
816
 
680
- # @njit(cache=True)
817
+ def LDI_k(
818
+ u: NDArray[np.float64],
819
+ parameters: NDArray[np.float64],
820
+ total_time: int,
821
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
822
+ jacobian: Callable[
823
+ [NDArray[np.float64], NDArray[np.float64], Callable], NDArray[np.float64]
824
+ ],
825
+ k: int,
826
+ sample_times: Union[NDArray[np.int32], NDArray[np.int64]],
827
+ return_history: bool = False,
828
+ tol: float = 1e-16,
829
+ transient_time: Optional[int] = None,
830
+ seed: int = 13,
831
+ ) -> Union[NDArray[np.float64], Tuple[NDArray[np.float64], NDArray[np.float64]]]:
832
+ """
833
+ Compute the linear dependence index (LDI) for a dynamical system.
681
834
 
835
+ LDI is a measure of chaos in dynamical systems, calculated using the evolution
836
+ of `k` initially orthonormal deviation vectors under the system's Jacobian.
682
837
 
683
- def LDI_k(
838
+ Parameters
839
+ ----------
840
+ u : NDArray[np.float64]
841
+ Initial state vector of the system (shape: `(neq,)`).
842
+ parameters : NDArray[np.float64]
843
+ System parameters (shape: arbitrary, passed to `mapping` and `jacobian`).
844
+ total_time : int
845
+ Total number of iterations (time steps) to simulate.
846
+ mapping : Callable[[NDArray, NDArray], NDArray]
847
+ Function representing the system's time evolution (maps state `u` to next state).
848
+ jacobian : Callable[[NDArray, NDArray, Callable], NDArray]
849
+ Function computing the Jacobian matrix of `mapping` at state `u`.
850
+ k : int
851
+ Number of deviation vectors to track.
852
+ sample_times: Union[NDArray[np.int32], NDArray[np.int64]],
853
+ Specific time steps at which to record LDI (if `return_history=True`).
854
+ return_history : bool, optional
855
+ If True, return GALI values at each time step (or `sample_times`). Default: False.
856
+ tol : float, optional
857
+ Tolerance for early stopping if GALI drops below this value (default: 1e-16).
858
+ transient_time : Optional[int], optional
859
+ Number of initial iterations to discard as transient (default: None).
860
+ seed : int, optional
861
+ Random seed for reproducibility (default 13)
862
+
863
+ Returns
864
+ -------
865
+ Union[NDArray[np.float64], Tuple[NDArray[np.float64], NDArray[np.float64]]]
866
+ - If `return_history=False`: Final LDI value (shape: `(1,)`).
867
+ - If `return_history=True`: Array of LDI values at each sampled time.
868
+
869
+ Raises
870
+ ------
871
+ ValueError
872
+ If `sample_times` contains values exceeding `total_time`.
873
+
874
+ Notes
875
+ -----
876
+ - Early termination occurs if LDI < `tol` (indicating chaotic behavior).
877
+ """
878
+
879
+ np.random.seed(seed) # For reproducibility
880
+
881
+ neq = len(u)
882
+
883
+ # Generate random orthonormal deviation vectors
884
+ v = np.ascontiguousarray(np.random.rand(neq, k))
885
+ v, _ = qr(v)
886
+
887
+ if transient_time is not None:
888
+ # Discard transient time
889
+ sample_size = total_time - transient_time
890
+ for i in range(transient_time):
891
+ u = mapping(u, parameters)
892
+ else:
893
+ sample_size = total_time
894
+
895
+ # Initialize history tracking
896
+ if return_history:
897
+ if sample_times.max() > sample_size:
898
+ raise ValueError("sample_times must be ≤ total_time - transient_time")
899
+ history = np.zeros(len(sample_times))
900
+
901
+ sample_idx = 0
902
+ prev_j = 0
903
+ for st in sample_times:
904
+ steps = st - prev_j
905
+ for _ in range(steps):
906
+ u = mapping(u, parameters)
907
+ J = np.ascontiguousarray(jacobian(u, parameters, mapping))
908
+
909
+ # Update deviation vectors
910
+ for i in range(k):
911
+ v[:, i] = np.ascontiguousarray(J) @ np.ascontiguousarray(v[:, i])
912
+ v[:, i] = v[:, i] / np.linalg.norm(v[:, i])
913
+
914
+ # Compute LDI
915
+ S = np.linalg.svd(v, full_matrices=False, compute_uv=False)
916
+ # ldi = np.prod(S) # LDI is the product of the singular values
917
+ ldi = np.exp(np.sum(np.log(S))) # LDI is the product of all singular values
918
+ # Instead of computing prod(S) directly, which could lead to underflows
919
+ # or overflows, we compute the sum_{i=1}^k log(S_i) and then take the
920
+ # exponential of this sum.
921
+
922
+ if return_history:
923
+ history[sample_idx] = ldi
924
+ sample_idx += 1
925
+ prev_j = st
926
+
927
+ if ldi < tol:
928
+ break
929
+
930
+ if return_history:
931
+ return history
932
+ else:
933
+ return np.array([ldi])
934
+
935
+
936
+ def GALI_k(
684
937
  u: NDArray[np.float64],
685
938
  parameters: NDArray[np.float64],
686
939
  total_time: int,
@@ -688,15 +941,15 @@ def LDI_k(
688
941
  jacobian: Callable[
689
942
  [NDArray[np.float64], NDArray[np.float64], Callable], NDArray[np.float64]
690
943
  ],
691
- k: int = 2,
944
+ k: int,
945
+ sample_times: Union[NDArray[np.int32], NDArray[np.int64]],
692
946
  return_history: bool = False,
693
- sample_times: Optional[NDArray[np.int32]] = None,
694
947
  tol: float = 1e-16,
695
948
  transient_time: Optional[int] = None,
696
949
  seed: int = 13,
697
950
  ) -> Union[NDArray[np.float64], Tuple[NDArray[np.float64], NDArray[np.float64]]]:
698
951
  """
699
- Compute the Generalized Alignment Index (GALI) for a dynamical system.
952
+ Compute the Generalized Aligment Index (GALI) for a dynamical system.
700
953
 
701
954
  GALI is a measure of chaos in dynamical systems, calculated using the evolution
702
955
  of `k` initially orthonormal deviation vectors under the system's Jacobian.
@@ -713,12 +966,12 @@ def LDI_k(
713
966
  Function representing the system's time evolution (maps state `u` to next state).
714
967
  jacobian : Callable[[NDArray, NDArray, Callable], NDArray]
715
968
  Function computing the Jacobian matrix of `mapping` at state `u`.
716
- k : int, optional
717
- Number of deviation vectors to track (default: 2).
969
+ k : int
970
+ Number of deviation vectors to track.
971
+ sample_times: Union[NDArray[np.int32], NDArray[np.int64]],
972
+ Specific time steps at which to record LDI (if `return_history=True`).
718
973
  return_history : bool, optional
719
974
  If True, return GALI values at each time step (or `sample_times`). Default: False.
720
- sample_times : Optional[NDArray[np.int32]], optional
721
- Specific time steps at which to record GALI (if `return_history=True`).
722
975
  tol : float, optional
723
976
  Tolerance for early stopping if GALI drops below this value (default: 1e-16).
724
977
  transient_time : Optional[int], optional
@@ -739,9 +992,7 @@ def LDI_k(
739
992
 
740
993
  Notes
741
994
  -----
742
- - The function uses QR decomposition to maintain orthonormality of deviation vectors.
743
995
  - Early termination occurs if GALI < `tol` (indicating chaotic behavior).
744
- - For performance, the function is optimized with `@njit(cache=True)`.
745
996
  """
746
997
 
747
998
  np.random.seed(seed) # For reproducibility
@@ -760,39 +1011,32 @@ def LDI_k(
760
1011
  else:
761
1012
  sample_size = total_time
762
1013
 
1014
+ # Initialize history tracking
763
1015
  if return_history:
764
- if sample_times is not None:
765
- if sample_times.max() > sample_size:
766
- raise ValueError(
767
- "Maximum sample time should be smaller than the total time."
768
- )
769
- count = 0
770
- history = np.zeros(len(sample_times))
771
- else:
772
- history = np.zeros(sample_size)
773
-
774
- for j in range(sample_size):
775
- # Update the state
776
- u = mapping(u, parameters)
777
-
778
- # Compute the Jacobian
779
- J = np.ascontiguousarray(jacobian(u, parameters, mapping))
1016
+ if sample_times.max() > sample_size:
1017
+ raise ValueError("sample_times must be ≤ total_time - transient_time")
1018
+ history = np.zeros(len(sample_times))
1019
+
1020
+ sample_idx = 0
1021
+ prev_j = 0
1022
+ for st in sample_times:
1023
+ steps = st - prev_j
1024
+ for _ in range(steps):
1025
+ u = mapping(u, parameters)
1026
+ J = np.ascontiguousarray(jacobian(u, parameters, mapping))
780
1027
 
781
- # Update deviation vectors
782
- for i in range(k):
783
- v[:, i] = np.ascontiguousarray(J) @ np.ascontiguousarray(v[:, i])
784
- v[:, i] = v[:, i] / np.linalg.norm(v[:, i])
1028
+ # Update deviation vectors
1029
+ for i in range(k):
1030
+ v[:, i] = np.ascontiguousarray(J) @ np.ascontiguousarray(v[:, i])
1031
+ v[:, i] = v[:, i] / np.linalg.norm(v[:, i])
785
1032
 
786
- # Compute GALI
787
- S = np.linalg.svd(v, full_matrices=False, compute_uv=False)
788
- gali = np.prod(S) # GALI is the product of the singular values
1033
+ # Compute GALI
1034
+ gali = wedge_norm(v)
789
1035
 
790
1036
  if return_history:
791
- if sample_times is None:
792
- history[j] = gali
793
- elif (j + 1) in sample_times:
794
- history[count] = gali
795
- count += 1
1037
+ history[sample_idx] = gali
1038
+ sample_idx += 1
1039
+ prev_j = st
796
1040
 
797
1041
  if gali < tol:
798
1042
  break
@@ -803,7 +1047,7 @@ def LDI_k(
803
1047
  return np.array([gali])
804
1048
 
805
1049
 
806
- @njit(cache=True)
1050
+ @njit
807
1051
  def hurst_exponent(
808
1052
  u: NDArray[np.float64],
809
1053
  parameters: NDArray[np.float64],
@@ -980,13 +1224,13 @@ def finite_time_hurst_exponent(
980
1224
  return H_values
981
1225
 
982
1226
 
983
- @njit(cache=True)
1227
+ @njit
984
1228
  def lyapunov_vectors():
985
1229
  # ! To be implemented...
986
1230
  pass
987
1231
 
988
1232
 
989
- @njit(cache=True)
1233
+ @njit
990
1234
  def lagrangian_descriptors(
991
1235
  u: NDArray[np.float64],
992
1236
  parameters: NDArray[np.float64],