pynamicalsys 1.3.0__py3-none-any.whl → 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. pynamicalsys/__init__.py +2 -0
  2. pynamicalsys/__version__.py +2 -2
  3. pynamicalsys/common/recurrence_quantification_analysis.py +1 -1
  4. pynamicalsys/common/time_series_metrics.py +85 -0
  5. pynamicalsys/common/utils.py +3 -3
  6. pynamicalsys/continuous_time/chaotic_indicators.py +306 -8
  7. pynamicalsys/continuous_time/models.py +25 -0
  8. pynamicalsys/continuous_time/numerical_integrators.py +7 -7
  9. pynamicalsys/continuous_time/trajectory_analysis.py +460 -13
  10. pynamicalsys/core/continuous_dynamical_systems.py +933 -35
  11. pynamicalsys/core/discrete_dynamical_systems.py +20 -9
  12. pynamicalsys/core/hamiltonian_systems.py +1193 -0
  13. pynamicalsys/core/time_series_metrics.py +65 -0
  14. pynamicalsys/discrete_time/dynamical_indicators.py +13 -102
  15. pynamicalsys/discrete_time/models.py +2 -2
  16. pynamicalsys/discrete_time/trajectory_analysis.py +10 -10
  17. pynamicalsys/discrete_time/transport.py +1 -1
  18. pynamicalsys/hamiltonian_systems/__init__.py +16 -0
  19. pynamicalsys/hamiltonian_systems/chaotic_indicators.py +638 -0
  20. pynamicalsys/hamiltonian_systems/models.py +68 -0
  21. pynamicalsys/hamiltonian_systems/numerical_integrators.py +248 -0
  22. pynamicalsys/hamiltonian_systems/trajectory_analysis.py +293 -0
  23. pynamicalsys/hamiltonian_systems/validators.py +114 -0
  24. {pynamicalsys-1.3.0.dist-info → pynamicalsys-1.4.0.dist-info}/METADATA +37 -8
  25. pynamicalsys-1.4.0.dist-info/RECORD +36 -0
  26. pynamicalsys-1.3.0.dist-info/RECORD +0 -28
  27. {pynamicalsys-1.3.0.dist-info → pynamicalsys-1.4.0.dist-info}/WHEEL +0 -0
  28. {pynamicalsys-1.3.0.dist-info → pynamicalsys-1.4.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1193 @@
1
+ # hamiltonian_systems.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 numbers import Integral, Real
19
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Union, Tuple
20
+
21
+ import numpy as np
22
+ from numpy.typing import NDArray
23
+
24
+ from pynamicalsys.hamiltonian_systems.models import (
25
+ henon_heiles_grad_T,
26
+ henon_heiles_grad_V,
27
+ henon_heiles_hess_T,
28
+ henon_heiles_hess_V,
29
+ )
30
+
31
+ from pynamicalsys.hamiltonian_systems.numerical_integrators import (
32
+ velocity_verlet_2nd_step_tangent,
33
+ yoshida_4th_step,
34
+ velocity_verlet_2nd_step,
35
+ yoshida_4th_step_tangent,
36
+ )
37
+
38
+ from pynamicalsys.hamiltonian_systems.validators import (
39
+ validate_initial_conditions,
40
+ validate_non_negative,
41
+ validate_parameters,
42
+ )
43
+
44
+ from pynamicalsys.common.utils import qr, householder_qr, fit_poly
45
+
46
+ from pynamicalsys.hamiltonian_systems.trajectory_analysis import (
47
+ generate_trajectory,
48
+ ensemble_trajectories,
49
+ generate_poincare_section,
50
+ ensemble_poincare_section,
51
+ )
52
+
53
+ from pynamicalsys.hamiltonian_systems.chaotic_indicators import (
54
+ lyapunov_spectrum,
55
+ maximum_lyapunov_exponent,
56
+ SALI,
57
+ LDI,
58
+ GALI,
59
+ recurrence_time_entropy,
60
+ hurst_exponent_wrapped,
61
+ )
62
+
63
+
64
+ class HamiltonianSystem:
65
+ """
66
+ Class for defining, simulating, and analyzing Hamiltonian dynamical systems.
67
+
68
+ This class provides access to predefined Hamiltonian models (e.g., the
69
+ Hénon-Heiles system) or allows the user to define a custom Hamiltonian
70
+ system via gradient and optional Hessian functions. It supports multiple
71
+ numerical symplectic integrators and a variety of trajectory and chaos
72
+ analysis tools, such as Lyapunov exponents, SALI, LDI, and GALI.
73
+
74
+ Examples
75
+ --------
76
+ >>> from pynamicalsys import HamiltonianSystem
77
+ >>> hs = HamiltonianSystem(model="henon heiles")
78
+ >>> hs.available_models()
79
+ ['henon heiles']
80
+ >>> hs.available_integrators()
81
+ ['svy4', 'vv2']
82
+ """
83
+
84
+ __AVAILABLE_MODELS: Dict[str, Dict[str, Any]] = {
85
+ "henon heiles": {
86
+ "description": "two d.o.f. Hénon-Heiles Hamiltonian system",
87
+ "has hessian": True,
88
+ "grad_T": henon_heiles_grad_T,
89
+ "grad_V": henon_heiles_grad_V,
90
+ "hess_T": henon_heiles_hess_T,
91
+ "hess_V": henon_heiles_hess_V,
92
+ "degrees of freedom": 2,
93
+ "number of parameters": 0,
94
+ "parameters": [],
95
+ },
96
+ }
97
+
98
+ __AVAILABLE_INTEGRATORS: Dict[str, Dict[str, Any]] = {
99
+ "svy4": {
100
+ "description": "4th order Yoshida method",
101
+ "integrator": yoshida_4th_step,
102
+ "tangent integrator": yoshida_4th_step_tangent,
103
+ },
104
+ "vv2": {
105
+ "description": "2nd order velocity Verlet method",
106
+ "integrator": velocity_verlet_2nd_step,
107
+ "tangent integrator": velocity_verlet_2nd_step_tangent,
108
+ },
109
+ }
110
+
111
+ def __init__(
112
+ self,
113
+ model: Optional[str] = None,
114
+ grad_T: Optional[Callable] = None,
115
+ grad_V: Optional[Callable] = None,
116
+ hess_T: Optional[Callable] = None,
117
+ hess_V: Optional[Callable] = None,
118
+ degrees_of_freedom: Optional[int] = None,
119
+ number_of_parameters: Optional[int] = None,
120
+ ) -> None:
121
+ if model is not None and (grad_T is not None or grad_V is not None):
122
+ raise ValueError("Cannot specify both model and custom system")
123
+
124
+ if model is not None:
125
+ model = model.lower()
126
+ if model not in self.__AVAILABLE_MODELS:
127
+ available = "\n".join(
128
+ f"- {name}: {info['description']}"
129
+ for name, info in self.__AVAILABLE_MODELS.items()
130
+ )
131
+ raise ValueError(
132
+ f"Model '{model}' not implemented. Available models:\n{available}"
133
+ )
134
+
135
+ model_info = self.__AVAILABLE_MODELS[model]
136
+ self.__model = model
137
+ self.__grad_T = model_info["grad_T"]
138
+ self.__grad_V = model_info["grad_V"]
139
+ self.__hess_T = model_info["hess_T"]
140
+ self.__hess_V = model_info["hess_V"]
141
+ self.__degrees_of_freedom = model_info["degrees of freedom"]
142
+ self.__number_of_parameters = model_info["number of parameters"]
143
+ elif (
144
+ grad_T is not None
145
+ and grad_V is not None
146
+ and degrees_of_freedom is not None
147
+ and number_of_parameters is not None
148
+ ):
149
+ if not callable(grad_T) or not callable(grad_V):
150
+ raise TypeError(
151
+ "The custom system (grad V and grad T) must be callable"
152
+ )
153
+
154
+ self.__grad_T = grad_T
155
+ self.__grad_V = grad_V
156
+
157
+ self.__hess_T = hess_T
158
+ self.__hess_V = hess_V
159
+
160
+ if (
161
+ self.__hess_T is not None
162
+ and self.__hess_V is not None
163
+ and not callable(self.__hess_T)
164
+ and not callable(self.__hess_V)
165
+ ):
166
+ raise TypeError("Custom Hessian functions must be callable")
167
+
168
+ validate_non_negative(degrees_of_freedom, "degrees_of_freedom", Integral)
169
+ validate_non_negative(
170
+ number_of_parameters, "number_of_parameters", Integral
171
+ )
172
+
173
+ self.__degrees_of_freedom = degrees_of_freedom
174
+ self.__number_of_parameters = number_of_parameters
175
+ else:
176
+ raise ValueError(
177
+ "Must specify either a model name or custom system function (grad V and grad T) with its dimension and number of paramters."
178
+ )
179
+
180
+ self.__integrator = "svy4"
181
+ self.__integrator_func = yoshida_4th_step
182
+ self.__tangent_integrator_func = yoshida_4th_step_tangent
183
+ self.__time_step = 1e-2
184
+
185
+ @classmethod
186
+ def available_models(cls) -> List[str]:
187
+ """
188
+ List the available predefined Hamiltonian models.
189
+
190
+ Returns
191
+ -------
192
+ list of str
193
+ Names of the supported models.
194
+ """
195
+ return list(cls.__AVAILABLE_MODELS.keys())
196
+
197
+ @classmethod
198
+ def available_integrators(cls) -> List[str]:
199
+ """
200
+ List the available predefined Hamiltonian models.
201
+
202
+ Returns
203
+ -------
204
+ list of str
205
+ Names of the supported models.
206
+ """
207
+ return list(cls.__AVAILABLE_INTEGRATORS.keys())
208
+
209
+ @property
210
+ def info(self) -> Dict[str, Any]:
211
+ """
212
+ Information dictionary for the selected model.
213
+
214
+ Returns
215
+ -------
216
+ dict
217
+ Dictionary containing metadata such as description, gradients,
218
+ Hessians, degrees of freedom, and parameters.
219
+
220
+ Raises
221
+ ------
222
+ ValueError
223
+ If no predefined model was used to initialize the system.
224
+ """
225
+
226
+ if self.__model is None:
227
+ raise ValueError(
228
+ "The 'info' property is only available when a model is provided."
229
+ )
230
+
231
+ model = self.__model.lower()
232
+
233
+ return self.__AVAILABLE_MODELS[model]
234
+
235
+ @property
236
+ def integrator_info(self) -> Dict[str, Any]:
237
+ """
238
+ Information dictionary for the current integrator.
239
+
240
+ Returns
241
+ -------
242
+ dict
243
+ Dictionary containing the integrator description and associated
244
+ step functions.
245
+ """
246
+ integrator = self.__integrator.lower()
247
+
248
+ return self.__AVAILABLE_INTEGRATORS[integrator]
249
+
250
+ def integrator(self, integrator, time_step=1e-2) -> None:
251
+ """
252
+ Set the numerical integrator and time step.
253
+
254
+ Parameters
255
+ ----------
256
+ integrator : str
257
+ Name of the integrator. Options:
258
+ - 'svy4' : 4th order Yoshida method
259
+ - 'vv2' : 2nd order velocity-Verlet
260
+ time_step : float, optional
261
+ Integration time step (default is 1e-2).
262
+
263
+ Raises
264
+ ------
265
+ ValueError
266
+ If `time_step` is negative or if `integrator` is not available.
267
+ TypeError
268
+ If `integrator` is not a string or `time_step` is not numeric.
269
+
270
+ Examples
271
+ --------
272
+ >>> from pynamicalsys import HamiltonianSystem
273
+ >>> HamiltonianSystem.available_integrators()
274
+ ['svy4', 'vv2']
275
+ >>> hs = HamiltonianSystem(model="henon heiles")
276
+ >>> hs.integrator("svy4", time_step=0.001) # To use the SVY4 integrator with a time step of 10^{-3}
277
+ >>> hs.integrator("vv2", time_step=0.001) # To use the VV2 integrator
278
+ """
279
+
280
+ if not isinstance(integrator, str):
281
+ raise ValueError("integrator must be a string.")
282
+ validate_non_negative(time_step, "time_step", type_=Real)
283
+
284
+ if integrator in self.__AVAILABLE_INTEGRATORS:
285
+ self.__integrator = integrator.lower()
286
+ integrator_info = self.__AVAILABLE_INTEGRATORS[self.__integrator]
287
+ self.__integrator_func = integrator_info["integrator"]
288
+ self.__tangent_integrator_func = integrator_info["tangent integrator"]
289
+ self.__time_step = time_step
290
+
291
+ else:
292
+ integrator = integrator.lower()
293
+ if integrator not in self.__AVAILABLE_INTEGRATORS:
294
+ available = "\n".join(
295
+ f"- {name}: {info['description']}"
296
+ for name, info in self.__AVAILABLE_INTEGRATORS.items()
297
+ )
298
+ raise ValueError(
299
+ f"Integrator '{integrator}' not implemented. Available integrators:\n{available}"
300
+ )
301
+
302
+ def step(
303
+ self,
304
+ q: NDArray[np.float64],
305
+ p: NDArray[np.float64],
306
+ parameters: Union[None, Sequence[float], NDArray[np.float64]] = None,
307
+ ) -> NDArray[np.float64]:
308
+ """
309
+ Advance the system by one integration step.
310
+
311
+ Parameters
312
+ ----------
313
+ q : ndarray
314
+ Initial generalized coordinates (shape: (dof,) or (N, dof)).
315
+ p : ndarray
316
+ Initial generalized momenta (same shape as `q`).
317
+ parameters : array-like, optional
318
+ System parameters, if required.
319
+
320
+ Returns
321
+ -------
322
+ q_new, p_new : tuple of ndarray
323
+ Updated coordinates and momenta after one integration step.
324
+
325
+ Raises
326
+ ------
327
+ ValueError
328
+ If `q` and `p` have mismatched shapes.
329
+ TypeError
330
+ If inputs are not scalar or array-like.
331
+ """
332
+ q = validate_initial_conditions(q, self.__degrees_of_freedom)
333
+ q = q.copy()
334
+ p = validate_initial_conditions(p, self.__degrees_of_freedom)
335
+ p = p.copy()
336
+
337
+ if q.ndim != p.ndim:
338
+ raise ValueError("q and p must have the same dimension and shape")
339
+
340
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
341
+
342
+ if q.ndim == 1:
343
+ q, p = self.__integrator_func(
344
+ q, p, self.__time_step, self.__grad_T, self.__grad_V
345
+ )
346
+ else:
347
+ num_ic = q.shape[0]
348
+ for i in range(num_ic):
349
+ q[i], p[i] = self.__integrator_func(
350
+ q[i], p[i], self.__time_step, self.__grad_T, self.__grad_V
351
+ )
352
+
353
+ return q, p
354
+
355
+ def trajectory(
356
+ self,
357
+ q: NDArray[np.float64],
358
+ p: NDArray[np.float64],
359
+ total_time: np.float64,
360
+ parameters: Union[None, Sequence[float], NDArray[np.float64]] = None,
361
+ ) -> NDArray[np.float64]:
362
+ """
363
+ Generate a trajectory for the system.
364
+
365
+ Parameters
366
+ ----------
367
+ q : ndarray
368
+ Initial coordinates.
369
+ p : ndarray
370
+ Initial momenta.
371
+ total_time : float
372
+ Total integration time.
373
+ parameters : array-like, optional
374
+ System parameters.
375
+
376
+ Returns
377
+ -------
378
+ trajectory : ndarray
379
+ Trajectory data with shape depending on whether ensemble or single
380
+ ICs are provided.
381
+
382
+ Raises
383
+ ------
384
+ ValueError
385
+ If `q` and `p` shapes mismatch or if `total_time` is negative.
386
+ """
387
+ q = validate_initial_conditions(q, self.__degrees_of_freedom)
388
+ q = q.copy()
389
+
390
+ p = validate_initial_conditions(p, self.__degrees_of_freedom)
391
+ p = p.copy()
392
+
393
+ if q.ndim != p.ndim:
394
+ raise ValueError("q and p must have the same dimension and shape")
395
+
396
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
397
+
398
+ validate_non_negative(total_time, "total time", type_=Real)
399
+
400
+ if q.ndim == 1:
401
+ trajectory_function = generate_trajectory
402
+ else:
403
+ trajectory_function = ensemble_trajectories
404
+
405
+ return trajectory_function(
406
+ q,
407
+ p,
408
+ total_time,
409
+ parameters,
410
+ self.__grad_T,
411
+ self.__grad_V,
412
+ self.__time_step,
413
+ self.__integrator_func,
414
+ )
415
+
416
+ def poincare_section(
417
+ self,
418
+ q: NDArray[np.float64],
419
+ p: NDArray[np.float64],
420
+ num_intersections: int,
421
+ parameters: Union[None, Sequence[float], NDArray[np.float64]] = None,
422
+ section_index: int = 0,
423
+ section_value: float = 0.0,
424
+ crossing: int = 1,
425
+ ) -> NDArray[np.float64]:
426
+ """
427
+ Compute a Poincaré section of the trajectory.
428
+
429
+ Parameters
430
+ ----------
431
+ q, p : ndarray
432
+ Initial coordinates and momenta.
433
+ total_time : float
434
+ Total simulation time.
435
+ parameters : array-like, optional
436
+ System parameters.
437
+ section_index : int, default=0
438
+ Index of the phase space coordinate for the section.
439
+ section_value : float, default=0.0
440
+ Value at which the section is taken.
441
+ crossing : {-1, 0, 1}, default=1
442
+ Direction of crossing:
443
+ -1 : downward
444
+ 0 : all crossings
445
+ 1 : upward
446
+
447
+ Returns
448
+ -------
449
+ section_points : ndarray
450
+ Points of the trajectory lying on the Poincaré section.
451
+
452
+ Raises
453
+ ------
454
+ ValueError
455
+ If shapes mismatch, if `section_index` is invalid,
456
+ or if `crossing` not in {-1, 0, 1}.
457
+ TypeError
458
+ If `section_value` is not numeric.
459
+ """
460
+ q = validate_initial_conditions(q, self.__degrees_of_freedom)
461
+ q = q.copy()
462
+
463
+ p = validate_initial_conditions(p, self.__degrees_of_freedom)
464
+ p = p.copy()
465
+
466
+ if q.ndim != p.ndim:
467
+ raise ValueError("q and p must have the same dimension and shape")
468
+
469
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
470
+
471
+ validate_non_negative(
472
+ num_intersections, "num_intersections time", type_=Integral
473
+ )
474
+
475
+ validate_non_negative(section_index, "section_index")
476
+ if section_index >= 2 * self.__degrees_of_freedom:
477
+ raise ValueError(
478
+ "section_index must be less or equal to the system dimension"
479
+ )
480
+
481
+ if not isinstance(section_value, Real):
482
+ raise TypeError("section_value must be a valid number")
483
+
484
+ if crossing not in [-1, 0, 1]:
485
+ raise ValueError(
486
+ "crossing must be either -1, 0, or 1, indicating downward, all crossings, and upward crossings, respectively"
487
+ )
488
+
489
+ if q.ndim == 1:
490
+ poincare_section_function = generate_poincare_section
491
+ else:
492
+ poincare_section_function = ensemble_poincare_section
493
+
494
+ return poincare_section_function(
495
+ q,
496
+ p,
497
+ num_intersections,
498
+ parameters,
499
+ self.__grad_T,
500
+ self.__grad_V,
501
+ self.__time_step,
502
+ self.__integrator_func,
503
+ section_index,
504
+ section_value,
505
+ crossing,
506
+ )
507
+
508
+ def lyapunov(
509
+ self,
510
+ q: NDArray[np.float64],
511
+ p: NDArray[np.float64],
512
+ total_time: np.float64,
513
+ parameters: Union[None, Sequence[float], NDArray[np.float64]] = None,
514
+ num_exponents: Optional[int] = None,
515
+ return_history: bool = False,
516
+ seed: int = 13,
517
+ log_base: np.float64 = np.e,
518
+ qr_interval: int = 1,
519
+ method: str = "QR",
520
+ ) -> NDArray[np.float64]:
521
+ """
522
+ Compute Lyapunov exponents.
523
+
524
+ Parameters
525
+ ----------
526
+ q, p : ndarray
527
+ Initial conditions (single orbit only).
528
+ total_time : float
529
+ Total integration time.
530
+ parameters : array-like, optional
531
+ System parameters.
532
+ num_exponents : int, optional
533
+ Number of exponents to compute (default: full spectrum).
534
+ return_history : bool, default=False
535
+ If True, return time evolution instead of final values.
536
+ seed : int, default=13
537
+ Random seed for initial deviation vectors.
538
+ log_base : float, default=e
539
+ Logarithm base for exponent calculation.
540
+ qr_interval : int, default=1
541
+ Interval for reorthonormalization.
542
+ method : {'QR', 'QR_HH'}, default='QR'
543
+ QR decomposition method.
544
+
545
+ Returns
546
+ -------
547
+ exponents : ndarray
548
+ Computed Lyapunov exponents.
549
+
550
+ Raises
551
+ ------
552
+ ValueError
553
+ If Hessians are missing, if inputs are invalid, or if base=1.
554
+ TypeError
555
+ If types are inconsistent with expectations.
556
+ """
557
+
558
+ if self.__hess_T is None or self.__hess_V is None:
559
+ raise ValueError(
560
+ "Hessian functions are required to compute the Lyapunov exponents"
561
+ )
562
+
563
+ q = validate_initial_conditions(
564
+ q, self.__degrees_of_freedom, allow_ensemble=False
565
+ )
566
+ q = q.copy()
567
+ p = validate_initial_conditions(
568
+ p, self.__degrees_of_freedom, allow_ensemble=False
569
+ )
570
+ p = p.copy()
571
+
572
+ validate_non_negative(total_time, "total time", type_=Real)
573
+
574
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
575
+
576
+ if num_exponents is None:
577
+ num_exponents = 2 * self.__degrees_of_freedom
578
+ elif num_exponents > 2 * self.__degrees_of_freedom or num_exponents < 1:
579
+ raise ValueError("num_exponents must be <= system_dimension")
580
+ else:
581
+ validate_non_negative(num_exponents, "num_exponents", Integral)
582
+
583
+ if not isinstance(return_history, bool):
584
+ raise TypeError("return_history must be True or False")
585
+
586
+ if not isinstance(seed, Integral):
587
+ raise TypeError("seed must be an integer")
588
+
589
+ validate_non_negative(log_base, "log_base", Real)
590
+ if log_base == 1:
591
+ raise ValueError("The logarithm function is not defined with base 1")
592
+
593
+ validate_non_negative(qr_interval, "qr_interval", Integral)
594
+
595
+ if not isinstance(method, str):
596
+ raise TypeError("method must be a string")
597
+
598
+ method = method.upper()
599
+ if method == "QR":
600
+ qr_func = qr
601
+ elif method == "QR_HH":
602
+ qr_func = householder_qr
603
+ else:
604
+ raise ValueError("method must be QR or QR_HH")
605
+
606
+ if num_exponents > 1:
607
+ result = lyapunov_spectrum(
608
+ q,
609
+ p,
610
+ total_time,
611
+ self.__time_step,
612
+ parameters,
613
+ self.__grad_T,
614
+ self.__grad_V,
615
+ self.__hess_T,
616
+ self.__hess_V,
617
+ num_exponents,
618
+ qr_interval,
619
+ return_history,
620
+ seed,
621
+ log_base,
622
+ qr_func,
623
+ self.__integrator_func,
624
+ self.__tangent_integrator_func,
625
+ )
626
+ else:
627
+ result = maximum_lyapunov_exponent(
628
+ q,
629
+ p,
630
+ total_time,
631
+ self.__time_step,
632
+ parameters,
633
+ self.__grad_T,
634
+ self.__grad_V,
635
+ self.__hess_T,
636
+ self.__hess_V,
637
+ return_history,
638
+ seed,
639
+ log_base,
640
+ self.__integrator_func,
641
+ self.__tangent_integrator_func,
642
+ )
643
+
644
+ if return_history:
645
+ return np.array(result)
646
+ else:
647
+ return np.array(result[0])
648
+
649
+ def SALI(
650
+ self,
651
+ q: NDArray[np.float64],
652
+ p: NDArray[np.float64],
653
+ total_time: float,
654
+ parameters: Union[None, Sequence[float], NDArray[np.float64]] = None,
655
+ return_history: bool = False,
656
+ seed: int = 13,
657
+ threshold: float = 1e-16,
658
+ ) -> NDArray[np.float64]:
659
+ """
660
+ Compute the Smaller Alignment Index (SALI).
661
+
662
+ SALI distinguishes between chaotic and regular motion by evolving two
663
+ deviation vectors and monitoring their alignment over time. In chaotic
664
+ systems, SALI tends exponentially to zero; in regular systems, it
665
+ stabilizes to a nonzero value.
666
+
667
+ Parameters
668
+ ----------
669
+ q, p : ndarray
670
+ Initial coordinates and momenta (1D arrays).
671
+ total_time : float
672
+ Total integration time.
673
+ parameters : array-like, optional
674
+ System parameters.
675
+ return_history : bool, default=False
676
+ If True, return SALI evolution over time.
677
+ seed : int, default=13
678
+ Random seed for initializing deviation vectors.
679
+ threshold : float, default=1e-8
680
+ Early termination threshold for SALI. If SALI ≤ threshold,
681
+ integration stops.
682
+
683
+ Returns
684
+ -------
685
+ sali : ndarray
686
+ - If `return_history=True`, array of shape (N, 2) with columns
687
+ [time, SALI].
688
+ - If `return_history=False`, array of shape (1, 2) with the final
689
+ [time, SALI].
690
+
691
+ Raises
692
+ ------
693
+ ValueError
694
+ If Hessians are missing, if `total_time` is negative, or if
695
+ `threshold` ≤ 0.
696
+ TypeError
697
+ If input types are invalid (e.g., non-numeric values).
698
+ """
699
+ if self.__hess_T is None or self.__hess_V is None:
700
+ raise ValueError(
701
+ "Hessian functions are required to compute the Lyapunov exponents"
702
+ )
703
+
704
+ q = validate_initial_conditions(
705
+ q, self.__degrees_of_freedom, allow_ensemble=False
706
+ )
707
+ q = q.copy()
708
+ p = validate_initial_conditions(
709
+ p, self.__degrees_of_freedom, allow_ensemble=False
710
+ )
711
+ p = p.copy()
712
+
713
+ validate_non_negative(total_time, "total time", type_=Real)
714
+
715
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
716
+
717
+ if not isinstance(return_history, bool):
718
+ raise TypeError("return_history must be True or False")
719
+
720
+ if not isinstance(seed, Integral):
721
+ raise TypeError("seed must be an integer")
722
+
723
+ validate_non_negative(threshold, "threshold", Real)
724
+
725
+ result = SALI(
726
+ q,
727
+ p,
728
+ total_time,
729
+ self.__time_step,
730
+ parameters,
731
+ self.__grad_T,
732
+ self.__grad_V,
733
+ self.__hess_T,
734
+ self.__hess_V,
735
+ return_history,
736
+ seed,
737
+ self.__integrator_func,
738
+ self.__tangent_integrator_func,
739
+ threshold,
740
+ )
741
+
742
+ if return_history:
743
+ return np.array(result)
744
+ else:
745
+ return result[0]
746
+
747
+ def LDI(
748
+ self,
749
+ q: NDArray[np.float64],
750
+ p: NDArray[np.float64],
751
+ total_time: float,
752
+ k: int,
753
+ parameters: Union[None, Sequence[float], NDArray[np.float64]] = None,
754
+ return_history: bool = False,
755
+ seed: int = 13,
756
+ threshold: float = 1e-16,
757
+ ) -> NDArray[np.float64]:
758
+ """
759
+ Compute the Linear Dependence Index (LDI).
760
+
761
+ LDI measures the linear dependence among `k` deviation vectors evolved
762
+ along a trajectory. It is computed from the product of singular values
763
+ of the deviation matrix. In chaotic systems, LDI tends rapidly to zero;
764
+ in regular systems, it remains bounded away from zero.
765
+
766
+ Parameters
767
+ ----------
768
+ q, p : ndarray
769
+ Initial coordinates and momenta (1D arrays).
770
+ total_time : float
771
+ Total integration time.
772
+ parameters : array-like, optional
773
+ System parameters.
774
+ k : int, default=2
775
+ Number of deviation vectors to evolve.
776
+ return_history : bool, default=False
777
+ If True, return LDI evolution over time.
778
+ seed : int, default=13
779
+ Random seed for initializing deviation vectors.
780
+ threshold : float, default=1e-8
781
+ Early termination threshold for LDI. If LDI ≤ threshold,
782
+ integration stops.
783
+
784
+ Returns
785
+ -------
786
+ ldi : ndarray
787
+ - If `return_history=True`, array of shape (N, 2) with columns
788
+ [time, LDI].
789
+ - If `return_history=False`, array of shape (1, 2) with the final
790
+ [time, LDI].
791
+
792
+ Raises
793
+ ------
794
+ ValueError
795
+ If Hessians are missing, if `k` ≤ 1, if `total_time` is negative,
796
+ or if `threshold` ≤ 0.
797
+ TypeError
798
+ If input types are invalid (e.g., `k` not an integer).
799
+ """
800
+ if self.__hess_T is None or self.__hess_V is None:
801
+ raise ValueError(
802
+ "Hessian functions are required to compute the Lyapunov exponents"
803
+ )
804
+
805
+ q = validate_initial_conditions(
806
+ q, self.__degrees_of_freedom, allow_ensemble=False
807
+ )
808
+ q = q.copy()
809
+ p = validate_initial_conditions(
810
+ p, self.__degrees_of_freedom, allow_ensemble=False
811
+ )
812
+ p = p.copy()
813
+
814
+ validate_non_negative(total_time, "total time", type_=Real)
815
+
816
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
817
+
818
+ validate_non_negative(k, "k", Integral)
819
+ if k <= 1 or k > 2 * self.__degrees_of_freedom:
820
+ raise ValueError("k must be 2 < k < system dimension")
821
+
822
+ if not isinstance(return_history, bool):
823
+ raise TypeError("return_history must be True or False")
824
+
825
+ if not isinstance(seed, Integral):
826
+ raise TypeError("seed must be an integer")
827
+
828
+ validate_non_negative(threshold, "threshold", Real)
829
+
830
+ result = LDI(
831
+ q,
832
+ p,
833
+ total_time,
834
+ self.__time_step,
835
+ parameters,
836
+ self.__grad_T,
837
+ self.__grad_V,
838
+ self.__hess_T,
839
+ self.__hess_V,
840
+ k,
841
+ return_history,
842
+ seed,
843
+ self.__integrator_func,
844
+ self.__tangent_integrator_func,
845
+ threshold,
846
+ )
847
+
848
+ if return_history:
849
+ return np.array(result)
850
+ else:
851
+ return result[0]
852
+
853
+ def GALI(
854
+ self,
855
+ q: NDArray[np.float64],
856
+ p: NDArray[np.float64],
857
+ total_time: float,
858
+ k: int,
859
+ parameters: Union[None, Sequence[float], NDArray[np.float64]] = None,
860
+ return_history: bool = False,
861
+ seed: int = 13,
862
+ threshold: float = 1e-16,
863
+ ) -> NDArray[np.float64]:
864
+ """
865
+ Compute the Generalized Alignment Index (GALI).
866
+
867
+ GALI extends SALI by considering the evolution of `k` deviation
868
+ vectors. It is defined as the volume of the parallelepiped formed by
869
+ the normalized deviation vectors (via the wedge product). In chaotic
870
+ systems, GALI decays exponentially; in regular systems, it follows a
871
+ power law or stabilizes.
872
+
873
+ Parameters
874
+ ----------
875
+ q, p : ndarray
876
+ Initial coordinates and momenta (1D arrays).
877
+ total_time : float
878
+ Total integration time.
879
+ parameters : array-like, optional
880
+ System parameters.
881
+ k : int, default=2
882
+ Number of deviation vectors to evolve.
883
+ return_history : bool, default=False
884
+ If True, return GALI evolution over time.
885
+ seed : int, default=13
886
+ Random seed for initializing deviation vectors.
887
+ threshold : float, default=1e-8
888
+ Early termination threshold for GALI. If GALI ≤ threshold,
889
+ integration stops.
890
+
891
+ Returns
892
+ -------
893
+ gali : ndarray
894
+ - If `return_history=True`, array of shape (N, 2) with columns
895
+ [time, GALI].
896
+ - If `return_history=False`, array of shape (1, 2) with the final
897
+ [time, GALI].
898
+
899
+ Raises
900
+ ------
901
+ ValueError
902
+ If Hessians are missing, if `k` ≤ 1, if `total_time` is negative,
903
+ or if `threshold` ≤ 0.
904
+ TypeError
905
+ If input types are invalid (e.g., `k` not an integer).
906
+ """
907
+ if self.__hess_T is None or self.__hess_V is None:
908
+ raise ValueError(
909
+ "Hessian functions are required to compute the Lyapunov exponents"
910
+ )
911
+
912
+ q = validate_initial_conditions(
913
+ q, self.__degrees_of_freedom, allow_ensemble=False
914
+ )
915
+ q = q.copy()
916
+ p = validate_initial_conditions(
917
+ p, self.__degrees_of_freedom, allow_ensemble=False
918
+ )
919
+ p = p.copy()
920
+
921
+ validate_non_negative(total_time, "total time", type_=Real)
922
+
923
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
924
+
925
+ validate_non_negative(k, "k", Integral)
926
+ if k <= 1 or k > 2 * self.__degrees_of_freedom:
927
+ raise ValueError("k must be 2 < k < system dimension")
928
+
929
+ if not isinstance(return_history, bool):
930
+ raise TypeError("return_history must be True or False")
931
+
932
+ if not isinstance(seed, Integral):
933
+ raise TypeError("seed must be an integer")
934
+
935
+ validate_non_negative(threshold, "threshold", Real)
936
+
937
+ result = GALI(
938
+ q,
939
+ p,
940
+ total_time,
941
+ self.__time_step,
942
+ parameters,
943
+ self.__grad_T,
944
+ self.__grad_V,
945
+ self.__hess_T,
946
+ self.__hess_V,
947
+ k,
948
+ return_history,
949
+ seed,
950
+ self.__integrator_func,
951
+ self.__tangent_integrator_func,
952
+ threshold,
953
+ )
954
+
955
+ if return_history:
956
+ return np.array(result)
957
+ else:
958
+ return result[0]
959
+
960
+ def recurrence_time_entropy(
961
+ self,
962
+ q: Union[NDArray[np.float64], Sequence[float]],
963
+ p: Union[NDArray[np.float64], Sequence[float]],
964
+ num_intersections: int,
965
+ parameters: Union[None, Sequence[float], NDArray[np.float64]] = None,
966
+ section_index: int = 0,
967
+ section_value: float = 0.0,
968
+ crossing: int = 1,
969
+ **kwargs,
970
+ ):
971
+ """
972
+ Compute the recurrence time entropy (RTE) for a Hamiltonian system.
973
+
974
+ Parameters
975
+ ----------
976
+ q, p : Union[NDArray[np.float64], Sequence[float]]
977
+ Initial coordinates and momenta (1D arrays).
978
+ num_intersections: int
979
+ Number of intersections to record in the Poincaré section.
980
+ parameters : array-like, optional
981
+ System parameters.
982
+ section_index : Optional[int]
983
+ Index of the coordinate to define the Poincaré section (0-based). Only used when map_type="PS".
984
+ section_value : Optional[float]
985
+ Value of the coordinate at which the section is defined. Only used when map_type="PS".
986
+ crossing : Optional[int]
987
+ Specifies the type of crossing to consider:
988
+ - 1 : positive crossing (from below to above section_value)
989
+ - -1 : negative crossing (from above to below section_value)
990
+ - 0 : all crossings
991
+ metric: {"supremum", "euclidean", "manhattan"}, default = "supremum"
992
+ Distance metric used for phase space reconstruction.
993
+ std_metric: {"supremum", "euclidean", "manhattan"}, default = "supremum"
994
+ Distance metric used for standard deviation calculation.
995
+ lmin: int, default = 1
996
+ Minimum line length to consider in recurrence quantification.
997
+ threshold: float, default = 0.1
998
+ Recurrence threshold(relative to data range).
999
+ threshold_std: bool, default = True
1000
+ Whether to scale threshold by data standard deviation.
1001
+ return_final_state: bool, default = False
1002
+ Whether to return the final system state in results.
1003
+ return_recmat: bool, default = False
1004
+ Whether to return the recurrence matrix.
1005
+ return_p: bool, default = False
1006
+ Whether to return white vertical line length distribution.
1007
+
1008
+ Returns
1009
+ -------
1010
+ Union[float, Tuple[float, NDArray[np.float64]]]
1011
+ - float: RTE value(base case)
1012
+ - Tuple: (RTE, white_line_distribution) if return_distribution = True
1013
+
1014
+ Raises
1015
+ ------
1016
+ ValueError
1017
+ - If `q` or `p` are not a 1D array matching the number of degrees of freedom.
1018
+ - If `parameters` is not `None` and does not match the expected number of parameters.
1019
+ - If `parameters` is `None` but the system expects parameters.
1020
+ - If `parameters` is a scalar or array-like but not 1D.
1021
+ - If `section_index` is negative or ≥ system dimension.
1022
+ - If `crossing` is not one of {-1, 0, 1}.
1023
+ TypeError
1024
+ - If `q` or `p` are not a scalar or array-like type.
1025
+ - If `parameters` is not a scalar or array-like type.
1026
+ - If `section_value` is not a real.
1027
+ - If `crossing` is not an integer.
1028
+ - If `sampling_time` is not a real number.
1029
+
1030
+ Notes
1031
+ -----
1032
+ - Higher RTE indicates more complex dynamics
1033
+ - Set min_recurrence_time = 2 to ignore single-point recurrences
1034
+ - Implementation follows [1]
1035
+
1036
+ References
1037
+ ----------
1038
+ [1] Sales et al., Chaos 33, 033140 (2023)
1039
+ """
1040
+ q = validate_initial_conditions(
1041
+ q, self.__degrees_of_freedom, allow_ensemble=False
1042
+ )
1043
+ q = q.copy()
1044
+ p = validate_initial_conditions(
1045
+ p, self.__degrees_of_freedom, allow_ensemble=False
1046
+ )
1047
+ p = p.copy()
1048
+
1049
+ validate_non_negative(num_intersections, "num_intersections", type_=Real)
1050
+
1051
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
1052
+
1053
+ validate_non_negative(section_index, "section_index")
1054
+ if section_index >= 2 * self.__degrees_of_freedom:
1055
+ raise ValueError(
1056
+ "section_index must be less or equal to the system dimension"
1057
+ )
1058
+
1059
+ if not isinstance(section_value, Real):
1060
+ raise TypeError("section_value must be a valid number")
1061
+
1062
+ if crossing not in [-1, 0, 1]:
1063
+ raise ValueError(
1064
+ "crossing must be either -1, 0, or 1, indicating downward, all crossings, and upward crossings, respectively"
1065
+ )
1066
+
1067
+ return recurrence_time_entropy(
1068
+ q,
1069
+ p,
1070
+ num_intersections,
1071
+ parameters,
1072
+ self.__grad_T,
1073
+ self.__grad_V,
1074
+ self.__time_step,
1075
+ self.__integrator_func,
1076
+ section_index,
1077
+ section_value,
1078
+ crossing,
1079
+ **kwargs,
1080
+ )
1081
+
1082
+ def hurst_exponent(
1083
+ self,
1084
+ q: Union[NDArray[np.float64], Sequence[float]],
1085
+ p: Union[NDArray[np.float64], Sequence[float]],
1086
+ num_intersections: int,
1087
+ parameters: Union[None, Sequence[float], NDArray[np.float64]] = None,
1088
+ transient_time: Optional[float] = None,
1089
+ wmin: int = 2,
1090
+ section_index: int = 0,
1091
+ section_value: float = 0.0,
1092
+ crossing: int = 1,
1093
+ ) -> Union[float, Tuple[float, NDArray[np.float64]]]:
1094
+ """
1095
+ Estimate the Hurst exponent for a system trajectory using the rescaled range (R/S) method.
1096
+
1097
+ Parameters
1098
+ ----------
1099
+ u : NDArray[np.float64]
1100
+ Initial condition vector of shape (n,).
1101
+ parameters : Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
1102
+ Parameters passed to the mapping function.
1103
+ total_time : int
1104
+ Total number of iterations used to generate the trajectory.
1105
+ transient_time : Optional[int], optional
1106
+ Number of initial iterations to discard as transient. If `None`, no transient is removed. Default is `None`.
1107
+ wmin : int, optional
1108
+ Minimum window size for the rescaled range calculation. Default is 2.
1109
+ section_index : Optional[int]
1110
+ Index of the coordinate to define the Poincaré section (0-based). Only used when map_type="PS".
1111
+ section_value : Optional[float]
1112
+ Value of the coordinate at which the section is defined. Only used when map_type="PS".
1113
+ crossing : Optional[int]
1114
+ Specifies the type of crossing to consider:
1115
+ - 1 : positive crossing (from below to above section_value)
1116
+ - -1 : negative crossing (from above to below section_value)
1117
+ - 0 : all crossings
1118
+
1119
+ Returns
1120
+ -------
1121
+ NDArray[np.float64]
1122
+ Estimated Hurst exponents for each dimension of the system (2 * dof).
1123
+
1124
+ Raises
1125
+ ------
1126
+ TypeError
1127
+ - If `map_type` is not a string.
1128
+ - If `section_value` is not a real number.
1129
+ - If `crossing` is not an integer.
1130
+ ValueError
1131
+ - If `section_index` is negative or ≥ system dimension.
1132
+ - If `crossing` is not in {-1, 0, 1}.
1133
+ - If `wmin` is less than 2 or greater than or equal to `num_intersections // 2`.
1134
+
1135
+ Notes
1136
+ -----
1137
+ The Hurst exponent is a measure of the long-term memory of a time series:
1138
+
1139
+ - H = 0.5 indicates a random walk (no memory).
1140
+ - H > 0.5 indicates persistent behavior (positive autocorrelation).
1141
+ - H < 0.5 indicates anti-persistent behavior (negative autocorrelation).
1142
+
1143
+ This implementation computes the rescaled range (R/S) for various window sizes and
1144
+ performs a linear regression in log-log space to estimate the exponent.
1145
+
1146
+ The function supports multivariate time series, estimating one Hurst exponent per dimension.
1147
+ """
1148
+ q = validate_initial_conditions(
1149
+ q, self.__degrees_of_freedom, allow_ensemble=False
1150
+ )
1151
+ q = q.copy()
1152
+ p = validate_initial_conditions(
1153
+ p, self.__degrees_of_freedom, allow_ensemble=False
1154
+ )
1155
+ p = p.copy()
1156
+
1157
+ validate_non_negative(num_intersections, "num_intersections", type_=Real)
1158
+
1159
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
1160
+
1161
+ validate_non_negative(section_index, "section_index")
1162
+ if section_index >= 2 * self.__degrees_of_freedom:
1163
+ raise ValueError(
1164
+ "section_index must be less or equal to the system dimension"
1165
+ )
1166
+
1167
+ if not isinstance(section_value, Real):
1168
+ raise TypeError("section_value must be a valid number")
1169
+
1170
+ if crossing not in [-1, 0, 1]:
1171
+ raise ValueError(
1172
+ "crossing must be either -1, 0, or 1, indicating downward, all crossings, and upward crossings, respectively"
1173
+ )
1174
+
1175
+ if wmin < 2 or wmin >= num_intersections // 2:
1176
+ raise ValueError(
1177
+ f"`wmin` must be an integer >= 2 and <= total_time / 2. Got {wmin}."
1178
+ )
1179
+
1180
+ return hurst_exponent_wrapped(
1181
+ q,
1182
+ p,
1183
+ num_intersections,
1184
+ parameters,
1185
+ self.__grad_T,
1186
+ self.__grad_V,
1187
+ self.__time_step,
1188
+ self.__integrator_func,
1189
+ section_index,
1190
+ section_value,
1191
+ crossing,
1192
+ wmin,
1193
+ )