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,1459 @@
1
+ # trajectory_analysis.py
2
+
3
+ # Copyright (C) 2025 Matheus Rolim Sales
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ from typing import Optional, Callable, Union, Tuple, Dict, List, Any, Sequence
19
+ from numpy.typing import NDArray
20
+ import numpy as np
21
+ from numba import njit, prange
22
+
23
+
24
+ @njit(cache=True)
25
+ def iterate_mapping(
26
+ u: NDArray[np.float64],
27
+ parameters: NDArray[np.float64],
28
+ total_time: int,
29
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
30
+ transient_time: Optional[int] = None,
31
+ ) -> NDArray[np.float64]:
32
+ """
33
+ Iterate a dynamical system mapping function with optional transient handling.
34
+
35
+ This function evolves a state vector through repeated application of a mapping function,
36
+ with Numba-optimized performance. Useful for both simulation and transient removal.
37
+
38
+ Parameters
39
+ ----------
40
+ u : NDArray[np.float64]
41
+ Initial state vector of shape (neq,), where neq is the system dimension
42
+ parameters : NDArray[np.float64]
43
+ System parameters passed to the mapping function
44
+ total_time : int
45
+ Total number of iterations to perform (after any transient)
46
+ mapping : Callable[[NDArray, NDArray], NDArray]
47
+ System mapping function: u_next = mapping(u, parameters)
48
+ transient_time : Optional[int], optional
49
+ Number of initial iterations to discard as transient (default: None)
50
+
51
+ Returns
52
+ -------
53
+ NDArray[np.float64]
54
+ Final state vector after all iterations (shape: (neq,))
55
+
56
+ Raises
57
+ ------
58
+ ValueError
59
+ If total_time is not positive
60
+ If transient_time is negative
61
+ """
62
+ # Input validation
63
+ if total_time <= 0:
64
+ raise ValueError("total_time must be positive")
65
+ if transient_time is not None and transient_time < 0:
66
+ raise ValueError("transient_time must be non-negative")
67
+
68
+ # Handle transient
69
+ if transient_time is not None:
70
+ for _ in range(transient_time):
71
+ u = mapping(u, parameters)
72
+
73
+ # Main iteration
74
+ for _ in range(total_time):
75
+ u = mapping(u, parameters)
76
+
77
+ return u
78
+
79
+
80
+ @njit(cache=True)
81
+ def generate_trajectory(
82
+ u: NDArray[np.float64],
83
+ parameters: NDArray[np.float64],
84
+ total_time: int,
85
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
86
+ transient_time: Optional[int] = None,
87
+ ) -> NDArray[np.float64]:
88
+ """
89
+ Generate a trajectory for a dynamical system from a single initial condition.
90
+
91
+ This Numba-optimized function efficiently computes the system's evolution while
92
+ optionally discarding an initial transient period. The implementation minimizes
93
+ memory allocations and maximizes computational performance.
94
+
95
+ Parameters
96
+ ----------
97
+ u : NDArray[np.float64]
98
+ Initial state vector (shape: (neq,)), where neq is the system dimension
99
+ parameters : NDArray[np.float64]
100
+ System parameters passed to the mapping function
101
+ total_time : int
102
+ Total number of iterations to compute (including transient if specified)
103
+ mapping : Callable[[NDArray, NDArray], NDArray]
104
+ System evolution function: u_next = mapping(u, parameters)
105
+ transient_time : Optional[int], optional
106
+ Number of initial iterations to discard (default: None)
107
+
108
+ Returns
109
+ -------
110
+ NDArray[np.float64]
111
+ Time series array of shape (sample_size, neq), where:
112
+ - sample_size = total_time (if no transient)
113
+ - sample_size = total_time - transient_time (with transient)
114
+
115
+ Raises
116
+ ------
117
+ ValueError
118
+ If total_time is not positive
119
+ If transient_time exceeds total_time
120
+
121
+ Notes
122
+ -----
123
+ - Memory efficient: Pre-allocates output array
124
+ - Numerically stable: Works with both discrete and continuous systems
125
+ - For continuous systems, ensure proper time scaling in the mapping function
126
+ """
127
+ # Input validation
128
+ if total_time <= 0:
129
+ raise ValueError("total_time must be positive")
130
+ if transient_time is not None:
131
+ if transient_time < 0:
132
+ raise ValueError("transient_time must be non-negative")
133
+ if transient_time >= total_time:
134
+ raise ValueError("transient_time must be less than total_time")
135
+
136
+ # Handle transient
137
+ state = u.copy()
138
+ if transient_time is not None:
139
+ state = iterate_mapping(state, parameters, transient_time, mapping)
140
+ sample_size = total_time - transient_time
141
+ else:
142
+ sample_size = total_time
143
+
144
+ # Pre-allocate trajectory array
145
+ neq = len(state)
146
+ trajectory = np.empty((sample_size, neq))
147
+
148
+ # Generate trajectory
149
+ for i in range(sample_size):
150
+ state = mapping(state, parameters)
151
+ trajectory[i] = state
152
+
153
+ return trajectory
154
+
155
+
156
+ @njit(cache=True, parallel=True)
157
+ def ensemble_trajectories(
158
+ u: NDArray[np.float64],
159
+ parameters: NDArray[np.float64],
160
+ total_time: int,
161
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
162
+ transient_time: Optional[int] = None,
163
+ ) -> NDArray[np.float64]:
164
+ """
165
+ Generate parallelized ensemble trajectories for multiple initial conditions.
166
+
167
+ This function efficiently computes trajectories for an ensemble of initial conditions
168
+ using Numba's parallel processing capabilities. Each trajectory is computed independently,
169
+ making it ideal for large ensembles or parameter studies.
170
+
171
+ Parameters
172
+ ----------
173
+ u : NDArray[np.float64]
174
+ Array of initial conditions with shape (num_ic, neq), where:
175
+ - num_ic: number of initial conditions
176
+ - neq: system dimension (number of equations)
177
+ parameters : NDArray[np.float64]
178
+ System parameters (shape: arbitrary, passed to mapping)
179
+ total_time : int
180
+ Total iterations per trajectory (including transient if specified)
181
+ mapping : Callable[[NDArray, NDArray], NDArray]
182
+ System evolution function: u_next = mapping(u, parameters)
183
+ transient_time : Optional[int], optional
184
+ Initial iterations to discard per trajectory (default: None)
185
+
186
+ Returns
187
+ -------
188
+ NDArray[np.float64]
189
+ Concatenated trajectories of shape (num_ic * sample_size, neq), where:
190
+ sample_size = total_time - (transient_time or 0)
191
+ Trajectories are stacked in input order [IC1_t0..tN, IC2_t0..tN, ...]
192
+
193
+ Raises
194
+ ------
195
+ ValueError
196
+ If total_time ≤ transient_time
197
+ If u is not 2D
198
+ If parameters are incompatible with mapping
199
+
200
+ Notes
201
+ -----
202
+ - Parallelization: Each IC processed independently using prange
203
+ - Memory: Pre-allocates output array for optimal performance
204
+ - Performance: ~10-100x faster than sequential for large ensembles
205
+ - Post-processing: Use .reshape(num_ic, sample_size, neq) to separate trajectories
206
+ """
207
+ # Input validation
208
+ if u.ndim != 2:
209
+ raise ValueError("Initial conditions must be 2D array (num_ic, neq)")
210
+ if transient_time is not None and transient_time >= total_time:
211
+ raise ValueError("transient_time must be < total_time")
212
+
213
+ num_ic, neq = u.shape
214
+ sample_size = total_time - (transient_time if transient_time else 0)
215
+
216
+ # Pre-allocate output array
217
+ ensemble_ts = np.empty((num_ic * sample_size, neq))
218
+
219
+ # Parallel trajectory generation
220
+ for i in prange(num_ic): # Parallel loop over initial conditions
221
+ # Generate trajectory for i-th initial condition
222
+ traj = generate_trajectory(
223
+ u[i], parameters, total_time, mapping, transient_time
224
+ )
225
+ # Store in pre-allocated array
226
+ ensemble_ts[i * sample_size : (i + 1) * sample_size] = traj
227
+
228
+ return ensemble_ts
229
+
230
+
231
+ def bifurcation_diagram(
232
+ u: NDArray[np.float64],
233
+ parameters: NDArray[np.float64],
234
+ param_index: int,
235
+ param_range: Union[NDArray[np.float64], Tuple[float, float, int]],
236
+ total_time: int,
237
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
238
+ transient_time: Optional[int] = None,
239
+ continuation: bool = False,
240
+ return_last_state: bool = False,
241
+ observable_fn: Optional[Callable[[NDArray[np.float64]], float]] = None,
242
+ ) -> Union[
243
+ Tuple[NDArray[np.float64], NDArray[np.float64]],
244
+ Tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]],
245
+ ]:
246
+ """
247
+ Generate a bifurcation diagram by varying a system parameter and recording system states.
248
+
249
+ Parameters
250
+ ----------
251
+ u : NDArray[np.float64]
252
+ Initial state vector (shape: (neq,))
253
+ parameters : NDArray[np.float64]
254
+ System parameters (will be modified during sweep)
255
+ param_index : int
256
+ Index of parameter to vary in parameters array
257
+ param_range : Union[NDArray[np.float64], Tuple[float, float, int]]
258
+ Either:
259
+ - Precomputed array of parameter values, or
260
+ - Tuple of (start, stop, num_points) for linspace generation
261
+ total_time : int
262
+ Total iterations per parameter value (including transient)
263
+ mapping : Callable[[NDArray, NDArray], NDArray]
264
+ System evolution function: u_next = mapping(u, parameters)
265
+ transient_time : Optional[int], optional
266
+ Initial iterations to discard (default: total_time//10)
267
+ observable_fn : Optional[Callable[[NDArray], float]], optional
268
+ Function mapping state vector to plottable value (default: first coordinate)
269
+
270
+ Returns
271
+ -------
272
+ Tuple[NDArray[np.float64], NDArray[np.float64]]
273
+ - param_values: Array of parameter values used
274
+ - observations: Array of shape (num_params, sample_size) containing observed values
275
+
276
+ Notes
277
+ -----
278
+ - For periodic windows, increase total_time to capture full cycles
279
+ - The default 10% transient discard is often sufficient for most systems
280
+ - For higher-dimensional observations, provide a custom observable_fn
281
+ """
282
+
283
+ u = u.copy()
284
+
285
+ # Process parameter range
286
+ if isinstance(param_range, tuple):
287
+ param_values = np.linspace(param_range[0], param_range[1], param_range[2])
288
+ else:
289
+ param_values = np.ascontiguousarray(param_range)
290
+
291
+ # Set default transient time
292
+ if transient_time is None:
293
+ transient_time = total_time // 10
294
+ sample_size = total_time - transient_time
295
+
296
+ # Set default observable
297
+ if observable_fn is None:
298
+
299
+ def observable_fn(x):
300
+ return x[0]
301
+
302
+ # Pre-allocate results array
303
+ num_points = len(param_values)
304
+ results = np.empty((num_points, sample_size))
305
+ current_params = parameters.copy()
306
+
307
+ trajectory: NDArray[np.float64] = np.empty(
308
+ (total_time - transient_time, u.shape[0])
309
+ )
310
+
311
+ # Main parameter sweep loop
312
+ for i in range(num_points):
313
+ current_params[param_index] = param_values[i]
314
+
315
+ # Generate and process trajectory
316
+ trajectory = generate_trajectory(
317
+ u, current_params, total_time, mapping, transient_time
318
+ )
319
+
320
+ # Store observable values
321
+ for j in range(sample_size):
322
+ results[i, j] = observable_fn(trajectory[j])
323
+
324
+ if continuation:
325
+ u = trajectory[-1] # Update state for next iteration
326
+
327
+ if return_last_state:
328
+ return param_values, results, trajectory[-1]
329
+ else:
330
+ return param_values, results
331
+
332
+
333
+ @njit(cache=True)
334
+ def period_counter(
335
+ u: NDArray[np.float64],
336
+ parameters: NDArray[np.float64],
337
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
338
+ total_time: int = 5000,
339
+ transient_time: Optional[int] = None,
340
+ tolerance: float = 1e-10,
341
+ min_period: int = 1,
342
+ max_period: int = 1000,
343
+ stability_checks: int = 3,
344
+ ) -> int:
345
+ """Detects the period of a dynamical system by analyzing state recurrence.
346
+
347
+ This function determines the smallest period p where the system satisfies:
348
+ ||x_{n+p} - x_n|| < tolerance for consecutive states after transients.
349
+
350
+ Parameters
351
+ ----------
352
+ u : NDArray[np.float64]
353
+ Initial state vector (shape: (neq,))
354
+ parameters : NDArray[np.float64]
355
+ System parameters passed to mapping function
356
+ mapping : Callable[[NDArray, NDArray], NDArray]
357
+ System evolution function: x_next = mapping(x, parameters)
358
+ total_time : int, optional
359
+ Maximum iterations to analyze (default: 5000)
360
+ transient_time : Optional[int], optional
361
+ Initial iterations to discard (default: None)
362
+ tolerance : float, optional
363
+ Numerical tolerance for period detection (default: 1e-10)
364
+ min_period : int, optional
365
+ Minimum period to consider (default: 1)
366
+ max_period : int, optional
367
+ Maximum period to consider (default: 1000)
368
+ stability_checks : int, optional
369
+ Number of consecutive period matches required (default: 3)
370
+
371
+ Returns
372
+ -------
373
+ int
374
+ Detected period, or -1 if no period found
375
+ """
376
+
377
+ # Make a copy of the provided initial condition to avoid modifying the original state
378
+ state = u.copy()
379
+
380
+ # Handle transient period
381
+ if transient_time is not None:
382
+ if transient_time >= total_time:
383
+ return -1
384
+ state = iterate_mapping(state, parameters, transient_time, mapping)
385
+ sample_size = total_time - transient_time
386
+ else:
387
+ sample_size = total_time
388
+
389
+ state_ini = state.copy()
390
+ p = 1
391
+ period = np.full(stability_checks, -1) # Ring buffer for stability check
392
+ idx = 0
393
+
394
+ for _ in range(sample_size):
395
+ state = mapping(state, parameters)
396
+
397
+ if np.allclose(state, state_ini, atol=tolerance):
398
+ period[idx % stability_checks] = p
399
+ idx += 1
400
+
401
+ # Check if last 'stability_checks' periods are equal and valid
402
+ if idx >= stability_checks:
403
+ same = True
404
+ for i in range(1, stability_checks):
405
+ if period[i] != period[0]:
406
+ same = False
407
+ break
408
+ if same and min_period <= period[0] <= max_period:
409
+ return period[0]
410
+ p = 0 # reset period counter after a match
411
+
412
+ p += 1
413
+
414
+ return -1
415
+
416
+
417
+ @njit(cache=True)
418
+ def rotation_number(
419
+ u: Union[NDArray[np.float64], Sequence[float], float],
420
+ parameters: Union[NDArray[np.float64], Sequence[float], float],
421
+ total_time: int,
422
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
423
+ mod: float = 1.0,
424
+ ) -> float:
425
+
426
+ u_old = u.copy()
427
+
428
+ rn = 0
429
+
430
+ for i in range(total_time):
431
+ u_new = mapping(u_old, parameters)
432
+ rn += (u_new[0] - u_old[0]) % mod
433
+ u_old = u_new.copy()
434
+
435
+ rn /= total_time
436
+
437
+ return rn
438
+
439
+
440
+ @njit(cache=True)
441
+ def escape_basin_and_time_entering(
442
+ u: NDArray[np.float64],
443
+ parameters: NDArray[np.float64],
444
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
445
+ max_time: int,
446
+ exits: NDArray[np.float64],
447
+ ) -> Tuple[int, int]:
448
+ """
449
+ Track system evolution until it escapes through predefined exit regions.
450
+
451
+ This function simulates a dynamical system until its state enters one of
452
+ the specified exit regions or until max_time is reached. Useful for studying
453
+ basin boundaries and escape dynamics.
454
+
455
+ Parameters
456
+ ----------
457
+ u : NDArray[np.float64]
458
+ Initial state vector (shape: (n_dim,))
459
+ parameters : NDArray[np.float64]
460
+ System parameters passed to mapping function
461
+ mapping : Callable[[NDArray, NDArray], NDArray]
462
+ System evolution function: u_next = mapping(u, parameters)
463
+ max_time : int
464
+ Maximum iterations to simulate (must be positive)
465
+ exits : NDArray[np.float64]
466
+ Center of the holes or exit regions, shape (n_exits, n_dim):
467
+ tolerance : float, optional
468
+ Numerical tolerance for boundary checks (default: 1e-12)
469
+
470
+ Returns
471
+ -------
472
+ Tuple[int, int]
473
+ - exit_index: 0-based exit region index (-1 if no escape)
474
+ - escape_time: Iteration when escape occurred (max_time if no escape)
475
+
476
+ Raises
477
+ ------
478
+ ValueError
479
+ If max_time is not positive
480
+ If exits array has invalid shape
481
+
482
+ Notes
483
+ -----
484
+ - Uses Numba optimization for fast iteration
485
+ - Exit checks are performed using vectorized comparisons
486
+ - For conservative systems, consider larger max_time values
487
+ """
488
+ # Input validation
489
+ if max_time <= 0:
490
+ raise ValueError("max_time must be positive")
491
+ if exits.ndim != 3 or exits.shape[2] != 2:
492
+ raise ValueError("exits must have shape (n_exits, n_dim, 2)")
493
+
494
+ n_exits = exits.shape[0]
495
+ n_dim = exits.shape[1]
496
+ u_current = u.copy()
497
+
498
+ for time in range(1, max_time + 1):
499
+ u_current = mapping(u_current, parameters)
500
+
501
+ # Check all exit regions
502
+ for exit_idx in range(n_exits):
503
+ in_exit = True
504
+ for dim in range(n_dim):
505
+ lower = exits[exit_idx, dim, 0]
506
+ upper = exits[exit_idx, dim, 1]
507
+ if not (lower <= u_current[dim] <= upper):
508
+ in_exit = False
509
+ break
510
+
511
+ if in_exit:
512
+ return exit_idx, time # 0-based indexing
513
+
514
+ return -1, max_time
515
+
516
+
517
+ @njit(cache=True)
518
+ def escape_time_exiting(
519
+ u: NDArray[np.float64],
520
+ parameters: NDArray[np.float64],
521
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
522
+ max_time: int,
523
+ region_limits: NDArray[np.float64],
524
+ ) -> Tuple[int, int]:
525
+ """
526
+ Track system evolution until it escapes a defined region through any boundary face.
527
+
528
+ This function simulates a dynamical system until its state exits a specified
529
+ hyperrectangular region or until max_time is reached. The escape face is
530
+ identified for boundary analysis.
531
+
532
+ Parameters
533
+ ----------
534
+ u : NDArray[np.float64]
535
+ Initial state vector (shape: (n_dim,))
536
+ parameters : NDArray[np.float64]
537
+ System parameters passed to mapping function
538
+ mapping : Callable[[NDArray, NDArray], NDArray]
539
+ System evolution function: u_next = mapping(u, parameters)
540
+ max_time : int
541
+ Maximum iterations to simulate (must be positive)
542
+ region_limits : NDArray[np.float64]
543
+ Region boundaries of shape (n_dim, 2) where:
544
+ region_limits[i,0] = lower bound in dimension i
545
+ region_limits[i,1] = upper bound in dimension i
546
+
547
+ Returns
548
+ -------
549
+ Tuple[int, int]
550
+ - escape_time: Iteration when escape occurred (max_time if no escape)
551
+ - face_index: Escaped face index (0 to 2*n_dim-1), or -1 if no escape
552
+ Faces are ordered as [dim0_lower, dim0_upper, dim1_lower,...]
553
+
554
+ Raises
555
+ ------
556
+ ValueError
557
+ If max_time is not positive
558
+ If region_limits has invalid shape
559
+
560
+ Notes
561
+ -----
562
+ - Face indexing: For dimension i, face 2*i is lower bound, 2*i+1 is upper
563
+ - Uses Numba optimization for fast iteration
564
+ - For conservative systems, consider larger max_time values
565
+ """
566
+ # Input validation
567
+ if max_time <= 0:
568
+ raise ValueError("max_time must be positive")
569
+ if region_limits.ndim != 2 or region_limits.shape[1] != 2:
570
+ raise ValueError("region_limits must have shape (n_dim, 2)")
571
+
572
+ n_dim = region_limits.shape[0]
573
+ u_current = u.copy()
574
+ for time in range(1, max_time + 1):
575
+ u_current = mapping(u_current, parameters)
576
+ # Check all dimensions for boundary crossing
577
+ for dim in range(n_dim):
578
+ if u_current[dim] < region_limits[dim, 0]:
579
+ return 2 * dim, time # lower face escape
580
+ if u_current[dim] > region_limits[dim, 1]:
581
+ return 2 * dim + 1, time # upper face escape
582
+
583
+ return -1, max_time # No escape
584
+
585
+
586
+ @njit(cache=True)
587
+ def survival_probability(
588
+ escape_times: NDArray[np.int32],
589
+ max_time: np.int32,
590
+ min_time: int = 1,
591
+ time_step: int = 1,
592
+ ) -> Tuple[NDArray[np.int64], NDArray[np.float64]]:
593
+ """
594
+ Calculate the survival probability function S(t) from observed escape times.
595
+
596
+ The survival probability S(t) represents the probability that a system remains
597
+ in a given region beyond time t. This implementation uses efficient sorting
598
+ and searching algorithms for optimal performance with large datasets.
599
+
600
+ Parameters
601
+ ----------
602
+ escape_times : NDArray[np.int64]
603
+ Array of escape times for each trajectory (must be ≥ 1)
604
+ max_time : int
605
+ Maximum time to evaluate (must be > min_time)
606
+ min_time : int, optional
607
+ Minimum time to evaluate (default: 1)
608
+ time_step : int, optional
609
+ Time resolution for evaluation (default: 1)
610
+
611
+ Returns
612
+ -------
613
+ Tuple[NDArray[np.int64], NDArray[np.float64]]
614
+ - t_values: Array of evaluation times
615
+ - survival_probs: Corresponding survival probabilities S(t)
616
+
617
+ Raises
618
+ ------
619
+ ValueError
620
+ If max_time ≤ min_time
621
+ If time_step ≤ 0
622
+ If escape_times contains values < 1
623
+
624
+ Notes
625
+ -----
626
+ - Implementation uses numpy's searchsorted for O(n log n) performance
627
+ - Handles right-censored data (escape_times > max_time are treated as censored)
628
+ - For smooth results with few samples, consider kernel density methods
629
+ """
630
+ # Input validation
631
+ if max_time <= min_time:
632
+ raise ValueError("max_time must be > min_time")
633
+ if time_step <= 0:
634
+ raise ValueError("time_step must be positive")
635
+ if np.any(escape_times < 1):
636
+ raise ValueError("All escape_times must be ≥ 1")
637
+
638
+ # Filter and sort escape times
639
+ valid_times = escape_times[(escape_times >= min_time) & (escape_times <= max_time)]
640
+ valid_times = np.sort(valid_times)
641
+ n_samples = len(escape_times)
642
+ n_valid = len(valid_times)
643
+
644
+ # Handle case where all times exceed max_time
645
+ if n_valid == 0:
646
+ t_values = np.arange(min_time, max_time + 1, time_step)
647
+ return t_values, np.ones_like(t_values, dtype=np.float64)
648
+
649
+ # Create evaluation points
650
+ t_values = np.arange(min_time, max_time + 1, time_step)
651
+
652
+ # Find insertion indices for each t in sorted escape_times
653
+ indices = np.searchsorted(valid_times, t_values, side="right")
654
+
655
+ # Compute Kaplan-Meier survival probability
656
+ survival_probs = 1.0 - indices / n_samples
657
+
658
+ return t_values, survival_probs
659
+
660
+
661
+ @njit(cache=True)
662
+ def is_periodic(
663
+ u: NDArray[np.float64],
664
+ parameters: NDArray[np.float64],
665
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
666
+ period: int,
667
+ tolerance: float = 1e-10,
668
+ transient_time: Optional[int] = None,
669
+ ) -> bool:
670
+ """Check if a point is periodic with given period under the system mapping.
671
+
672
+ Parameters
673
+ ----------
674
+ u : NDArray[np.float64]
675
+ Initial condition of shape (d,)
676
+ parameters : NDArray[np.float64]
677
+ System parameters of shape (p,)
678
+ mapping : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
679
+ System mapping function (must be Numba-compatible)
680
+ period : int
681
+ Period to check (must be > 0)
682
+ tolerance : float, optional
683
+ Tolerance for periodicity check (default: 1e-10)
684
+ transient_time : Optional[int], optional
685
+ Initial iterations to discard (default: None)
686
+
687
+ Returns
688
+ -------
689
+ bool
690
+ True if f^period(u) ≈ u within tolerance
691
+ False otherwise
692
+
693
+ Notes
694
+ -----
695
+ - Checks if mapping^period(u) ≈ u
696
+ - For fixed points, use period=1
697
+ - The check is performed component-wise
698
+ - Lower tolerance gives stricter periodicity check
699
+ - For reliable results:
700
+ - tolerance should be > numerical error accumulation
701
+ - period should be < system's expected maximum period
702
+ """
703
+
704
+ # Compute mapped point
705
+ u_periodic = u.copy()
706
+
707
+ if transient_time is not None:
708
+ # Apply transient mapping
709
+ u_periodic = iterate_mapping(
710
+ u_periodic,
711
+ parameters,
712
+ transient_time,
713
+ mapping,
714
+ transient_time=transient_time,
715
+ )
716
+
717
+ u_periodic = iterate_mapping(u_periodic, parameters, period, mapping)
718
+
719
+ # Check periodicity component-wise
720
+ periodic = True
721
+ for i in range(u.shape[0]):
722
+ if abs(u[i] - u_periodic[i]) > tolerance:
723
+ periodic = False
724
+ break
725
+
726
+ return periodic
727
+
728
+
729
+ @njit(cache=True, parallel=True)
730
+ def scan_phase_space(
731
+ grid_points: NDArray[np.float64],
732
+ parameters: NDArray[np.float64],
733
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
734
+ period: int,
735
+ tolerance: float = 1e-10,
736
+ transient_time: Optional[int] = None,
737
+ ) -> NDArray[np.float64]:
738
+ """Scan phase space grid for periodic orbits of specified period.
739
+
740
+ Parameters
741
+ ----------
742
+ grid_points : NDArray[np.float64]
743
+ 3D array of initial conditions with shape (nx, ny, d) where:
744
+ - nx: number of x-axis grid points
745
+ - ny: number of y-axis grid points
746
+ - d: system dimension (must be ≥ 2)
747
+ parameters : NDArray[np.float64]
748
+ System parameters of shape (p,)
749
+ mapping : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
750
+ Numba-compatible system mapping function
751
+ period : int
752
+ Period to search for (must be ≥ 1)
753
+ tolerance : float, optional
754
+ Tolerance for periodicity check (default: 1e-10)
755
+ transient_time : Optional[int], optional
756
+ Initial iterations to discard (default: None)
757
+
758
+ Returns
759
+ -------
760
+ NDArray[np.float64]
761
+ Array of periodic points found, shape (n_found, d)
762
+
763
+ Raises
764
+ ------
765
+ ValueError
766
+ If grid_points has invalid dimensions
767
+ If period is not positive
768
+ If tolerance is not positive
769
+
770
+ Notes
771
+ -----
772
+ - Uses parallel processing over grid points
773
+ - Typical workflow:
774
+ 1. Create phase space grid with np.meshgrid
775
+ 2. Reshape into (nx, ny, d) array
776
+ 3. Call this function
777
+ - Memory efficient - returns only found points
778
+ """
779
+
780
+ # Input validation
781
+ if grid_points.ndim != 3:
782
+ raise ValueError("grid_points must be 3D array (nx, ny, d)")
783
+
784
+ nx = grid_points.shape[0]
785
+ ny = grid_points.shape[1]
786
+ n_dim = grid_points.shape[2]
787
+
788
+ result = np.zeros((nx * ny, n_dim), dtype=np.float64)
789
+
790
+ # Iterate over grid points
791
+ for i in prange(nx):
792
+ for j in range(ny):
793
+ k = i * ny + j
794
+ u = np.empty(n_dim)
795
+ u[0] = grid_points[i, j, 0]
796
+ u[1] = grid_points[i, j, 1]
797
+ # Check if periodic
798
+ if is_periodic(
799
+ u,
800
+ parameters,
801
+ mapping,
802
+ period,
803
+ tolerance=tolerance,
804
+ transient_time=transient_time,
805
+ ):
806
+ # Store periodic point
807
+ result[k, :] = grid_points[i, j, :]
808
+ # number_of_periodic_points += 1
809
+
810
+ return result
811
+
812
+
813
+ @njit(cache=True)
814
+ def scan_symmetry_line(
815
+ points: NDArray[np.float64],
816
+ parameters: NDArray[np.float64],
817
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
818
+ period: int,
819
+ tolerance: float = 1e-10,
820
+ transient_time: Optional[int] = None,
821
+ ) -> NDArray[np.float64]:
822
+ n_points = points.shape[0]
823
+ n_dim = points.shape[1]
824
+
825
+ periodic_points = np.empty((n_points, n_dim), dtype=np.float64)
826
+ number_of_periodic_points = 0
827
+
828
+ for i in range(n_points):
829
+ u = np.empty(n_dim)
830
+ u[0] = points[i, 0]
831
+ u[1] = points[i, 1]
832
+ # Check if periodic
833
+ if is_periodic(
834
+ u,
835
+ parameters,
836
+ mapping,
837
+ period,
838
+ tolerance=tolerance,
839
+ transient_time=transient_time,
840
+ ):
841
+ # Store periodic point
842
+ periodic_points[number_of_periodic_points, :] = points[i, :]
843
+ number_of_periodic_points += 1
844
+
845
+ # If no periodic points found, return empty array
846
+ if number_of_periodic_points == 0:
847
+ return np.empty((0, n_dim), dtype=np.float64)
848
+ # Resize result to only include found periodic points
849
+ return periodic_points[:number_of_periodic_points, :]
850
+
851
+
852
+ def find_periodic_orbit_symmetry_line(
853
+ points: NDArray[np.float64],
854
+ parameters: NDArray[np.float64],
855
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
856
+ period: int,
857
+ func: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
858
+ axis: int,
859
+ tolerance: float = 1e-10,
860
+ max_iter: int = 1000,
861
+ convergence_threshold: float = 1e-15,
862
+ tolerance_decay_factor: float = 1 / 4,
863
+ verbose: bool = False,
864
+ transient_time: Optional[int] = None,
865
+ ) -> NDArray[np.float64]:
866
+
867
+ #  Make a copy of the points to avoid modifying the original
868
+ points = points.copy()
869
+ points = generate_symmetry_points(points, func, axis, parameters)
870
+ n_points = points.shape[0]
871
+ n_dim = points.shape[1]
872
+
873
+ # Initialize periodic orbit
874
+ periodic_orbit = np.zeros(n_dim)
875
+
876
+ for j in range(max_iter):
877
+ # Find periodic points in current grid
878
+ periodic_points = scan_symmetry_line(
879
+ points,
880
+ parameters,
881
+ mapping,
882
+ period,
883
+ tolerance=tolerance,
884
+ transient_time=transient_time,
885
+ )
886
+
887
+ # If no periodic points are found, exit the loop
888
+ if len(periodic_points) == 0:
889
+ if verbose:
890
+ print(f"No periodic points found at iteration {j}")
891
+ if j == 0:
892
+ raise ValueError("No periodic points found in the initial grid")
893
+ break
894
+
895
+ # Calculate the new periodic orbit
896
+ periodic_orbit_new = np.zeros(n_dim)
897
+ periodic_orbit_new[0] = periodic_points[:, 0].mean()
898
+ periodic_orbit_new[1] = periodic_points[:, 1].mean()
899
+
900
+ # Define the new phase space limits
901
+ x_range = (
902
+ periodic_points[:, 0].min() + tolerance,
903
+ periodic_points[:, 0].max() - tolerance,
904
+ )
905
+ y_range = (
906
+ periodic_points[:, 1].min() + tolerance,
907
+ periodic_points[:, 1].max() - tolerance,
908
+ )
909
+
910
+ # Check convergence
911
+ delta_orbit = np.abs(periodic_orbit_new - periodic_orbit)
912
+ delta_bounds = np.abs(
913
+ np.array([x_range[1] - x_range[0], y_range[1] - y_range[0]])
914
+ )
915
+
916
+ if verbose:
917
+ print(
918
+ f"Iter {j}: Δorbit={delta_orbit}, Δbounds={delta_bounds}, tol={tolerance:.2e}"
919
+ )
920
+
921
+ if np.all(delta_orbit < convergence_threshold) and np.all(
922
+ delta_bounds < convergence_threshold
923
+ ):
924
+ if verbose:
925
+ print(f"Converged at iteration {j}")
926
+ break
927
+ # Update the periodic orbit
928
+ periodic_orbit = periodic_orbit_new.copy()
929
+
930
+ # Update the tolerance for the next iteration
931
+ tolerance = max(
932
+ tolerance * tolerance_decay_factor, (delta_bounds[axis] / n_points)
933
+ )
934
+
935
+ if axis == 0:
936
+ array = np.linspace(x_range[0], x_range[1], n_points)
937
+ else:
938
+ array = np.linspace(y_range[0], y_range[1], n_points)
939
+ # Update the grid points
940
+ points = generate_symmetry_points(array, func, axis, parameters)
941
+
942
+ return periodic_orbit
943
+
944
+
945
+ def find_periodic_orbit(
946
+ grid_points: NDArray[np.float64],
947
+ parameters: NDArray[np.float64],
948
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
949
+ period: int,
950
+ tolerance: float = 1e-10,
951
+ max_iter: int = 1000,
952
+ convergence_threshold: float = 1e-15,
953
+ tolerance_decay_factor: float = 1 / 4,
954
+ verbose: bool = False,
955
+ transient_time: Optional[int] = None,
956
+ ) -> NDArray[np.float64]:
957
+ """Find periodic orbits through iterative grid refinement.
958
+
959
+ Parameters
960
+ ----------
961
+ grid_points : NDArray[np.float64]
962
+ 3D array of initial conditions with shape (nx, ny, 2)
963
+ parameters : NDArray[np.float64]
964
+ System parameters of shape (p,)
965
+ mapping : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
966
+ System mapping function
967
+ period : int
968
+ Period of orbits to find (must be ≥ 1)
969
+ tolerance : float, optional
970
+ Initial periodicity tolerance (default: 1e-10)
971
+ max_iter : int, optional
972
+ Maximum refinement iterations (default: 1000)
973
+ convergence_threshold : float, optional
974
+ Convergence threshold for orbit position (default: 1e-15)
975
+ tolerance_decay_factor : float, optional
976
+ Tolerance reduction factor per iteration (default: 0.25)
977
+ verbose : bool, optional
978
+ Print convergence info if True (default: False)
979
+ transient_time : Optional[int], optional
980
+ Initial iterations to discard (default: None)
981
+
982
+ Returns
983
+ -------
984
+ NDArray[np.float64]
985
+ Found periodic orbit of shape (2,)
986
+
987
+ Raises
988
+ ------
989
+ ValueError
990
+ If no periodic points found in initial grid
991
+ If invalid grid dimensions
992
+ If invalid period
993
+
994
+ Notes
995
+ -----
996
+ - Implements iterative grid refinement:
997
+ 1. Scan current grid for periodic points
998
+ 2. Calculate mean position and new search bounds
999
+ 3. Refine grid around found points
1000
+ 4. Repeat until convergence
1001
+ - For best results:
1002
+ - Start with coarse grid covering expected region
1003
+ - Use moderate tolerance (1e-8 to 1e-12)
1004
+ - Monitor convergence with verbose=True
1005
+ """
1006
+
1007
+ # Make a copy of the grid points to avoid modifying the original
1008
+ grid_points = grid_points.copy()
1009
+ grid_size_x = grid_points.shape[0]
1010
+ grid_size_y = grid_points.shape[1]
1011
+
1012
+ # Initialize periodic orbit
1013
+ periodic_orbit = np.zeros(2)
1014
+
1015
+ for j in range(max_iter):
1016
+
1017
+ # Scan the phase space grid for periodic points
1018
+ scan = scan_phase_space(
1019
+ grid_points,
1020
+ parameters,
1021
+ mapping,
1022
+ period,
1023
+ tolerance=tolerance,
1024
+ transient_time=transient_time,
1025
+ )
1026
+
1027
+ # Check if any periodic points were found
1028
+ nonzero_rows = np.any(scan != 0, axis=1)
1029
+
1030
+ # Count non-zero rows to determine number of periodic points found
1031
+ number_of_periodic_points = np.count_nonzero(nonzero_rows)
1032
+
1033
+ # If no periodic points are found, exit the loop
1034
+ if number_of_periodic_points == 0:
1035
+ if verbose:
1036
+ print(f"No periodic points found at iteration {j}")
1037
+ if j == 0:
1038
+ raise ValueError("No periodic points found in the initial grid")
1039
+ break
1040
+
1041
+ # Resize scan to only include found periodic points
1042
+ periodic_points = scan[nonzero_rows]
1043
+
1044
+ # Calculate the new periodic orbit
1045
+ periodic_orbit_new = np.zeros(2)
1046
+ periodic_orbit_new[0] = periodic_points[:, 0].mean()
1047
+ periodic_orbit_new[1] = periodic_points[:, 1].mean()
1048
+
1049
+ # Define the new phase space limits
1050
+ x_range = (
1051
+ periodic_points[:, 0].min() + tolerance,
1052
+ periodic_points[:, 0].max() - tolerance,
1053
+ )
1054
+ y_range = (
1055
+ periodic_points[:, 1].min() + tolerance,
1056
+ periodic_points[:, 1].max() - tolerance,
1057
+ )
1058
+
1059
+ # Update the grid points
1060
+ X = np.linspace(x_range[0], x_range[1], grid_size_x)
1061
+ Y = np.linspace(y_range[0], y_range[1], grid_size_y)
1062
+ X, Y = np.meshgrid(X, Y)
1063
+ grid_points = np.empty((grid_size_x, grid_size_y, 2))
1064
+ grid_points[:, :, 0] = X
1065
+ grid_points[:, :, 1] = Y
1066
+
1067
+ # Check convergence
1068
+ delta_orbit = np.abs(periodic_orbit_new - periodic_orbit)
1069
+ delta_bounds = np.abs(
1070
+ np.array([x_range[1] - x_range[0], y_range[1] - y_range[0]])
1071
+ )
1072
+
1073
+ if verbose:
1074
+ print(
1075
+ f"Iter {j}: Δorbit={delta_orbit}, Δbounds={delta_bounds}, tol={tolerance:.2e}"
1076
+ )
1077
+
1078
+ if np.all(delta_orbit < convergence_threshold) and np.all(
1079
+ delta_bounds < convergence_threshold
1080
+ ):
1081
+ if verbose:
1082
+ print(f"Converged after {j} iterations")
1083
+ break
1084
+
1085
+ # Update the periodic orbit
1086
+ periodic_orbit = periodic_orbit_new.copy()
1087
+
1088
+ # Update the tolerance for the next iteration
1089
+ tolerance = max(
1090
+ tolerance * tolerance_decay_factor,
1091
+ (delta_bounds[0] / grid_size_x + delta_bounds[1] / grid_size_y),
1092
+ )
1093
+
1094
+ return periodic_orbit
1095
+
1096
+
1097
+ @njit(cache=True)
1098
+ def eigenvalues_and_eigenvectors(
1099
+ u: NDArray[np.float64],
1100
+ parameters: NDArray[np.float64],
1101
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
1102
+ jacobian: Callable[
1103
+ [NDArray[np.float64], NDArray[np.float64], Callable], NDArray[np.float64]
1104
+ ],
1105
+ period: int,
1106
+ normalize: bool = True,
1107
+ sort_by_magnitude: bool = True,
1108
+ ) -> Tuple[NDArray[np.complex128], NDArray[np.complex128]]:
1109
+ """Compute eigenvalues and eigenvectors of the Jacobian matrix for a periodic orbit.
1110
+
1111
+ Parameters
1112
+ ----------
1113
+ u : NDArray[np.float64]
1114
+ Initial condition of shape (d,) where d is system dimension
1115
+ parameters : NDArray[np.float64]
1116
+ System parameters of shape (p,)
1117
+ mapping : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
1118
+ System mapping function
1119
+ jacobian : Callable[[NDArray[np.float64], NDArray[np.float64], Callable], NDArray[np.float64]]
1120
+ Function to compute Jacobian matrix
1121
+ period : int
1122
+ Period of the orbit (must be ≥ 1)
1123
+ normalize : bool, optional
1124
+ Whether to normalize eigenvectors (default: True)
1125
+ sort_by_magnitude : bool, optional
1126
+ Whether to sort by eigenvalue magnitude (default: True)
1127
+
1128
+ Returns
1129
+ -------
1130
+ Tuple[NDArray[np.complex128], NDArray[np.complex128]]
1131
+ - eigenvalues: Array of eigenvalues (shape (d,))
1132
+ - eigenvectors: Array of eigenvectors (shape (d, d))
1133
+ (each column is an eigenvector)
1134
+
1135
+ Raises
1136
+ ------
1137
+ ValueError
1138
+ If period is not positive
1139
+ If input dimensions are invalid
1140
+
1141
+ Notes
1142
+ -----
1143
+ - Computes the nth iterated Jacobian matrix J = J_p * J_{p-1} * ... * J_1
1144
+ - Complex eigenvalues come in conjugate pairs
1145
+ - Eigenvectors indicate directions of stretching/contraction
1146
+ """
1147
+ # Input validation
1148
+ if period < 1:
1149
+ raise ValueError("period must be ≥ 1")
1150
+ if u.ndim != 1:
1151
+ raise ValueError("u must be 1D array")
1152
+ if jacobian is None:
1153
+ raise ValueError("Jacobian function must be provided")
1154
+
1155
+ neq = len(u)
1156
+ J = np.eye(neq, dtype=np.complex128)
1157
+ current_u = u.copy()
1158
+
1159
+ # Compute Jacobian matrix
1160
+ for _ in range(period):
1161
+ current_u = mapping(current_u, parameters)
1162
+ J = (
1163
+ np.asarray(jacobian(current_u, parameters, mapping), dtype=np.complex128)
1164
+ @ J
1165
+ )
1166
+
1167
+ # Eigen decomposition
1168
+ eigenvalues, eigenvectors = np.linalg.eig(J)
1169
+
1170
+ # Post-processing
1171
+ if normalize:
1172
+ for i in range(neq):
1173
+ norm = np.linalg.norm(eigenvectors[:, i])
1174
+ if norm > 0:
1175
+ eigenvectors[:, i] /= norm
1176
+
1177
+ if sort_by_magnitude:
1178
+ idx = np.argsort(np.abs(eigenvalues))[::-1] # Descending order
1179
+ eigenvalues = eigenvalues[idx]
1180
+ eigenvectors = eigenvectors[:, idx]
1181
+
1182
+ return eigenvalues, eigenvectors
1183
+
1184
+
1185
+ def classify_stability(
1186
+ u: NDArray[np.float64],
1187
+ parameters: NDArray[np.float64],
1188
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
1189
+ jacobian: Callable[
1190
+ [NDArray[np.float64], NDArray[np.float64], Callable], NDArray[np.float64]
1191
+ ],
1192
+ period: int,
1193
+ threshold: float = 1.0,
1194
+ tol: float = 1e-8,
1195
+ ) -> Dict[str, Union[str, NDArray[np.complex128]]]:
1196
+ """
1197
+ Classify the local stability of a 2D periodic orbit in a discrete map.
1198
+
1199
+ Parameters
1200
+ ----------
1201
+ u : (2,) ndarray
1202
+ Initial condition in 2D.
1203
+ parameters : ndarray
1204
+ System parameters.
1205
+ mapping : Callable
1206
+ Map function f(u, parameters).
1207
+ jacobian : Callable
1208
+ Jacobian function J(u, parameters, mapping).
1209
+ period : int
1210
+ Period of the orbit.
1211
+ threshold : float
1212
+ Radius of the unit circle (default: 1.0).
1213
+ tol : float
1214
+ Tolerance to determine closeness to the unit circle.
1215
+
1216
+ Returns
1217
+ -------
1218
+ dict
1219
+ Dictionary with:
1220
+ - 'classification': str
1221
+ - 'eigenvalues': ndarray
1222
+ - 'eigenvectors': ndarray
1223
+
1224
+ Raises
1225
+ ------
1226
+ ValueError
1227
+ If u is not 2D or if the Jacobian is not 2x2.
1228
+ """
1229
+ if u.shape != (2,):
1230
+ raise ValueError(
1231
+ "This function only supports 2D systems (u.shape must be (2,))."
1232
+ )
1233
+
1234
+ # Compute eigenvalues
1235
+ eigenvalues, eigenvectors = eigenvalues_and_eigenvectors(
1236
+ u, parameters, mapping, jacobian, period
1237
+ )
1238
+ if eigenvalues.shape[0] != 2:
1239
+ raise ValueError("Jacobian must be 2x2 for this classification.")
1240
+
1241
+ λ1, λ2 = eigenvalues
1242
+ abs_λ1, abs_λ2 = np.abs(λ1), np.abs(λ2)
1243
+
1244
+ is_real = np.isreal(λ1) and np.isreal(λ2)
1245
+
1246
+ # Classification logic
1247
+ if abs_λ1 < threshold - tol and abs_λ2 < threshold - tol:
1248
+ classification = "stable node" if is_real else "stable spiral"
1249
+ elif abs_λ1 > threshold + tol and abs_λ2 > threshold + tol:
1250
+ classification = "unstable node" if is_real else "unstable spiral"
1251
+ elif (abs_λ1 < threshold - tol and abs_λ2 > threshold + tol) or (
1252
+ abs_λ2 < threshold - tol and abs_λ1 > threshold + tol
1253
+ ):
1254
+ classification = "saddle"
1255
+ elif abs(abs_λ1 - threshold) <= tol and abs(abs_λ2 - threshold) <= tol:
1256
+ classification = "center" if is_real else "elliptic (quasi-periodic)"
1257
+ else:
1258
+ classification = "marginal or degenerate"
1259
+
1260
+ return {
1261
+ "classification": classification,
1262
+ "eigenvalues": eigenvalues,
1263
+ "eigenvectors": eigenvectors,
1264
+ }
1265
+
1266
+
1267
+ def calculate_manifolds(
1268
+ u: NDArray[np.float64],
1269
+ parameters: NDArray[np.float64],
1270
+ forward_mapping: Callable[
1271
+ [NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]
1272
+ ],
1273
+ backward_mapping: Callable[
1274
+ [NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]
1275
+ ],
1276
+ jacobian: Callable[
1277
+ [NDArray[np.float64], NDArray[np.float64], Callable], NDArray[np.float64]
1278
+ ],
1279
+ period: int,
1280
+ delta: float = 1e-4,
1281
+ n_points: Union[NDArray[np.int32], List[int], int] = 100,
1282
+ iter_time: Union[List[int], int] = 100,
1283
+ stability: str = "unstable",
1284
+ ) -> List[np.ndarray]:
1285
+ """Calculate stable or unstable manifolds of a saddle periodic orbit.
1286
+
1287
+ Parameters
1288
+ ----------
1289
+ u : NDArray[np.float64]
1290
+ Initial condition of periodic orbit (shape (2,))
1291
+ parameters : NDArray[np.float64]
1292
+ System parameters (shape (p,))
1293
+ forward_mapping : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
1294
+ Forward time system mapping
1295
+ backward_mapping : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
1296
+ Backward time system mapping
1297
+ jacobian : Callable[[NDArray[np.float64], NDArray[np.float64], Callable], NDArray[np.float64]]
1298
+ Jacobian computation function
1299
+ period : int
1300
+ Period of the orbit (must be ≥ 1)
1301
+ delta : float, optional
1302
+ Initial displacement from orbit (default: 1e-4)
1303
+ n_points : Union[List[int], int], optional
1304
+ Number of points per branch (default: 100)
1305
+ iter_time : Union[List[int], int], optional
1306
+ Iterations per branch (default: 100)
1307
+ stability : str, optional
1308
+ 'stable' or 'unstable' manifold (default: 'unstable')
1309
+
1310
+ Returns
1311
+ -------
1312
+ List[NDArray[np.float64]]
1313
+ List containing two arrays:
1314
+ - [0]: Upper branch manifold points
1315
+ - [1]: Lower branch manifold points
1316
+ Each array has shape (n_points * iter_time, 2)
1317
+
1318
+ Raises
1319
+ ------
1320
+ ValueError
1321
+ If input is not a saddle point
1322
+ If invalid stability type
1323
+ If invalid point counts or iterations
1324
+
1325
+ Notes
1326
+ -----
1327
+ - Works only for 2D systems
1328
+ - The periodic orbit must be a saddle point
1329
+ - Manifold quality depends on:
1330
+ - delta (smaller = closer to linear approximation)
1331
+ - n_points (more = smoother manifold)
1332
+ - iter_time (more = longer manifold)
1333
+ """
1334
+ # Validate and process n_points
1335
+ if isinstance(n_points, int):
1336
+ n_points = [n_points, n_points]
1337
+ elif len(n_points) != 2:
1338
+ raise ValueError("n_points must be int or list of 2 ints")
1339
+ n_points = [int(n) for n in n_points]
1340
+ if any(n < 1 for n in n_points):
1341
+ raise ValueError("n_points must be ≥ 1")
1342
+
1343
+ # Validate and process iter_time
1344
+ if isinstance(iter_time, int):
1345
+ iter_time = [iter_time, iter_time]
1346
+ elif len(iter_time) != 2:
1347
+ raise ValueError("iter_time must be int or list of 2 ints")
1348
+ iter_time = [int(t) for t in iter_time]
1349
+ if any(t < 1 for t in iter_time):
1350
+ raise ValueError("iter_time must be ≥ 1")
1351
+
1352
+ # Verify saddle point
1353
+ stability_info = classify_stability(
1354
+ u, parameters, forward_mapping, jacobian, period
1355
+ )
1356
+ if stability_info["classification"] != "saddle":
1357
+ raise ValueError(
1358
+ "Manifolds require saddle point (1 stable + 1 unstable direction)"
1359
+ )
1360
+
1361
+ # Get eigenvectors
1362
+ eigenvectors: NDArray[np.complex128] = stability_info["eigenvectors"]
1363
+ vu = eigenvectors[:, 0]
1364
+ vs = eigenvectors[:, 1]
1365
+
1366
+ # Select manifold type
1367
+ if stability == "unstable":
1368
+ v = vu
1369
+ mapping = forward_mapping
1370
+ elif stability == "stable":
1371
+ v = vs
1372
+ mapping = backward_mapping
1373
+ else:
1374
+ raise ValueError("stability must be 'stable' or 'unstable'")
1375
+
1376
+ # Calculate eigenvector angle (ignore orientation)
1377
+ theta = np.arctan2(v[1].real, v[0].real) % np.pi
1378
+
1379
+ def calculate_branch(y_sign):
1380
+ if y_sign == 1:
1381
+ # Upper branch
1382
+ branch = 0
1383
+ else:
1384
+ # Lower branch
1385
+ branch = 1
1386
+ """Calculate manifold branch in specified direction."""
1387
+ y_range = u[1], (u[1] + y_sign * delta * np.sin(theta))
1388
+ # y = np.logspace(np.log10(y_range[0]), np.log10(y_range[1]), n_points[0])
1389
+ y = np.linspace(y_range[0], y_range[1], n_points[branch])
1390
+ x = (y - u[1]) / np.tan(theta) + u[0]
1391
+ points = np.column_stack((x, y))
1392
+ return ensemble_trajectories(points, parameters, iter_time[branch], mapping)
1393
+
1394
+ # Calculate both branches
1395
+ return [calculate_branch(+1), calculate_branch(-1)] # Upper branch # Lower branch
1396
+
1397
+
1398
+ def generate_symmetry_points(
1399
+ array: NDArray[np.float64],
1400
+ func: Callable[..., NDArray[np.float64]],
1401
+ axis: int,
1402
+ *args: Any,
1403
+ **kwargs: Any,
1404
+ ) -> NDArray[np.float64]:
1405
+ """
1406
+ Generate points along a symmetry line or curve.
1407
+
1408
+ Parameters:
1409
+ x_array (array-like): x-coordinates or y-coordinates depending on axis
1410
+ func: constant value (for horizontal/vertical) or function (for curve)
1411
+ axis (int): 0 for y = f(x), 1 for x = g(y)
1412
+ *args, **kwargs: extra parameters for the function if func is callable
1413
+
1414
+ Returns:
1415
+ np.ndarray: 2D array of points [[x, y], [x, y], ...]
1416
+ """
1417
+
1418
+ if not callable(func):
1419
+ raise TypeError(
1420
+ f"func must be a number or a callable function, got {type(func)}."
1421
+ )
1422
+
1423
+ if axis == 0:
1424
+ # y = f(x)
1425
+ x_array = array.copy()
1426
+ y_array = func(x_array, *args, **kwargs)
1427
+ elif axis == 1:
1428
+ # x = g(y)
1429
+ y_array = np.asarray(array)
1430
+ x_array = func(y_array, *args, **kwargs)
1431
+ else:
1432
+ raise ValueError(f"Invalid axis {axis}. Use 0 for y = f(x), 1 for x = g(y).")
1433
+
1434
+ return np.column_stack((x_array, y_array))
1435
+
1436
+
1437
+ @njit(cache=True, parallel=True)
1438
+ def ensemble_time_average(
1439
+ u: NDArray[np.float64],
1440
+ parameters: NDArray[np.float64],
1441
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
1442
+ total_time: int,
1443
+ axis: int = 1,
1444
+ ) -> NDArray[np.float64]:
1445
+
1446
+ u = u.copy()
1447
+ num_ic = u.shape[0]
1448
+ average = np.zeros(num_ic, dtype=np.float64)
1449
+
1450
+ for i in prange(num_ic):
1451
+ for _ in range(total_time):
1452
+ u[i] = mapping(u[i], parameters)
1453
+ average[i] += u[i, axis]
1454
+
1455
+ x_average = np.sum(average) / (num_ic * total_time)
1456
+
1457
+ average = average - total_time * x_average
1458
+
1459
+ return average