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,501 @@
1
+ # transport.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
19
+ from numpy.typing import NDArray
20
+ import numpy as np
21
+ from numba import njit, prange
22
+ from .trajectory_analysis import iterate_mapping
23
+
24
+
25
+ @njit(cache=True, parallel=True)
26
+ def diffusion_coefficient(
27
+ u0: NDArray[np.float64],
28
+ parameters: NDArray[np.float64],
29
+ total_time: int,
30
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
31
+ axis: int = 1,
32
+ ) -> np.float64:
33
+ """
34
+ Compute the diffusion coefficient for an ensemble of trajectories.
35
+
36
+ The diffusion coefficient D is estimated using the Einstein relation:
37
+ D = lim_{t→∞} ⟨(x(t) - x(0))²⟩ / (2t)
38
+ where ⟨·⟩ denotes ensemble averaging.
39
+
40
+ Parameters
41
+ ----------
42
+ u0 : NDArray[np.float64]
43
+ Array of initial conditions (shape: (num_ic, neq))
44
+ parameters : NDArray[np.float64]
45
+ System parameters passed to mapping function
46
+ total_time : int
47
+ Total evolution time (must be > transient_time)
48
+ mapping : Callable[[NDArray, NDArray], NDArray]
49
+ System evolution function: u_next = mapping(u, parameters)
50
+ axis : int, optional
51
+ axis index to analyze (default: 1)
52
+
53
+ Returns
54
+ -------
55
+ float
56
+ Estimated diffusion coefficient D
57
+
58
+ Raises
59
+ ------
60
+ ValueError
61
+ If total_time ≤ transient_time
62
+ If axis index is invalid
63
+
64
+ Notes
65
+ -----
66
+ - Assumes normal diffusion (linear mean squared displacement growth)
67
+ - For anisotropic systems, analyze each axis separately
68
+ - Parallelized over initial conditions for large ensembles
69
+ """
70
+ # Input validation
71
+ if axis < 0 or axis >= u0.shape[1]:
72
+ raise ValueError(f"axis must be in [0, {u0.shape[1]-1}]")
73
+
74
+ num_ic = u0.shape[0]
75
+ u_final = np.empty_like(u0)
76
+
77
+ # Parallel evolution of trajectories
78
+ for i in prange(num_ic):
79
+ # Evolve each initial condition
80
+ u_final[i] = iterate_mapping(u0[i], parameters, total_time, mapping)
81
+
82
+ # Compute mean squared displacement
83
+ msd = np.mean((u_final[:, axis] - u0[:, axis]) ** 2)
84
+
85
+ return msd / (2 * (total_time))
86
+
87
+
88
+ @njit(cache=True, parallel=True)
89
+ def average_vs_time(
90
+ u: NDArray[np.float64],
91
+ parameters: NDArray[np.float64],
92
+ total_time: int,
93
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
94
+ sample_times: Optional[NDArray[np.int32]] = None,
95
+ axis: int = 1,
96
+ transient_time: int = 0,
97
+ ) -> NDArray[np.float64]:
98
+ """
99
+ Compute the time evolution of ensemble averages for a dynamical system.
100
+
101
+ Tracks the average value of a specified coordinate across multiple trajectories,
102
+ with options for downsampling and transient removal. Useful for studying
103
+ convergence to equilibrium or statistical properties.
104
+
105
+ Parameters
106
+ ----------
107
+ u : NDArray[np.float64]
108
+ Array of initial conditions (shape: (num_ic, num_dim))
109
+ parameters : NDArray[np.float64]
110
+ System parameters passed to mapping function
111
+ total_time : int
112
+ Total number of iterations (must be > transient_time)
113
+ mapping : Callable[[NDArray, NDArray], NDArray]
114
+ System evolution function: u_next = mapping(u, parameters)
115
+ sample_times : Optional[NDArray[np.int64]], optional
116
+ Specific time steps to record (default: record all steps)
117
+ axis : int, optional
118
+ Coordinate index to analyze (default: 1)
119
+ transient_time : int, optional
120
+ Initial iterations to discard (default: 0)
121
+
122
+ Returns
123
+ -------
124
+ NDArray[np.float64]
125
+ Array of average values at requested times
126
+
127
+ Raises
128
+ ------
129
+ ValueError
130
+ If total_time ≤ transient_time
131
+ If sample_times contains values > total_time
132
+ If axis is invalid
133
+
134
+ Notes
135
+ -----
136
+ - Uses parallel processing over initial conditions
137
+ - For large ensembles, consider using sample_times to reduce memory
138
+ - The output length matches len(sample_times) if provided, else (total_time - transient_time)
139
+ """
140
+ # Input validation
141
+ if total_time <= transient_time:
142
+ raise ValueError("total_time must be > transient_time")
143
+ if axis < 0 or axis >= u.shape[1]:
144
+ raise ValueError(f"axis must be in [0, {u.shape[1]-1}]")
145
+ if sample_times is not None:
146
+ if np.any(sample_times >= total_time):
147
+ raise ValueError("All sample_times must be < total_time")
148
+
149
+ # Initialize tracking
150
+ num_ic = u.shape[0]
151
+ effective_time = total_time - transient_time
152
+ u_current = u.copy()
153
+
154
+ # Handle output array
155
+ if sample_times is not None:
156
+ output = np.empty(len(sample_times))
157
+ else:
158
+ output = np.empty(effective_time)
159
+
160
+ output_idx = 0
161
+
162
+ # Main evolution loop
163
+ for t in range(total_time):
164
+ # Parallel evolution
165
+ for i in prange(num_ic):
166
+ u_current[i] = mapping(u_current[i], parameters)
167
+
168
+ # Record if past transient and matches sampling
169
+ if t >= transient_time:
170
+ output[output_idx] = np.mean(u_current[:, axis])
171
+ if sample_times is None:
172
+ output_idx += 1
173
+ elif t in sample_times:
174
+ output_idx += 1
175
+
176
+ return output
177
+
178
+
179
+ @njit(cache=True, parallel=True)
180
+ def cumulative_average_vs_time(
181
+ u: NDArray[np.float64],
182
+ parameters: NDArray[np.float64],
183
+ total_time: int,
184
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
185
+ sample_times: Optional[NDArray[np.int32]] = None,
186
+ axis: int = 1,
187
+ transient_time: int = 0,
188
+ ) -> NDArray[np.float64]:
189
+ """
190
+ Compute the time evolution of the cumulative average of a coordinate across trajectories.
191
+
192
+ Parameters
193
+ ----------
194
+ u : NDArray[np.float64]
195
+ Array of initial conditions (shape: (num_ic, num_dim))
196
+ parameters : NDArray[np.float64]
197
+ System parameters passed to mapping function
198
+ total_time : int
199
+ Total number of iterations (must be > transient_time)
200
+ mapping : Callable[[NDArray, NDArray], NDArray]
201
+ System evolution function: u_next = mapping(u, parameters)
202
+ sample_times : Optional[NDArray[np.int64]], optional
203
+ Specific time steps to record (default: record all steps)
204
+ axis : int, optional
205
+ Coordinate index to analyze (default: 1)
206
+ transient_time : int, optional
207
+ Initial iterations to discard (default: 0)
208
+
209
+ Returns
210
+ -------
211
+ NDArray[np.float64]
212
+ Array of cumulative average values at requested times
213
+
214
+ Raises
215
+ ------
216
+ ValueError
217
+ If total_time ≤ transient_time
218
+ If sample_times contains invalid values
219
+ If axis is invalid
220
+
221
+ Notes
222
+ -----
223
+ - Uses parallel processing over initial conditions
224
+ - For large total_time, use sample_times to reduce memory usage
225
+ - The cumulative average is computed over the ensemble after removing transient
226
+ """
227
+
228
+ num_ic = u.shape[0]
229
+ u_current = u.copy()
230
+ sum_values = np.zeros(num_ic)
231
+
232
+ # Initialize output array
233
+ if sample_times is not None:
234
+ output_size = len(sample_times)
235
+ else:
236
+ output_size = total_time - transient_time
237
+
238
+ cumul_average = np.zeros(output_size)
239
+ output_idx = 0
240
+
241
+ # Main evolution loop
242
+ for t in range(1, total_time + 1):
243
+ # Parallel evolution
244
+ for i in prange(num_ic):
245
+ u_current[i] = mapping(u_current[i], parameters)
246
+
247
+ # Record if past transient and matches sampling
248
+ if t > transient_time:
249
+ # Update running sum of squares
250
+ sum_values += u_current[:, axis]
251
+ cumul_average[output_idx] = np.mean(sum_values / t)
252
+ if sample_times is None:
253
+ output_idx += 1
254
+ elif t in sample_times:
255
+ output_idx += 1
256
+
257
+ return cumul_average
258
+
259
+
260
+ @njit(cache=True, parallel=True)
261
+ def root_mean_squared(
262
+ u: NDArray[np.float64],
263
+ parameters: NDArray[np.float64],
264
+ total_time: int,
265
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
266
+ sample_times: Optional[NDArray[np.int32]] = None,
267
+ axis: int = 1,
268
+ transient_time: int = 0,
269
+ ) -> NDArray[np.float64]:
270
+ """
271
+ Compute the time evolution of the root mean square (RMS) of a coordinate across trajectories.
272
+
273
+ The RMS is calculated as:
274
+ RMS(t) = sqrt(∑(x_i(t)²)/N)
275
+ where N is the number of trajectories and x_i are the coordinate values.
276
+
277
+ Parameters
278
+ ----------
279
+ u : NDArray[np.float64]
280
+ Array of initial conditions (shape: (num_ic, num_dim))
281
+ parameters : NDArray[np.float64]
282
+ System parameters passed to mapping function
283
+ total_time : int
284
+ Total number of iterations (must be > transient_time)
285
+ mapping : Callable[[NDArray, NDArray], NDArray]
286
+ System evolution function: u_next = mapping(u, parameters)
287
+ sample_times : Optional[NDArray[np.int64]], optional
288
+ Specific time steps to record (default: record all steps)
289
+ axis : int, optional
290
+ Coordinate index to analyze (default: 1)
291
+ transient_time : int, optional
292
+ Initial iterations to discard (default: 0)
293
+
294
+ Returns
295
+ -------
296
+ NDArray[np.float64]
297
+ Array of RMS values at requested times
298
+
299
+ Raises
300
+ ------
301
+ ValueError
302
+ If total_time ≤ transient_time
303
+ If sample_times contains invalid values
304
+ If axis is invalid
305
+
306
+ Notes
307
+ -----
308
+ - Uses parallel processing over initial conditions
309
+ - For large total_time, use sample_times to reduce memory usage
310
+ - The RMS is computed over the ensemble after removing transient
311
+ """
312
+
313
+ num_ic = u.shape[0]
314
+ u_current = u.copy()
315
+ sum_squares = np.zeros(num_ic)
316
+
317
+ # Initialize output array
318
+ if sample_times is not None:
319
+ output_size = len(sample_times)
320
+ else:
321
+ output_size = total_time - transient_time
322
+
323
+ rms = np.zeros(output_size)
324
+ output_idx = 0
325
+
326
+ # Main evolution loop
327
+ for t in range(1, total_time + 1):
328
+ # Parallel evolution
329
+ for i in prange(num_ic):
330
+ u_current[i] = mapping(u_current[i], parameters)
331
+
332
+ # Record if past transient and matches sampling
333
+ if t > transient_time:
334
+ # Update running sum of squares
335
+ sum_squares += u_current[:, axis] ** 2
336
+ rms[output_idx] = np.sqrt(np.mean(sum_squares / t))
337
+ if sample_times is None:
338
+ output_idx += 1
339
+ elif t in sample_times:
340
+ output_idx += 1
341
+
342
+ return rms
343
+
344
+
345
+ @njit(cache=True, parallel=True)
346
+ def mean_squared_displacement(
347
+ u0: NDArray[np.float64],
348
+ parameters: NDArray[np.float64],
349
+ total_time: int,
350
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
351
+ sample_times: Optional[NDArray[np.int32]] = None,
352
+ axis: int = 1,
353
+ transient_time: int = 0,
354
+ ) -> NDArray[np.float64]:
355
+ """
356
+ Compute the mean squared displacement (MSD) of a coordinate across multiple trajectories.
357
+
358
+ The MSD is calculated as:
359
+ MSD(t) = ⟨(x_i(t) - x_i(0))²⟩
360
+ where ⟨·⟩ denotes the average over all trajectories.
361
+
362
+ Parameters
363
+ ----------
364
+ u0 : NDArray[np.float64]
365
+ Array of initial conditions (shape: (num_ic, num_dim))
366
+ parameters : NDArray[np.float64]
367
+ System parameters passed to mapping function
368
+ total_time : int
369
+ Total number of iterations (must be > transient_time)
370
+ mapping : Callable[[NDArray, NDArray], NDArray]
371
+ System evolution function: u_next = mapping(u, parameters)
372
+ sample_times : Optional[NDArray[np.int64]], optional
373
+ Specific time steps to record (default: record all steps)
374
+ axis : int, optional
375
+ Coordinate index to analyze (default: 1)
376
+ transient_time : int, optional
377
+ Initial iterations to discard (default: 0)
378
+
379
+ Returns
380
+ -------
381
+ NDArray[np.float64]
382
+ Array of MSD values at requested times
383
+
384
+ Raises
385
+ ------
386
+ ValueError
387
+ If total_time ≤ transient_time
388
+ If sample_times contains invalid values
389
+ If axis is invalid
390
+
391
+ Notes
392
+ -----
393
+ - Uses parallel processing over initial conditions
394
+ - For normal diffusion, MSD grows linearly with time
395
+ - The output length matches len(sample_times) if provided, else (total_time - transient_time)
396
+ """
397
+ # Input validation
398
+
399
+ num_ic = u0.shape[0]
400
+ u = u0.copy()
401
+ # Store initial values for MSD calculation
402
+ initial_values = u0[:, axis].copy()
403
+
404
+ # Initialize output array
405
+ if sample_times is not None:
406
+ output_size = len(sample_times)
407
+ else:
408
+ output_size = total_time - transient_time
409
+
410
+ msd = np.zeros(output_size)
411
+ output_idx = 0
412
+
413
+ # Main evolution loop
414
+ for t in range(1, total_time + 1):
415
+ # Parallel evolution
416
+ for i in prange(num_ic):
417
+ u[i] = mapping(u[i], parameters)
418
+
419
+ # Calculate and store MSD if past transient
420
+ if t > transient_time:
421
+ displacements = u[:, axis] - initial_values
422
+ msd[output_idx] = np.mean(displacements**2)
423
+ if sample_times is None:
424
+ output_idx += 1
425
+ elif t in sample_times:
426
+ output_idx += 1
427
+
428
+ return msd
429
+
430
+
431
+ @njit(cache=True)
432
+ def recurrence_times(
433
+ u: NDArray[np.float64],
434
+ parameters: NDArray[np.float64],
435
+ total_time: int,
436
+ mapping: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
437
+ eps: float,
438
+ transient_time: Optional[int] = None,
439
+ ) -> NDArray[np.float64]:
440
+ """Compute recurrence times to a neighborhood of the initial condition.
441
+
442
+ Parameters
443
+ ----------
444
+ u : NDArray[np.float64]
445
+ Initial state vector (shape: `(neq,)`).
446
+ parameters : NDArray[np.float64]
447
+ System parameters passed to `mapping` and `jacobian`.
448
+ total_time : int
449
+ Total number of iterations to compute (must be > 0)
450
+ mapping : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
451
+ System mapping function (must be Numba-compatible)
452
+ eps : float
453
+ Size of the neighborhood (must be > 0)
454
+ transient_time : Optional[int], optional
455
+ Number of initial iterations to discard (default: None, no transient removal)
456
+
457
+ Returns
458
+ -------
459
+ NDArray[np.float64]
460
+ Array of recurrence times where:
461
+ - Each element is the time between returns to the eps-neighborhood
462
+ - Empty array if no recurrences occur
463
+
464
+ Notes
465
+ -----
466
+ - A recurrence occurs when the trajectory enters the hypercube:
467
+ [u-eps/2, u+eps/2]^d
468
+ - Useful for analyzing:
469
+ - Stickiness in Hamiltonian systems
470
+ - Chaotic vs regular orbits
471
+ - For meaningful results:
472
+ - eps should be small but not smaller than numerical precision
473
+ - total_time should be >> expected recurrence times
474
+ """
475
+
476
+ u = u.copy()
477
+
478
+ if transient_time is not None:
479
+ u = iterate_mapping(u, parameters, transient_time, mapping)
480
+
481
+ lower_bound = u - eps / 2
482
+ upper_bound = u + eps / 2
483
+
484
+ # Initialize recurrence time and list
485
+ rt = 0
486
+ rts = []
487
+
488
+ # Iterate over the total time
489
+ for t in range(total_time):
490
+ # Evolve the system
491
+ u = mapping(u, parameters)
492
+
493
+ # Increment the recurrence time
494
+ rt += 1
495
+
496
+ # Check if the state has entered the box
497
+ if np.all(u >= lower_bound) and np.all(u <= upper_bound):
498
+ rts.append(rt)
499
+ rt = 0
500
+
501
+ return np.array(rts)