pynamicalsys 1.0.1__py3-none-any.whl → 1.2.1__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,337 @@
1
+ # numerical_integrators.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
+ from pynamicalsys.continuous_time.models import variational_equations
24
+
25
+
26
+ @njit(cache=True)
27
+ def rk4_step(
28
+ t: float,
29
+ u: NDArray[np.float64],
30
+ parameters: NDArray[np.float64],
31
+ equations_of_motion: Callable[
32
+ [float, NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]
33
+ ],
34
+ time_step: float = 0.01,
35
+ ) -> NDArray[np.float64]:
36
+
37
+ k1 = equations_of_motion(t, u, parameters)
38
+ k2 = equations_of_motion(t + 0.5 * time_step, u + 0.5 * time_step * k1, parameters)
39
+ k3 = equations_of_motion(t + 0.5 * time_step, u + 0.5 * time_step * k2, parameters)
40
+ k4 = equations_of_motion(t + time_step, u + time_step * k3, parameters)
41
+
42
+ u_next = u + (time_step / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)
43
+ return u_next
44
+
45
+
46
+ @njit(cache=True)
47
+ def variational_rk4_step(
48
+ t: float,
49
+ u: NDArray[np.float64],
50
+ parameters: NDArray[np.float64],
51
+ equations_of_motion: Callable[
52
+ [float, NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]
53
+ ],
54
+ jacobian: Callable[
55
+ [float, NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]
56
+ ],
57
+ time_step: float = 0.01,
58
+ number_of_deviation_vectors: Optional[int] = None,
59
+ ) -> NDArray[np.float64]:
60
+
61
+ k1 = variational_equations(
62
+ t,
63
+ u,
64
+ parameters,
65
+ equations_of_motion,
66
+ jacobian,
67
+ number_of_deviation_vectors=number_of_deviation_vectors,
68
+ )
69
+
70
+ k2 = variational_equations(
71
+ t + 0.5 * time_step,
72
+ u + 0.5 * time_step * k1,
73
+ parameters,
74
+ equations_of_motion,
75
+ jacobian,
76
+ number_of_deviation_vectors=number_of_deviation_vectors,
77
+ )
78
+ k3 = variational_equations(
79
+ t + 0.5 * time_step,
80
+ u + 0.5 * time_step * k2,
81
+ parameters,
82
+ equations_of_motion,
83
+ jacobian,
84
+ number_of_deviation_vectors=number_of_deviation_vectors,
85
+ )
86
+ k4 = variational_equations(
87
+ t + time_step,
88
+ u + time_step * k3,
89
+ parameters,
90
+ equations_of_motion,
91
+ jacobian,
92
+ number_of_deviation_vectors=number_of_deviation_vectors,
93
+ )
94
+
95
+ u_next = u + (time_step / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)
96
+ return u_next
97
+
98
+
99
+ # RK45 Dormand–Prince method coefficients
100
+ RK45_C = np.array([0.0, 1 / 5, 3 / 10, 4 / 5, 8 / 9, 1.0, 1.0])
101
+ RK45_A = np.array(
102
+ [
103
+ [0, 0, 0, 0, 0, 0],
104
+ [1 / 5, 0, 0, 0, 0, 0],
105
+ [3 / 40, 9 / 40, 0, 0, 0, 0],
106
+ [44 / 45, -56 / 15, 32 / 9, 0, 0, 0],
107
+ [19372 / 6561, -25360 / 2187, 64448 / 6561, -212 / 729, 0, 0],
108
+ [9017 / 3168, -355 / 33, 46732 / 5247, 49 / 176, -5103 / 18656, 0],
109
+ [35 / 384, 0, 500 / 1113, 125 / 192, -2187 / 6784, 11 / 84],
110
+ ]
111
+ )
112
+ RK45_B5 = np.array([35 / 384, 0, 500 / 1113, 125 / 192, -2187 / 6784, 11 / 84, 0])
113
+ RK45_B4 = np.array(
114
+ [5179 / 57600, 0, 7571 / 16695, 393 / 640, -92097 / 339200, 187 / 2100, 1 / 40]
115
+ )
116
+
117
+
118
+ @njit(cache=True)
119
+ def rk45_step(t, u, parameters, equations_of_motion, time_step, atol=1e-6, rtol=1e-3):
120
+ """Single adaptive step of RK45 (Dormand-Prince).
121
+
122
+ Returns:
123
+ u_new: next state
124
+ t_new: next time
125
+ h_new: adjusted step size
126
+ accept: whether step was accepted
127
+ """
128
+ k = np.empty((7, u.size), dtype=np.float64)
129
+ for i in range(7):
130
+ ti = t + RK45_C[i] * time_step
131
+ ui = u.copy()
132
+ for j in range(i):
133
+ ui += time_step * RK45_A[i, j] * k[j]
134
+ k[i] = equations_of_motion(ti, ui, parameters)
135
+
136
+ # Compute 5th and 4th order estimates
137
+ u5 = u.copy()
138
+ u4 = u.copy()
139
+ for i in range(7):
140
+ u5 += time_step * RK45_B5[i] * k[i]
141
+ u4 += time_step * RK45_B4[i] * k[i]
142
+
143
+ # Compute element-wise error estimate
144
+ error = np.abs(u5 - u4)
145
+ scale = atol + rtol * np.maximum(np.abs(u), np.abs(u5))
146
+ error_ratio = error / scale
147
+ err = np.max(error_ratio)
148
+
149
+ # Adapt step size
150
+ if err == 0:
151
+ factor = 2.0
152
+ else:
153
+ factor = 0.9 * err**-0.25
154
+
155
+ if factor < 0.1:
156
+ factor = 0.1
157
+ elif factor > 2.0:
158
+ factor = 2.0
159
+
160
+ time_step_new = time_step * factor
161
+
162
+ accept = err < 1.0
163
+ return u5, t + time_step, time_step_new, accept
164
+
165
+
166
+ @njit(cache=True)
167
+ def variational_rk45_step(
168
+ t,
169
+ u,
170
+ parameters,
171
+ equations_of_motion,
172
+ jacobian,
173
+ time_step,
174
+ number_of_deviation_vectors=None,
175
+ atol=1e-6,
176
+ rtol=1e-3,
177
+ ):
178
+ """Single adaptive step of RK45 (Dormand-Prince).
179
+
180
+ Returns:
181
+ u_new: next state
182
+ t_new: next time
183
+ h_new: adjusted step size
184
+ accept: whether step was accepted
185
+ """
186
+ k = np.empty((7, u.size), dtype=np.float64)
187
+ for i in range(7):
188
+ ti = t + RK45_C[i] * time_step
189
+ ui = u.copy()
190
+ for j in range(i):
191
+ ui += time_step * RK45_A[i][j] * k[j]
192
+ k[i] = variational_equations(
193
+ ti,
194
+ ui,
195
+ parameters,
196
+ equations_of_motion,
197
+ jacobian,
198
+ number_of_deviation_vectors=number_of_deviation_vectors,
199
+ )
200
+
201
+ # Compute 5th and 4th order estimates
202
+ u5 = u.copy()
203
+ u4 = u.copy()
204
+ for i in range(7):
205
+ u5 += time_step * RK45_B5[i] * k[i]
206
+ u4 += time_step * RK45_B4[i] * k[i]
207
+
208
+ # Compute element-wise error estimate
209
+ error = np.abs(u5 - u4)
210
+ scale = atol + rtol * np.maximum(np.abs(u), np.abs(u5))
211
+ error_ratio = error / scale
212
+ err = np.max(error_ratio)
213
+
214
+ # Adapt step size
215
+ if err == 0:
216
+ factor = 2.0
217
+ else:
218
+ factor = 0.9 * err**-0.25
219
+ if factor < 0.1:
220
+ factor = 0.1
221
+ elif factor > 2.0:
222
+ factor = 2.0
223
+
224
+ time_step_new = time_step * factor
225
+
226
+ accept = err < 1.0
227
+ return u5, t + time_step, time_step_new, accept
228
+
229
+
230
+ @njit(cache=True)
231
+ def rk4_step_wrapped(
232
+ t: float,
233
+ u: NDArray[np.float64],
234
+ parameters: NDArray[np.float64],
235
+ equations_of_motion: Callable[
236
+ [float, NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]
237
+ ],
238
+ jacobian=None,
239
+ time_step: float = 0.01,
240
+ number_of_deviation_vectors=None,
241
+ atol: float = 1e-6, # unused, just to match signature
242
+ rtol: float = 1e-3, # unused, just to match signature
243
+ ) -> tuple[NDArray[np.float64], float, float, bool]:
244
+ """
245
+ Wrapper around rk4_step to match rk45_step return format.
246
+ Returns (u_next, t_next, h_next, accept) with accept always True.
247
+ """
248
+
249
+ if jacobian is None:
250
+ u_next = rk4_step(t, u, parameters, equations_of_motion, time_step)
251
+ else:
252
+ u_next = variational_rk4_step(
253
+ t,
254
+ u,
255
+ parameters,
256
+ equations_of_motion,
257
+ jacobian,
258
+ time_step=time_step,
259
+ number_of_deviation_vectors=number_of_deviation_vectors,
260
+ )
261
+
262
+ t_next = t + time_step
263
+ h_next = time_step
264
+ accept = True
265
+ return u_next, t_next, h_next, accept
266
+
267
+
268
+ @njit(cache=True)
269
+ def rk45_step_wrapped(
270
+ t: float,
271
+ u: NDArray[np.float64],
272
+ parameters: NDArray[np.float64],
273
+ equations_of_motion: Callable[
274
+ [float, NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]
275
+ ],
276
+ jacobian=None,
277
+ time_step: float = 0.01,
278
+ number_of_deviation_vectors=None,
279
+ atol: float = 1e-6,
280
+ rtol: float = 1e-3,
281
+ ) -> tuple[NDArray[np.float64], float, float, bool]:
282
+ """
283
+ Wrapper around rk4_step to match rk45_step return format.
284
+ Returns (u_next, t_next, h_next, accept) with accept always True.
285
+ """
286
+
287
+ if jacobian is None:
288
+ return rk45_step(
289
+ t, u, parameters, equations_of_motion, time_step, atol=atol, rtol=rtol
290
+ )
291
+ else:
292
+ return variational_rk45_step(
293
+ t,
294
+ u,
295
+ parameters,
296
+ equations_of_motion,
297
+ jacobian,
298
+ time_step,
299
+ number_of_deviation_vectors=number_of_deviation_vectors,
300
+ atol=atol,
301
+ rtol=rtol,
302
+ )
303
+
304
+
305
+ @njit(cache=True)
306
+ def estimate_initial_step(
307
+ t0: float,
308
+ u0: np.ndarray,
309
+ parameters: np.ndarray,
310
+ equations_of_motion: Callable[[float, np.ndarray, np.ndarray], np.ndarray],
311
+ order: int = 5, # Dormand-Prince method is 5th order
312
+ atol: float = 1e-6,
313
+ rtol: float = 1e-3,
314
+ ) -> float:
315
+ """Estimate a good initial time step for adaptive integration."""
316
+ f0 = equations_of_motion(t0, u0, parameters)
317
+
318
+ scale = atol + rtol * np.abs(u0)
319
+ d0 = np.linalg.norm(u0 / scale)
320
+ d1 = np.linalg.norm(f0 / scale)
321
+
322
+ if d0 < 1e-5 or d1 < 1e-5:
323
+ h0 = 1e-6
324
+ else:
325
+ h0 = 0.01 * d0 / d1
326
+
327
+ # Take one Euler step to estimate second derivative
328
+ u1 = u0 + h0 * f0
329
+ f1 = equations_of_motion(t0 + h0, u1, parameters)
330
+ d2 = np.linalg.norm((f1 - f0) / scale) / h0
331
+
332
+ if d1 <= 1e-15 and d2 <= 1e-15:
333
+ h1 = max(1e-6, h0 * 1e-3)
334
+ else:
335
+ h1 = (0.01 / max(d1, d2)) ** (1.0 / (order + 1))
336
+
337
+ return min(100 * h0, h1)
@@ -0,0 +1,163 @@
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
+ from pynamicalsys.continuous_time.numerical_integrators import (
24
+ rk4_step_wrapped,
25
+ )
26
+
27
+
28
+ @njit(cache=True)
29
+ def evolve_system(
30
+ u: NDArray[np.float64],
31
+ parameters: NDArray[np.float64],
32
+ total_time: float,
33
+ equations_of_motion: Callable[
34
+ [NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]
35
+ ],
36
+ time_step: float = 0.01,
37
+ atol: float = 1e-6,
38
+ rtol: float = 1e-3,
39
+ integrator=rk4_step_wrapped,
40
+ ) -> NDArray[np.float64]:
41
+
42
+ u = u.copy()
43
+
44
+ # number_of_steps = round(total_time / time_step)
45
+
46
+ time = 0
47
+ while time < total_time:
48
+ if time + time_step > total_time:
49
+ time_step = total_time - time
50
+ u_new, time_new, time_step_new, accept = integrator(
51
+ time,
52
+ u,
53
+ parameters,
54
+ equations_of_motion,
55
+ time_step=time_step,
56
+ atol=atol,
57
+ rtol=rtol,
58
+ )
59
+ if accept:
60
+ time = time_new
61
+ u = u_new.copy()
62
+
63
+ time_step = time_step_new
64
+
65
+ return u
66
+
67
+
68
+ @njit(cache=True)
69
+ def generate_trajectory(
70
+ u: NDArray[np.float64],
71
+ parameters: NDArray[np.float64],
72
+ total_time: float,
73
+ equations_of_motion: Callable[
74
+ [np.float64, NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]
75
+ ],
76
+ transient_time: Optional[float] = None,
77
+ time_step: float = 0.01,
78
+ atol: float = 1e-6,
79
+ rtol: float = 1e-3,
80
+ integrator=rk4_step_wrapped,
81
+ ) -> NDArray[np.float64]:
82
+
83
+ u = u.copy()
84
+ if transient_time is not None:
85
+ u = evolve_system(
86
+ u,
87
+ parameters,
88
+ transient_time,
89
+ equations_of_motion,
90
+ time_step=time_step,
91
+ atol=atol,
92
+ rtol=rtol,
93
+ integrator=integrator,
94
+ )
95
+ time = transient_time
96
+ else:
97
+ time = 0
98
+
99
+ neq = len(u)
100
+ result = np.zeros(neq + 1)
101
+ trajectory = []
102
+
103
+ while time < total_time:
104
+ if time + time_step > total_time:
105
+ time_step = total_time - time
106
+ u_new, time_new, time_step_new, accept = integrator(
107
+ time,
108
+ u,
109
+ parameters,
110
+ equations_of_motion,
111
+ time_step=time_step,
112
+ atol=atol,
113
+ rtol=rtol,
114
+ )
115
+ if accept:
116
+ time = time_new
117
+ u = u_new.copy()
118
+ result = [time]
119
+ for i in range(neq):
120
+ result.append(u[i])
121
+ trajectory.append(result)
122
+ time_step = time_step_new
123
+
124
+ return trajectory
125
+
126
+
127
+ @njit(cache=True, parallel=True)
128
+ def ensemble_trajectories(
129
+ u: NDArray[np.float64],
130
+ parameters: NDArray[np.float64],
131
+ total_time: float,
132
+ equations_of_motion: Callable[
133
+ [np.float64, NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]
134
+ ],
135
+ transient_time: Optional[float] = None,
136
+ time_step: float = 0.01,
137
+ atol: float = 1e-6,
138
+ rtol: float = 1e-3,
139
+ integrator=rk4_step_wrapped,
140
+ ) -> NDArray[np.float64]:
141
+
142
+ if u.ndim != 2:
143
+ raise ValueError("Initial conditions must be 2D array (num_ic, neq)")
144
+
145
+ num_ic, neq = u.shape
146
+
147
+ trajectories = []
148
+
149
+ for i in prange(num_ic):
150
+ trajectory = generate_trajectory(
151
+ u[i],
152
+ parameters,
153
+ total_time,
154
+ equations_of_motion,
155
+ transient_time=transient_time,
156
+ time_step=time_step,
157
+ atol=atol,
158
+ rtol=rtol,
159
+ integrator=integrator,
160
+ )
161
+ trajectories.append(np.array(trajectory))
162
+
163
+ return trajectories
@@ -0,0 +1,114 @@
1
+ # validators.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 numbers import Integral, Real
19
+ from typing import Type, Union
20
+
21
+ import numpy as np
22
+ from numpy.typing import NDArray
23
+
24
+
25
+ def validate_non_negative(
26
+ value: Union[Integral, int, Real, float],
27
+ name: str,
28
+ type_: Type[Union[Integral, int, Real, float]] = Integral,
29
+ ) -> None:
30
+ if not isinstance(value, type_):
31
+ raise TypeError(f"{name} must be of type {type_.__name__}")
32
+ if value < 0:
33
+ raise ValueError(f"{name} must be non-negative")
34
+
35
+
36
+ def validate_times(transient_time, total_time, type_=Real) -> tuple[float, float]:
37
+
38
+ if isinstance(total_time, (Integral, Real)):
39
+ total_time = float(total_time)
40
+ else:
41
+ raise ValueError("total_time must be a valid number")
42
+ if total_time < 0:
43
+ raise ValueError("total_time must be non-negative")
44
+
45
+ if transient_time is not None:
46
+
47
+ if isinstance(transient_time, (Integral, Real)):
48
+ transient_time = float(transient_time)
49
+ else:
50
+ raise ValueError("transient_time must be a valid number")
51
+ if transient_time < 0:
52
+ raise ValueError("transient_time must be non-negative")
53
+
54
+ if transient_time >= total_time:
55
+ raise ValueError("transient_time must be less than total_time")
56
+
57
+ return transient_time, total_time
58
+
59
+
60
+ def validate_initial_conditions(
61
+ u, system_dimension, allow_ensemble=True
62
+ ) -> NDArray[np.float64]:
63
+ if np.isscalar(u):
64
+ u = np.array([u], dtype=np.float64)
65
+ else:
66
+ u = np.asarray(u, dtype=np.float64)
67
+ if u.ndim not in (1, 2):
68
+ raise ValueError("Initial condition must be 1D or 2D array")
69
+
70
+ u = np.ascontiguousarray(u).copy()
71
+
72
+ if u.ndim == 1:
73
+ if len(u) != system_dimension:
74
+ raise ValueError(
75
+ f"1D initial condition must have length {system_dimension}"
76
+ )
77
+ elif u.ndim == 2:
78
+ if not allow_ensemble:
79
+ raise ValueError(
80
+ "Ensemble of initial conditions not allowed in this context"
81
+ )
82
+ if u.shape[1] != system_dimension:
83
+ raise ValueError(
84
+ f"Each initial condition must have length {system_dimension}"
85
+ )
86
+ return u
87
+
88
+
89
+ def validate_parameters(parameters, number_of_parameters) -> NDArray[np.float64]:
90
+ if number_of_parameters == 0:
91
+ if parameters is not None:
92
+ raise ValueError("This system does not expect any parameters.")
93
+ return np.array([0], dtype=np.float64)
94
+
95
+ if parameters is None:
96
+ raise ValueError(
97
+ f"This system expects {number_of_parameters} parameter(s), but got None."
98
+ )
99
+
100
+ if np.isscalar(parameters):
101
+ parameters = np.array([parameters], dtype=np.float64)
102
+ else:
103
+ parameters = np.asarray(parameters, dtype=np.float64)
104
+ if parameters.ndim != 1:
105
+ raise ValueError(
106
+ f"`parameters` must be a 1D array or scalar. Got shape {parameters.shape}."
107
+ )
108
+
109
+ if parameters.size != number_of_parameters:
110
+ raise ValueError(
111
+ f"Expected {number_of_parameters} parameter(s), but got {parameters.size}."
112
+ )
113
+
114
+ return parameters