pynamicalsys 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pynamicalsys/__init__.py +24 -0
- pynamicalsys/__version__.py +21 -0
- pynamicalsys/common/__init__.py +16 -0
- pynamicalsys/common/basin_analysis.py +170 -0
- pynamicalsys/common/recurrence_quantification_analysis.py +426 -0
- pynamicalsys/common/utils.py +344 -0
- pynamicalsys/continuous_time/__init__.py +16 -0
- pynamicalsys/core/__init__.py +16 -0
- pynamicalsys/core/basin_metrics.py +206 -0
- pynamicalsys/core/continuous_dynamical_systems.py +18 -0
- pynamicalsys/core/discrete_dynamical_systems.py +3391 -0
- pynamicalsys/core/plot_styler.py +155 -0
- pynamicalsys/core/time_series_metrics.py +139 -0
- pynamicalsys/discrete_time/__init__.py +16 -0
- pynamicalsys/discrete_time/dynamical_indicators.py +1226 -0
- pynamicalsys/discrete_time/models.py +435 -0
- pynamicalsys/discrete_time/trajectory_analysis.py +1459 -0
- pynamicalsys/discrete_time/transport.py +501 -0
- pynamicalsys/discrete_time/validators.py +313 -0
- pynamicalsys-1.0.0.dist-info/METADATA +791 -0
- pynamicalsys-1.0.0.dist-info/RECORD +23 -0
- pynamicalsys-1.0.0.dist-info/WHEEL +5 -0
- pynamicalsys-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
|