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.
- pynamicalsys/__init__.py +24 -0
- pynamicalsys/__version__.py +21 -0
- pynamicalsys/common/__init__.py +16 -0
- pynamicalsys/common/basin_analysis.py +170 -0
- pynamicalsys/common/recurrence_quantification_analysis.py +426 -0
- pynamicalsys/common/utils.py +344 -0
- pynamicalsys/continuous_time/__init__.py +16 -0
- pynamicalsys/core/__init__.py +16 -0
- pynamicalsys/core/basin_metrics.py +206 -0
- pynamicalsys/core/continuous_dynamical_systems.py +18 -0
- pynamicalsys/core/discrete_dynamical_systems.py +3391 -0
- pynamicalsys/core/plot_styler.py +155 -0
- pynamicalsys/core/time_series_metrics.py +139 -0
- pynamicalsys/discrete_time/__init__.py +16 -0
- pynamicalsys/discrete_time/dynamical_indicators.py +1226 -0
- pynamicalsys/discrete_time/models.py +435 -0
- pynamicalsys/discrete_time/trajectory_analysis.py +1459 -0
- pynamicalsys/discrete_time/transport.py +501 -0
- pynamicalsys/discrete_time/validators.py +313 -0
- pynamicalsys-1.0.0.dist-info/METADATA +791 -0
- pynamicalsys-1.0.0.dist-info/RECORD +23 -0
- pynamicalsys-1.0.0.dist-info/WHEEL +5 -0
- pynamicalsys-1.0.0.dist-info/top_level.txt +1 -0
@@ -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
|