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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,11 +15,13 @@
15
15
  # You should have received a copy of the GNU General Public License
16
16
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
- from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
18
+ from typing import Callable, Dict, List, Optional, Sequence, Tuple, Union
19
19
 
20
+ from joblib import Parallel, delayed
20
21
  import numpy as np
21
22
  from numba import njit, prange
22
23
  from numpy.typing import NDArray
24
+ from sklearn.cluster import DBSCAN
23
25
 
24
26
  from pynamicalsys.continuous_time.numerical_integrators import rk4_step_wrapped
25
27
 
@@ -160,7 +162,6 @@ def generate_trajectory(
160
162
  return trajectory
161
163
 
162
164
 
163
- @njit(cache=True, parallel=True)
164
165
  def ensemble_trajectories(
165
166
  u: NDArray[np.float64],
166
167
  parameters: NDArray[np.float64],
@@ -175,25 +176,471 @@ def ensemble_trajectories(
175
176
  integrator=rk4_step_wrapped,
176
177
  ) -> NDArray[np.float64]:
177
178
 
178
- if u.ndim != 2:
179
- raise ValueError("Initial conditions must be 2D array (num_ic, neq)")
179
+ def run_one(u_i, parameters, total_time, equations_of_motion, **kwargs):
180
+ result = generate_trajectory(
181
+ u_i, parameters, total_time, equations_of_motion, **kwargs
182
+ )
183
+
184
+ return np.array(result)
180
185
 
186
+ results = Parallel(n_jobs=-1)( # -1 = use all cores
187
+ delayed(run_one)(
188
+ u[i],
189
+ parameters,
190
+ total_time,
191
+ equations_of_motion,
192
+ transient_time=transient_time,
193
+ time_step=time_step,
194
+ atol=atol,
195
+ rtol=rtol,
196
+ integrator=integrator,
197
+ )
198
+ for i in range(len(u))
199
+ )
200
+
201
+ return results
202
+
203
+
204
+ @njit
205
+ def generate_poincare_section(
206
+ u: NDArray[np.float64],
207
+ parameters: NDArray[np.float64],
208
+ num_intersections: int,
209
+ equations_of_motion: Callable[
210
+ [np.float64, NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]
211
+ ],
212
+ transient_time: float,
213
+ time_step: float,
214
+ atol: float,
215
+ rtol: float,
216
+ integrator,
217
+ section_index: int,
218
+ section_value: float,
219
+ crossing: int,
220
+ ) -> NDArray[np.float64]:
221
+ neq = len(u)
222
+ section_points = np.zeros((num_intersections, neq + 1))
223
+ count = 0
224
+
225
+ u = u.copy()
226
+ if transient_time is not None:
227
+ u = evolve_system(
228
+ u,
229
+ parameters,
230
+ transient_time,
231
+ equations_of_motion,
232
+ time_step=time_step,
233
+ atol=atol,
234
+ rtol=rtol,
235
+ integrator=integrator,
236
+ )
237
+ time = transient_time
238
+ else:
239
+ time = 0
240
+
241
+ time_step_prev = time_step
242
+ time_prev = time
243
+ u_prev = u.copy()
244
+ while count < num_intersections:
245
+ u_new, time_new, time_step_new = step(
246
+ time_prev,
247
+ u_prev,
248
+ parameters,
249
+ equations_of_motion,
250
+ time_step=time_step_prev,
251
+ atol=atol,
252
+ rtol=rtol,
253
+ integrator=integrator,
254
+ )
255
+
256
+ # Check for crossings
257
+ if (u_prev[section_index] - section_value) * (
258
+ u_new[section_index] - section_value
259
+ ) < 0.0:
260
+ lam = (section_value - u_prev[section_index]) / (
261
+ u_new[section_index] - u_prev[section_index]
262
+ )
263
+
264
+ t_cross = time_new - time_step_prev + lam * time_step_prev
265
+ u_cross = (1 - lam) * u_prev + lam * u_new
266
+ velocity = equations_of_motion(time, u_cross, parameters)[section_index]
267
+
268
+ if crossing == 0 or np.sign(velocity) == crossing:
269
+ section_points[count, 0] = t_cross
270
+ section_points[count, 1:] = u_cross
271
+ count += 1
272
+
273
+ time_prev = time_new
274
+ time_step_prev = time_step_new
275
+ u_prev = u_new
276
+
277
+ return section_points
278
+
279
+
280
+ @njit(parallel=True)
281
+ def ensemble_poincare_section(
282
+ u: NDArray[np.float64],
283
+ parameters: NDArray[np.float64],
284
+ num_intersections: int,
285
+ equations_of_motion: Callable[
286
+ [np.float64, NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]
287
+ ],
288
+ transient_time: float,
289
+ time_step: float,
290
+ atol: float,
291
+ rtol: float,
292
+ integrator,
293
+ section_index: int,
294
+ section_value: float,
295
+ crossing: int,
296
+ ) -> NDArray[np.float64]:
181
297
  num_ic, neq = u.shape
298
+ section_points = np.zeros((num_ic, num_intersections, neq + 1))
299
+ for i in prange(num_ic):
300
+ section_points[i] = generate_poincare_section(
301
+ u[i],
302
+ parameters,
303
+ num_intersections,
304
+ equations_of_motion,
305
+ transient_time,
306
+ time_step,
307
+ atol,
308
+ rtol,
309
+ integrator,
310
+ section_index,
311
+ section_value,
312
+ crossing,
313
+ )
314
+
315
+ return section_points
316
+
317
+
318
+ @njit
319
+ def generate_stroboscopic_map(
320
+ u: NDArray[np.float64],
321
+ parameters: NDArray[np.float64],
322
+ num_intersections: int,
323
+ sampling_time: float,
324
+ equations_of_motion: Callable[
325
+ [np.float64, NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]
326
+ ],
327
+ transient_time: float,
328
+ time_step: float,
329
+ atol: float,
330
+ rtol: float,
331
+ integrator,
332
+ ) -> NDArray[np.float64]:
333
+
334
+ u = np.asarray(u)
335
+ neq = len(u)
336
+ strobe_points = np.zeros((num_intersections, neq + 1))
337
+ if transient_time is not None:
338
+ u_curr = evolve_system(
339
+ u,
340
+ parameters,
341
+ transient_time,
342
+ equations_of_motion,
343
+ time_step=time_step,
344
+ atol=atol,
345
+ rtol=rtol,
346
+ integrator=integrator,
347
+ )
348
+ time_curr = transient_time
349
+ else:
350
+ u_curr = u.copy()
351
+ time_curr = 0
352
+
353
+ time_target = time_curr + sampling_time
354
+ count = 0
355
+ while count < num_intersections:
356
+ u_prev = u_curr.copy()
357
+ time_prev = time_curr
358
+ # Integrate until we reach or surpass the target strobe time
359
+ while time_curr < time_target:
360
+ u_curr, time_curr, time_step = step(
361
+ time_curr,
362
+ u_curr,
363
+ parameters,
364
+ equations_of_motion,
365
+ time_step=time_step,
366
+ atol=atol,
367
+ rtol=rtol,
368
+ integrator=integrator,
369
+ )
182
370
 
183
- trajectories = []
371
+ # Linear interpolation to exactly hit time_target
372
+ lam = (time_target - time_prev) / (time_curr - time_prev)
373
+ strobe_points[count, 0] = time_target
374
+ strobe_points[count, 1:] = (1 - lam) * u_prev + lam * u_curr
375
+ # print((1 - lam), u_prev, u_curr)
376
+ count += 1
377
+ time_target += sampling_time
378
+
379
+ return strobe_points
380
+
381
+
382
+ @njit(parallel=True)
383
+ def ensemble_stroboscopic_map(
384
+ u: NDArray[np.float64],
385
+ parameters: NDArray[np.float64],
386
+ num_intersections: int,
387
+ sampling_time: float,
388
+ equations_of_motion: Callable[
389
+ [np.float64, NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]
390
+ ],
391
+ transient_time: float,
392
+ time_step: float,
393
+ atol: float,
394
+ rtol: float,
395
+ integrator,
396
+ ) -> NDArray[np.float64]:
397
+ num_ic, neq = u.shape
398
+ strobe_points = np.zeros((num_ic, num_intersections, neq + 1))
184
399
 
185
400
  for i in prange(num_ic):
186
- trajectory = generate_trajectory(
401
+ strobe_points[i] = generate_stroboscopic_map(
187
402
  u[i],
188
403
  parameters,
189
- total_time,
404
+ num_intersections,
405
+ sampling_time,
406
+ equations_of_motion,
407
+ transient_time,
408
+ time_step,
409
+ atol,
410
+ rtol,
411
+ integrator,
412
+ )
413
+
414
+ return strobe_points
415
+
416
+
417
+ @njit
418
+ def generate_maxima_map(
419
+ u: NDArray[np.float64],
420
+ parameters: NDArray[np.float64],
421
+ num_peaks: int,
422
+ maxima_index: int,
423
+ equations_of_motion,
424
+ transient_time: float,
425
+ time_step: float,
426
+ atol: float,
427
+ rtol: float,
428
+ integrator,
429
+ ) -> NDArray[np.float64]:
430
+ """
431
+ Generate a maxima map of a specified state variable.
432
+
433
+ Parameters
434
+ ----------
435
+ u : np.ndarray
436
+ Initial state vector.
437
+ parameters : np.ndarray
438
+ Parameters for the system.
439
+ num_peaks : int
440
+ Number of maxima to collect.
441
+ maxima_index : int
442
+ Index of the variable whose maxima are to be recorded.
443
+ equations_of_motion : callable
444
+ Function f(t, u, parameters) returning du/dt.
445
+ transient_time : float
446
+ Time to integrate before starting maxima collection.
447
+ time_step : float
448
+ Initial integration time step.
449
+ atol, rtol : float
450
+ Absolute and relative tolerances for integration.
451
+ integrator : callable
452
+ Integration function or object, similar to your `step` function.
453
+
454
+ Returns
455
+ -------
456
+ maxima_points : np.ndarray
457
+ Array of shape (num_peaks, n_vars+1):
458
+ [time_of_max, u_1, u_2, ... u_n] at each maximum.
459
+ """
460
+ neq = len(u)
461
+ maxima_points = np.zeros((num_peaks, neq + 1))
462
+
463
+ # Transient
464
+ if transient_time is not None:
465
+ u = evolve_system(
466
+ u,
467
+ parameters,
468
+ transient_time,
190
469
  equations_of_motion,
191
- transient_time=transient_time,
192
470
  time_step=time_step,
193
471
  atol=atol,
194
472
  rtol=rtol,
195
473
  integrator=integrator,
196
474
  )
197
- trajectories.append(np.array(trajectory))
475
+ time = transient_time
476
+ else:
477
+ time = 0.0
478
+
479
+ # Initial step
480
+ time_step_prev = time_step
481
+ time_prev = time
482
+ u_prev = u.copy()
483
+
484
+ # We need three points to detect a local maximum
485
+ # (previous, current, next)
486
+ u_curr, time_curr, time_step_curr = step(
487
+ time_prev,
488
+ u_prev,
489
+ parameters,
490
+ equations_of_motion,
491
+ time_step=time_step_prev,
492
+ atol=atol,
493
+ rtol=rtol,
494
+ integrator=integrator,
495
+ )
496
+
497
+ count = 0
498
+ while count < num_peaks:
499
+ # Step to the next point
500
+ u_next, time_next, time_step_next = step(
501
+ time_curr,
502
+ u_curr,
503
+ parameters,
504
+ equations_of_motion,
505
+ time_step=time_step_curr,
506
+ atol=atol,
507
+ rtol=rtol,
508
+ integrator=integrator,
509
+ )
510
+
511
+ # Variable values at three times
512
+ y_prev = u_prev[maxima_index]
513
+ y_curr = u_curr[maxima_index]
514
+ y_next = u_next[maxima_index]
515
+
516
+ # Check for local maximum
517
+ if (y_curr > y_prev) and (y_curr > y_next):
518
+ # Quadratic interpolation for more precise max
519
+ # Fit parabola through (t_{i-1}, y_{i-1}), (t_i, y_i), (t_{i+1}, y_{i+1})
520
+ t1, t2, t3 = time_prev, time_curr, time_next
521
+ y1, y2, y3 = y_prev, y_curr, y_next
522
+
523
+ denom = (t1 - t2) * (t1 - t3) * (t2 - t3)
524
+ A = (t3 * (y2 - y1) + t2 * (y1 - y3) + t1 * (y3 - y2)) / denom
525
+ B = (t3**2 * (y1 - y2) + t2**2 * (y3 - y1) + t1**2 * (y2 - y3)) / denom
526
+
527
+ t_peak = -B / (2.0 * A) # vertex of the parabola
528
+
529
+ # Interpolate state vector linearly between u_curr and u_next at t_peak
530
+ lam = (t_peak - time_curr) / (time_next - time_curr)
531
+ u_peak = (1 - lam) * u_curr + lam * u_next
532
+
533
+ maxima_points[count, 0] = t_peak
534
+ maxima_points[count, 1:] = u_peak
535
+ count += 1
536
+
537
+ # Shift variables for next iteration
538
+ u_prev = u_curr
539
+ time_prev = time_curr
540
+ time_step_prev = time_step_curr
541
+
542
+ u_curr = u_next
543
+ time_curr = time_next
544
+ time_step_curr = time_step_next
545
+
546
+ return maxima_points
547
+
548
+
549
+ @njit
550
+ def ensemble_maxima_map(
551
+ u: NDArray[np.float64],
552
+ parameters: NDArray[np.float64],
553
+ num_peaks: int,
554
+ maxima_index: int,
555
+ equations_of_motion,
556
+ transient_time: float,
557
+ time_step: float,
558
+ atol: float,
559
+ rtol: float,
560
+ integrator,
561
+ ) -> NDArray[np.float64]:
562
+
563
+ num_ic, neq = u.shape
564
+ maxima_points = np.zeros((num_ic, num_peaks, neq + 1))
565
+
566
+ for i in prange(num_ic):
567
+ maxima_points[i] = generate_maxima_map(
568
+ u[i],
569
+ parameters,
570
+ num_peaks,
571
+ maxima_index,
572
+ equations_of_motion,
573
+ transient_time,
574
+ time_step,
575
+ atol,
576
+ rtol,
577
+ integrator,
578
+ )
579
+
580
+ return maxima_points
581
+
582
+
583
+ def basin_of_attraction(
584
+ u: NDArray[np.float64],
585
+ parameters: NDArray[np.float64],
586
+ num_intersections: int,
587
+ equations_of_motion: Callable[
588
+ [np.float64, NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]
589
+ ],
590
+ transient_time: float,
591
+ time_step: float,
592
+ atol: float,
593
+ rtol: float,
594
+ integrator: Callable,
595
+ select_map: str,
596
+ section_index: int = None,
597
+ section_value: float = None,
598
+ crossing: int = None,
599
+ sampling_time: float = None,
600
+ eps: float = 0.05,
601
+ min_samples: int = 1,
602
+ ) -> NDArray[np.int32]:
603
+
604
+ if select_map == "PS":
605
+ if section_index is None or section_value is None or crossing is None:
606
+ raise ValueError(
607
+ "You must provide section_index, section_value, and crossing"
608
+ )
609
+ data = ensemble_poincare_section(
610
+ u,
611
+ parameters,
612
+ num_intersections,
613
+ equations_of_motion,
614
+ transient_time,
615
+ time_step,
616
+ atol,
617
+ rtol,
618
+ integrator,
619
+ section_index,
620
+ section_value,
621
+ crossing,
622
+ )
623
+
624
+ elif select_map == "SM":
625
+ if sampling_time is None:
626
+ raise ValueError("You must provide sampling_time")
627
+
628
+ data = ensemble_stroboscopic_map(
629
+ u,
630
+ parameters,
631
+ num_intersections,
632
+ sampling_time,
633
+ equations_of_motion,
634
+ transient_time,
635
+ time_step,
636
+ atol,
637
+ rtol,
638
+ integrator,
639
+ )
640
+ traj_data = data[:, :, 1:]
641
+ trajectory_centroids = traj_data.mean(axis=1) # shape (num_ic, 2)
642
+
643
+ db = DBSCAN(eps=eps, min_samples=min_samples, n_jobs=-1).fit(trajectory_centroids)
644
+ labels = db.labels_ # shape (num_ic,)
198
645
 
199
- return trajectories
646
+ return labels