pynamicalsys 1.0.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,1226 @@
1
+ # dynamical_indicators.py
2
+
3
+ # Copyright (C) 2025 Matheus Rolim Sales
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ from typing import Optional, Tuple, Union, Callable
19
+ from numpy.typing import NDArray
20
+ import numpy as np
21
+ from numba import njit
22
+
23
+ from pynamicalsys.common.recurrence_quantification_analysis import (
24
+ RTEConfig,
25
+ recurrence_matrix,
26
+ white_vertline_distr,
27
+ )
28
+ from pynamicalsys.discrete_time.trajectory_analysis import (
29
+ iterate_mapping,
30
+ generate_trajectory,
31
+ )
32
+ from pynamicalsys.common.utils import qr, householder_qr, fit_poly
33
+
34
+
35
+ @njit(cache=True)
36
+ def lyapunov_1D(
37
+ u: NDArray[np.float64],
38
+ parameters: NDArray[np.float64],
39
+ total_time: int,
40
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
41
+ derivative_mapping: Callable[
42
+ [NDArray[np.float64], NDArray[np.float64], Callable], NDArray[np.float64]
43
+ ],
44
+ return_history: bool = False,
45
+ sample_times: Optional[NDArray[np.int32]] = None,
46
+ transient_time: Optional[int] = None,
47
+ log_base: float = np.e,
48
+ ) -> Union[NDArray[np.float64], float]:
49
+ """
50
+ Compute the Lyapunov exponent for a 1-dimensional dynamical system.
51
+
52
+ The Lyapunov exponent characterizes the rate of separation of infinitesimally close
53
+ trajectories, serving as a measure of chaos (λ > 0 indicates chaos).
54
+
55
+ Parameters
56
+ ----------
57
+ u : NDArray[np.float64]
58
+ Initial state vector (shape: `(1,)` for 1D systems).
59
+ parameters : NDArray[np.float64]
60
+ System parameters passed to `mapping` and `derivative_mapping`.
61
+ total_time : int
62
+ Total number of iterations (time steps) to compute.
63
+ mapping : Callable[[NDArray, NDArray], NDArray]
64
+ Function defining the system's evolution: `u_next = mapping(u, parameters)`.
65
+ derivative_mapping : Callable[[NDArray, NDArray, Callable], NDArray]
66
+ Function returning the derivative of `mapping` (Jacobian for 1D systems).
67
+ return_history : bool, optional
68
+ 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
+ transient_time : Optional[int], optional
72
+ Number of initial iterations to discard as transient (default: None).
73
+ log_base : float, optional
74
+ Logarithm base for exponent calculation (default: e).
75
+
76
+ Returns
77
+ -------
78
+ Union[NDArray[np.float64], float]
79
+ - If `return_history=False`: Final Lyapunov exponent (scalar).
80
+ - If `return_history=True`: Array of exponent estimates over time.
81
+
82
+ Notes
83
+ -----
84
+ - The Lyapunov exponent (λ) is computed as:
85
+ λ = (1/N) Σ log|f'(u_i)|, where N = `total_time - transient_time`.
86
+ - For 1D systems, `derivative_mapping` should return a 1x1 Jacobian (scalar value).
87
+ - Uses Numba (`@njit`) for accelerated computation.
88
+ """
89
+
90
+ # Handle transient time
91
+ if transient_time is not None:
92
+ sample_size = total_time - transient_time
93
+ for _ in range(transient_time):
94
+ u = mapping(u, parameters)
95
+ else:
96
+ sample_size = total_time
97
+
98
+ # Initialize history tracking
99
+ exponent = 0.0
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)
108
+
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)
114
+
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
121
+
122
+ return history if return_history else np.array([exponent / sample_size])
123
+
124
+
125
+ @njit(cache=True)
126
+ def lyapunov_er(
127
+ u: NDArray[np.float64],
128
+ parameters: NDArray[np.float64],
129
+ total_time: int,
130
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
131
+ jacobian: Callable[
132
+ [NDArray[np.float64], NDArray[np.float64], Callable], NDArray[np.float64]
133
+ ],
134
+ return_history: bool = False,
135
+ sample_times: Optional[NDArray[np.int32]] = None,
136
+ transient_time: Optional[int] = None,
137
+ log_base: float = np.e,
138
+ ) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
139
+ """
140
+ Compute Lyapunov exponents using the Eckmann-Ruelle (ER) method for 2D systems.
141
+
142
+ This method tracks the evolution of perturbations via continuous QR decomposition
143
+ using rotational angles, providing numerically stable exponent estimates.
144
+
145
+ Parameters
146
+ ----------
147
+ u : NDArray[np.float64]
148
+ Initial state vector (shape: `(2,)` for 2D systems).
149
+ parameters : NDArray[np.float64]
150
+ System parameters passed to `mapping` and `jacobian`.
151
+ total_time : int
152
+ Total number of iterations (time steps) to compute.
153
+ mapping : Callable[[NDArray, NDArray], NDArray]
154
+ System evolution function: `u_next = mapping(u, parameters)`.
155
+ jacobian : Callable[[NDArray, NDArray, Callable], NDArray]
156
+ Function returning the Jacobian matrix (shape: `(2, 2)`).
157
+ return_history : bool, optional
158
+ 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
+ transient_time : Optional[int], optional
162
+ Number of initial iterations to discard as transient (default: None).
163
+ log_base : float, optional
164
+ Logarithm base for exponent calculation (default: e).
165
+
166
+ Returns
167
+ -------
168
+ Tuple[NDArray[np.float64], NDArray[np.float64]]
169
+ - If `return_history=True`:
170
+ - `history`: Array of exponent estimates (shape: `(sample_size, 2)` or `(len(sample_times), 2)`)
171
+ - `final_state`: System state at termination (shape: `(2,)`)
172
+ - If `return_history=False`:
173
+ - `exponents`: Final Lyapunov exponents (shape: `(2, 1)`)
174
+ - `final_state`: System state at termination (shape: `(2,)`)
175
+
176
+ Notes
177
+ -----
178
+ - **Method**: Uses rotation angles for continuous QR decomposition [1].
179
+ - **Stability**: More robust than Gram-Schmidt for 2D systems.
180
+ - **Limitation**: Designed specifically for 2D maps (`neq=2`).
181
+ - **Numerics**: Exponents are averaged as:
182
+ λ_i = (1/N) Σ log|T_ii|, where T is the transformation matrix.
183
+
184
+ References
185
+ ----------
186
+ [1] J. Eckmann & D. Ruelle, "Ergodic theory of chaos and strange attractors",
187
+ Rev. Mod. Phys. 57, 617 (1985).
188
+ """
189
+
190
+ neq = len(u)
191
+ exponents = np.zeros(neq)
192
+ beta0 = 0.0 # Initial rotation angle
193
+ u_contig = np.ascontiguousarray(u)
194
+
195
+ # Handle transient time
196
+ if transient_time is not None:
197
+ sample_size = total_time - transient_time
198
+ for _ in range(transient_time):
199
+ u_contig = mapping(u_contig, parameters)
200
+ else:
201
+ sample_size = total_time
202
+
203
+ # Initialize history tracking
204
+ 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))
212
+
213
+ 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
241
+
242
+ beta0 = beta # Update angle for next iteration
243
+
244
+ # Format output
245
+ if return_history:
246
+ return history, u_contig
247
+ else:
248
+ aux_exponents = np.zeros((neq, 1))
249
+ aux_exponents[:, 0] = exponents / sample_size
250
+ return aux_exponents, u_contig
251
+
252
+
253
+ @njit(cache=True)
254
+ def lyapunov_qr(
255
+ u: NDArray[np.float64],
256
+ parameters: NDArray[np.float64],
257
+ total_time: int,
258
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
259
+ jacobian: Callable[
260
+ [NDArray[np.float64], NDArray[np.float64], Callable], NDArray[np.float64]
261
+ ],
262
+ QR: Callable[
263
+ [NDArray[np.float64]], Tuple[NDArray[np.float64], NDArray[np.float64]]
264
+ ] = qr,
265
+ return_history: bool = False,
266
+ sample_times: Optional[NDArray[np.int32]] = None,
267
+ transient_time: Optional[int] = None,
268
+ log_base: float = np.e,
269
+ ) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
270
+ """
271
+ Compute Lyapunov exponents using QR decomposition (Gram-Schmidt) for N-dimensional systems.
272
+
273
+ This method tracks the evolution of perturbation vectors with periodic orthogonalization
274
+ via QR decomposition, suitable for systems of arbitrary dimension.
275
+
276
+ Parameters
277
+ ----------
278
+ u : NDArray[np.float64]
279
+ Initial state vector (shape: `(neq,)`).
280
+ parameters : NDArray[np.float64]
281
+ System parameters passed to `mapping` and `jacobian`.
282
+ total_time : int
283
+ Total number of iterations (time steps) to compute.
284
+ mapping : Callable[[NDArray, NDArray], NDArray]
285
+ System evolution function: `u_next = mapping(u, parameters)`.
286
+ jacobian : Callable[[NDArray, NDArray, Callable], NDArray]
287
+ Function returning the Jacobian matrix (shape: `(neq, neq)`).
288
+ QR : Callable[[NDArray], Tuple[NDArray, NDArray]], optional
289
+ QR decomposition function (default: `numpy.linalg.qr`).
290
+ return_history : bool, optional
291
+ If True, returns exponent convergence history (default: False).
292
+ sample_times : Optional[NDArray[np.int32]], optional
293
+ Specific time steps to record exponents (if `return_history=True`).
294
+ transient_time : Optional[int], optional
295
+ Number of initial iterations to discard as transient (default: None).
296
+ log_base : float, optional
297
+ Logarithm base for exponent calculation (default: e).
298
+
299
+ Returns
300
+ -------
301
+ Tuple[NDArray[np.float64], NDArray[np.float64]]
302
+ - If `return_history=True`:
303
+ - `history`: Array of exponent estimates (shape: `(sample_size, neq)` or `(len(sample_times), neq)`)
304
+ - `final_state`: System state at termination (shape: `(neq,)`)
305
+ - If `return_history=False`:
306
+ - `exponents`: Final Lyapunov exponents (shape: `(neq, 1)`)
307
+ - `final_state`: System state at termination (shape: `(neq,)`)
308
+
309
+ Notes
310
+ -----
311
+ - **Method**: Uses QR decomposition for orthogonalization [1].
312
+ - **Dimensionality**: Works for systems of any dimension (`neq ≥ 1`).
313
+ - **Numerics**:
314
+ - Exponents computed as: λ_i = (1/N) Σ log|R_ii|, where R is from QR decomposition.
315
+ - **Performance**: Optimized with Numba's `@njit`.
316
+
317
+ References
318
+ ----------
319
+ [1] A. Wolf et al., "Determining Lyapunov exponents from a time series",
320
+ Physica D 16D, 285-317 (1985).
321
+ """
322
+
323
+ neq = len(u)
324
+ v = np.ascontiguousarray(np.random.rand(neq, neq))
325
+ v = np.ascontiguousarray(np.eye(neq))
326
+ v, _ = qr(v) # Initialize orthonormal vectors
327
+ exponents = np.zeros(neq)
328
+ u_contig = np.ascontiguousarray(u.copy())
329
+
330
+ # Handle transient time
331
+ if transient_time is not None:
332
+ sample_size = total_time - transient_time
333
+ for _ in range(transient_time):
334
+ u_contig = mapping(u_contig, parameters)
335
+ else:
336
+ sample_size = total_time
337
+
338
+ # Initialize history tracking
339
+ 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)
358
+
359
+ # Record history if requested
360
+ 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
366
+
367
+ # Format output
368
+ if return_history:
369
+ return history, u_contig
370
+ else:
371
+ aux_exponents = np.zeros((neq, 1))
372
+ aux_exponents[:, 0] = exponents / sample_size
373
+ return aux_exponents, u_contig
374
+
375
+
376
+ def finite_time_lyapunov(
377
+ u: NDArray[np.float64],
378
+ parameters: NDArray[np.float64],
379
+ total_time: int,
380
+ finite_time: int,
381
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
382
+ jacobian: Callable[
383
+ [NDArray[np.float64], NDArray[np.float64], Callable], NDArray[np.float64]
384
+ ],
385
+ method: str = "QR",
386
+ transient_time: Optional[int] = None,
387
+ log_base: float = np.e,
388
+ return_points: bool = False,
389
+ ) -> Union[NDArray[np.float64], Tuple[NDArray[np.float64], NDArray[np.float64]]]:
390
+ """
391
+ Compute finite-time Lyapunov exponents (FTLEs) for a dynamical system.
392
+
393
+ FTLEs reveal how chaotic behavior varies over different time scales by computing
394
+ Lyapunov exponents over sliding windows. Supports both Eckmann-Ruelle (ER) and
395
+ QR-based methods (Gram-Schmidt or Householder).
396
+
397
+ Parameters
398
+ ----------
399
+ u : NDArray[np.float64]
400
+ Initial state vector (shape: `(neq,)`).
401
+ parameters : NDArray[np.float64]
402
+ System parameters passed to `mapping` and `jacobian`.
403
+ total_time : int
404
+ Total number of iterations to simulate.
405
+ finite_time : int
406
+ Length of each analysis window (iterations).
407
+ mapping : Callable[[NDArray, NDArray], NDArray]
408
+ System evolution function: `u_next = mapping(u, parameters)`.
409
+ jacobian : Callable[[NDArray, NDArray, Callable], NDArray]
410
+ Function returning the Jacobian matrix (shape: `(neq, neq)`).
411
+ method : str, optional
412
+ Computation method: 'ER' (2D only), 'QR' (Gram-Schmidt), or 'QR_HH' (Householder)
413
+ (default: 'ER').
414
+ transient_time : Optional[int], optional
415
+ Initial iterations to discard (default: None).
416
+ log_base : float, optional
417
+ Logarithm base for exponent calculation (default: e).
418
+
419
+ Returns
420
+ -------
421
+ NDArray[np.float64]
422
+ Array of FTLEs (shape: `(num_windows, neq)`), where:
423
+ `num_windows = floor((total_time - transient_time) / finite_time)`
424
+
425
+ Raises
426
+ ------
427
+ ValueError
428
+ - If `method` is invalid
429
+
430
+ Notes
431
+ -----
432
+ - **Window Processing**: Total time is divided into non-overlapping windows.
433
+ - **Method Selection**:
434
+ - 'QR': General N-dimensional (Gram-Schmidt orthogonalization)
435
+ - 'QR_HH': More stable for ill-conditioned systems (Householder QR)
436
+ - **Numerics**: Each window's exponents are independent estimates.
437
+ """
438
+ # Handle transient
439
+ if transient_time is not None:
440
+ sample_size = total_time - transient_time
441
+ for _ in range(transient_time):
442
+ u = mapping(u, parameters)
443
+ else:
444
+ sample_size = total_time
445
+
446
+ # Validate window size
447
+ if finite_time > sample_size:
448
+ raise ValueError(
449
+ f"finite_time ({finite_time}) exceeds available samples ({sample_size})"
450
+ )
451
+
452
+ neq = len(u)
453
+ num_windows = sample_size // finite_time
454
+ exponents = np.zeros((num_windows, neq))
455
+ phase_space_points = np.zeros((num_windows, neq))
456
+
457
+ # Compute exponents for each window
458
+ for i in range(num_windows):
459
+ if method == "ER":
460
+ window_exponents, u_new = lyapunov_er(
461
+ u, parameters, finite_time, mapping, jacobian, log_base=log_base
462
+ )
463
+ elif method == "QR":
464
+ window_exponents, u_new = lyapunov_qr(
465
+ u, parameters, finite_time, mapping, jacobian, log_base=log_base
466
+ )
467
+ elif method == "QR_HH":
468
+ window_exponents, u_new = lyapunov_qr(
469
+ u,
470
+ parameters,
471
+ finite_time,
472
+ mapping,
473
+ jacobian,
474
+ QR=householder_qr,
475
+ log_base=log_base,
476
+ )
477
+ else:
478
+ raise ValueError("method must be 'ER', 'QR', or 'QR_HH'")
479
+
480
+ exponents[i] = window_exponents.flatten()
481
+ phase_space_points[i] = u
482
+ u = u_new.copy()
483
+
484
+ if return_points:
485
+ return exponents, phase_space_points
486
+ else:
487
+ return exponents
488
+
489
+
490
+ def dig(
491
+ u: NDArray[np.float64],
492
+ parameters: NDArray[np.float64],
493
+ total_time: int,
494
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
495
+ func: Callable[[NDArray[np.float64]], NDArray[np.float64]],
496
+ transient_time: Optional[int] = None,
497
+ ) -> float:
498
+ """Compute the Dynamic Indicator for Globalness (DIG) of a trajectory.
499
+
500
+ Parameters
501
+ ----------
502
+ u : NDArray[np.float64]
503
+ Initial condition of shape (d,)
504
+ parameters : NDArray[np.float64]
505
+ System parameters
506
+ total_time : int
507
+ Total number of iterations (must be even and >= 100)
508
+ mapping : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
509
+ System mapping function (must be Numba-compatible)
510
+ func : Callable[[NDArray[np.float64]], NDArray[np.float64]]
511
+ Observable function
512
+ transient_time : Optional[int]
513
+ Burn-in period to discard
514
+
515
+ Returns
516
+ -------
517
+ float
518
+ DIG value (higher values indicate better convergence)
519
+ Returns 16 if perfect convergence detected
520
+
521
+ Notes
522
+ -----
523
+ - Implements the weighted Birkhoff average method
524
+ - Requires total_time to be even (split into two halves)
525
+ - For reliable results, total_time should be >= 1000
526
+ """
527
+
528
+ u = u.copy()
529
+
530
+ # Handle transient
531
+ if transient_time is not None:
532
+ if transient_time >= total_time:
533
+ raise ValueError("transient_time must be < total_time")
534
+ u = iterate_mapping(u, parameters, transient_time, mapping)
535
+ sample_size = total_time - transient_time
536
+ else:
537
+ sample_size = total_time
538
+
539
+ N = sample_size // 2
540
+ if N < 2:
541
+ raise ValueError("Effective sample size too small after transient removal")
542
+
543
+ N = sample_size // 2
544
+
545
+ t = np.arange(1, N) / N
546
+ S = np.exp(-1 / (t * (1 - t))).sum()
547
+ w = np.exp(-1 / (t * (1 - t))) / S
548
+
549
+ # Weighted Birkhoff average for the first half of iterations
550
+ time_series = generate_trajectory(u, parameters, N, mapping)
551
+ WB0 = (w * func(time_series[:-1, :])).sum()
552
+
553
+ # Weighted Birkhoff average for the second half of iterations
554
+ u = time_series[-1, :]
555
+ time_series = generate_trajectory(u, parameters, N, mapping)
556
+ WB1 = (w * func(time_series[:-1, :])).sum()
557
+
558
+ return -np.log10(abs(WB0 - WB1))
559
+
560
+
561
+ @njit(cache=True)
562
+ def SALI(
563
+ u: NDArray[np.float64],
564
+ parameters: NDArray[np.float64],
565
+ total_time: int,
566
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
567
+ jacobian: Callable[
568
+ [NDArray[np.float64], NDArray[np.float64], Callable], NDArray[np.float64]
569
+ ],
570
+ return_history: bool = False,
571
+ sample_times: Optional[NDArray[np.int32]] = None,
572
+ tol: float = 1e-16,
573
+ transient_time: Optional[int] = None,
574
+ seed: int = 13,
575
+ ) -> Union[NDArray[np.float64], Tuple[NDArray[np.float64], NDArray[np.float64]]]:
576
+ """
577
+ Compute the Smallest Alignment Index (SALI) for a dynamical system.
578
+
579
+ SALI quantifies chaos by tracking the alignment of deviation vectors in tangent space.
580
+ For regular motion, SALI oscillates near 1; for chaotic motion, it decays exponentially.
581
+
582
+ Parameters
583
+ ----------
584
+ u : NDArray[np.float64]
585
+ Initial state vector of the system (shape: `(neq,)`).
586
+ parameters : NDArray[np.float64]
587
+ System parameters (shape: arbitrary, passed to `mapping` and `jacobian`).
588
+ total_time : int
589
+ Total number of iterations (time steps) to simulate.
590
+ mapping : Callable[[NDArray, NDArray], NDArray]
591
+ Function representing the system's time evolution: `u_next = mapping(u, parameters)`.
592
+ jacobian : Callable[[NDArray, NDArray, Callable], NDArray]
593
+ Function computing the Jacobian matrix of `mapping` at state `u`.
594
+ return_history : bool, optional
595
+ 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
+ tol : float, optional
599
+ Tolerance for early stopping if SALI < `tol` (default: 1e-16).
600
+ transient_time : Optional[int], optional
601
+ Number of initial iterations to discard as transient (default: None).
602
+ seed : int, optional
603
+ Random seed for reproducibility (default: 13)
604
+
605
+ Returns
606
+ -------
607
+ Union[NDArray[np.float64], Tuple[NDArray[np.float64], NDArray[np.float64]]]
608
+ - If `return_history=False`: Final SALI value (shape: `(1,)`).
609
+ - If `return_history=True`: Array of SALI values at sampled times.
610
+
611
+ Raises
612
+ ------
613
+ ValueError
614
+ If `sample_times` contains values exceeding `total_time`.
615
+
616
+ Notes
617
+ -----
618
+ - Uses QR decomposition to initialize orthonormal deviation vectors.
619
+ - Computes both Parallel (PAI) and Antiparallel (AAI) Alignment Indices.
620
+ - Early termination occurs if SALI < `tol` (indicating chaotic behavior).
621
+ - Optimized with `@njit(cache=True)` for performance.
622
+ """
623
+
624
+ np.random.seed(seed) # For reproducibility
625
+
626
+ neq = len(u)
627
+
628
+ # Only need 2 vectors for SALI
629
+ v = np.ascontiguousarray(np.random.rand(neq, 2))
630
+ v, _ = qr(v)
631
+
632
+ # Handle transient time
633
+ if transient_time is not None:
634
+ sample_size = total_time - transient_time
635
+ for _ in range(transient_time):
636
+ u = mapping(u, parameters)
637
+ else:
638
+ sample_size = total_time
639
+
640
+ # Initialize history tracking
641
+ 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))
654
+
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])
659
+
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)
664
+
665
+ # Record history if requested
666
+ 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
672
+
673
+ # Early termination
674
+ if sali_val < tol:
675
+ break
676
+
677
+ return history if return_history else np.array([sali_val])
678
+
679
+
680
+ # @njit(cache=True)
681
+
682
+
683
+ def LDI_k(
684
+ u: NDArray[np.float64],
685
+ parameters: NDArray[np.float64],
686
+ total_time: int,
687
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
688
+ jacobian: Callable[
689
+ [NDArray[np.float64], NDArray[np.float64], Callable], NDArray[np.float64]
690
+ ],
691
+ k: int = 2,
692
+ return_history: bool = False,
693
+ sample_times: Optional[NDArray[np.int32]] = None,
694
+ tol: float = 1e-16,
695
+ transient_time: Optional[int] = None,
696
+ seed: int = 13,
697
+ ) -> Union[NDArray[np.float64], Tuple[NDArray[np.float64], NDArray[np.float64]]]:
698
+ """
699
+ Compute the Generalized Alignment Index (GALI) for a dynamical system.
700
+
701
+ GALI is a measure of chaos in dynamical systems, calculated using the evolution
702
+ of `k` initially orthonormal deviation vectors under the system's Jacobian.
703
+
704
+ Parameters
705
+ ----------
706
+ u : NDArray[np.float64]
707
+ Initial state vector of the system (shape: `(neq,)`).
708
+ parameters : NDArray[np.float64]
709
+ System parameters (shape: arbitrary, passed to `mapping` and `jacobian`).
710
+ total_time : int
711
+ Total number of iterations (time steps) to simulate.
712
+ mapping : Callable[[NDArray, NDArray], NDArray]
713
+ Function representing the system's time evolution (maps state `u` to next state).
714
+ jacobian : Callable[[NDArray, NDArray, Callable], NDArray]
715
+ Function computing the Jacobian matrix of `mapping` at state `u`.
716
+ k : int, optional
717
+ Number of deviation vectors to track (default: 2).
718
+ return_history : bool, optional
719
+ 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
+ tol : float, optional
723
+ Tolerance for early stopping if GALI drops below this value (default: 1e-16).
724
+ transient_time : Optional[int], optional
725
+ Number of initial iterations to discard as transient (default: None).
726
+ seed : int, optional
727
+ Random seed for reproducibility (default 13)
728
+
729
+ Returns
730
+ -------
731
+ Union[NDArray[np.float64], Tuple[NDArray[np.float64], NDArray[np.float64]]]
732
+ - If `return_history=False`: Final GALI value (shape: `(1,)`).
733
+ - If `return_history=True`: Array of GALI values at each sampled time.
734
+
735
+ Raises
736
+ ------
737
+ ValueError
738
+ If `sample_times` contains values exceeding `total_time`.
739
+
740
+ Notes
741
+ -----
742
+ - The function uses QR decomposition to maintain orthonormality of deviation vectors.
743
+ - Early termination occurs if GALI < `tol` (indicating chaotic behavior).
744
+ - For performance, the function is optimized with `@njit(cache=True)`.
745
+ """
746
+
747
+ np.random.seed(seed) # For reproducibility
748
+
749
+ neq = len(u)
750
+
751
+ # Generate random orthonormal deviation vectors
752
+ v = np.ascontiguousarray(np.random.rand(neq, k))
753
+ v, _ = qr(v)
754
+
755
+ if transient_time is not None:
756
+ # Discard transient time
757
+ sample_size = total_time - transient_time
758
+ for i in range(transient_time):
759
+ u = mapping(u, parameters)
760
+ else:
761
+ sample_size = total_time
762
+
763
+ 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))
780
+
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])
785
+
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
789
+
790
+ 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
796
+
797
+ if gali < tol:
798
+ break
799
+
800
+ if return_history:
801
+ return history
802
+ else:
803
+ return np.array([gali])
804
+
805
+
806
+ @njit(cache=True)
807
+ def hurst_exponent(
808
+ u: NDArray[np.float64],
809
+ parameters: NDArray[np.float64],
810
+ total_time: int,
811
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
812
+ wmin: int = 2,
813
+ transient_time: Optional[int] = None,
814
+ return_last: bool = False,
815
+ ) -> NDArray[np.float64]:
816
+ """
817
+ Estimate the Hurst exponent for a system trajectory using the rescaled range (R/S) method.
818
+
819
+ Parameters
820
+ ----------
821
+ u : NDArray[np.float64]
822
+ Initial condition vector of shape (n,).
823
+ parameters : NDArray[np.float64]
824
+ Parameters passed to the mapping function.
825
+ total_time : int
826
+ Total number of iterations used to generate the trajectory.
827
+ mapping : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
828
+ A function that defines the system dynamics, i.e., how `u` evolves over time given `parameters`.
829
+ wmin : int, optional
830
+ Minimum window size for the rescaled range calculation. Default is 2.
831
+ transient_time : Optional[int], optional
832
+ Number of initial iterations to discard as transient. If `None`, no transient is removed. Default is `None`.
833
+
834
+ Returns
835
+ -------
836
+ NDArray[np.float64]
837
+ Estimated Hurst exponents for each dimension of the input vector `u`, of shape (n,).
838
+
839
+ Notes
840
+ -----
841
+ The Hurst exponent is a measure of the long-term memory of a time series:
842
+
843
+ - H = 0.5 indicates a random walk (no memory).
844
+ - H > 0.5 indicates persistent behavior (positive autocorrelation).
845
+ - H < 0.5 indicates anti-persistent behavior (negative autocorrelation).
846
+
847
+ This implementation computes the rescaled range (R/S) for various window sizes and
848
+ performs a linear regression in log-log space to estimate the exponent.
849
+
850
+ The function supports multivariate time series, estimating one Hurst exponent per dimension.
851
+ """
852
+
853
+ u = u.copy()
854
+ neq = len(u)
855
+ H = np.zeros(neq)
856
+
857
+ # Handle transient time
858
+ if transient_time is not None:
859
+ sample_size = total_time - transient_time
860
+ for i in range(transient_time):
861
+ u = mapping(u, parameters)
862
+ else:
863
+ sample_size = total_time
864
+
865
+ time_series = generate_trajectory(
866
+ u, parameters, total_time, mapping, transient_time=transient_time
867
+ )
868
+
869
+ ells = np.arange(wmin, sample_size // 2)
870
+ RS = np.empty((ells.shape[0], neq))
871
+ for j in range(neq):
872
+
873
+ for i, ell in enumerate(ells):
874
+ num_blocks = sample_size // ell
875
+ R_over_S = np.empty(num_blocks)
876
+
877
+ for block in range(num_blocks):
878
+ start = block * ell
879
+ end = start + ell
880
+ block_series = time_series[start:end, j]
881
+
882
+ # Mean adjustment
883
+ mean_adjusted_series = block_series - np.mean(block_series)
884
+
885
+ # Cumulative sum
886
+ Z = np.cumsum(mean_adjusted_series)
887
+
888
+ # Range (R)
889
+ R = np.max(Z) - np.min(Z)
890
+
891
+ # Standard deviation (S)
892
+ S = np.std(block_series)
893
+
894
+ # Avoid division by zero
895
+ if S > 0:
896
+ R_over_S[block] = R / S
897
+ else:
898
+ R_over_S[block] = 0
899
+
900
+ if np.all(R_over_S == 0):
901
+ RS[i, j] == 0
902
+ else:
903
+ RS[i, j] = np.mean(R_over_S[R_over_S > 0])
904
+
905
+ if np.all(RS[:, j] == 0):
906
+ H[j] = 0
907
+ else:
908
+ # Log-log plot and linear regression to estimate the Hurst exponent
909
+ inds = np.where(RS[:, j] > 0)[0]
910
+ x_fit = np.log(ells[inds])
911
+ y_fit = np.log(RS[inds, j])
912
+ fitting = fit_poly(x_fit, y_fit, 1)
913
+
914
+ H[j] = fitting[0]
915
+
916
+ if return_last:
917
+ result = np.zeros(2 * neq)
918
+ result[:neq] = H
919
+ result[neq:] = time_series[-1, :]
920
+ return result
921
+ else:
922
+ return H
923
+
924
+
925
+ def finite_time_hurst_exponent(
926
+ u: NDArray[np.float64],
927
+ parameters: NDArray[np.float64],
928
+ total_time: int,
929
+ finite_time: int,
930
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
931
+ wmin: int = 2,
932
+ return_points: bool = False,
933
+ ) -> Union[NDArray[np.float64], Tuple[NDArray[np.float64], NDArray[np.float64]]]:
934
+ """
935
+ Compute finite-time Hurst exponents for a dynamical system.
936
+
937
+ Parameters
938
+ ----------
939
+ u : NDArray[np.float64]
940
+ Initial condition vector of shape (n,).
941
+ parameters : NDArray[np.float64]
942
+ Parameters passed to the mapping function.
943
+ total_time : int
944
+ Total number of iterations used to generate the trajectory.
945
+ finite_time : int
946
+ Length of each analysis window (iterations).
947
+ mapping : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
948
+ A function that defines the system dynamics, i.e., how `u` evolves over time given `parameters`.
949
+ wmin : int, optional
950
+ Minimum window size for the rescaled range calculation. Default is 2.
951
+
952
+ Returns
953
+ -------
954
+ NDArray[np.float64]
955
+ Array of estimated Hurst exponents for each window.
956
+
957
+ Notes
958
+ -----
959
+ The function computes the Hurst exponent for non-overlapping windows of size `finite_time`.
960
+ """
961
+
962
+ u = u.copy()
963
+
964
+ num_windows = total_time // finite_time
965
+ H_values = np.zeros((num_windows, len(u)))
966
+ phase_space_points = np.zeros((num_windows, len(u)))
967
+
968
+ # Compute Hurst exponent for each window
969
+ for i in range(num_windows):
970
+ result = hurst_exponent(
971
+ u, parameters, finite_time, mapping, wmin=wmin, return_last=True
972
+ )
973
+ H_values[i] = result[: len(u)]
974
+ phase_space_points[i] = u
975
+ u = result[len(u) :]
976
+
977
+ if return_points:
978
+ return H_values, phase_space_points
979
+ else:
980
+ return H_values
981
+
982
+
983
+ @njit(cache=True)
984
+ def lyapunov_vectors():
985
+ # ! To be implemented...
986
+ pass
987
+
988
+
989
+ @njit(cache=True)
990
+ def lagrangian_descriptors(
991
+ u: NDArray[np.float64],
992
+ parameters: NDArray[np.float64],
993
+ total_time: int,
994
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
995
+ backwards_mapping: Callable[
996
+ [NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]
997
+ ],
998
+ mod: float = 1.0,
999
+ transient_time: Optional[int] = None,
1000
+ ) -> NDArray[np.float64]:
1001
+ """Compute Lagrangian Descriptors (LDs) for a dynamical system.
1002
+
1003
+ Parameters
1004
+ ----------
1005
+ u : NDArray[np.float64]
1006
+ Initial condition of shape (d,), where d is system dimension
1007
+ parameters : NDArray[np.float64]
1008
+ System parameters of shape (p,)
1009
+ total_time : int
1010
+ Total number of iterations (must be > 0)
1011
+ mapping : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
1012
+ Forward mapping function: u_{n+1} = mapping(u_n, parameters)
1013
+ backwards_mapping : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
1014
+ Backward mapping function: u_{n-1} = backwards_mapping(u_n, parameters)
1015
+ transient_time : Optional[int], optional
1016
+ Number of initial iterations to discard (default None)
1017
+
1018
+ Returns
1019
+ -------
1020
+ NDArray[np.float64]
1021
+ Array of shape (2,) containing:
1022
+ - LDs[0]: Forward LD (sum of forward trajectory distances)
1023
+ - LDs[1]: Backward LD (sum of backward trajectory distances)
1024
+
1025
+ Notes
1026
+ -----
1027
+ - LDs reveal phase space structures and invariant manifolds
1028
+ - Higher values indicate more "stretching" in phase space
1029
+ - For best results:
1030
+ - Use total_time >> 1 (typically 1000-10000)
1031
+ - Ensure mapping and backwards_mapping are exact inverses
1032
+ - Numba-optimized for performance
1033
+
1034
+ Examples
1035
+ --------
1036
+ >>> # Basic usage
1037
+ >>> u0 = np.array([0.1, 0.2])
1038
+ >>> params = np.array([0.5, 1.0])
1039
+ >>> lds = lagrangian_descriptors(u0, params, 1000, fwd_map, bwd_map)
1040
+ >>> forward_ld, backward_ld = lds
1041
+ """
1042
+ # Initialize descriptors
1043
+ LDs = np.zeros(2)
1044
+ u_forward = u.copy()
1045
+ u_backward = u.copy()
1046
+
1047
+ # Handle transient period
1048
+ if transient_time is not None:
1049
+ if transient_time >= total_time:
1050
+ return LDs # Return zeros if no sample time remains
1051
+
1052
+ # Evolve through transient
1053
+ for _ in range(transient_time):
1054
+ u_forward = mapping(u_forward, parameters)
1055
+ u_backward = backwards_mapping(u_backward, parameters)
1056
+ sample_size = total_time - transient_time
1057
+ else:
1058
+ sample_size = total_time
1059
+
1060
+ # Main computation loop
1061
+ for _ in range(sample_size):
1062
+ # Forward evolution
1063
+ u_new_forward = mapping(u_forward, parameters)
1064
+ dx = abs(u_new_forward[0] - u_forward[0])
1065
+ if dx > mod / 2:
1066
+ dx = mod - dx
1067
+ dy = u_new_forward[1] - u_forward[1]
1068
+ LDs[0] += np.sqrt(dx**2 + dy**2)
1069
+ u_forward = u_new_forward
1070
+
1071
+ # Backward evolution
1072
+ u_new_backward = backwards_mapping(u_backward, parameters)
1073
+ dx = abs(u_new_backward[0] - u_backward[0])
1074
+ if dx > mod / 2:
1075
+ dx = mod - dx
1076
+ dy = u_new_backward[1] - u_backward[1]
1077
+ LDs[1] += np.sqrt(dx**2 + dy**2)
1078
+ u_backward = u_new_backward
1079
+
1080
+ return LDs
1081
+
1082
+
1083
+ def RTE(
1084
+ u: NDArray[np.float64],
1085
+ parameters: NDArray[np.float64],
1086
+ total_time: int,
1087
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
1088
+ transient_time: Optional[int] = None,
1089
+ **kwargs,
1090
+ ) -> Union[float, Tuple]:
1091
+ """
1092
+ Calculate Recurrence Time Entropy (RTE) for a dynamical system.
1093
+
1094
+ RTE quantifies the complexity of a system by analyzing the distribution
1095
+ of white vertical lines, i.e., the gap between two diagonal lines.
1096
+ Higher entropy indicates more complex dynamics.
1097
+
1098
+ Parameters
1099
+ ----------
1100
+ u : NDArray[np.float64]
1101
+ Initial state vector (shape: (neq,))
1102
+ parameters : NDArray[np.float64]
1103
+ System parameters passed to mapping function
1104
+ total_time : int
1105
+ Number of iterations to simulate
1106
+ mapping : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
1107
+ System evolution function: u_next = mapping(u, parameters)
1108
+ transient_time : Optional[int], default=None
1109
+ Time to wait before starting RTE calculation.
1110
+ **kwargs
1111
+ Configuration parameters (see RTEConfig)
1112
+
1113
+ Returns
1114
+ -------
1115
+ Union[float, Tuple]
1116
+ - Base case: RTE value (float)
1117
+ - With optional returns: List containing [RTE, *requested_additional_data]
1118
+
1119
+ Raises
1120
+ ------
1121
+ ValueError
1122
+ - If invalid metric specified
1123
+ - If trajectory generation fails
1124
+
1125
+ Notes
1126
+ -----
1127
+ - Implements the method described in [1]
1128
+ - For optimal results:
1129
+ - Use total_time > 1000 for reliable statistics
1130
+ - Typical threshold values: 0.05-0.3
1131
+ - Set lmin=1 to include single-point recurrences
1132
+
1133
+ References
1134
+ ----------
1135
+ [1] M. R. Sales, M. Mugnaine, J. Szezech, José D., R. L. Viana, I. L. Caldas, N. Marwan, and J. Kurths, Stickiness and recurrence plots: An entropy-based approach, Chaos: An Interdisciplinary Journal of Nonlinear Science 33, 033140 (2023)
1136
+ """
1137
+
1138
+ u = u.copy()
1139
+
1140
+ # Configuration handling
1141
+ config = RTEConfig(**kwargs)
1142
+
1143
+ # Metric setup
1144
+ metric_map = {"supremum": np.inf, "euclidean": 2, "manhattan": 1}
1145
+
1146
+ try:
1147
+ ord = metric_map[config.std_metric.lower()]
1148
+ except KeyError:
1149
+ raise ValueError(
1150
+ f"Invalid std_metric: {config.std_metric}. Must be {list(metric_map.keys())}"
1151
+ )
1152
+
1153
+ if transient_time is not None:
1154
+ u = iterate_mapping(u, parameters, transient_time, mapping)
1155
+ total_time -= transient_time
1156
+
1157
+ # Generate trajectory
1158
+ try:
1159
+ time_series = generate_trajectory(u, parameters, total_time, mapping)
1160
+ except Exception as e:
1161
+ raise ValueError(f"Trajectory generation failed: {str(e)}")
1162
+
1163
+ # Threshold calculation
1164
+ if config.threshold_std:
1165
+ std = np.std(time_series, axis=0)
1166
+ eps = config.threshold * np.linalg.norm(std, ord=ord)
1167
+ if eps <= 0:
1168
+ eps = 0.1
1169
+ else:
1170
+ eps = config.threshold
1171
+
1172
+ # Recurrence matrix calculation
1173
+ recmat = recurrence_matrix(time_series, float(eps), metric=config.metric)
1174
+
1175
+ # White line distribution
1176
+ P = white_vertline_distr(recmat)[config.lmin :]
1177
+ P = P[P > 0] # Remove zeros
1178
+ P /= P.sum() # Normalize
1179
+
1180
+ # Entropy calculation
1181
+ rte = -np.sum(P * np.log(P))
1182
+
1183
+ # Prepare output
1184
+ result = [rte]
1185
+ if config.return_final_state:
1186
+ result.append(time_series[-1])
1187
+ if config.return_recmat:
1188
+ result.append(recmat)
1189
+ if config.return_p:
1190
+ result.append(P)
1191
+
1192
+ return result[0] if len(result) == 1 else tuple(result)
1193
+
1194
+
1195
+ def finite_time_RTE(
1196
+ u: NDArray[np.float64],
1197
+ parameters: NDArray[np.float64],
1198
+ total_time: int,
1199
+ finite_time: int,
1200
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
1201
+ return_points: bool = False,
1202
+ **kwargs,
1203
+ ) -> Union[NDArray[np.float64], Tuple[NDArray[np.float64], NDArray[np.float64]]]:
1204
+ # Validate window size
1205
+ if finite_time > total_time:
1206
+ raise ValueError(
1207
+ f"finite_time ({finite_time}) exceeds available samples ({total_time})"
1208
+ )
1209
+
1210
+ num_windows = total_time // finite_time
1211
+ RTE_values = np.zeros(num_windows)
1212
+ phase_space_points = np.zeros((num_windows, u.shape[0]))
1213
+
1214
+ for i in range(num_windows):
1215
+ result = RTE(
1216
+ u, parameters, finite_time, mapping, return_final_state=True, **kwargs
1217
+ )
1218
+ if isinstance(result, tuple):
1219
+ RTE_values[i], u_new = result
1220
+ phase_space_points[i] = u
1221
+ u = u_new.copy()
1222
+
1223
+ if return_points:
1224
+ return RTE_values, phase_space_points
1225
+ else:
1226
+ return RTE_values