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,3391 @@
1
+ # discrete_dynamical_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
+
19
+ import numpy as np
20
+ from numbers import Integral, Real
21
+ from typing import Optional, Tuple, Union, Callable, List, Dict, Sequence, Any
22
+ from numpy.typing import NDArray
23
+
24
+ from pynamicalsys.common.recurrence_quantification_analysis import RTEConfig
25
+ from pynamicalsys.discrete_time.dynamical_indicators import (
26
+ lyapunov_er,
27
+ lyapunov_qr,
28
+ finite_time_lyapunov,
29
+ lyapunov_1D,
30
+ SALI,
31
+ LDI_k,
32
+ lagrangian_descriptors,
33
+ dig,
34
+ hurst_exponent,
35
+ finite_time_hurst_exponent,
36
+ RTE,
37
+ finite_time_RTE,
38
+ )
39
+ from pynamicalsys.discrete_time.models import (
40
+ standard_map,
41
+ standard_map_backwards,
42
+ standard_map_jacobian,
43
+ unbounded_standard_map,
44
+ henon_map,
45
+ henon_map_jacobian,
46
+ logistic_map,
47
+ logistic_map_jacobian,
48
+ standard_nontwist_map,
49
+ standard_nontwist_map_backwards,
50
+ standard_nontwist_map_jacobian,
51
+ extended_standard_nontwist_map,
52
+ extended_standard_nontwist_map_backwards,
53
+ extended_standard_nontwist_map_jacobian,
54
+ leonel_map,
55
+ leonel_map_jacobian,
56
+ leonel_map_backwards,
57
+ symplectic_map_4D,
58
+ symplectic_map_4D_backwards,
59
+ symplectic_map_4D_jacobian,
60
+ lozi_map,
61
+ lozi_map_jacobian,
62
+ rulkov_map,
63
+ rulkov_map_jacobian,
64
+ )
65
+ from pynamicalsys.discrete_time.trajectory_analysis import (
66
+ generate_trajectory,
67
+ ensemble_trajectories,
68
+ bifurcation_diagram,
69
+ period_counter,
70
+ escape_basin_and_time_entering,
71
+ escape_time_exiting,
72
+ survival_probability,
73
+ find_periodic_orbit,
74
+ find_periodic_orbit_symmetry_line,
75
+ eigenvalues_and_eigenvectors,
76
+ classify_stability,
77
+ calculate_manifolds,
78
+ rotation_number,
79
+ iterate_mapping,
80
+ ensemble_time_average,
81
+ )
82
+ from pynamicalsys.discrete_time.transport import (
83
+ diffusion_coefficient,
84
+ average_vs_time,
85
+ root_mean_squared,
86
+ mean_squared_displacement,
87
+ recurrence_times,
88
+ cumulative_average_vs_time,
89
+ )
90
+
91
+ from pynamicalsys.common.utils import finite_difference_jacobian, householder_qr
92
+
93
+ from .time_series_metrics import TimeSeriesMetrics as tsm
94
+
95
+ from pynamicalsys.discrete_time.validators import (
96
+ validate_initial_conditions,
97
+ validate_parameters,
98
+ validate_non_negative,
99
+ validate_transient_time,
100
+ validate_and_convert_param_range,
101
+ validate_positive,
102
+ validate_sample_times,
103
+ validate_axis,
104
+ validate_finite_time,
105
+ )
106
+
107
+
108
+ class DiscreteDynamicalSystem:
109
+ """Class representing a discrete dynamical system with various models and methods for analysis.
110
+
111
+ This class allows users to work with predefined dynamical models or custom mappings,
112
+ compute trajectories, bifurcation diagrams, periods, and perform various dynamical analyses.
113
+ It supports both single initial conditions and ensembles of initial conditions, providing
114
+ methods for generating trajectories, computing bifurcation diagrams, and analyzing stability.
115
+
116
+ Parameters
117
+ ----------
118
+ model : str, optional
119
+ Name of the predefined model to use (e.g., "henon map"). If provided, overrides custom mappings.
120
+ mapping : callable, optional
121
+ Custom mapping function with signature f(u, parameters) -> array_like.
122
+ If provided, model must be None.
123
+ jacobian : callable, optional
124
+ Custom Jacobian function with signature J(u, parameters, *args) -> array_like.
125
+ If provided, must be compatible with the mapping function.
126
+ backwards_mapping : callable, optional
127
+ Custom inverse mapping function with signature f_inv(u, parameters) -> array_like.
128
+ If provided, must be compatible with the mapping function.
129
+ system_dimension : int, optional
130
+ Dimension of the system (number of variables in the mapping).
131
+ Required if using custom mappings without a predefined model.
132
+
133
+ Raises
134
+ ------
135
+ ValueError
136
+ - If neither model nor mapping is provided, or if provided model name is not implemented.
137
+ - If mapping is provided without jacobian for models requiring it.
138
+
139
+ TypeError
140
+ - If provided mapping or jacobian is not callable.
141
+
142
+ Notes
143
+ -----
144
+ - When providing custom functions, either provide both mapping and jacobian,
145
+ or just mapping (in which case finite differences will be used for Jacobian)
146
+ - When providing custom functions, the mapping function signature should be f(u, parameters) -> NDArray[np.float64]
147
+ - The class supports various predefined models such as the standard map, Hénon map, logistic map, and others.
148
+ - The available models can be queried using the `available_models` class method.
149
+
150
+ Examples
151
+ --------
152
+ >>> # Using predefined model
153
+ >>> system = DiscreteDynamicalSystem(model="henon map")
154
+ >>> # Using custom mappings
155
+ >>> def my_map(u, parameters):
156
+ ... return np.array([u[0] + parameters[0] * u[1], u[1] - parameters[1] * u[0]])
157
+ >>> def my_jacobian(u, parameters):
158
+ ... return np.array([[1, parameters[0]], [-parameters[1], 1]])
159
+ >>> system = DiscreteDynamicalSystem(
160
+ mapping=my_map,
161
+ jacobian=my_jacobian,
162
+ system_dimension=2,
163
+ number_of_parameters=2
164
+ )
165
+ """
166
+
167
+ # Class-level constant defining all available models
168
+ __AVAILABLE_MODELS: Dict[str, Dict[str, Any]] = {
169
+ "standard map": {
170
+ "description": "Standard Chirikov-Taylor map (area-preserving 2D)",
171
+ "has_jacobian": True,
172
+ "has_backwards_map": True,
173
+ "mapping": standard_map,
174
+ "jacobian": standard_map_jacobian,
175
+ "backwards_mapping": standard_map_backwards,
176
+ "dimension": 2,
177
+ "number_of_parameters": 1,
178
+ "parameters": ["k"],
179
+ },
180
+ "unbounded standard map": {
181
+ "description": "Standard Chirikov-Taylor map withou boundaries on the y varibles. Useful to study diffusion",
182
+ "has_jacobian": False,
183
+ "has_backwards_map": False,
184
+ "mapping": unbounded_standard_map,
185
+ "jacobian": None,
186
+ "backwards_mapping": None,
187
+ "dimension": 2,
188
+ "number_of_parameters": 1,
189
+ "parameters": ["k"],
190
+ },
191
+ "henon map": {
192
+ "description": "Hénon quadratic map",
193
+ "has_jacobian": True,
194
+ "has_backwards_map": False,
195
+ "mapping": henon_map,
196
+ "jacobian": henon_map_jacobian,
197
+ "backwards_mapping": None,
198
+ "dimension": 2,
199
+ "number_of_parameters": 2,
200
+ "parameters": ["a", "b"],
201
+ },
202
+ "lozi map": {
203
+ "description": "Lozi map",
204
+ "has_jacobian": True,
205
+ "has_backwards_map": False,
206
+ "mapping": lozi_map,
207
+ "jacobian": lozi_map_jacobian,
208
+ "backwards_mapping": None,
209
+ "dimension": 2,
210
+ "number_of_parameters": 2,
211
+ "parameters": ["a", "b"],
212
+ },
213
+ "rulkov map": {
214
+ "description": "Rulkov map",
215
+ "has_jacobian": True,
216
+ "has_backwards_map": False,
217
+ "mapping": rulkov_map,
218
+ "jacobian": rulkov_map_jacobian,
219
+ "backwards_mapping": None,
220
+ "dimension": 2,
221
+ "number_of_parameters": 3,
222
+ "parameters": ["alpha", "sigma", "mu"],
223
+ },
224
+ "logistic map": {
225
+ "description": "Logistic map (1D nonlinear system)",
226
+ "has_jacobian": True,
227
+ "has_backwards_map": False,
228
+ "mapping": logistic_map,
229
+ "jacobian": logistic_map_jacobian,
230
+ "backwards_mapping": None,
231
+ "dimension": 1,
232
+ "number_of_parameters": 1,
233
+ "parameters": ["r"],
234
+ },
235
+ "standard nontwist map": {
236
+ "description": "Standard nontwist map (area-preserving but violates twist condition)",
237
+ "has_jacobian": True,
238
+ "has_backwards_map": True,
239
+ "mapping": standard_nontwist_map,
240
+ "jacobian": standard_nontwist_map_jacobian,
241
+ "backwards_mapping": standard_nontwist_map_backwards,
242
+ "dimension": 2,
243
+ "number_of_parameters": 2,
244
+ "parameters": ["a", "b"],
245
+ },
246
+ "extended standard nontwist map": {
247
+ "description": "Extended version of standard nontwist map",
248
+ "has_jacobian": True,
249
+ "has_backwards_map": True,
250
+ "mapping": extended_standard_nontwist_map,
251
+ "jacobian": extended_standard_nontwist_map_jacobian,
252
+ "backwards_mapping": extended_standard_nontwist_map_backwards,
253
+ "dimension": 2,
254
+ "number_of_parameters": 4,
255
+ "parameters": ["a", "b", "c", "m"],
256
+ },
257
+ "leonel map": {
258
+ "description": "Leonel's map model",
259
+ "has_jacobian": True,
260
+ "has_backwards_map": True,
261
+ "mapping": leonel_map,
262
+ "jacobian": leonel_map_jacobian,
263
+ "backwards_mapping": leonel_map_backwards,
264
+ "dimension": 2,
265
+ "number_of_parameters": 2,
266
+ "parameters": ["eps", "gamma"],
267
+ },
268
+ "4d symplectic map": {
269
+ "description": "4D symplectic map: two coupled standard maps",
270
+ "has_jacobian": True,
271
+ "has_backwards_map": True,
272
+ "mapping": symplectic_map_4D,
273
+ "jacobian": symplectic_map_4D_jacobian,
274
+ "backwards_mapping": symplectic_map_4D_backwards,
275
+ "dimension": 4,
276
+ "number_of_parameters": 3,
277
+ "parameters": ["eps1", "eps2", "xi"],
278
+ },
279
+ }
280
+
281
+ def __init__(
282
+ self,
283
+ model: Optional[str] = None,
284
+ mapping: Optional[Callable] = None,
285
+ jacobian: Optional[Callable] = None,
286
+ backwards_mapping: Optional[Callable] = None,
287
+ system_dimension: Optional[int] = None,
288
+ number_of_parameters: Optional[int] = None,
289
+ ) -> None:
290
+ """Initialize the discrete dynamical system with either a predefined model or custom mappings.
291
+
292
+ Parameters
293
+ ----------
294
+ model : str, optional
295
+ Name of the predefined model to use.
296
+ mapping : callable, optional
297
+ Custom mapping function with signature f(u, parameters) -> array_like
298
+ jacobian : callable, optional
299
+ Custom Jacobian function with signature J(u, parameters, *args) -> array_like
300
+ backwards_mapping : callable, optional
301
+ Custom inverse mapping function with signature f_inv(u, parameters) -> array_like
302
+ system_dimension : int, optional
303
+ Dimension of the system (number of variables in the mapping).
304
+
305
+ Raises
306
+ ------
307
+ ValueError
308
+ - If neither model nor mapping is provided.
309
+ - If both model or mapping are provided.
310
+ - If provided model name is not implemented.
311
+
312
+ Notes
313
+ -----
314
+ - When providing custom functions, either provide both mapping and jacobian,
315
+ or just mapping (in which case finite differences will be used for Jacobian)
316
+ - When providing custom functions, the mapping function signature should be f(u, parameters) -> NDArray[np.float64]
317
+
318
+ Examples
319
+ --------
320
+ >>> # Using predefined model
321
+ >>> system = DynamicalSystem(model="henon_map")
322
+ >>> # Using custom mappings
323
+ >>> system = DynamicalSystem(mapping=my_map, jacobian=my_jacobian, system_dimension=dim)
324
+ """
325
+
326
+ if model is not None and mapping is not None:
327
+ raise ValueError("Cannot specify both model and custom mapping")
328
+
329
+ if model is not None:
330
+ model = model.lower()
331
+ if model not in self.__AVAILABLE_MODELS:
332
+ available = "\n".join(
333
+ f"- {name}: {info['description']}"
334
+ for name, info in self.__AVAILABLE_MODELS.items()
335
+ )
336
+ raise ValueError(
337
+ f"Model '{model}' not implemented. Available models:\n{available}"
338
+ )
339
+
340
+ model_info = self.__AVAILABLE_MODELS[model]
341
+ self.__model = model
342
+ self.__mapping = model_info["mapping"]
343
+ self.__jacobian = model_info["jacobian"]
344
+ self.__backwards_mapping = model_info["backwards_mapping"]
345
+ self.__system_dimension = model_info["dimension"]
346
+ self.__number_of_parameters = model_info["number_of_parameters"]
347
+
348
+ if jacobian is not None: # Allow override of default Jacobian
349
+ self.__jacobian = jacobian
350
+
351
+ if backwards_mapping is not None: # Allow override of default backwards map
352
+ self.__backwards_mapping = backwards_mapping
353
+
354
+ elif (
355
+ mapping is not None
356
+ and system_dimension is not None
357
+ and number_of_parameters is not None
358
+ ):
359
+ self.__mapping = mapping
360
+ self.__jacobian = (
361
+ jacobian if jacobian is not None else finite_difference_jacobian
362
+ )
363
+ self.__backwards_mapping = backwards_mapping
364
+
365
+ validate_non_negative(system_dimension, "system_dimension", Integral)
366
+ validate_non_negative(
367
+ number_of_parameters, "number_of_parameters", Integral
368
+ )
369
+
370
+ self.__system_dimension = system_dimension
371
+ self.__number_of_parameters = number_of_parameters
372
+
373
+ # Validate custom functions
374
+ if not callable(self.__mapping):
375
+ raise TypeError("Custom mapping must be callable")
376
+
377
+ if self.__jacobian is not None and not callable(self.__jacobian):
378
+ raise TypeError("Custom Jacobian must be callable or None")
379
+
380
+ if self.__backwards_mapping is not None and not callable(
381
+ self.__backwards_mapping
382
+ ):
383
+ raise TypeError("Custom backwards mapping must be callable or None")
384
+
385
+ else:
386
+ raise ValueError(
387
+ "Must specify either a model name or custom mapping function with its dimension and number of paramters."
388
+ )
389
+
390
+ @classmethod
391
+ def available_models(cls) -> List[str]:
392
+ """Return a list of available models."""
393
+ return list(cls.__AVAILABLE_MODELS.keys())
394
+
395
+ @property
396
+ def info(self) -> Dict[str, Any]:
397
+ """Return a dictionary with information about the current model."""
398
+
399
+ if self.__model is None:
400
+ raise ValueError(
401
+ "The 'info' property is only available when a model is provided."
402
+ )
403
+
404
+ model = self.__model.lower()
405
+
406
+ return self.__AVAILABLE_MODELS[model]
407
+
408
+ def trajectory(
409
+ self,
410
+ u: Union[NDArray[np.float64], Sequence[float], float],
411
+ total_time: int,
412
+ parameters: Union[
413
+ None, float, Sequence[np.float64], NDArray[np.float64]
414
+ ] = None,
415
+ transient_time: Optional[int] = None,
416
+ ) -> NDArray[np.float64]:
417
+ """Generate trajectory for either single initial condition or ensemble of initial conditions.
418
+
419
+ Automatically dispatches to appropriate implementation based on input dimensionality.
420
+ For ensembles, trajectories are concatenated along time dimension for efficient storage.
421
+
422
+ Parameters
423
+ ----------
424
+ u : Union[NDArray[np.float64], Sequence[float]]
425
+ Initial condition(s):
426
+ - Single IC: 1D array of shape (d,) where d is system dimension
427
+ - Ensemble: 2D array of shape (n, d) for n initial conditions
428
+ - Also accepts sequence types that will be converted to numpy arrays
429
+ - Scalar (will be converted to 1D array)
430
+ total_time : int
431
+ Total number of iterations to compute
432
+ parameters : Union[NDArray[np.float64], Sequence[float], float], optional
433
+ Parameters of the dynamical system, shape (p,) where p is number of parameters
434
+ transient_time : Optional[int], optional
435
+ Number of initial iterations to discard as transient, by default None
436
+ If provided, must be less than total_time
437
+
438
+ Returns
439
+ -------
440
+ NDArray[np.float64]
441
+ Time series array:
442
+
443
+ - Single IC: shape (sample_size, d)
444
+ - Ensemble: shape (sample_size * n, d) where sample_size = total_time - (transient_time or 0)
445
+
446
+ Raises
447
+ ------
448
+ ValueError
449
+ - If `u` is not a scalar, 1D, or 2D array, or if its shape does not match the expected system dimension.
450
+ - If `u` is a 1D array but its length does not match the system dimension, or if `u` is a 2D array but does not match the expected shape for an ensemble.
451
+ - If `parameters` is not None and does not match the expected number of parameters.
452
+ - If `parameters` is None but the system expects parameters.
453
+ - If `parameters` is a scalar or array-like but not 1D.
454
+ - If `total_time` is negative.
455
+ - If `trasient_time` is negative.
456
+ - If `transient_time` is greater than or equal to total_time.
457
+
458
+ TypeError
459
+ - If `u` is not a scalar or array-like type.
460
+ - If `parameters` is not a scalar or array-like type.
461
+ - If `total_time` is not int.
462
+ - If `transient_time` is not int.
463
+
464
+ Notes
465
+ -----
466
+ - For ensembles, use reshape() to separate trajectories: result.reshape(n, sample_size, d)
467
+
468
+ Examples
469
+ --------
470
+ >>> # Single initial condition
471
+ >>> u0 = np.array([0.1, 0.2])
472
+ >>> ts = system.trajectory(u0, 5000, parameters=[0.5, 1.0])
473
+ >>> ts.shape # (5000, 2)
474
+
475
+ >>> # Ensemble of 100 initial conditions
476
+ >>> ics = np.random.rand(100, 2)
477
+ >>> ts = system.trajectory(ics, 10000, parameters=[1.0, 0.1], transient_time=1000)
478
+ >>> separated = ts.reshape(100, 9000, 2) # 9000 = 10000-1000
479
+ """
480
+
481
+ u = validate_initial_conditions(u, self.__system_dimension, allow_ensemble=True)
482
+
483
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
484
+
485
+ validate_non_negative(total_time, "total_time", Integral)
486
+ validate_transient_time(transient_time, total_time, type_=Integral)
487
+
488
+ if u.ndim == 1:
489
+ return generate_trajectory(
490
+ u, parameters, total_time, self.__mapping, transient_time=transient_time
491
+ )
492
+ else:
493
+ return ensemble_trajectories(
494
+ u, parameters, total_time, self.__mapping, transient_time=transient_time
495
+ )
496
+
497
+ def bifurcation_diagram(
498
+ self,
499
+ u: Union[NDArray[np.float64], Sequence[float], float],
500
+ param_index: int,
501
+ param_range: Union[NDArray[np.float64], Tuple[float, float, int]],
502
+ total_time: int,
503
+ parameters: Optional[NDArray[np.float64]] = None,
504
+ transient_time: Optional[int] = None,
505
+ continuation: bool = False,
506
+ return_last_state: bool = False,
507
+ observable_index: int = 0,
508
+ ) -> Union[
509
+ Tuple[NDArray[np.float64], NDArray[np.float64]],
510
+ Tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]],
511
+ ]:
512
+ """Compute bifurcation diagram by varying a specified parameter.
513
+
514
+ Parameters
515
+ ----------
516
+ u : Union[NDArray[np.float64], Sequence[float]]
517
+ Initial condition vector. Can be:
518
+ - 1D numpy array of shape (d,) where d is system dimension
519
+ - Sequence that can be converted to numpy array
520
+ - Scalar (will be converted to 1D array)
521
+ parameters : Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
522
+ Base parameter array of shape (p,)
523
+ param_index : int
524
+ Index of parameter to vary in parameters array (0 <= param_index < p)
525
+ param_range : Union[NDArray[np.float64], Tuple[float, float, int]]
526
+ Parameter range specification, either:
527
+ - Precomputed 1D array of parameter values, or
528
+ - Tuple (start, stop, num_points) for linear spacing
529
+ total_time : int, optional
530
+ Total iterations per parameter value, by default 10000
531
+ transient_time : Optional[int], optional
532
+ Burn-in iterations to discard (default: 20% of total_time)
533
+ continuation: bool, optional
534
+ Whether to perform a continuation sweep, i.e., the initial condition for the next parameter value is the last state of the previous parameter.
535
+ return_last_state: bool, optional
536
+ Whether to return the last state at the last parameter value.
537
+ observable_index: int, optional
538
+ Defines the coordinate to be used in the bifurcation diagram (default = 0).
539
+
540
+ Returns
541
+ -------
542
+ Tuple[NDArray[np.float64], NDArray[np.float64]]
543
+ Tuple containing:
544
+
545
+ - parameter_values: 1D array of varied parameter values
546
+ - observables: 1D array of observable values (after transients)
547
+
548
+ Raises
549
+ ------
550
+ ValueError
551
+ - If `u` is not a scalar, or 1D array, or if its shape does not match the expected system dimension.
552
+ - If `parameters` is not None and does not match the expected number of parameters.
553
+ - If `parameters` is None but the system expects parameters.
554
+ - If `parameters` is a scalar or array-like but not 1D.
555
+ - If `total_time` is negative.
556
+ - If `trasient_time` is negative.
557
+ - If `transient_time` is greater than or equal to total_time.
558
+ - If `param_index` is negative or out of bounds for the number of parameters.
559
+ - If `observable_index` is negative or out of bounds for the system dimension.
560
+ TypeError
561
+ - If `u` is not a scalar or array-like type.
562
+ - If `parameters` is not a scalar or array-like type.
563
+ - If `total_time` is not int.
564
+ - If `transient_time` is not int.
565
+
566
+ Notes
567
+ -----
568
+ - Uses Numba-optimized bifurcation_diagram function
569
+ - For large total_time, consider using a smaller transient_time
570
+ - The observable function should be vectorized for best performance
571
+
572
+ Examples
573
+ --------
574
+ >>> # Basic usage with precomputed parameter range
575
+ >>> param_range = np.linspace(0.5, 1.5, 100)
576
+ >>> u0 = np.array([0.1, 0.1])
577
+ >>> param_vals, obs = sys.bifurcation_diagram(
578
+ ... u0, 0, param_range, 5000, parameters=[0.5, 1.0])
579
+
580
+ >>> # With tuple parameter range
581
+ >>> param_range = (0.5, 1.5, 100)
582
+ >>> param_vals, obs = sys.bifurcation_diagram(
583
+ ... u0, 0, param_range, 5000, parameters=[0.5, 1.0])
584
+
585
+ """
586
+
587
+ u = validate_initial_conditions(
588
+ u, self.__system_dimension, allow_ensemble=False
589
+ )
590
+
591
+ parameters = validate_parameters(parameters, self.__number_of_parameters - 1)
592
+
593
+ validate_non_negative(param_index, "param_index", Integral)
594
+ if param_index >= self.__number_of_parameters:
595
+ raise ValueError(
596
+ f"param_index {param_index} out of bounds for system with {self.__number_of_parameters} parameters"
597
+ )
598
+
599
+ parameters = np.insert(parameters, param_index, 0)
600
+
601
+ param_values = validate_and_convert_param_range(param_range)
602
+
603
+ validate_non_negative(total_time, "total_time", Integral)
604
+ validate_transient_time(transient_time, total_time, Integral)
605
+
606
+ validate_non_negative(observable_index, "observable_index", Integral)
607
+ if observable_index >= self.__system_dimension:
608
+ raise ValueError(
609
+ f"observable_index {observable_index} out of bounds for system dimension {self.__system_dimension}"
610
+ )
611
+
612
+ def observable_fn(x):
613
+ return x[observable_index]
614
+
615
+ return bifurcation_diagram(
616
+ u=u,
617
+ parameters=parameters,
618
+ param_index=param_index,
619
+ param_range=param_values,
620
+ total_time=total_time,
621
+ mapping=self.__mapping,
622
+ transient_time=transient_time,
623
+ continuation=continuation,
624
+ return_last_state=return_last_state,
625
+ observable_fn=observable_fn,
626
+ )
627
+
628
+ def period(
629
+ self,
630
+ u: Union[NDArray[np.float64], Sequence[float], float],
631
+ max_time: int = 10000,
632
+ parameters: Union[
633
+ None, float, Sequence[np.float64], NDArray[np.float64]
634
+ ] = None,
635
+ transient_time: Optional[int] = None,
636
+ tolerance: float = 1e-10,
637
+ min_period: int = 1,
638
+ max_period: int = 1000,
639
+ stability_checks: int = 3,
640
+ ) -> int:
641
+ """Compute the period of a trajectory.
642
+
643
+ This function determines the smallest period p where the system satisfies:
644
+ ||x_{n+p} - x_n|| < tolerance for consecutive states after transients.
645
+
646
+ Parameters
647
+ ----------
648
+ u : Union[NDArray[np.float64], Sequence[float]], float
649
+ Initial condition of the system. Can be:
650
+ - 1D numpy array of shape (d,) where d is system dimension
651
+ - Sequence that can be converted to numpy array
652
+ - Scalar (will be converted to 1D array)
653
+ parameters : Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
654
+ Parameters of the dynamical system, shape (p,)
655
+ max_time : int, optional
656
+ Total number of iterations to compute, by default 10000
657
+ Must be sufficiently large to detect periodicity
658
+ transient_time : Optional[int], optional
659
+ Number of initial iterations to discard as transient, by default None
660
+ If None, uses 10% of total_time
661
+ tolerance : float, optional
662
+ Numerical tolerance for period detection (default: 1e-10)
663
+ min_period : int, optional
664
+ Minimum period to consider (default: 1)
665
+ max_period : int, optional
666
+ Maximum period to consider (default: 1000)
667
+ stability_checks : int, optional
668
+ Number of consecutive period matches required (default: 3)
669
+
670
+ Returns
671
+ -------
672
+ int
673
+ The detected period of the trajectory.
674
+
675
+ - A positive integer indicates a periodic orbit.
676
+ - -1 indicates aperiodic or chaotic behavior.
677
+ - 1 indicates a fixed point.
678
+
679
+ Raises
680
+ ------
681
+ ValueError
682
+ - If `u` is not a scalar, or 1D array, or if its shape does not match the expected system dimension.
683
+ - If `parameters` is not None and does not match the expected number of parameters.
684
+ - If `parameters` is None but the system expects parameters.
685
+ - If `parameters` is a scalar or array-like but not 1D.
686
+ - If `max_time` is negative.
687
+ - If `trasient_time` is negative.
688
+ - If `transient_time` is greater than or equal to total_time.
689
+ - If `min_period` is negative or zero.
690
+ - If `max_period` is negative or less than min_period.
691
+ - If `stability_checks` is not larger than 1.
692
+ - If `tolerance` is negative or zero.
693
+ TypeError
694
+ - If `u` is not a scalar or array-like type.
695
+ - If `parameters` is not a scalar or array-like type.
696
+ - If `max_time` is not int.
697
+ - If `transient_time` is not int.
698
+ - If `min_period` is not int.
699
+ - If `max_period` is not int.
700
+ - If `stability_checks` is not int.
701
+
702
+ Notes
703
+ -----
704
+ - For reliable results, max_time should be much larger than expected period
705
+ - Fixed points return period 1 (constant signal)
706
+ - Chaotic trajectories return period -1 (no significant autocorrelation peaks)
707
+
708
+ Examples
709
+ --------
710
+ >>> # Basic usage
711
+ >>> u0 = np.array([0.1, 0.2])
712
+ >>> params = np.array([0.5, 1.0])
713
+ >>> period = system.period(u0, params)
714
+
715
+ >>> # With custom time parameters
716
+ >>> period = system.period(
717
+ ... u0, params, total_time=5000, transient_time=500)
718
+ """
719
+
720
+ #  Validate initial condition
721
+ u = validate_initial_conditions(
722
+ u, self.__system_dimension, allow_ensemble=False
723
+ )
724
+
725
+ # Validate parameters
726
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
727
+
728
+ # Validate time parameters
729
+ validate_non_negative(max_time, "total_time", Integral)
730
+ validate_transient_time(transient_time, max_time, Integral)
731
+
732
+ # Validate min and max period
733
+ validate_positive(min_period, "min_period", Integral)
734
+ validate_positive(max_period, "max_period", Integral)
735
+
736
+ # Validate stability checks
737
+ if not isinstance(stability_checks, Integral) or stability_checks < 1:
738
+ raise ValueError("stability_checks must be positive integer")
739
+
740
+ # Validade tolerance
741
+ validate_non_negative(tolerance, "tolerance", Real)
742
+
743
+ return period_counter(
744
+ u=u,
745
+ parameters=parameters,
746
+ mapping=self.__mapping,
747
+ total_time=max_time,
748
+ transient_time=transient_time,
749
+ tolerance=tolerance,
750
+ min_period=min_period,
751
+ max_period=max_period,
752
+ stability_checks=stability_checks,
753
+ )
754
+
755
+ def find_periodic_orbit(
756
+ self,
757
+ grid_points: Union[NDArray[np.float64], Sequence[float]],
758
+ period: int,
759
+ parameters: Union[None, NDArray[np.float64], Sequence[float], float] = None,
760
+ tolerance: float = 1e-5,
761
+ max_iter: int = 1000,
762
+ convergence_threshold: float = 1e-15,
763
+ tolerance_decay_factor: float = 1 / 2,
764
+ verbose: bool = False,
765
+ symmetry_line: Optional[Callable] = None,
766
+ axis: Optional[int] = None,
767
+ ) -> NDArray[np.float64]:
768
+ """
769
+ Find a periodic orbit using iterative grid refinement.
770
+
771
+ Parameters
772
+ ----------
773
+ grid_points : np.ndarray
774
+ n
775
+ period : int
776
+ The period of the orbit to find. Must be ≥ 1.
777
+ parameters : Union[None, NDArray[np.float64], Sequence[float], float], optional
778
+ Array of system parameters (shape (p,)) or a scalar to be broadcasted.
779
+ tolerance : float, optional
780
+ Initial periodicity tolerance. Must be positive. Default is 1e-5.
781
+ max_iter : int, optional
782
+ Maximum number of refinement iterations. Must be ≥ 1. Default is 1000.
783
+ convergence_threshold : float, optional
784
+ Convergence threshold for both position and bounding box. Must be positive.
785
+ Default is 1e-15.
786
+ tolerance_decay_factor : float, optional
787
+ Factor by which to reduce tolerance each iteration. Must be in (0, 1).
788
+ Default is 0.25.
789
+ verbose : bool, optional
790
+ Whether to print iteration progress and convergence information. Default is False.
791
+ symmetry_line : Optional[Callable], optional
792
+ A callable function representing a symmetry line in the system. If provided,
793
+ the search will be restricted to points on this line.
794
+ axis : Optional[int], optional
795
+ Axis of symmetry line. Must be 0 (x-axis) or 1 (y-axis)
796
+
797
+ Returns
798
+ -------
799
+ np.ndarray
800
+ A 1D array of shape (2,) representing the coordinates of the found periodic orbit.
801
+
802
+ Raises
803
+ ------
804
+ ValueError
805
+ - If grid_points is not of shape (grid_size_x, grid_size_y, 2).
806
+ - If period is less than 1.
807
+ - If tolerance is not positive.
808
+ - If max_iter is not positive.
809
+ - If convergence_threshold is not positive.
810
+ - If tolerance_decay_factor is not in (0, 1).
811
+ - If symmetry_line is provided but axis is not specified.
812
+ - If symmetry_line is not callable.
813
+ - If axis is not 0 (x-axis) or 1 (y-axis).
814
+ - If system_dimension is not 2D.
815
+ - If grid_points is a scalar or not a 3D array when symmetry_line is None.
816
+
817
+ TypeError
818
+ - If grid_points is not a numpy array or a sequence that can be converted to a numpy array.
819
+ - If parameters is not None and not a numpy array or sequence that can be converted to a numpy array.
820
+ - If period is not an integer.
821
+ - If tolerance is not float.
822
+ - If max_iter is not int.
823
+ - If convergence_threshold is not float.
824
+ - If tolerance_decay_factor is not float.
825
+ - If axis is not an integer.
826
+
827
+ Notes
828
+ -----
829
+ This function wraps the core `find_periodic_orbit` function using the instance's mapping.
830
+ The underlying implementation performs a multiscale search for periodic points
831
+ through iterative refinement around previously found periodic locations.
832
+ """
833
+
834
+ if self.__system_dimension != 2:
835
+ raise ValueError("find_periodic_orbit is only implemented for 2D systems")
836
+
837
+ # Check if symmetry line is provided
838
+ if symmetry_line is not None and axis is None:
839
+ raise ValueError("axis must be provided when symmetry_line is specified")
840
+
841
+ # Check if symmetry line is valid
842
+ if symmetry_line is not None and not callable(symmetry_line):
843
+ raise ValueError("symmetry_line must be a callable function")
844
+
845
+ # Check if axis is valid
846
+ if axis is not None and axis not in [0, 1]:
847
+ raise ValueError("axis must be 0 (x-axis) or 1 (y-axis)")
848
+
849
+ if np.isscalar(grid_points):
850
+ raise ValueError(
851
+ "grid_points must be a 3D array with shape (grid_size_x, grid_size_y, 2) if symmetry_line is None and 1D array otherwise"
852
+ )
853
+
854
+ grid_points = np.asarray(grid_points, dtype=np.float64)
855
+ # Validate grid points
856
+ if symmetry_line is None:
857
+ if grid_points.ndim != 3 or grid_points.shape[2] != 2:
858
+ raise ValueError(
859
+ "grid_points must be a 3D array with shape (grid_size_x, grid_size_y, 2)"
860
+ )
861
+ else:
862
+ if grid_points.ndim != 1:
863
+ raise ValueError(
864
+ "grid_points must be a 1D array when symmetry_line is provided"
865
+ )
866
+
867
+ # Validate parameters
868
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
869
+
870
+ # Validate period
871
+ validate_positive(period, "period", Integral)
872
+
873
+ # Validate tolerance
874
+ validate_non_negative(tolerance, "tolerance", Real)
875
+
876
+ # Validate max_iter
877
+ validate_positive(max_iter, "max_iter", Integral)
878
+
879
+ # Validate convergence threshold
880
+ validate_non_negative(convergence_threshold, "convergence_threshold", Real)
881
+
882
+ # Validate tolerance decay factor
883
+ validate_non_negative(tolerance_decay_factor, "tolerance_decay_factor", Real)
884
+
885
+ if tolerance_decay_factor >= 1:
886
+ raise ValueError("tolerance_decay_factor must be in (0, 1)")
887
+
888
+ if symmetry_line is not None:
889
+ return find_periodic_orbit_symmetry_line(
890
+ grid_points,
891
+ parameters,
892
+ self.__mapping,
893
+ period,
894
+ symmetry_line,
895
+ axis,
896
+ tolerance=tolerance,
897
+ max_iter=max_iter,
898
+ convergence_threshold=convergence_threshold,
899
+ tolerance_decay_factor=tolerance_decay_factor,
900
+ verbose=verbose,
901
+ )
902
+ else:
903
+ return find_periodic_orbit(
904
+ grid_points,
905
+ parameters,
906
+ self.__mapping,
907
+ period,
908
+ tolerance=tolerance,
909
+ max_iter=max_iter,
910
+ convergence_threshold=convergence_threshold,
911
+ tolerance_decay_factor=tolerance_decay_factor,
912
+ verbose=verbose,
913
+ )
914
+
915
+ def eigenvalues_and_eigenvectors(
916
+ self,
917
+ u: Union[NDArray[np.float64], Sequence[float]],
918
+ period: int,
919
+ parameters: Union[
920
+ None, float, Sequence[np.float64], NDArray[np.float64]
921
+ ] = None,
922
+ normalize: bool = True,
923
+ sort_by_magnitude: bool = True,
924
+ ) -> Tuple[NDArray[np.complex128], NDArray[np.complex128]]:
925
+ """
926
+ Compute eigenvalues and eigenvectors of the Jacobian matrix for a periodic orbit.
927
+
928
+ Parameters
929
+ ----------
930
+ u : Union[NDArray[np.float64], Sequence[float]]
931
+ Initial condition of the system. Can be:
932
+ - 1D numpy array of shape (d,) where d is the system dimension
933
+ - Sequence that can be converted to numpy array
934
+ parameters : Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
935
+ System parameters of shape (p,).
936
+ period : int
937
+ Period of the orbit (must be ≥ 1).
938
+ normalize : bool, optional
939
+ Whether to normalize eigenvectors to unit length (default is True).
940
+ sort_by_magnitude : bool, optional
941
+ Whether to sort eigenvalues and eigenvectors by the magnitude of the eigenvalues (default is True).
942
+
943
+ Returns
944
+ -------
945
+ Tuple[NDArray[np.complex128], NDArray[np.complex128]]
946
+
947
+ - eigenvalues : (d,) array of complex eigenvalues.
948
+ - eigenvectors : (d, d) array where each column is a normalized eigenvector corresponding to an eigenvalue.
949
+
950
+ Raises
951
+ ------
952
+ ValueError
953
+ - If `u` is not a scalar, or 1D array, or if its shape does not match the expected system dimension.
954
+ - If `parameters` is not None and does not match the expected number of parameters.
955
+ - If `parameters` is None but the system expects parameters.
956
+ - If `parameters` is a scalar or array-like but not 1D.
957
+ - If `period` is negative or zero.
958
+ TypeError
959
+ - If `u` is not a scalar or array-like type.
960
+ - If `parameters` is not a scalar or array-like type.
961
+ - If `period` is not int.
962
+
963
+ Notes
964
+ -----
965
+ - Computes the Jacobian matrix over `period` iterations using the product of Jacobians.
966
+ - Eigenvectors indicate local directions of stretching or contraction in phase space.
967
+ - Complex eigenvalues appear in conjugate pairs in real-valued systems.
968
+
969
+ Examples
970
+ --------
971
+ >>> # Example usage
972
+ >>> from pynamicalsys import DiscreteDynamicalSystem as dds
973
+ >>> obj = dds(model="henon map")
974
+ >>> u0 = np.array([0.1, 0.2])
975
+ >>> params = np.array([1.0, 0.1])
976
+ >>> evals, evecs = obj.eigenvalues_and_eigenvectors(
977
+ ... u0, params, period=3)
978
+ """
979
+
980
+ # Validate initial condition
981
+ u = validate_initial_conditions(
982
+ u, self.__system_dimension, allow_ensemble=False
983
+ )
984
+
985
+ # Validate parameters
986
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
987
+
988
+ # Validate period
989
+ validate_positive(period, "period", Integral)
990
+
991
+ return eigenvalues_and_eigenvectors(
992
+ u,
993
+ parameters,
994
+ self.__mapping,
995
+ self.__jacobian,
996
+ period,
997
+ normalize,
998
+ sort_by_magnitude,
999
+ )
1000
+
1001
+ def classify_stability(
1002
+ self,
1003
+ u: Union[NDArray[np.float64], Sequence[float]],
1004
+ period: int,
1005
+ parameters: Union[
1006
+ None, float, Sequence[np.float64], NDArray[np.float64]
1007
+ ] = None,
1008
+ threshold: float = 1.0,
1009
+ tol: float = 1e-8,
1010
+ ) -> Dict[str, Union[str, NDArray[np.complex128]]]:
1011
+ """
1012
+ Classify the stability of a periodic orbit using the eigenvalues of the Jacobian matrix for a 2D discrete map.
1013
+
1014
+ Parameters
1015
+ ----------
1016
+ u : Union[NDArray[np.float64], Sequence[float]]
1017
+ Initial condition of the system. Can be:
1018
+ - 1D numpy array of shape (2,) where 2 is the system dimension
1019
+ - Sequence that can be converted to numpy array
1020
+ period : int
1021
+ Period of the orbit (must be ≥ 1).
1022
+ parameters : Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
1023
+ System parameters of shape (p,).
1024
+ threshold : float, optional
1025
+ Threshold for stability classification (default is 1.0).
1026
+ tol : float, optional
1027
+ Tolerance for numerical stability checks (default is 1e-8).
1028
+
1029
+ Returns
1030
+ -------
1031
+ dict
1032
+ Dictionary with:
1033
+
1034
+ - "classification": str
1035
+ - "eigenvalues": ndarray
1036
+ - "eigenvectors": ndarray
1037
+
1038
+ Raises
1039
+ ------
1040
+ ValueError
1041
+ - If `u` is not a scalar, or 1D array, or if its shape does not match the expected system dimension.
1042
+ - If `parameters` is not None and does not match the expected number of parameters.
1043
+ - If `parameters` is None but the system expects parameters.
1044
+ - If `parameters` is a scalar or array-like but not 1D.
1045
+ - If `period` is negative or zero.
1046
+ TypeError
1047
+ - If `u` is not a scalar or array-like type.
1048
+ - If `parameters` is not a scalar or array-like type.
1049
+ - If `period` is not int.
1050
+
1051
+ Notes
1052
+ -----
1053
+ - The classification is based on the eigenvalues of the Jacobian matrix.
1054
+ - The eigenvalues are computed over `period` iterations using the product of Jacobians.
1055
+ - The classification can be one of:
1056
+ - "stable node": All eigenvalues have magnitudes < threshold.
1057
+ - "stable spiral": Complex conjugate eigenvalues with magnitudes < threshold.
1058
+ - "unstable node": All eigenvalues have magnitudes > threshold.
1059
+ - "unstable spiral": Complex conjugate eigenvalues with magnitudes > threshold.
1060
+ - "saddle": One eigenvalue > threshold and one < threshold.
1061
+ - "center": Real eigenvalues with magnitudes ≈ threshold.
1062
+ - "elliptic": Complex eigenvalues with magnitudes ≈ threshold.
1063
+ - "marginal or degenerate": Eigenvalues with magnitudes ≈ 1.
1064
+
1065
+ Examples
1066
+ --------
1067
+ >>> u0 = np.array([0.1, 0.2])
1068
+ >>> params = np.array([1.0, 0.1])
1069
+ >>> stability = obj.classify_stability(u0, params, period=3)
1070
+ >>> print(stability["classification"]) # e.g., "stable node"
1071
+ >>> print(stability["eigenvalues"]) # Eigenvalues of the Jacobian
1072
+ >>> print(stability["eigenvectors"])
1073
+ """
1074
+
1075
+ if self.__system_dimension != 2:
1076
+ raise ValueError("classify_stability is only implemented for 2D systems")
1077
+
1078
+ u = validate_initial_conditions(
1079
+ u, self.__system_dimension, allow_ensemble=False
1080
+ )
1081
+
1082
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
1083
+
1084
+ validate_positive(period, "period", Integral)
1085
+
1086
+ return classify_stability(
1087
+ u,
1088
+ parameters,
1089
+ self.__mapping,
1090
+ self.__jacobian,
1091
+ period,
1092
+ threshold=threshold,
1093
+ tol=tol,
1094
+ )
1095
+
1096
+ def manifold(
1097
+ self,
1098
+ u: Union[NDArray[np.float64], Sequence[float]],
1099
+ period: int,
1100
+ parameters: Union[
1101
+ None, float, Sequence[np.float64], NDArray[np.float64]
1102
+ ] = None,
1103
+ delta: float = 1e-4,
1104
+ n_points: Union[NDArray[np.int32], List[int], int] = 100,
1105
+ iter_time: Union[List[int], int] = 100,
1106
+ stability: str = "unstable",
1107
+ ) -> List[np.ndarray]:
1108
+ """Calculate stable or unstable manifolds of a saddle periodic orbit of the system.
1109
+
1110
+ Parameters
1111
+ ----------
1112
+ u : Union[NDArray[np.float64], Sequence[float]]
1113
+ Initial condition of the system. Can be:
1114
+ - 1D numpy array of shape (2,) where 2 is the system dimension
1115
+ - Sequence that can be converted to numpy array
1116
+ period : int
1117
+ Period of the orbit (must be ≥ 1)
1118
+ parameters : Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
1119
+ Parameters of the dynamical system. Can be:
1120
+
1121
+ - 1D numpy array of shape (p,) where p is the number of parameters
1122
+ - Sequence that can be converted to numpy array
1123
+ - Scalar value (will be broadcasted)
1124
+ delta : float, optional
1125
+ Initial displacement from orbit (default: 1e-4)
1126
+ n_points : Union[List[int], int], optional
1127
+ Number of points per branch (default: 100)
1128
+ iter_time : Union[List[int], int], optional
1129
+ Iterations per branch (default: 100)
1130
+ stability : str, optional
1131
+ "stable" or "unstable" manifold (default: "unstable")
1132
+
1133
+ Returns
1134
+ -------
1135
+ List[np.ndarray]
1136
+ List containing two arrays: [0] is upper branch manifold points and [1] is lower branch manifold points. Each array has shape (n_points * iter_time, 2)
1137
+
1138
+ Raises
1139
+ ------
1140
+ ValueError
1141
+ - If `u` is not a scalar, or 1D array, or if its shape does not match the expected system dimension.
1142
+ - If `parameters` is not None and does not match the expected number of parameters.
1143
+ - If `parameters` is None but the system expects parameters.
1144
+ - If `parameters` is a scalar or array-like but not 1D.
1145
+ - If `period` is negative or zero.
1146
+ - If `delta` is negative or zero.
1147
+ - If `n_points` is not a positive integer or a list of two positive integers.
1148
+ - If `iter_time` is not a positive integer or a list of two positive integers.
1149
+ - If `stability` is not "stable" or "unstable".
1150
+ - If system dimension is not 2D.
1151
+ TypeError
1152
+ - If `u` is not a scalar or array-like type.
1153
+ - If `parameters` is not a scalar or array-like type.
1154
+ - If `period` is not int.
1155
+ RuntimeError
1156
+ - If `stability` is "stable" but backwards mapping function is not defined.
1157
+
1158
+ Notes
1159
+ -----
1160
+ - Works only for 2D systems
1161
+ - The periodic orbit must be a saddle point
1162
+ - Manifold quality depends on:
1163
+ - delta (smaller = closer to linear approximation)
1164
+ - n_points (more = smoother manifold)
1165
+ - iter_time (more = longer manifold)
1166
+
1167
+ Examples
1168
+ --------
1169
+ >>> # Example usage
1170
+ >>> from pynamicalsys import DiscreteDynamicalSystem as dds
1171
+ >>> # Define the system
1172
+ >>> obj = dds(model="standard map")
1173
+ >>> # Calculate unstable manifold
1174
+ >>> mani = obj.manifold(
1175
+ ... orbit_point, params,
1176
+ ... period=3, delta=1e-5, n_points=200, iter_time=500)
1177
+ >>> upper_branch, lower_branch = manifolds
1178
+ """
1179
+
1180
+ if self.__system_dimension != 2:
1181
+ raise ValueError("manifold is only implemented for 2D systems")
1182
+
1183
+ if self.__backwards_mapping is None and stability == "stable":
1184
+ raise RuntimeError("Backwards mapping function must be provided")
1185
+
1186
+ u = validate_initial_conditions(
1187
+ u, self.__system_dimension, allow_ensemble=False
1188
+ )
1189
+
1190
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
1191
+
1192
+ validate_positive(period, "period", Integral)
1193
+
1194
+ validate_non_negative(delta, "delta", Real)
1195
+
1196
+ # Validate n_points
1197
+ if isinstance(n_points, int):
1198
+ # If n_points is a single integer, make it a list of two identical integers
1199
+ n_points = [n_points] * 2
1200
+ n_points = np.asarray(n_points, dtype=np.int32)
1201
+ elif isinstance(n_points, (list, np.ndarray)):
1202
+ if len(n_points) != 2:
1203
+ raise ValueError("n_points must be a list or array of two integers")
1204
+ if not all(isinstance(n, int) and n > 0 for n in n_points):
1205
+ raise ValueError("n_points must be a list of two positive integers")
1206
+ n_points = np.asarray(n_points, dtype=np.int32)
1207
+ else:
1208
+ raise ValueError("n_points must be an int or a list of two ints")
1209
+
1210
+ return calculate_manifolds(
1211
+ u,
1212
+ parameters,
1213
+ self.__mapping,
1214
+ self.__backwards_mapping,
1215
+ self.__jacobian,
1216
+ period,
1217
+ delta=delta,
1218
+ n_points=n_points,
1219
+ iter_time=iter_time,
1220
+ stability=stability,
1221
+ )
1222
+
1223
+ def rotation_number(
1224
+ self,
1225
+ u: Union[NDArray[np.float64], Sequence[float], float],
1226
+ total_time: int,
1227
+ parameters: Union[
1228
+ None, float, Sequence[np.float64], NDArray[np.float64]
1229
+ ] = None,
1230
+ mod: int = 1,
1231
+ ) -> float:
1232
+ """Compute the rotation number of a trajectory.
1233
+
1234
+ Parameters
1235
+ ----------
1236
+ u : Union[NDArray[np.float64], Sequence[float], float]
1237
+ Initial condition of the system. Can be:
1238
+ - 1D numpy array of shape (d,) where d is system dimension
1239
+ - Sequence that can be converted to numpy array
1240
+ - Scalar value
1241
+ total_time : int
1242
+ Total number of iterations to compute
1243
+ parameters : Union[None, float,
1244
+ Sequence[np.float64], NDArray[np.float64]]
1245
+ Parameters of the dynamical system, shape (p,)
1246
+ mod : int, optional
1247
+ Modulus for the rotation number calculation, by default 1
1248
+
1249
+ Returns
1250
+ -------
1251
+ float
1252
+ The computed rotation number.
1253
+
1254
+ Raises
1255
+ ------
1256
+ ValueError
1257
+ - If `u` is not a scalar, or 1D array, or if its shape does not match the expected system dimension.
1258
+ - If `parameters` is not None and does not match the expected number of parameters.
1259
+ - If `parameters` is None but the system expects parameters.
1260
+ - If `parameters` is a scalar or array-like but not 1D.
1261
+ - If `total_time` is negative.
1262
+ TypeError
1263
+ - If `u` is not a scalar or array-like type.
1264
+ - If `parameters` is not a scalar or array-like type.
1265
+ - If `total_time` is not int.
1266
+
1267
+ Notes
1268
+ -----
1269
+ - The rotation number is a measure of the average angular displacement
1270
+ of a trajectory in phase space.
1271
+ - It is computed as the limit of the average angular displacement
1272
+ over a large number of iterations.
1273
+ - The rotation number is useful for analyzing the behavior of
1274
+ periodic orbits and chaotic dynamics.
1275
+
1276
+ Examples
1277
+ --------
1278
+ >>> # Basic usage
1279
+ >>> u0 = np.array([0.1, 0.2])
1280
+ >>> params = np.array([0.5, 1.0])
1281
+ >>> rotation_num = system.compute_rotation_number(u0, params)
1282
+ >>> # With custom time parameters
1283
+ >>> rotation_num = system.compute_rotation_number(
1284
+ ... u0, params, total_time=5000)
1285
+ """
1286
+
1287
+ if self.__system_dimension != 2:
1288
+ raise ValueError("rotation_number is only implemented for 2D systems")
1289
+
1290
+ u = validate_initial_conditions(
1291
+ u, self.__system_dimension, allow_ensemble=False
1292
+ )
1293
+
1294
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
1295
+
1296
+ validate_non_negative(total_time, "total_time", Integral)
1297
+
1298
+ return rotation_number(u, parameters, total_time, self.__mapping, mod=mod)
1299
+
1300
+ def escape_analysis(
1301
+ self,
1302
+ u: Union[NDArray[np.float64], Sequence[float]],
1303
+ max_time: int,
1304
+ exits: Union[List[NDArray[np.float64]], NDArray[np.float64]],
1305
+ parameters: Union[
1306
+ None, float, Sequence[np.float64], NDArray[np.float64]
1307
+ ] = None,
1308
+ escape: str = "entering",
1309
+ hole_size: Optional[float] = None,
1310
+ ) -> Tuple[int, int]:
1311
+ """Compute escape basin index and time for a single trajectory.
1312
+
1313
+ Parameters
1314
+ ----------
1315
+ u : Union[NDArray[np.float64], Sequence[float]]
1316
+ Initial state vector of shape (d,) where d is system dimension.
1317
+ Can be any sequence convertible to numpy array.
1318
+ max_time : int
1319
+ Maximum number of iterations to simulate (must be positive).
1320
+ exits : Union[List[NDArray[np.float64]], NDArray[np.float64]]
1321
+ - Exit regions specification:
1322
+ - List of d arrays of shape (2,) representing [min, max] per dimension
1323
+ - Array of shape (n_exits, d, 2) for multiple exit regions
1324
+ parameters : Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
1325
+ System parameters of shape (p,) passed to the mapping function.
1326
+ escape : str, optional
1327
+ Escape condition type: "entering" or "exiting" (default "entering").
1328
+ hole_size : Optional[float], optional
1329
+ Size of the hole (default None, meaning no size constraint). Only used for "entering" escape type.
1330
+
1331
+ Returns
1332
+ -------
1333
+ Tuple[int, int]
1334
+ A tuple containing:
1335
+
1336
+ - exit_index: 0-based index of escape region (-1 if no escape)
1337
+ - escape_time: Time step of escape (max_time if no escape)
1338
+
1339
+ Raises
1340
+ ------
1341
+ ValueError
1342
+ - If `u` is not a scalar, or 1D array, or if its shape does not match the expected system dimension.
1343
+ - If `parameters` is not None and does not match the expected number of parameters.
1344
+ - If `parameters` is None but the system expects parameters.
1345
+ - If `parameters` is a scalar or array-like but not 1D.
1346
+ - If `max_time` is negative or zero.
1347
+ - If `exits` is not a list of (d,2) arrays or (n,d,2) array.
1348
+ - If `escape` is not "entering" or "exiting".
1349
+ - If exit regions do not match system dimension.
1350
+ - If exit regions do not provide [min, max] pairs.
1351
+ TypeError
1352
+ - If `u` is not a scalar or array-like type.
1353
+ - If `parameters` is not a scalar or array-like type.
1354
+ - If `max_time` is not int.
1355
+
1356
+ Notes
1357
+ -----
1358
+ - For "entering": trajectory must enter the exit region
1359
+ - For "exiting": trajectory must exit the region of interest
1360
+ - Exit regions are defined as hyperrectangles [min, max] in each dimension
1361
+
1362
+ Examples
1363
+ --------
1364
+ >>> # Single exit region (entering)
1365
+ >>> u0 = np.array([0.1, 0.2])
1366
+ >>> params = np.array([1.0, 0.1])
1367
+ >>> exit_region = np.array([[-1, 1], [-1, 1]]) # 2D box
1368
+ >>> idx, time = sys.escape_analysis(u0, params, 1000, exit_region)
1369
+
1370
+ >>> # Multiple exit regions (exiting)
1371
+ >>> exits = [
1372
+ ... np.array([[0, 1], [0, 1]]), # First exit region
1373
+ ... np.array([[-1, 0], [-1, 0]]) # Second exit region
1374
+ ... ]
1375
+ >>> idx, time = sys.escape_analysis(u0, params, 1000, exits, "exiting")
1376
+ """
1377
+
1378
+ u = validate_initial_conditions(
1379
+ u, self.__system_dimension, allow_ensemble=False
1380
+ )
1381
+
1382
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
1383
+
1384
+ validate_non_negative(max_time, "max_time", Integral)
1385
+
1386
+ # Validate escape type
1387
+ if escape not in ("entering", "exiting"):
1388
+ raise ValueError("escape must be either 'entering' or 'exiting'")
1389
+
1390
+ if escape == "entering" and hole_size is None:
1391
+ raise ValueError("hole_size must be specified for 'entering' escape type")
1392
+
1393
+ # Process exit regions
1394
+ if escape == "entering":
1395
+ # If exits is a list, convert to an array
1396
+ if isinstance(exits, list):
1397
+ exits_arr = np.stack(exits, axis=0)
1398
+ else:
1399
+ exits_arr = np.asarray(exits, dtype=np.float64)
1400
+
1401
+ # If exits is a single point, convert to 2D array
1402
+ if exits_arr.ndim == 1:
1403
+ exits_arr = exits_arr.reshape(1, -1)
1404
+
1405
+ # Validate exits array shape
1406
+ if exits_arr.ndim != 2:
1407
+ raise ValueError(
1408
+ "Exits must be a list of (d,) arrays or a 2D array of shape (n, d)"
1409
+ )
1410
+
1411
+ # Validate exits dimension
1412
+ if exits_arr.shape[1] != self.__system_dimension:
1413
+ raise ValueError(
1414
+ f"Exit region dimension {exits_arr.shape[1]} != system dimension {self.__system_dimension}"
1415
+ )
1416
+
1417
+ # Create the exit regions as hyperrectangles
1418
+ # Stack per coordinate axis
1419
+ lower = exits_arr - hole_size / 2
1420
+ upper = exits_arr + hole_size / 2
1421
+ exits_arr = np.stack([lower.T, upper.T], axis=1).transpose(2, 0, 1)
1422
+
1423
+ if escape == "exiting":
1424
+ if isinstance(exits, list):
1425
+ exits_arr = np.asarray(exits, dtype=np.float64)
1426
+ else:
1427
+ exits_arr = np.asarray(exits, dtype=np.float64)
1428
+
1429
+ # Validate exits array shape
1430
+ if exits_arr.ndim != 2 or exits_arr.shape[1] != 2:
1431
+ raise ValueError(
1432
+ "Exits must be a 2D array of shape (d, 2) for exiting escape type"
1433
+ )
1434
+
1435
+ # Validate exits dimension
1436
+ if exits_arr.shape[0] != self.__system_dimension:
1437
+ raise ValueError(
1438
+ f"Exit region dimension {exits_arr.shape[0]} != system dimension {self.__system_dimension}"
1439
+ )
1440
+
1441
+ # Dispatch to appropriate computation
1442
+ if escape == "entering":
1443
+ return escape_basin_and_time_entering(
1444
+ u=u,
1445
+ parameters=parameters,
1446
+ mapping=self.__mapping,
1447
+ max_time=max_time,
1448
+ exits=exits_arr,
1449
+ )
1450
+ else:
1451
+ return escape_time_exiting(
1452
+ u=u,
1453
+ parameters=parameters,
1454
+ mapping=self.__mapping,
1455
+ max_time=max_time,
1456
+ region_limits=exits_arr,
1457
+ )
1458
+
1459
+ def survival_probability(
1460
+ self, escape_times: Union[NDArray[np.int32], Sequence[int]], max_time: np.int32
1461
+ ) -> Tuple[NDArray[np.int64], NDArray[np.float64]]:
1462
+ """Compute the survival probability based on escape times.
1463
+
1464
+ Parameters
1465
+ ----------
1466
+ escape_times : Union[NDArray[np.float64], Sequence[int]]
1467
+ Array of escape times for N trajectories where:
1468
+ - escape_times[i] = time when i-th trajectory escaped
1469
+ - Use max_time for trajectories that didn't escape
1470
+ - Should be shape (N,) with dtype=int32
1471
+ max_time : int
1472
+ Maximum simulation time (must be > 0)
1473
+
1474
+ Returns
1475
+ -------
1476
+ NDArray[np.float64][float64]
1477
+ Survival probability curve S(t) where:
1478
+
1479
+ - S[0] = 1.0 (all trajectories survive at t=0)
1480
+ - S[t] = fraction surviving at time t
1481
+ - Shape (max_time + 1,)
1482
+
1483
+ Raises
1484
+ ------
1485
+ ValueError
1486
+ - If escape_times contains values > max_time
1487
+ - If escape_times contains negative values
1488
+ - If max_time <= 0
1489
+ TypeError
1490
+ - If escape_times cannot be converted to int32 array
1491
+
1492
+ Notes
1493
+ -----
1494
+ - S(t) = P(T > t) where T is escape time
1495
+ - Implemented via survival_probability() function
1496
+ - For N trajectories: S(t) = (number of T_i > t) / N
1497
+
1498
+ Examples
1499
+ --------
1500
+ >>> escape_times = np.array([5, 10, 10, 20], dtype=np.int32)
1501
+ >>> surv = system.compute_survival_probability(escape_times, 20)
1502
+ >>> surv[0] # 1.0 at t=0
1503
+ >>> surv[5] # 0.75 at t=5
1504
+ >>> surv[10] # 0.25 at t=10
1505
+ >>> surv[20] # 0.0 at t=20
1506
+ """
1507
+ # Input validation
1508
+ try:
1509
+ escape_arr = np.asarray(escape_times, dtype=np.int32)
1510
+ except (TypeError, ValueError) as e:
1511
+ raise TypeError("escape_times must be convertible to int32 array") from e
1512
+
1513
+ if escape_arr.ndim != 1:
1514
+ raise ValueError("escape_times must be 1D array")
1515
+
1516
+ validate_non_negative(max_time, "max_time", Integral)
1517
+
1518
+ if np.any(escape_arr < 0):
1519
+ raise ValueError("escape_times cannot contain negative values")
1520
+
1521
+ if np.any(escape_arr > max_time):
1522
+ raise ValueError(f"escape_times cannot exceed max_time ({max_time})")
1523
+
1524
+ # Compute survival probability
1525
+ return survival_probability(escape_arr, max_time)
1526
+
1527
+ def diffusion_coefficient(
1528
+ self,
1529
+ u: Union[NDArray[np.float64], Sequence[Sequence[float]]],
1530
+ total_time: int,
1531
+ parameters: Union[
1532
+ None, float, Sequence[np.float64], NDArray[np.float64]
1533
+ ] = None,
1534
+ axis: int = 1,
1535
+ ) -> np.float64:
1536
+ """Compute the diffusion coefficient from ensemble trajectories.
1537
+
1538
+ Parameters
1539
+ ----------
1540
+ u : Union[NDArray[np.float64], Sequence[Sequence[float]]]
1541
+ Initial conditions array where:
1542
+ - Shape (N, d) for N trajectories in d-dimensional space
1543
+ - Can be list of lists or numpy array
1544
+ total_time : int
1545
+ Number of iterations to compute (must be ≥ 1)
1546
+ parameters : Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
1547
+ System parameters passed to mapping function, shape (p,)
1548
+ axis : int, default=1
1549
+ Coordinate index to compute diffusion (0 for x, 1 for y, etc.)
1550
+
1551
+ Returns
1552
+ -------
1553
+ float
1554
+ Diffusion coefficient D calculated as:
1555
+ D = ⟨(y(t) - y(0))²⟩/(2t) where y is typically the second coordinate and ⟨·⟩ denotes ensemble average
1556
+
1557
+ Raises
1558
+ ------
1559
+ ValueError
1560
+ - If `u` is not a 2D array, or if its shape does not match the expected system dimension.
1561
+ - If `parameters` is not None and does not match the expected number of parameters.
1562
+ - If `parameters` is None but the system expects parameters.
1563
+ - If `parameters` is a scalar or array-like but not 1D.
1564
+ - If `total_time` is negative or zero.
1565
+ - If `axis` is not valid for the system dimension.
1566
+ TypeError
1567
+ - If `u` is not a scalar or array-like type.
1568
+ - If `parameters` is not a scalar or array-like type.
1569
+ - If `total_time` is not int.
1570
+ - If `axis` is not int.
1571
+
1572
+ Notes
1573
+ -----
1574
+ - Uses the system's mapping function for evolution
1575
+ - For accurate results, use:
1576
+ - total_time >> 1
1577
+ - N >> 1 initial conditions
1578
+ - Implements Einstein relation for discrete time
1579
+
1580
+ Examples
1581
+ --------
1582
+ >>> # With numpy array input
1583
+ >>> ics = np.random.rand(100, 2) # 100 trajectories in 2D
1584
+ >>> params = np.array([0.5, 1.0])
1585
+ >>> D = system.diffusion_coefficient(ics, params, 1000)
1586
+
1587
+ >>> # With list input
1588
+ >>> ics = [[0.1, 0.2], [0.3, 0.4]] # 2 trajectories
1589
+ >>> D = system.diffusion_coefficient(ics, params, 500)
1590
+ """
1591
+
1592
+ u = validate_initial_conditions(u, self.__system_dimension, allow_ensemble=True)
1593
+
1594
+ if u.ndim != 2:
1595
+ raise ValueError(
1596
+ f"Initial conditions must be a 2D array of shape (N, d), got shape {u.shape}"
1597
+ )
1598
+
1599
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
1600
+
1601
+ validate_non_negative(total_time, "total_time", Integral)
1602
+
1603
+ validate_axis(axis, self.__system_dimension)
1604
+
1605
+ return diffusion_coefficient(
1606
+ u, parameters, total_time, self.__mapping, axis=axis
1607
+ )
1608
+
1609
+ def average_in_time(
1610
+ self,
1611
+ u: Union[NDArray[np.float64], Sequence[Sequence[float]]],
1612
+ total_time: int,
1613
+ parameters: Union[
1614
+ None, float, Sequence[np.float64], NDArray[np.float64]
1615
+ ] = None,
1616
+ sample_times: Optional[Union[NDArray[np.float64], Sequence[int]]] = None,
1617
+ axis: int = 1,
1618
+ ) -> NDArray[np.float64]:
1619
+ """Compute time evolution of coordinate average across trajectories.
1620
+
1621
+ Parameters
1622
+ ----------
1623
+ u : Union[NDArray[np.float64], Sequence[Sequence[float]]]
1624
+ Initial conditions array where:
1625
+ - Shape (N, d) for N trajectories in d-dimensional space
1626
+ - Can be list of lists or numpy array
1627
+ total_time : int
1628
+ Total number of iterations to compute (must be ≥ 1)
1629
+ parameters : Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
1630
+ System parameters passed to mapping function, shape (p,)
1631
+ sample_times : Optional[Union[NDArray[np.float64], Sequence[int]]], default=None
1632
+ Specific time steps to record (1D array of integers). If None,
1633
+ records at every time step from 0 to total_time.
1634
+ axis : int, default=1
1635
+ Coordinate index to average over (0 for x, 1 for y, etc.)
1636
+
1637
+ Returns
1638
+ -------
1639
+ NDArray[np.float64]
1640
+ Array of average values with shape:
1641
+
1642
+ - (len(sample_times),) if sample_times provided
1643
+ - (total_time + 1,) if sample_times=None
1644
+
1645
+ Raises
1646
+ ------
1647
+ ValueError
1648
+ - If `u` is not a 2D array, or if its shape does not match the expected system dimension.
1649
+ - If `parameters` is not None and does not match the expected number of parameters.
1650
+ - If `parameters` is None but the system expects parameters.
1651
+ - If `parameters` is a scalar or array-like but not 1D.
1652
+ - If `total_time` is negative or zero.
1653
+ - If `sample_times` contains invalid values.
1654
+ - If `sample_times` is not a 1D array of integers.
1655
+ - If `axis` is not valid for the system dimension.
1656
+ TypeError
1657
+ - If `u` is not a scalar or array-like type.
1658
+ - If `parameters` is not a scalar or array-like type.
1659
+ - If `total_time` is not int.
1660
+ - If `axis` is not int.
1661
+
1662
+ Notes
1663
+ -----
1664
+ - Uses the system's mapping function for trajectory evolution
1665
+ - For smooth results, use N >> 1 initial conditions
1666
+ - The average is computed as ⟨xᵢ(t)⟩ where i is the axis index
1667
+ - First output value (t=0) is the initial average
1668
+
1669
+ Examples
1670
+ --------
1671
+ >>> # Basic usage with default sampling
1672
+ >>> ics = np.random.rand(100, 2) # 100 trajectories in 2D
1673
+ >>> params = np.array([1.0, 0.1])
1674
+ >>> avg = system.average_in_time(ics, params, 1000)
1675
+
1676
+ >>> # With custom sampling times
1677
+ >>> times = np.linspace(0, 1000, 11, dtype=int)
1678
+ >>> avg = system.average_in_time(ics, params, 1000, times)
1679
+ """
1680
+
1681
+ u = validate_initial_conditions(u, self.__system_dimension, allow_ensemble=True)
1682
+
1683
+ if u.ndim != 2:
1684
+ raise ValueError(
1685
+ f"Initial conditions must be a 2D array of shape (N, d), got shape {u.shape}"
1686
+ )
1687
+
1688
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
1689
+
1690
+ validate_non_negative(total_time, "total_time", Integral)
1691
+
1692
+ sample_times_arr = validate_sample_times(sample_times, total_time)
1693
+
1694
+ validate_axis(axis, self.__system_dimension)
1695
+
1696
+ return average_vs_time(
1697
+ u,
1698
+ parameters,
1699
+ total_time,
1700
+ self.__mapping,
1701
+ sample_times=sample_times_arr,
1702
+ axis=axis,
1703
+ )
1704
+
1705
+ def cumulative_average(
1706
+ self,
1707
+ u: Union[NDArray[np.float64], Sequence[Sequence[float]]],
1708
+ total_time: int,
1709
+ parameters: Union[
1710
+ None, float, Sequence[np.float64], NDArray[np.float64]
1711
+ ] = None,
1712
+ sample_times: Optional[Union[NDArray[np.float64], Sequence[int]]] = None,
1713
+ axis: int = 1,
1714
+ ) -> NDArray[np.float64]:
1715
+ """Compute cumulative average of a coordinate across trajectories.
1716
+
1717
+ Parameters
1718
+ ----------
1719
+ u : Union[NDArray[np.float64], Sequence[Sequence[float]]]
1720
+ Initial conditions array where:
1721
+ - Shape (N, d) for N trajectories in d-dimensional space
1722
+ - Can be list of lists or numpy array
1723
+ parameters : Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
1724
+ System parameters passed to mapping function, shape (p,)
1725
+ total_time : int
1726
+ Total number of iterations to compute (must be ≥ 1)
1727
+ sample_times : Optional[Union[NDArray[np.float64], Sequence[int]]], default=None
1728
+ Specific time steps to record (1D array of integers). If None,
1729
+ records at every time step from 0 to total_time.
1730
+ axis : int, default=1
1731
+ Coordinate index to average over (0 for x, 1 for y, etc.)
1732
+
1733
+ Returns
1734
+ -------
1735
+ NDArray[np.float64]
1736
+ Array of average values with shape:
1737
+
1738
+ - (len(sample_times),) if sample_times provided
1739
+ - (total_time + 1,) if sample_times=None
1740
+
1741
+ Raises
1742
+ ------
1743
+ ValueError
1744
+ - If `u` is not a 2D array, or if its shape does not match the expected system dimension.
1745
+ - If `parameters` is not None and does not match the expected number of parameters.
1746
+ - If `parameters` is None but the system expects parameters.
1747
+ - If `parameters` is a scalar or array-like but not 1D.
1748
+ - If `total_time` is negative or zero.
1749
+ - If `sample_times` contains invalid values.
1750
+ - If `sample_times` is not a 1D array of integers.
1751
+ - If `axis` is not valid for the system dimension.
1752
+ TypeError
1753
+ - If `u` is not a scalar or array-like type.
1754
+ - If `parameters` is not a scalar or array-like type.
1755
+ - If `total_time` is not int.
1756
+ - If `axis` is not int.
1757
+
1758
+ Notes
1759
+ -----
1760
+ - Uses the system's mapping function for trajectory evolution
1761
+ - For smooth results, use N >> 1 initial conditions
1762
+ - The average is computed as ⟨xᵢ(t)⟩ where i is the axis index
1763
+ - First output value (t=0) is the initial average
1764
+
1765
+ Examples
1766
+ --------
1767
+ >>> # Basic usage with default sampling
1768
+ >>> ics = np.random.rand(100, 2) # 100 trajectories in 2D
1769
+ >>> params = np.array([1.0, 0.1])
1770
+ >>> avg = system.cumulative_average(ics, params, 1000)
1771
+
1772
+ >>> # With custom sampling times
1773
+ >>> times = np.linspace(0, 1000, 11, dtype=int)
1774
+ >>> avg = system.cumulative_average(ics, params, 1000, times)
1775
+ """
1776
+
1777
+ u = validate_initial_conditions(u, self.__system_dimension, allow_ensemble=True)
1778
+
1779
+ if u.ndim != 2:
1780
+ raise ValueError(
1781
+ f"Initial conditions must be a 2D array of shape (N, d), got shape {u.shape}"
1782
+ )
1783
+
1784
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
1785
+
1786
+ validate_non_negative(total_time, "total_time", Integral)
1787
+
1788
+ sample_times_arr = validate_sample_times(sample_times, total_time)
1789
+
1790
+ validate_axis(axis, self.__system_dimension)
1791
+
1792
+ return cumulative_average_vs_time(
1793
+ u,
1794
+ parameters,
1795
+ total_time,
1796
+ self.__mapping,
1797
+ sample_times=sample_times_arr,
1798
+ axis=axis,
1799
+ )
1800
+
1801
+ def root_mean_squared(
1802
+ self,
1803
+ u: Union[NDArray[np.float64], Sequence[Sequence[float]]],
1804
+ total_time: int,
1805
+ parameters: Union[
1806
+ None, float, Sequence[np.float64], NDArray[np.float64]
1807
+ ] = None,
1808
+ sample_times: Optional[Union[NDArray[np.float64], Sequence[int]]] = None,
1809
+ axis: int = 1,
1810
+ ) -> NDArray[np.float64]:
1811
+ """Compute root mean squared (RMS) evolution of a coordinate across trajectories.
1812
+
1813
+ Parameters
1814
+ ----------
1815
+ u : Union[NDArray[np.float64], Sequence[Sequence[float]]]
1816
+ Initial conditions array where:
1817
+ - Shape (N, d) for N trajectories in d-dimensional space
1818
+ - Can be list of lists or numpy array
1819
+ total_time : int
1820
+ Total number of iterations to compute (must be ≥ 1)
1821
+ parameters : Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
1822
+ System parameters passed to mapping function, shape (p,)
1823
+ Must be 1D float array
1824
+ sample_times : Optional[Union[NDArray[np.float64], Sequence[int]]], default=None
1825
+ Specific time steps to record (1D array of integers). If None,
1826
+ records at every time step from 0 to total_time.
1827
+ axis : int, default=1
1828
+ Coordinate index for RMS calculation (0 for x, 1 for y, etc.)
1829
+
1830
+ Returns
1831
+ -------
1832
+ NDArray[np.float64]
1833
+ root mean squared values with shape:
1834
+
1835
+ - (len(sample_times),) if sample_times provided
1836
+ - (total_time + 1,) if sample_times=None
1837
+
1838
+ Raises
1839
+ ------
1840
+ ValueError
1841
+ - If `u` is not a 2D array, or if its shape does not match the expected system dimension.
1842
+ - If `parameters` is not None and does not match the expected number of parameters.
1843
+ - If `parameters` is None but the system expects parameters.
1844
+ - If `parameters` is a scalar or array-like but not 1D.
1845
+ - If `total_time` is negative or zero.
1846
+ - If `sample_times` contains invalid values.
1847
+ - If `sample_times` is not a 1D array of integers.
1848
+ - If `axis` is not valid for the system dimension.
1849
+ TypeError
1850
+ - If `u` is not a scalar or array-like type.
1851
+ - If `parameters` is not a scalar or array-like type.
1852
+ - If `total_time` is not int.
1853
+ - If `axis` is not int.
1854
+
1855
+ Notes
1856
+ -----
1857
+ - root mean squared is computed as sqrt(⟨xᵢ(t)²⟩) where:
1858
+ - i is the axis index
1859
+ - ⟨·⟩ denotes ensemble average
1860
+ - First output value (t=0) is the initial RMS
1861
+ - For diffusion analysis, often used with axis=1 (y-coordinate)
1862
+
1863
+ Examples
1864
+ --------
1865
+ >>> # Basic usage with default sampling
1866
+ >>> ics = np.random.rand(100, 2) # 100 trajectories in 2D
1867
+ >>> params = np.array([1.0, 0.1], dtype=np.float64)
1868
+ >>> rms = system.root_mean_squared(ics, params, 1000)
1869
+
1870
+ >>> # With custom sampling times and x-coordinate (axis=0)
1871
+ >>> times = np.arange(0, 1001, 100, dtype=int)
1872
+ >>> rms = system.root_mean_squared(ics, params, 1000, times, axis=0)
1873
+ """
1874
+
1875
+ u = validate_initial_conditions(u, self.__system_dimension, allow_ensemble=True)
1876
+
1877
+ if u.ndim != 2:
1878
+ raise ValueError(
1879
+ f"Initial conditions must be a 2D array of shape (N, d), got shape {u.shape}"
1880
+ )
1881
+
1882
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
1883
+
1884
+ validate_non_negative(total_time, "total_time", Integral)
1885
+
1886
+ sample_times_arr = validate_sample_times(sample_times, total_time)
1887
+
1888
+ validate_axis(axis, self.__system_dimension)
1889
+
1890
+ return root_mean_squared(
1891
+ u,
1892
+ parameters,
1893
+ total_time,
1894
+ self.__mapping,
1895
+ sample_times=sample_times_arr,
1896
+ axis=axis,
1897
+ )
1898
+
1899
+ def mean_squared_displacement(
1900
+ self,
1901
+ u: Union[NDArray[np.float64], Sequence[Sequence[float]]],
1902
+ total_time: int,
1903
+ parameters: Union[
1904
+ None, float, Sequence[np.float64], NDArray[np.float64]
1905
+ ] = None,
1906
+ sample_times: Optional[Union[NDArray[np.int32], Sequence[int]]] = None,
1907
+ axis: int = 1,
1908
+ ) -> NDArray[np.float64]:
1909
+ """Compute the Mean Squared Displacement (MSD) for system trajectories.
1910
+
1911
+ Parameters
1912
+ ----------
1913
+ u : Union[NDArray[np.float64], Sequence[Sequence[float]]]
1914
+ Initial conditions array where:
1915
+ - Shape (N, d) for N trajectories in d-dimensional space
1916
+ - Can be list of lists or numpy array
1917
+ total_time : int
1918
+ Total number of iterations (must be > transient_time)
1919
+ parameters : Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
1920
+ System parameters of shape (p,) passed to mapping function
1921
+ sample_times : Optional[Union[NDArray[np.float64], Sequence[int]]], default=None
1922
+ Specific time steps to record (1D array of integers). If None,
1923
+ records at every time step after transient_time.
1924
+ axis : int, default=1
1925
+ Coordinate index to analyze (0 for x, 1 for y, etc.)
1926
+ transient_time : Optional[int], default=None
1927
+ Initial iterations to discard (default: 0 if None)
1928
+
1929
+ Returns
1930
+ -------
1931
+ NDArray[np.float64]
1932
+ Mean Squared Displacement values with shape:
1933
+
1934
+ - (len(sample_times),) if sample_times provided
1935
+ - (total_time - transient_time,) if sample_times=None
1936
+
1937
+ Raises
1938
+ ------
1939
+ ValueError
1940
+ - If `u` is not a 2D array, or if its shape does not match the expected system dimension.
1941
+ - If `parameters` is not None and does not match the expected number of parameters.
1942
+ - If `parameters` is None but the system expects parameters.
1943
+ - If `parameters` is a scalar or array-like but not 1D.
1944
+ - If `total_time` is negative or zero.
1945
+ - If `sample_times` contains invalid values.
1946
+ - If `sample_times` is not a 1D array of integers.
1947
+ - If `axis` is not valid for the system dimension.
1948
+ TypeError
1949
+ - If `u` is not a scalar or array-like type.
1950
+ - If `parameters` is not a scalar or array-like type.
1951
+ - If `total_time` is not int.
1952
+ - If `axis` is not int.
1953
+
1954
+ Notes
1955
+ -----
1956
+ - Mean Squared Displacement is calculated as ⟨(x_i(t) - x_i(0))²⟩ where ⟨·⟩ is ensemble average
1957
+ - For normal diffusion, Mean Squared Displacement ∝ t
1958
+ - For anomalous diffusion, Mean Squared Displacement ∝ t^α (α≠1)
1959
+ - Uses parallel processing for efficient computation
1960
+
1961
+ Examples
1962
+ --------
1963
+ >>> # Basic usage with default sampling
1964
+ >>> ics = np.random.rand(100, 2) # 100 trajectories in 2D
1965
+ >>> params = np.array([1.0, 0.1])
1966
+ >>> msd_vals = system.mean_squared_displacement(ics, params, 1000)
1967
+
1968
+ >>> # With custom sampling times
1969
+ >>> times = np.arange(0, 1000, 10, dtype=int)
1970
+ >>> msd_vals = system.mean_squared_displacement(ics, params, 1000, sample_times=times)
1971
+ """
1972
+
1973
+ u = validate_initial_conditions(u, self.__system_dimension, allow_ensemble=True)
1974
+
1975
+ if u.ndim != 2:
1976
+ raise ValueError(
1977
+ f"Initial conditions must be a 2D array of shape (N, d), got shape {u.shape}"
1978
+ )
1979
+
1980
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
1981
+
1982
+ validate_non_negative(total_time, "total_time", Integral)
1983
+
1984
+ sample_times_arr = validate_sample_times(sample_times, total_time)
1985
+
1986
+ validate_axis(axis, self.__system_dimension)
1987
+
1988
+ return mean_squared_displacement(
1989
+ u,
1990
+ parameters,
1991
+ total_time,
1992
+ self.__mapping,
1993
+ sample_times=sample_times_arr,
1994
+ axis=axis,
1995
+ )
1996
+
1997
+ def ensemble_time_average(
1998
+ self,
1999
+ u: Union[NDArray[np.float64], Sequence[Sequence[float]]],
2000
+ total_time: int,
2001
+ parameters: Union[
2002
+ None, float, Sequence[np.float64], NDArray[np.float64]
2003
+ ] = None,
2004
+ axis: int = 1,
2005
+ ) -> NDArray[np.float64]:
2006
+ """Compute ensemble time average of a coordinate across trajectories.
2007
+
2008
+ Parameters
2009
+ ----------
2010
+ u : Union[NDArray[np.float64], Sequence[Sequence[float]]]
2011
+ Initial conditions array where:
2012
+ - Shape (N, d) for N trajectories in d-dimensional space
2013
+ - Can be list of lists or numpy array
2014
+ total_time : int
2015
+ Total number of iterations to compute (must be ≥ 1)
2016
+ parameters : Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
2017
+ System parameters passed to mapping function, shape (p,)
2018
+ axis : int, default=1
2019
+ Coordinate index to average over (0 for x, 1 for y, etc.)
2020
+
2021
+ Returns
2022
+ -------
2023
+ NDArray[np.float64]
2024
+ Array of average values with shape (u.shape[0],)
2025
+
2026
+ Raises
2027
+ ------
2028
+ ValueError
2029
+ - If `u` is not a 2D array, or if its shape does not match the expected system dimension.
2030
+ - If `parameters` is not None and does not match the expected number of parameters.
2031
+ - If `parameters` is None but the system expects parameters.
2032
+ - If `parameters` is a scalar or array-like but not 1D.
2033
+ - If `total_time` is negative or zero.
2034
+ - If `axis` is not valid for the system dimension.
2035
+ TypeError
2036
+ - If `u` is not a scalar or array-like type.
2037
+ - If `parameters` is not a scalar or array-like type.
2038
+ - If `total_time` is not int.
2039
+ - If `axis` is not int.
2040
+
2041
+ Notes
2042
+ -----
2043
+ - Uses the system's mapping function for trajectory evolution
2044
+ - For smooth results, use N >> 1 initial conditions
2045
+ - The average is computed as ⟨xᵢ(t)⟩ where i is the axis index
2046
+ - First output value (t=0) is the initial average
2047
+
2048
+ Examples
2049
+ --------
2050
+ >>> # Basic usage with default axis (1)
2051
+ >>> ics = np.random.rand(100, 2) # 100 trajectories in 2D
2052
+ >>> params = np.array([1.0, 0.1])
2053
+ >>> avg = system.ensemble_time_average(ics, params, 1000)
2054
+ >>> # With custom axis (0 for x-coordinate)
2055
+ >>> avg_x = system.ensemble_time_average(ics, params, 1000, axis=0)
2056
+ """
2057
+
2058
+ u = validate_initial_conditions(u, self.__system_dimension, allow_ensemble=True)
2059
+
2060
+ if u.ndim != 2:
2061
+ raise ValueError(
2062
+ f"Initial conditions must be a 2D array of shape (N, d), got shape {u.shape}"
2063
+ )
2064
+
2065
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
2066
+
2067
+ validate_non_negative(total_time, "total_time", Integral)
2068
+
2069
+ validate_axis(axis, self.__system_dimension)
2070
+
2071
+ return ensemble_time_average(
2072
+ u, parameters, self.__mapping, total_time, axis=axis
2073
+ )
2074
+
2075
+ def recurrence_times(
2076
+ self,
2077
+ u: Union[NDArray[np.float64], Sequence[float], float],
2078
+ total_time: int,
2079
+ parameters: Union[
2080
+ None, float, Sequence[np.float64], NDArray[np.float64]
2081
+ ] = None,
2082
+ eps: float = 1e-2,
2083
+ transient_time: Optional[int] = None,
2084
+ ) -> NDArray[np.float64]:
2085
+ """
2086
+ Compute recurrence times to a neighborhood around the initial condition.
2087
+
2088
+ Parameters
2089
+ ----------
2090
+ u : Union[NDArray[np.float64], list, tuple]
2091
+ Initial condition vector (shape: `(neq,)`). Will be converted to a contiguous float64 NumPy array.
2092
+ total_time : int
2093
+ Total number of iterations to simulate. Must be a positive integer.
2094
+ parameters : Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
2095
+ System parameters passed to the mapping function. Scalars and sequences will be converted automatically.
2096
+ eps : float, optional
2097
+ Size of the neighborhood for recurrence detection (default is 1e-2).
2098
+ Must be a positive number.
2099
+ transient_time : Optional[int], optional
2100
+ Initial iterations to discard (default is None, meaning no transient time).
2101
+ If provided, must be a non-negative integer.
2102
+
2103
+ Returns
2104
+ -------
2105
+ NDArray[np.float64]
2106
+ Array of recurrence times (time steps between re-entries into the neighborhood). Returns an empty array if no recurrences occur.
2107
+
2108
+ Raises
2109
+ ------
2110
+ TypeError
2111
+ - If `u` is not a scalar, or 1D array, or if its shape does not match the expected system dimension.
2112
+ - If `parameters` is not None and does not match the expected number of parameters.
2113
+ - If `parameters` is None but the system expects parameters.
2114
+ - If `parameters` is a scalar or array-like but not 1D.
2115
+ - If `total_time` is negative.
2116
+ - If `trasient_time` is negative.
2117
+ - If `transient_time` is greater than or equal to total_time.
2118
+ - If `eps` is not a positive float.
2119
+ TypeError
2120
+ - If `u` is not a scalar or array-like type.
2121
+ - If `parameters` is not a scalar or array-like type
2122
+ - If `total_time` is not int.
2123
+ - If `transient_time` is not int.
2124
+ - If `eps` is not float.
2125
+
2126
+
2127
+ Notes
2128
+ -----
2129
+ - This method wraps a JIT-compiled function for performance.
2130
+ - A recurrence is counted when the system state re-enters the axis-aligned hypercube:
2131
+ [u - eps/2, u + eps/2]^d
2132
+ - This is commonly used in nonlinear dynamics to study:
2133
+ - Stickiness
2134
+ - Poincaré recurrences
2135
+ - Mixing and ergodicity
2136
+
2137
+ Examples
2138
+ --------
2139
+ >>> u0 = [0.1, 0.1]
2140
+ >>> parameters = [0.6, 0.4]
2141
+ >>> rec_times = system.recurrence_times(u0, parameters, 10000, eps=0.01)
2142
+ >>> print(rec_times)
2143
+ array([400, 523, 861, ...])
2144
+ """
2145
+
2146
+ u = validate_initial_conditions(
2147
+ u, self.__system_dimension, allow_ensemble=False
2148
+ )
2149
+
2150
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
2151
+
2152
+ validate_non_negative(total_time, "total_time", Integral)
2153
+
2154
+ validate_transient_time(transient_time, total_time, Integral)
2155
+
2156
+ validate_non_negative(eps, "eps", Real)
2157
+
2158
+ return recurrence_times(
2159
+ u,
2160
+ parameters,
2161
+ total_time,
2162
+ self.__mapping,
2163
+ eps,
2164
+ transient_time=transient_time,
2165
+ )
2166
+
2167
+ def dig(
2168
+ self,
2169
+ u: Union[NDArray[np.float64], Sequence[float]],
2170
+ total_time: int,
2171
+ parameters: Union[
2172
+ None, float, Sequence[np.float64], NDArray[np.float64]
2173
+ ] = None,
2174
+ func: Callable[[NDArray[np.float64]], NDArray[np.float64]] = lambda x: np.cos(
2175
+ 2 * np.pi * x[:, 0]
2176
+ ),
2177
+ transient_time: Optional[int] = None,
2178
+ ) -> float:
2179
+ """Compute the number of zeros after the decimal point of the average
2180
+ of the observable function over time.
2181
+
2182
+ Parameters
2183
+ ----------
2184
+ u : Union[NDArray[np.float64], Sequence[float]]
2185
+ Initial condition of shape (d,) where d is system dimension
2186
+ parameters : Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
2187
+ System parameters of shape (p,)
2188
+ total_time : int
2189
+ Total iterations to compute (must be even and ≥ 100)
2190
+ func : Callable[[NDArray[np.float64]], float], optional
2191
+ Observable function (default: lambda x: np.cos(x[:, 0]))
2192
+ Should accept a 2D array (sample_size, ndim) and return a 1D array
2193
+ of shape (sample_size,) with the observable values
2194
+ transient_time : Optional[int], optional
2195
+ Initial iterations to discard (default None)
2196
+
2197
+ Returns
2198
+ -------
2199
+ float
2200
+ DIG value where:
2201
+
2202
+ - Higher values indicate better convergence, i.e., regular dynamics
2203
+
2204
+ Raises
2205
+ ------
2206
+ ValueError
2207
+ - If `u` is not a scalar, or 1D array, or if its shape does not match the expected system dimension.
2208
+ - If `parameters` is not None and does not match the expected number of parameters.
2209
+ - If `parameters` is None but the system expects parameters.
2210
+ - If `parameters` is a scalar or array-like but not 1D.
2211
+ - If `total_time` is negative.
2212
+ - If `trasient_time` is negative.
2213
+ - If `transient_time` is greater than or equal to total_time.
2214
+ - If `func` is not callable or does not return a 1D array.
2215
+ TypeError
2216
+ - If `u` is not a scalar or array-like type.
2217
+ - If `parameters` is not a scalar or array-like type.
2218
+ - If `total_time` is not int.
2219
+ - If `transient_time` is not int.
2220
+
2221
+ Examples
2222
+ --------
2223
+ >>> # Using cosine of x-coordinate observable
2224
+ >>> x_obs = lambda X: cos(X[:, 0])
2225
+ >>> convergence = system.dig(u0, params, 1000, x_obs)
2226
+ >>> # Using sin of the sum of x and y coordinates
2227
+ >>> convergence = system.dig(u0, params, 1000, func=lambda X: sin(X[:, 0] + X[:, 1]))
2228
+ >>> # With transient period
2229
+ >>> convergence = system.dig(u0, params, 2000, x_obs, transient_time=500)
2230
+ """
2231
+
2232
+ u = validate_initial_conditions(
2233
+ u, self.__system_dimension, allow_ensemble=False
2234
+ )
2235
+
2236
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
2237
+
2238
+ validate_non_negative(total_time, "total_time", Integral)
2239
+
2240
+ if total_time % 2 != 0:
2241
+ total_time += 1 # Ensure even total_time
2242
+
2243
+ validate_transient_time(transient_time, total_time, Integral)
2244
+
2245
+ if not callable(func):
2246
+ raise ValueError("`func` must be a callable function")
2247
+ if (
2248
+ not isinstance(func(np.array([u])), np.ndarray)
2249
+ or func(np.array([u])).ndim != 1
2250
+ ):
2251
+ raise ValueError("`func` must return a 1D array")
2252
+
2253
+ return dig(
2254
+ u,
2255
+ parameters,
2256
+ total_time,
2257
+ self.__mapping,
2258
+ func,
2259
+ transient_time=transient_time,
2260
+ )
2261
+
2262
+ def lyapunov(
2263
+ self,
2264
+ u: Union[NDArray[np.float64], Sequence[float], float],
2265
+ total_time: int,
2266
+ parameters: Union[
2267
+ None, float, Sequence[np.float64], NDArray[np.float64]
2268
+ ] = None,
2269
+ method: str = "QR",
2270
+ return_history: bool = False,
2271
+ sample_times: Optional[Union[NDArray[np.int32], Sequence[int]]] = None,
2272
+ transient_time: Optional[int] = None,
2273
+ log_base: float = np.e,
2274
+ ) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
2275
+ """Compute Lyapunov exponents using specified numerical method.
2276
+
2277
+ Parameters
2278
+ ----------
2279
+ u : Union[NDArray[np.float64], Sequence[float]]
2280
+ Initial condition(s) of shape (d,) or (n, d) where d is system dimension
2281
+ total_time : int
2282
+ Total iterations to compute (default 10000, must be ≥ 1)
2283
+ parameters : Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
2284
+ System parameters of shape (p,) passed to mapping function
2285
+ method : str, optional
2286
+ Computation method:
2287
+ - "QR": QR decomposition
2288
+ - "QR_HH": Householder QR (more stable)
2289
+ return_history : bool, optional
2290
+ If True, returns convergence history (default False)
2291
+ sample_times : Optional[Union[NDArray[np.float64], Sequence[int]]], optional
2292
+ Specific times to sample when return_history=True
2293
+ transient_time : Optional[int], optional
2294
+ Initial iterations to discard (default None → total_time//10)
2295
+ log_base : float, optional (default np.e)
2296
+ Logarithm base for exponents (e.g. e, 2, or 10)
2297
+
2298
+ Returns
2299
+ -------
2300
+ Union[Tuple[NDArray[np.float64], NDArray[np.float64]],
2301
+ Tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]]
2302
+
2303
+ - If return_history=False: exponents
2304
+ - If return_history=True: history
2305
+
2306
+ Raises
2307
+ ------
2308
+ ValueError
2309
+ - If `u` is not a scalar, or 1D array, or if its shape does not match the expected system dimension.
2310
+ - If `parameters` is not None and does not match the expected number of parameters.
2311
+ - If `parameters` is None but the system expects parameters.
2312
+ - If `parameters` is a scalar or array-like but not 1D.
2313
+ - If `total_time` is negative.
2314
+ - If `trasient_time` is negative.
2315
+ - If `transient_time` is greater than or equal to total_time.
2316
+ - If `method` is not "QR" or "QR_HH".
2317
+ - If `sample_times` is not a 1D array of integers.
2318
+ - If `log_base` is not positive
2319
+ TypeError
2320
+ - If `u` is not a scalar or array-like type.
2321
+ - If `parameters` is not a scalar or array-like type.
2322
+ - If `total_time` is not int.
2323
+ - If `transient_time` is not int.
2324
+ - If `log_base` is not float.
2325
+ - If sample_times cannot be converted to a 1D array of integers.
2326
+ - If `method` is not a string.
2327
+
2328
+ Notes
2329
+ -----
2330
+ - ER method is fastest for 2D systems
2331
+ - QR methods are more stable for higher dimensions
2332
+ - Sample times are automatically sorted and deduplicated
2333
+ - Final exponents are averaged over last 10% of iterations
2334
+
2335
+ References
2336
+ ----------
2337
+ [1] Eckmann & Ruelle, Rev. Mod. Phys 57, 617 (1985)
2338
+ [2] Wolf et al., Physica 16D 285-317 (1985)
2339
+
2340
+ Examples
2341
+ --------
2342
+ >>> # Basic 2D system with ER method
2343
+ >>> u0 = np.array([0.1, 0.2])
2344
+ >>> params = np.array([0.5, 1.0])
2345
+ >>> lyapunov_exponents = system.lyapunov(u0, 10000,
2346
+ ... parameters=params)
2347
+
2348
+ >>> # With convergence history
2349
+ >>> lyapunov_exponents = system.lyapunov(u0, 10000,
2350
+ ... parameters=params, return_history=True)
2351
+ >>> # Using Householder QR for better stability
2352
+ >>> lyapunov_exponents = system.lyapunov(u0, 10000,
2353
+ ... parameters=params, method="QR_HH", return_history=True)
2354
+ >>> # With transient time and logarithm base 10
2355
+ >>> lyapunov_exponents = system.lyapunov(u0, 10000,
2356
+ ... parameters=params, transient_time=1000,
2357
+ ... log_base=10.0, return_history=True)
2358
+ """
2359
+
2360
+ u = validate_initial_conditions(
2361
+ u, self.__system_dimension, allow_ensemble=False
2362
+ )
2363
+
2364
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
2365
+
2366
+ validate_non_negative(total_time, "total_time", Integral)
2367
+ validate_transient_time(transient_time, total_time, Integral)
2368
+
2369
+ # Validate method
2370
+ if not isinstance(method, str):
2371
+ raise TypeError("method must be a string")
2372
+ method = method.upper()
2373
+ if method not in ("QR", "QR_HH"):
2374
+ raise ValueError("method must be 'QR' or 'QR_HH'")
2375
+
2376
+ # Validate method for system dimension
2377
+ if method == "QR" and self.__system_dimension == 2:
2378
+ method = "ER" # Fallback to QR for higher dimensions
2379
+
2380
+ sample_times = validate_sample_times(sample_times, total_time)
2381
+
2382
+ validate_non_negative(log_base, "log_base", Real)
2383
+ if log_base == 1:
2384
+ raise ValueError("The logarithm function is not defined with base 1.")
2385
+
2386
+ # Dispatch to appropriate computation
2387
+ if self.__system_dimension == 1:
2388
+ compute_func = lyapunov_1D
2389
+ else:
2390
+ if method == "ER":
2391
+ compute_func = lyapunov_er
2392
+ elif method == "QR":
2393
+ compute_func = lyapunov_qr
2394
+ else: # QR_HH
2395
+ compute_func = lambda *args, **kwargs: lyapunov_qr(
2396
+ *args, QR=householder_qr, **kwargs
2397
+ )
2398
+
2399
+ result = compute_func(
2400
+ u,
2401
+ parameters,
2402
+ total_time,
2403
+ self.__mapping,
2404
+ self.__jacobian,
2405
+ return_history=return_history,
2406
+ sample_times=sample_times,
2407
+ transient_time=transient_time,
2408
+ log_base=log_base,
2409
+ )
2410
+
2411
+ if return_history:
2412
+ return result[0]
2413
+ else:
2414
+ return result[0][:, 0] if self.__system_dimension > 1 else result[0]
2415
+
2416
+ def finite_time_lyapunov(
2417
+ self,
2418
+ u: Union[NDArray[np.float64], Sequence[float], float],
2419
+ total_time: int,
2420
+ finite_time: int,
2421
+ parameters: Union[
2422
+ None, float, Sequence[np.float64], NDArray[np.float64]
2423
+ ] = None,
2424
+ method: str = "QR",
2425
+ transient_time: Optional[int] = None,
2426
+ log_base: float = np.e,
2427
+ return_points: bool = False,
2428
+ ) -> Union[NDArray[np.float64], Tuple[NDArray[np.float64], NDArray[np.float64]]]:
2429
+ """Compute finite-time Lyapunov exponents (FTLE) along trajectory.
2430
+
2431
+ Parameters
2432
+ ----------
2433
+ u : Union[NDArray[np.float64], Sequence[float]]
2434
+ Initial condition of shape (d,) where d is system dimension
2435
+ total_time : int
2436
+ Total simulation time steps (must be > finite_time, default 10000)
2437
+ finite_time : int
2438
+ Averaging window size in time steps (default 100)
2439
+ parameters : Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
2440
+ System parameters of shape (p,) passed to mapping function
2441
+ method : str, optional
2442
+ Computation method:
2443
+ - "ER": Eckmann-Ruelle (optimal for 2D systems)
2444
+ - "QR": Gram-Schmidt QR decomposition
2445
+ - "QR_HH": Householder QR (more stable)
2446
+ transient_time : Optional[int], optional
2447
+ Initial burn-in period to discard (default None → finite_time)
2448
+
2449
+ Returns
2450
+ -------
2451
+ NDArray[np.float64]
2452
+ FTLE matrix of shape (n_windows, d) where:
2453
+
2454
+ - n_windows = (total_time - transient_time) // finite_time
2455
+ - Each row contains exponents for one time window
2456
+ - Columns are ordered by decreasing exponent magnitude
2457
+
2458
+ Raises
2459
+ ------
2460
+ ValueError
2461
+ - If `u` is not a scalar, or 1D array, or if its shape does not match the expected system dimension.
2462
+ - If `parameters` is not None and does not match the expected number of parameters.
2463
+ - If `parameters` is None but the system expects parameters.
2464
+ - If `parameters` is a scalar or array-like but not 1D.
2465
+ - If `total_time` is negative.
2466
+ - If `finite_time` is negative or zero.
2467
+ - If `trasient_time` is negative.
2468
+ - If `transient_time` is greater than or equal to total_time.
2469
+ - If `method` is not "QR" or "QR_HH".
2470
+ - If `log_base` is not positive
2471
+ TypeError
2472
+ - If `u` is not a scalar or array-like type.
2473
+ - If `parameters` is not a scalar or array-like type.
2474
+ - If `total_time` is not int.
2475
+ - If `transient_time` is not int.
2476
+ - If `log_base` is not float.
2477
+ - If `method` is not a string.
2478
+ - If `return_points` is not a boolean.
2479
+
2480
+ Notes
2481
+ -----
2482
+ - FTLE measure local stretching rates over finite intervals
2483
+ - For chaotic systems, FTLE → true exponents as finite_time → ∞
2484
+ - ER method is faster but limited to 2D systems
2485
+ - Results are more reliable when:
2486
+ - finite_time >> 1
2487
+ - (total_time - transient_time) // finite_time >> 1
2488
+
2489
+ Examples
2490
+ --------
2491
+ >>> # Basic usage with defaults
2492
+ >>> u0 = np.array([0.1, 0.2])
2493
+ >>> params = np.array([0.5, 1.0])
2494
+ >>> ftle = system.finite_time_lyapunov_exponents(u0, params)
2495
+
2496
+ >>> # With custom parameters
2497
+ >>> ftle = system.finite_time_lyapunov_exponents(
2498
+ ... u0, params,
2499
+ ... total_time=5000,
2500
+ ... finite_time=50,
2501
+ ... method="GS"
2502
+ ... )
2503
+ """
2504
+
2505
+ u = validate_initial_conditions(
2506
+ u, self.__system_dimension, allow_ensemble=False
2507
+ )
2508
+
2509
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
2510
+
2511
+ validate_non_negative(total_time, "total_time", Integral)
2512
+ validate_positive(finite_time, "finite_time", Integral)
2513
+ validate_finite_time(finite_time, total_time)
2514
+ validate_transient_time(transient_time, total_time, Integral)
2515
+
2516
+ # Validate method
2517
+ if not isinstance(method, str):
2518
+ raise TypeError("method must be a string")
2519
+ method = method.upper()
2520
+ if method not in ("QR", "QR_HH"):
2521
+ raise ValueError("method must be 'QR' or 'QR_HH'")
2522
+
2523
+ # Validate method for system dimension
2524
+ if method == "QR" and self.__system_dimension == 2:
2525
+ method = "ER" # Fallback to QR for higher dimensions
2526
+
2527
+ validate_non_negative(log_base, "log_base", Real)
2528
+ if log_base == 1:
2529
+ raise ValueError("The logarithm function is not defined with base 1.")
2530
+
2531
+ if not isinstance(return_points, bool):
2532
+ raise TypeError("return_points must be a boolean")
2533
+
2534
+ return finite_time_lyapunov(
2535
+ u,
2536
+ parameters,
2537
+ total_time,
2538
+ finite_time,
2539
+ self.__mapping,
2540
+ self.__jacobian,
2541
+ method=method,
2542
+ transient_time=transient_time,
2543
+ log_base=log_base,
2544
+ return_points=return_points,
2545
+ )
2546
+
2547
+ def hurst_exponent(
2548
+ self,
2549
+ u: Union[NDArray[np.float64], Sequence[float], float],
2550
+ total_time: int,
2551
+ parameters: Union[
2552
+ None, float, Sequence[np.float64], NDArray[np.float64]
2553
+ ] = None,
2554
+ wmin: int = 2,
2555
+ transient_time: Optional[int] = None,
2556
+ ) -> NDArray[np.float64]:
2557
+ """
2558
+ Estimate the Hurst exponent for a system trajectory using the rescaled range (R/S) method.
2559
+
2560
+ Parameters
2561
+ ----------
2562
+ u : NDArray[np.float64]
2563
+ Initial condition vector of shape (n,).
2564
+ parameters : Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
2565
+ Parameters passed to the mapping function.
2566
+ total_time : int
2567
+ Total number of iterations used to generate the trajectory.
2568
+ mapping : Callable[[NDArray[np.float64],
2569
+ NDArray[np.float64]], NDArray[np.float64]]
2570
+ A function that defines the system dynamics, i.e., how `u` evolves over time given `parameters`.
2571
+ wmin : int, optional
2572
+ Minimum window size for the rescaled range calculation. Default is 2.
2573
+ transient_time : Optional[int], optional
2574
+ Number of initial iterations to discard as transient. If `None`, no transient is removed. Default is `None`.
2575
+
2576
+ Returns
2577
+ -------
2578
+ NDArray[np.float64]
2579
+ Estimated Hurst exponents for each dimension of the input vector `u`, of shape (n,).
2580
+
2581
+ Raises
2582
+ ------
2583
+ ValueError
2584
+ - If `u` is not a 2D array, or if its shape does not match the expected system dimension.
2585
+ - If `parameters` is not None and does not match the expected number of parameters.
2586
+ - If `parameters` is None but the system expects parameters.
2587
+ - If `parameters` is a scalar or array-like but not 1D.
2588
+ - If `total_time` is negative or zero.
2589
+ - If `transient_time` is negative or greater than or equal to `total_time`.
2590
+ - If `wmin` is not a positive integer or is less than 2 or greater than total_time // 2.
2591
+
2592
+ TypeError
2593
+ - If `u` is not a scalar or array-like type.
2594
+ - If `parameters` is not a scalar or array-like type.
2595
+ - If `total_time` is not int.
2596
+ - If `wmin` is not a positive integer.
2597
+
2598
+ Notes
2599
+ -----
2600
+ The Hurst exponent is a measure of the long-term memory of a time series:
2601
+
2602
+ - H = 0.5 indicates a random walk (no memory).
2603
+ - H > 0.5 indicates persistent behavior (positive autocorrelation).
2604
+ - H < 0.5 indicates anti-persistent behavior (negative autocorrelation).
2605
+
2606
+ This implementation computes the rescaled range (R/S) for various window sizes and
2607
+ performs a linear regression in log-log space to estimate the exponent.
2608
+
2609
+ The function supports multivariate time series, estimating one Hurst exponent per dimension.
2610
+ """
2611
+
2612
+ u = validate_initial_conditions(
2613
+ u, self.__system_dimension, allow_ensemble=False
2614
+ )
2615
+
2616
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
2617
+
2618
+ validate_non_negative(total_time, "total_time", Integral)
2619
+ validate_transient_time(transient_time, total_time, Integral)
2620
+
2621
+ validate_positive(wmin, "wmin", Integral)
2622
+ if wmin < 2 or wmin >= total_time // 2:
2623
+ raise ValueError(
2624
+ f"`wmin` must be an integer >= 2 and <= total_time / 2. Got {wmin}."
2625
+ )
2626
+
2627
+ return hurst_exponent(
2628
+ u,
2629
+ parameters,
2630
+ total_time,
2631
+ self.__mapping,
2632
+ wmin=wmin,
2633
+ transient_time=transient_time,
2634
+ )
2635
+
2636
+ def finite_time_hurst_exponent(
2637
+ self,
2638
+ u: Union[NDArray[np.float64], Sequence[float], float],
2639
+ total_time: int,
2640
+ finite_time: int,
2641
+ parameters: Union[
2642
+ None, float, Sequence[np.float64], NDArray[np.float64]
2643
+ ] = None,
2644
+ wmin: int = 2,
2645
+ return_points: bool = False,
2646
+ ) -> Union[NDArray[np.float64], Tuple[NDArray[np.float64], NDArray[np.float64]]]:
2647
+ """Compute finite-time Hurst exponent along a trajectory.
2648
+
2649
+ Parameters
2650
+ ----------
2651
+ u : Union[NDArray[np.float64], Sequence[float]]
2652
+ Initial condition of shape (d,) where d is system dimension
2653
+ total_time : int
2654
+ Total simulation time steps (must be > finite_time
2655
+ finite_time : int
2656
+ Averaging window size in time steps
2657
+ parameters : Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
2658
+ System parameters of shape (p,) passed to mapping function
2659
+ wmin : int, optional
2660
+ Minimum window size for the rescaled range calculation (default 2)
2661
+ return_points : bool, optional
2662
+ If True, returns full evolution (default False)
2663
+
2664
+ Returns
2665
+ -------
2666
+ Union[NDArray[np.float64], Tuple[NDArray[np.float64], NDArray[np.float64]]]
2667
+ - If return_points=False: Hurst exponent(scalar)
2668
+ - If return_points=True: Tuple of (Hurst history, final state) where Hurst history is 1D array of values
2669
+
2670
+ Raises
2671
+ ------
2672
+ ValueError
2673
+ - If `u` is not a scalar, or 1D array, or if its shape does not match the expected system dimension.
2674
+ - If `parameters` is not None and does not match the expected number of parameters.
2675
+ - If `parameters` is None but the system expects parameters.
2676
+ - If `parameters` is a scalar or array-like but not 1D.
2677
+ - If `total_time` is negative.
2678
+ - If `finite_time` is negative or zero.
2679
+ - If `trasient_time` is negative.
2680
+ - If `transient_time` is greater than or equal to total_time.
2681
+ - If `wmin` is not a positive integer or is less than 2 or greater than total_time // 2.
2682
+
2683
+ TypeError
2684
+ - If `u` is not a scalar or array-like type.
2685
+ - If `parameters` is not a scalar or array-like type.
2686
+ - If `total_time` is not int.
2687
+ - If `finite_time` is not int.
2688
+ - If `wmin` is not a positive integer.
2689
+ - If `return_points` is not a boolean.
2690
+
2691
+ Notes
2692
+ -----
2693
+ - Finite-time Hurst exponent measures local scaling behavior over finite intervals
2694
+ - For chaotic systems, FTHE → true exponents as finite_time → ∞
2695
+ - Results are more reliable when:
2696
+ - finite_time >> 1
2697
+ - (total_time - transient_time) // finite_time >> 1
2698
+
2699
+ Examples
2700
+ --------
2701
+ >>> # Basic usage with defaults
2702
+ >>> u0 = np.array([0.1, 0.2])
2703
+ >>> params = np.array([0.5, 1.0])
2704
+ >>> fthe = system.finite_time_hurst_exponent(u0, 100000, 100, parameters=params)
2705
+
2706
+ """
2707
+
2708
+ u = validate_initial_conditions(
2709
+ u, self.__system_dimension, allow_ensemble=False
2710
+ )
2711
+
2712
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
2713
+
2714
+ validate_non_negative(total_time, "total_time", Integral)
2715
+ validate_positive(finite_time, "finite_time", Integral)
2716
+ validate_finite_time(finite_time, total_time)
2717
+
2718
+ return finite_time_hurst_exponent(
2719
+ u,
2720
+ parameters,
2721
+ total_time,
2722
+ finite_time,
2723
+ self.__mapping,
2724
+ wmin=wmin,
2725
+ return_points=return_points,
2726
+ )
2727
+
2728
+ def SALI(
2729
+ self,
2730
+ u: Union[NDArray[np.float64], Sequence[float]],
2731
+ total_time: int,
2732
+ parameters: Union[
2733
+ None, float, Sequence[np.float64], NDArray[np.float64]
2734
+ ] = None,
2735
+ return_history: bool = False,
2736
+ sample_times: Optional[Union[NDArray[np.int32], Sequence[int]]] = None,
2737
+ tol: float = 1e-16,
2738
+ transient_time: Optional[int] = None,
2739
+ seed: int = 13,
2740
+ ) -> Union[NDArray[np.float64], Tuple[NDArray[np.float64], NDArray[np.float64]]]:
2741
+ """Compute Smallest Alignment Index(SALI) for chaos detection.
2742
+
2743
+ Parameters
2744
+ ----------
2745
+ u: Union[NDArray[np.float64], Sequence[float]]
2746
+ Initial condition of shape(d,) where d is system dimension
2747
+ total_time: int
2748
+ Maximum number of iterations(must be ≥ 1)
2749
+ parameters: Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
2750
+ System parameters of shape(p,) passed to mapping function
2751
+ return_history: bool, optional
2752
+ If True, returns full evolution(default False)
2753
+ sample_times: Optional[Union[NDArray[np.float64], Sequence[int]]], optional
2754
+ Specific times to sample(must be sorted, default None)
2755
+ tol: float, optional
2756
+ Early termination threshold(default 1e-16)
2757
+ transient_time: Optional[int], optional
2758
+ Initial iterations to discard(default None → total_time//10)
2759
+ seed: int, optional
2760
+ Random seed for reproducibility (default 13)
2761
+
2762
+ Returns
2763
+ -------
2764
+ Union[NDArray[np.float64], Tuple[NDArray[np.float64], NDArray[np.float64]]]
2765
+ - If return_history = False: Final SALI value(scalar)
2766
+ - If return_history = True: Tuple of(SALI_history, final_state) where SALI_history is 1D array of values
2767
+
2768
+ Raises
2769
+ ------
2770
+ ValueError
2771
+ - If `u` is not an 1D array, or if its shape does not match the expected system dimension.
2772
+ - If `parameters` is not None and does not match the expected number of parameters.
2773
+ - If `parameters` is None but the system expects parameters.
2774
+ - If `parameters` is a scalar or array-like but not 1D.
2775
+ - If `total_time` is negative.
2776
+ - If `trasient_time` is negative.
2777
+ - If `transient_time` is greater than or equal to total_time.
2778
+ - If `sample_times` is not a 1D array of integers.
2779
+ TypeError
2780
+ - If `u` is not a scalar or array-like type.
2781
+ - If `parameters` is not a scalar or array-like type.
2782
+ - If `total_time` is not int.
2783
+ - If `transient_time` is not int.
2784
+ - If sample_times cannot be converted to a 1D array of integers.
2785
+ - If `tol` is not a positive float.
2786
+ - If `seed` is not an integer.
2787
+
2788
+ Notes
2789
+ -----
2790
+ - SALI behavior:
2791
+ - → 0 exponentially for chaotic orbits
2792
+ - → positive constant for regular orbits
2793
+ - Typical threshold: SALI < 1e-8 suggests chaos
2794
+ - For Hamiltonian systems, uses 2 deviation vectors
2795
+ - Early termination when SALI < tol
2796
+
2797
+ Examples
2798
+ --------
2799
+ >>> # Basic usage (final value only)
2800
+ >>> u0 = np.array([0.1, 0.2])
2801
+ >>> params = np.array([0.5, 1.0])
2802
+ >>> sali = system.SALI(u0, params, 10000)
2803
+
2804
+ >>> # With full history
2805
+ >>> sali_hist, final = system.SALI(
2806
+ ... u0, params, 10000, return_history=True)
2807
+
2808
+ >>> # With custom sampling
2809
+ >>> times = np.array([100, 1000, 5000])
2810
+ >>> sali_samples, _ = system.SALI(
2811
+ ... u0, params, 10000, sample_times=times, return_history=True)
2812
+ """
2813
+
2814
+ u = validate_initial_conditions(
2815
+ u, self.__system_dimension, allow_ensemble=False
2816
+ )
2817
+
2818
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
2819
+
2820
+ validate_non_negative(total_time, "total_time", Integral)
2821
+ validate_transient_time(transient_time, total_time, Integral)
2822
+
2823
+ sample_times = validate_sample_times(sample_times, total_time)
2824
+
2825
+ validate_non_negative(tol, "tol", Real)
2826
+
2827
+ if not isinstance(seed, Integral):
2828
+ raise TypeError("seed must be an integer")
2829
+
2830
+ result = SALI(
2831
+ u,
2832
+ parameters,
2833
+ total_time,
2834
+ self.__mapping,
2835
+ self.__jacobian,
2836
+ return_history=return_history,
2837
+ sample_times=sample_times,
2838
+ transient_time=transient_time,
2839
+ tol=tol,
2840
+ seed=seed,
2841
+ )
2842
+
2843
+ return result if return_history else result[0]
2844
+
2845
+ def LDI(
2846
+ self,
2847
+ u: Union[NDArray[np.float64], Sequence[float]],
2848
+ total_time: int,
2849
+ k: int,
2850
+ parameters: Union[
2851
+ None, float, Sequence[np.float64], NDArray[np.float64]
2852
+ ] = None,
2853
+ return_history: bool = False,
2854
+ sample_times: Optional[Union[NDArray[np.int32], Sequence[int]]] = None,
2855
+ tol: float = 1e-16,
2856
+ transient_time: Optional[int] = None,
2857
+ seed: int = 13,
2858
+ ) -> Union[NDArray[np.float64], Tuple[NDArray[np.float64], NDArray[np.float64]]]:
2859
+ """Compute Linear Dependence Index(LDI_k) for chaos detection.
2860
+
2861
+ Parameters
2862
+ ----------
2863
+ u: Union[NDArray[np.float64], Sequence[float]]
2864
+ Initial condition of shape(d,) where d is system dimension
2865
+ total_time: int
2866
+ Maximum number of iterations(must be ≥ 1)
2867
+ k: int
2868
+ Number of deviation vectors to use(2 ≤ k ≤ d, default 2)
2869
+ parameters: Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
2870
+ System parameters of shape(p,) passed to mapping function
2871
+ return_history: bool, optional
2872
+ If True, returns full evolution(default False)
2873
+ sample_times: Optional[Union[NDArray[np.float64], Sequence[int]]], optional
2874
+ Specific times to sample(must be sorted, default None)
2875
+ tol: float, optional
2876
+ Early termination threshold(default 1e-16)
2877
+ transient_time: Optional[int], optional
2878
+ Initial iterations to discard(default None → total_time//10)
2879
+ seed: int, optional
2880
+ Random seed for reproducibility(default 13)
2881
+
2882
+ Returns
2883
+ -------
2884
+ Union[NDArray[np.float64], Tuple[NDArray[np.float64], NDArray[np.float64]]]
2885
+ - If return_history = False: Final LDI_k value(scalar)
2886
+ - If return_history = True: Tuple of(LDI_history, final_state) where LDI_history is 1D array of values
2887
+
2888
+ Raises
2889
+ ------
2890
+ ValueError
2891
+ - If `u` is not an 1D array, or if its shape does not match the expected system dimension.
2892
+ - If `parameters` is not None and does not match the expected number of parameters.
2893
+ - If `parameters` is None but the system expects parameters.
2894
+ - If `parameters` is a scalar or array-like but not 1D.
2895
+ - If `total_time` is negative.
2896
+ - If `trasient_time` is negative.
2897
+ - If `transient_time` is greater than or equal to total_time.
2898
+ - If `sample_times` is not a 1D array of integers.
2899
+ - If `k` is less than 2 or greater than system dimension.
2900
+
2901
+ TypeError
2902
+ - If `u` is not a scalar or array-like type.
2903
+ - If `parameters` is not a scalar or array-like type.
2904
+ - If `total_time` is not int.
2905
+ - If `transient_time` is not int.
2906
+ - If sample_times cannot be converted to a 1D array of integers.
2907
+ - If `tol` is not a positive float.
2908
+ - If `seed` is not an integer.
2909
+ - If `k` is not a positive integer.
2910
+
2911
+ Notes
2912
+ -----
2913
+ - LDI_k behavior:
2914
+ - → 0 exponentially for chaotic orbits(rate depends on k)
2915
+ - → positive constant for regular orbits
2916
+ - LDI_2 ~ SALI(same convergence rate)
2917
+ - Higher k indices decay faster for chaotic orbits
2918
+ - For Hamiltonian systems, k should be ≤ d/2
2919
+ - Early termination when LDI_k < tol
2920
+
2921
+ Examples
2922
+ --------
2923
+ >>> # Basic usage (LDI_2 final value)
2924
+ >>> u0 = np.array([0.1, 0.2, 0.0, 0.0])
2925
+ >>> params = np.array([0.5, 1.0])
2926
+ >>> LDI = system.LDI(u0, params, 10000, k=2)
2927
+
2928
+ >>> # LDI_3 with full history
2929
+ >>> LDI_hist, final = system.LDI(
2930
+ ... u0, params, 10000, k=3, return_history=True)
2931
+
2932
+ >>> # With custom sampling
2933
+ >>> times = np.array([100, 1000, 5000])
2934
+ >>> LDI_samples, _ = system.LDI(
2935
+ ... u0, params, 10000, k=2, sample_times=times, return_history=True)
2936
+ """
2937
+
2938
+ u = validate_initial_conditions(
2939
+ u, self.__system_dimension, allow_ensemble=False
2940
+ )
2941
+
2942
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
2943
+
2944
+ validate_non_negative(total_time, "total_time", Integral)
2945
+ validate_transient_time(transient_time, total_time, Integral)
2946
+
2947
+ validate_positive(k, "k", Integral)
2948
+ if k < 2 or k > self.__system_dimension:
2949
+ raise ValueError(f"k must be in range [2, {self.__system_dimension}]")
2950
+
2951
+ sample_times = validate_sample_times(sample_times, total_time)
2952
+
2953
+ validate_non_negative(tol, "tol", Real)
2954
+
2955
+ if not isinstance(seed, Integral):
2956
+ raise TypeError("seed must be an integer")
2957
+
2958
+ # Call underlying implementation
2959
+ result = LDI_k(
2960
+ u,
2961
+ parameters,
2962
+ total_time,
2963
+ self.__mapping,
2964
+ self.__jacobian,
2965
+ k=k,
2966
+ return_history=return_history,
2967
+ sample_times=sample_times,
2968
+ transient_time=transient_time,
2969
+ tol=tol,
2970
+ seed=seed,
2971
+ )
2972
+
2973
+ return result if return_history else result[0]
2974
+
2975
+ def __lagrangian_descriptors(
2976
+ self,
2977
+ u: Union[NDArray[np.float64], Sequence[float]],
2978
+ parameters: Union[float, Sequence[np.float64], NDArray[np.float64]],
2979
+ total_time: int = 10000,
2980
+ transient_time: Optional[int] = None,
2981
+ ) -> NDArray[np.float64]:
2982
+ """Compute Lagrangian Descriptors(LDs) for the dynamical system.
2983
+
2984
+ Parameters
2985
+ ----------
2986
+ u: Union[NDArray[np.float64], Sequence[float]]
2987
+ Initial condition of shape(d,) where d is system dimension.
2988
+ Can be any sequence convertible to numpy array.
2989
+ parameters: Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
2990
+ System parameters of shape(p,) passed to mapping functions.
2991
+ total_time: int, optional
2992
+ Total number of iterations to compute(default 10000, must be > 0).
2993
+ transient_time: Optional[int], optional
2994
+ Number of initial iterations to discard(default None → no transient).
2995
+
2996
+ Returns
2997
+ -------
2998
+ NDArray[np.float64]
2999
+ Array of shape(2,) containing:
3000
+
3001
+ - [0]: Forward Lagrangian descriptor
3002
+ - [1]: Backward Lagrangian descriptor
3003
+
3004
+ Raises
3005
+ ------
3006
+ NotImplementedError
3007
+ If mapping is not defined
3008
+ If backwards mapping is not defined for this system
3009
+ ValueError
3010
+ If initial condition has wrong dimension
3011
+ If parameters are invalid
3012
+ If time parameters are invalid
3013
+ TypeError
3014
+ If inputs cannot be converted to required types
3015
+
3016
+ Notes
3017
+ -----
3018
+ - LDs reveal phase space structures and invariant manifolds
3019
+ - Higher values indicate stronger stretching in phase space
3020
+ - For meaningful results:
3021
+ - Use total_time >> 1 (typically ≥ 1000)
3022
+ - Ensure mapping and backwards_mapping are exact inverses
3023
+ - Transient period helps avoid initialization artifacts
3024
+
3025
+ Examples
3026
+ --------
3027
+ >>> # Basic usage
3028
+ >>> u0 = np.array([0.1, 0.2])
3029
+ >>> params = np.array([0.5, 1.0])
3030
+ >>> lds = system.compute_lagrangian_descriptors(u0, params)
3031
+ >>> forward_ld, backward_ld = lds
3032
+
3033
+ >>> # With transient period
3034
+ >>> lds = system.compute_lagrangian_descriptors(
3035
+ ... u0, params, total_time=5000, transient_time=1000)
3036
+ """
3037
+
3038
+ # Check if mapping function is defined
3039
+ if self.__mapping is None:
3040
+ raise RuntimeError("Mapping function must be provided")
3041
+
3042
+ # Check if jacobian function is defined
3043
+ if self.__backwards_mapping is None:
3044
+ raise RuntimeError("Backwards mapping function must be provided")
3045
+
3046
+ # Input validation
3047
+ try:
3048
+ u_arr = np.asarray(u, dtype=np.float64)
3049
+ if u_arr.ndim != 1:
3050
+ raise ValueError("Initial condition must be 1D array")
3051
+ except (TypeError, ValueError) as e:
3052
+ raise TypeError(
3053
+ "Initial condition must be convertible to 1D float array"
3054
+ ) from e
3055
+
3056
+ if np.isscalar(parameters):
3057
+ parameters = np.array([parameters], dtype=np.float64)
3058
+ elif not isinstance(parameters, np.ndarray):
3059
+ parameters = np.asarray(parameters, dtype=np.float64)
3060
+
3061
+ if len(u_arr) != self.__system_dimension:
3062
+ raise ValueError(
3063
+ f"Initial condition dimension {len(u_arr)} != system dimension {self.__system_dimension}"
3064
+ )
3065
+
3066
+ if not isinstance(total_time, int) or total_time <= 0:
3067
+ raise ValueError("total_time must be positive integer")
3068
+
3069
+ if transient_time is not None:
3070
+ if not isinstance(transient_time, int) or transient_time < 0:
3071
+ raise ValueError("transient_time must be non-negative integer")
3072
+ if transient_time >= total_time:
3073
+ raise ValueError("transient_time must be < total_time")
3074
+
3075
+ # Call the compiled computation function
3076
+ return lagrangian_descriptors(
3077
+ u_arr,
3078
+ parameters,
3079
+ total_time,
3080
+ self.__mapping,
3081
+ self.__backwards_mapping,
3082
+ transient_time=transient_time,
3083
+ )
3084
+
3085
+ def recurrence_matrix(
3086
+ self,
3087
+ u: Union[NDArray[np.float64], Sequence[float]],
3088
+ total_time: int,
3089
+ parameters: Union[
3090
+ None, float, Sequence[np.float64], NDArray[np.float64]
3091
+ ] = None,
3092
+ transient_time: Optional[int] = None,
3093
+ **kwargs: Any,
3094
+ ) -> NDArray[np.float64]:
3095
+ """
3096
+ Compute the recurrence matrix of a univariate or multivariate time series.
3097
+
3098
+ Parameters
3099
+ ----------
3100
+ u: NDArray
3101
+ Time series data. Can be 1D(shape: (N,)) or 2D(shape: (N, d)).
3102
+ If 1D, the array is reshaped to (N, 1) automatically.
3103
+ total_time: int
3104
+ Total number of iterations to simulate.
3105
+ parameters: Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
3106
+ Parameters passed to the mapping function.
3107
+ transient_time: Optional[int], optional
3108
+ Number of initial iterations to discard as transient(default None).
3109
+ If None, no transient is removed.
3110
+ metric: {"supremum", "euclidean", "manhattan"}, default = "supremum"
3111
+ Distance metric used for phase space reconstruction.
3112
+ std_metric: {"supremum", "euclidean", "manhattan"}, default = "supremum"
3113
+ Distance metric used for standard deviation calculation.
3114
+ threshold: float, default = 0.1
3115
+ Recurrence threshold(relative to data range).
3116
+ threshold_std: bool, default = True
3117
+ Whether to scale threshold by data standard deviation.
3118
+
3119
+ Returns
3120
+ -------
3121
+ recmat: NDArray of shape(N, N), dtype = np.uint8
3122
+ Binary recurrence matrix indicating whether each pair of points are within the threshold distance.
3123
+
3124
+ Raises
3125
+ ------
3126
+ ValueError
3127
+ - If `u` is not an 1D array, or if its shape does not match the expected system dimension.
3128
+ - If `parameters` is not None and does not match the expected number of parameters.
3129
+ - If `parameters` is None but the system expects parameters.
3130
+ - If `parameters` is a scalar or array-like but not 1D.
3131
+ - If `total_time` is negative.
3132
+ - If `trasient_time` is negative.
3133
+ - If `transient_time` is greater than or equal to total_time.
3134
+ - If `lmin` is not a positive integer or is less than 1.
3135
+ - If `metric` or `std_metric` is not a valid string.
3136
+ - If `threshold` is not within [0, 1].
3137
+
3138
+ TypeError
3139
+ - If `u` is not a scalar or array-like type.
3140
+ - If `parameters` is not a scalar or array-like type.
3141
+ - If `total_time` is not int.
3142
+ - If `transient_time` is not int.
3143
+ - If `metric` or `std_metric` cannot be converted to a string.
3144
+ - If `threshold` is not a positive float.
3145
+ - If `lmin` is not an integer.
3146
+
3147
+ """
3148
+
3149
+ u = validate_initial_conditions(
3150
+ u, self.__system_dimension, allow_ensemble=False
3151
+ )
3152
+
3153
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
3154
+
3155
+ validate_non_negative(total_time, "total_time", Integral)
3156
+ validate_transient_time(transient_time, total_time, Integral)
3157
+
3158
+ # Configuration handling
3159
+ config = RTEConfig(**kwargs)
3160
+
3161
+ if transient_time is not None:
3162
+ u = iterate_mapping(u, parameters, transient_time, self.__mapping)
3163
+ total_time -= transient_time
3164
+
3165
+ time_series = generate_trajectory(u, parameters, total_time, self.__mapping)
3166
+
3167
+ # Recurrence matrix calculation
3168
+ TSM = tsm(time_series)
3169
+ recmat = TSM.recurrence_matrix(
3170
+ threshold=float(config.threshold),
3171
+ metric=config.metric,
3172
+ std_metric=config.std_metric,
3173
+ threshold_std=config.threshold_std,
3174
+ )
3175
+
3176
+ return recmat
3177
+
3178
+ def recurrence_time_entropy(
3179
+ self,
3180
+ u: Union[NDArray[np.float64], Sequence[float]],
3181
+ total_time: int,
3182
+ parameters: Union[
3183
+ None, float, Sequence[np.float64], NDArray[np.float64]
3184
+ ] = None,
3185
+ transient_time: Optional[int] = None,
3186
+ **kwargs: Any,
3187
+ ):
3188
+ """Compute Recurrence Time Entropy(RTE) for dynamical system analysis.
3189
+
3190
+ Parameters
3191
+ ----------
3192
+ u: Union[NDArray[np.float64], Sequence[float]]
3193
+ Initial condition of shape(d,) where d is system dimension
3194
+ total_time: int
3195
+ Number of iterations to simulate(must be > 100 for meaningful results)
3196
+ parameters: Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
3197
+ System parameters of shape(p,) passed to mapping function
3198
+ metric: {"supremum", "euclidean", "manhattan"}, default = "supremum"
3199
+ Distance metric used for phase space reconstruction.
3200
+ std_metric: {"supremum", "euclidean", "manhattan"}, default = "supremum"
3201
+ Distance metric used for standard deviation calculation.
3202
+ lmin: int, default = 1
3203
+ Minimum line length to consider in recurrence quantification.
3204
+ threshold: float, default = 0.1
3205
+ Recurrence threshold(relative to data range).
3206
+ threshold_std: bool, default = True
3207
+ Whether to scale threshold by data standard deviation.
3208
+ return_final_state: bool, default = False
3209
+ Whether to return the final system state in results.
3210
+ return_recmat: bool, default = False
3211
+ Whether to return the recurrence matrix.
3212
+ return_p: bool, default = False
3213
+ Whether to return white vertical line length distribution.
3214
+
3215
+ Returns
3216
+ -------
3217
+ Union[float, Tuple[float, NDArray[np.float64]]]
3218
+ - float: RTE value(base case)
3219
+ - Tuple: (RTE, white_line_distribution) if return_distribution = True
3220
+
3221
+ Raises
3222
+ ------
3223
+ ValueError
3224
+ - If `u` is not an 1D array, or if its shape does not match the expected system dimension.
3225
+ - If `parameters` is not None and does not match the expected number of parameters.
3226
+ - If `parameters` is None but the system expects parameters.
3227
+ - If `parameters` is a scalar or array-like but not 1D.
3228
+ - If `total_time` is negative.
3229
+ - If `trasient_time` is negative.
3230
+ - If `transient_time` is greater than or equal to total_time.
3231
+ - If `lmin` is not a positive integer or is less than 1.
3232
+ - If `metric` or `std_metric` is not a valid string.
3233
+ - If `threshold` is not within [0, 1].
3234
+ TypeError
3235
+ - If `u` is not a scalar or array-like type.
3236
+ - If `parameters` is not a scalar or array-like type.
3237
+ - If `total_time` is not int.
3238
+ - If `transient_time` is not int.
3239
+ - If `metric` or `std_metric` cannot be converted to a string.
3240
+ - If `threshold` is not a positive float.
3241
+ - If `lmin` is not an integer.
3242
+
3243
+ Notes
3244
+ -----
3245
+ - Higher RTE indicates more complex dynamics
3246
+ - For reliable results:
3247
+ - Use total_time > 1000
3248
+ - Typical threshold range: 0.01-0.3
3249
+ - Set min_recurrence_time = 2 to ignore single-point recurrences
3250
+ - Implementation follows[1]
3251
+
3252
+ References
3253
+ ----------
3254
+ [1] Sales et al., Chaos 33, 033140 (2023)
3255
+
3256
+ Examples
3257
+ --------
3258
+ >>> # Basic usage
3259
+ >>> rte = system.recurrence_time_entropy(u0, params, 5000)
3260
+
3261
+ >>> # With distribution output
3262
+ >>> rte, dist = system.recurrence_time_entropy(
3263
+ ... u0, params, 5000,
3264
+ ... return_distribution=True,
3265
+ ... recurrence_threshold=0.1
3266
+ ...)
3267
+ """
3268
+
3269
+ u = validate_initial_conditions(
3270
+ u, self.__system_dimension, allow_ensemble=False
3271
+ )
3272
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
3273
+ validate_non_negative(total_time, "total_time", Integral)
3274
+ validate_transient_time(transient_time, total_time, Integral)
3275
+
3276
+ return RTE(
3277
+ u,
3278
+ parameters,
3279
+ total_time,
3280
+ self.__mapping,
3281
+ transient_time=transient_time,
3282
+ **kwargs,
3283
+ )
3284
+
3285
+ def finite_time_recurrence_time_entropy(
3286
+ self,
3287
+ u: Union[NDArray[np.float64], Sequence[float]],
3288
+ total_time: int,
3289
+ finite_time: int,
3290
+ parameters: Union[
3291
+ None, float, Sequence[np.float64], NDArray[np.float64]
3292
+ ] = None,
3293
+ return_points: bool = False,
3294
+ **kwargs: Any,
3295
+ ) -> Union[NDArray[np.float64], Tuple[NDArray[np.float64], NDArray[np.float64]]]:
3296
+ """Compute the finite-time Recurrence Time Entropy(RTE) for dynamical system analysis.
3297
+
3298
+ Parameters
3299
+ ----------
3300
+ u: Union[NDArray[np.float64], Sequence[float]]
3301
+ Initial condition of shape(d,) where d is system dimension
3302
+ total_time: int
3303
+ Number of iterations to simulate(must be > 100 for meaningful results)
3304
+ finite_time: int
3305
+ Averaging window size in time steps
3306
+ parameters: Union[None, float, Sequence[np.float64], NDArray[np.float64]], optional
3307
+ System parameters of shape(p,) passed to mapping function
3308
+ return_points: bool, default = False
3309
+ Whether to return the finite-time RTE phase space points
3310
+ metric: {"supremum", "euclidean", "manhattan"}, default = "supremum"
3311
+ Distance metric used for phase space reconstruction.
3312
+ std_metric: {"supremum", "euclidean", "manhattan"}, default = "supremum"
3313
+ Distance metric used for standard deviation calculation.
3314
+ lmin: int, default = 1
3315
+ Minimum line length to consider in recurrence quantification.
3316
+ threshold: float, default = 0.1
3317
+ Recurrence threshold(relative to data range).
3318
+ threshold_std: bool, default = True
3319
+ Whether to scale threshold by data standard deviation.
3320
+ return_final_state: bool, default = False
3321
+ Whether to return the final system state in results.
3322
+ return_recmat: bool, default = False
3323
+ Whether to return the recurrence matrix.
3324
+ return_p: bool, default = False
3325
+ Whether to return white vertical line length distribution.
3326
+
3327
+ Returns
3328
+ -------
3329
+ NDArray[np.float64]
3330
+
3331
+ Raises
3332
+ ------
3333
+ ValueError
3334
+ - If `u` is not an 1D array, or if its shape does not match the expected system dimension.
3335
+ - If `parameters` is not None and does not match the expected number of parameters.
3336
+ - If `parameters` is None but the system expects parameters.
3337
+ - If `parameters` is a scalar or array-like but not 1D.
3338
+ - If `total_time` is negative.
3339
+ - If `trasient_time` is negative.
3340
+ - If `transient_time` is greater than or equal to total_time.
3341
+ - If `lmin` is not a positive integer or is less than 1.
3342
+ - If `metric` or `std_metric` is not a valid string.
3343
+ - If `threshold` is not within [0, 1].
3344
+ TypeError
3345
+ - If `u` is not a scalar or array-like type.
3346
+ - If `parameters` is not a scalar or array-like type.
3347
+ - If `total_time` is not int.
3348
+ - If `transient_time` is not int.
3349
+ - If `metric` or `std_metric` cannot be converted to a string.
3350
+ - If `threshold` is not a positive float.
3351
+ - If `lmin` is not an integer.
3352
+
3353
+ Notes
3354
+ -----
3355
+ - Higher RTE indicates more complex dynamics
3356
+ - For reliable results:
3357
+ - Use total_time > 1000
3358
+ - Typical threshold range: 0.01-0.3
3359
+ - Set min_recurrence_time = 2 to ignore single-point recurrences
3360
+ - Implementation follows [1]
3361
+
3362
+ References
3363
+ ----------
3364
+ [1] Sales et al., Chaos 33, 033140 (2023)
3365
+
3366
+ Examples
3367
+ --------
3368
+ >>> # Basic usage
3369
+ >>> ftrte = system.finite_time_recurrence_time_entropy(u0, params, 50000, 100)
3370
+
3371
+ """
3372
+
3373
+ u = validate_initial_conditions(
3374
+ u, self.__system_dimension, allow_ensemble=False
3375
+ )
3376
+
3377
+ parameters = validate_parameters(parameters, self.__number_of_parameters)
3378
+
3379
+ validate_non_negative(total_time, "total_time", Integral)
3380
+ validate_positive(finite_time, "finite_time", Integral)
3381
+ validate_finite_time(finite_time, total_time)
3382
+
3383
+ return finite_time_RTE(
3384
+ u,
3385
+ parameters,
3386
+ total_time,
3387
+ finite_time,
3388
+ self.__mapping,
3389
+ return_points=return_points,
3390
+ **kwargs,
3391
+ )