pynamicalsys 1.3.1__py3-none-any.whl → 1.4.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,248 @@
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 Callable, Tuple
19
+ from numpy.typing import NDArray
20
+ from numba import njit
21
+ import numpy as np
22
+
23
+ # Yoshida 4th-order symplectic integrator coefficients
24
+ ALPHA: float = 1.0 / (2.0 - 2.0 ** (1.0 / 3.0))
25
+ BETA: float = -(2.0 ** (1.0 / 3.0)) / (2.0 - 2.0 ** (1.0 / 3.0))
26
+
27
+
28
+ @njit
29
+ def velocity_verlet_2nd_step(
30
+ q: NDArray[np.float64],
31
+ p: NDArray[np.float64],
32
+ time_step: float,
33
+ grad_T: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
34
+ grad_V: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
35
+ parameters: NDArray[np.float64],
36
+ ) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
37
+ """
38
+ Perform one step of the velocity Verlet integrator (second-order, symplectic).
39
+
40
+ Parameters
41
+ ----------
42
+ q : NDArray[np.float64]
43
+ Current generalized coordinates.
44
+ p : NDArray[np.float64]
45
+ Current generalized momenta.
46
+ time_step : float
47
+ Integration time step.
48
+ grad_T : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
49
+ Function returning the gradient of the kinetic energy with respect to `p`.
50
+ grad_V : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
51
+ Function returning the gradient of the potential energy with respect to `q`.
52
+ parameters : NDArray[np.float64]
53
+ Additional parameters passed to `grad_T` and `grad_V`.
54
+
55
+ Returns
56
+ -------
57
+ q_new : NDArray[np.float64]
58
+ Updated generalized coordinates after one step.
59
+ p_new : NDArray[np.float64]
60
+ Updated generalized momenta after one step.
61
+ """
62
+ q_new = q.copy()
63
+ p_new = p.copy()
64
+
65
+ # Half kick
66
+ gradV = grad_V(q, parameters)
67
+ p_new -= 0.5 * time_step * gradV
68
+
69
+ # Drift
70
+ gradT = grad_T(p_new, parameters)
71
+ q_new += time_step * gradT
72
+
73
+ # Half kick
74
+ gradV = grad_V(q_new, parameters)
75
+ p_new -= 0.5 * time_step * gradV
76
+
77
+ return q_new, p_new
78
+
79
+
80
+ @njit
81
+ def velocity_verlet_2nd_step_tangent(
82
+ q: NDArray[np.float64],
83
+ p: NDArray[np.float64],
84
+ dq: NDArray[np.float64],
85
+ dp: NDArray[np.float64],
86
+ time_step: float,
87
+ hess_T: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
88
+ hess_V: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
89
+ parameters: NDArray[np.float64],
90
+ ) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
91
+ """
92
+ Perform one step of the tangent map associated with the velocity Verlet integrator.
93
+
94
+ This evolves deviation (tangent) vectors `(dq, dp)` along the flow of the system,
95
+ which is necessary for computing Lyapunov exponents and stability analysis.
96
+
97
+ Parameters
98
+ ----------
99
+ q : NDArray[np.float64]
100
+ Current generalized coordinates.
101
+ p : NDArray[np.float64]
102
+ Current generalized momenta.
103
+ dq : NDArray[np.float64]
104
+ Deviation in coordinates.
105
+ dp : NDArray[np.float64]
106
+ Deviation in momenta.
107
+ time_step : float
108
+ Integration time step.
109
+ hess_T : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
110
+ Function returning the Hessian of the kinetic energy with respect to `p`.
111
+ hess_V : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
112
+ Function returning the Hessian of the potential energy with respect to `q`.
113
+ parameters : NDArray[np.float64]
114
+ Additional parameters passed to `hess_T` and `hess_V`.
115
+
116
+ Returns
117
+ -------
118
+ dq_new : NDArray[np.float64]
119
+ Updated deviation in coordinates after one step.
120
+ dp_new : NDArray[np.float64]
121
+ Updated deviation in momenta after one step.
122
+ """
123
+ dq_new = dq.copy()
124
+ dp_new = dp.copy()
125
+
126
+ # Compute Hessians
127
+ HT = hess_T(p, parameters)
128
+ HV = hess_V(q, parameters)
129
+
130
+ # Half kick
131
+ HV_dot_dq = HV @ dq
132
+ dp_new -= 0.5 * time_step * HV_dot_dq
133
+
134
+ # Drift
135
+ HT_dot_dp = HT @ dp_new
136
+ dq_new += time_step * HT_dot_dp
137
+
138
+ # Half kick
139
+ HV_dot_dq = HV @ dq_new
140
+ dp_new -= 0.5 * time_step * HV_dot_dq
141
+
142
+ return dq_new, dp_new
143
+
144
+
145
+ @njit
146
+ def yoshida_4th_step(
147
+ q: NDArray[np.float64],
148
+ p: NDArray[np.float64],
149
+ time_step: float,
150
+ grad_T: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
151
+ grad_V: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
152
+ parameters: NDArray[np.float64],
153
+ ) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
154
+ """
155
+ Perform one step of the 4th-order Yoshida symplectic integrator.
156
+
157
+ This is constructed by composing three velocity Verlet steps with
158
+ appropriately chosen coefficients (`ALPHA`, `BETA`).
159
+
160
+ Parameters
161
+ ----------
162
+ q : NDArray[np.float64]
163
+ Current generalized coordinates.
164
+ p : NDArray[np.float64]
165
+ Current generalized momenta.
166
+ time_step : float
167
+ Integration time step.
168
+ grad_T : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
169
+ Function returning the gradient of the kinetic energy with respect to `p`.
170
+ grad_V : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
171
+ Function returning the gradient of the potential energy with respect to `q`.
172
+ parameters : NDArray[np.float64]
173
+ Additional parameters passed to `grad_T` and `grad_V`.
174
+
175
+ Returns
176
+ -------
177
+ q_new : NDArray[np.float64]
178
+ Updated generalized coordinates after one Yoshida step.
179
+ p_new : NDArray[np.float64]
180
+ Updated generalized momenta after one Yoshida step.
181
+ """
182
+ q_new, p_new = velocity_verlet_2nd_step(
183
+ q, p, ALPHA * time_step, grad_T, grad_V, parameters
184
+ )
185
+ q_new, p_new = velocity_verlet_2nd_step(
186
+ q_new, p_new, BETA * time_step, grad_T, grad_V, parameters
187
+ )
188
+ q_new, p_new = velocity_verlet_2nd_step(
189
+ q_new, p_new, ALPHA * time_step, grad_T, grad_V, parameters
190
+ )
191
+
192
+ return q_new, p_new
193
+
194
+
195
+ @njit
196
+ def yoshida_4th_step_tangent(
197
+ q: NDArray[np.float64],
198
+ p: NDArray[np.float64],
199
+ dq: NDArray[np.float64],
200
+ dp: NDArray[np.float64],
201
+ time_step: float,
202
+ hess_T: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
203
+ hess_V: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
204
+ parameters: NDArray[np.float64],
205
+ ) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
206
+ """
207
+ Perform one step of the tangent map associated with the Yoshida 4th-order integrator.
208
+
209
+ This evolves deviation vectors `(dq, dp)` along the flow of the Yoshida integrator,
210
+ which is useful for stability analysis and Lyapunov exponent computation.
211
+
212
+ Parameters
213
+ ----------
214
+ q : NDArray[np.float64]
215
+ Current generalized coordinates.
216
+ p : NDArray[np.float64]
217
+ Current generalized momenta.
218
+ dq : NDArray[np.float64]
219
+ Deviation in coordinates.
220
+ dp : NDArray[np.float64]
221
+ Deviation in momenta.
222
+ time_step : float
223
+ Integration time step.
224
+ hess_T : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
225
+ Function returning the Hessian of the kinetic energy with respect to `p`.
226
+ hess_V : Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
227
+ Function returning the Hessian of the potential energy with respect to `q`.
228
+ parameters : NDArray[np.float64]
229
+ Additional parameters passed to `hess_T` and `hess_V`.
230
+
231
+ Returns
232
+ -------
233
+ dq_new : NDArray[np.float64]
234
+ Updated deviation in coordinates after one Yoshida step.
235
+ dp_new : NDArray[np.float64]
236
+ Updated deviation in momenta after one Yoshida step.
237
+ """
238
+ dq_new, dp_new = velocity_verlet_2nd_step_tangent(
239
+ q, p, dq, dp, ALPHA * time_step, hess_T, hess_V, parameters
240
+ )
241
+ dq_new, dp_new = velocity_verlet_2nd_step_tangent(
242
+ q, p, dq_new, dp_new, BETA * time_step, hess_T, hess_V, parameters
243
+ )
244
+ dq_new, dp_new = velocity_verlet_2nd_step_tangent(
245
+ q, p, dq_new, dp_new, ALPHA * time_step, hess_T, hess_V, parameters
246
+ )
247
+
248
+ return dq_new, dp_new
@@ -0,0 +1,293 @@
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 Callable
19
+ import numpy as np
20
+ from numba import njit, prange
21
+ from numpy.typing import NDArray
22
+
23
+
24
+ @njit
25
+ def generate_trajectory(
26
+ q: NDArray[np.float64],
27
+ p: NDArray[np.float64],
28
+ total_time: np.float64,
29
+ parameters: NDArray[np.float64],
30
+ grad_T: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
31
+ grad_V: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
32
+ time_step: np.float64,
33
+ integrator: Callable[
34
+ [
35
+ NDArray[np.float64],
36
+ NDArray[np.float64],
37
+ float,
38
+ Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
39
+ Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
40
+ NDArray[np.float64],
41
+ ],
42
+ tuple[NDArray[np.float64], NDArray[np.float64]],
43
+ ],
44
+ ) -> NDArray[np.float64]:
45
+ """
46
+ Generate a single trajectory of a Hamiltonian system using a symplectic integrator.
47
+
48
+ Parameters
49
+ ----------
50
+ q : NDArray[np.float64], shape (dof,)
51
+ Initial generalized coordinates.
52
+ p : NDArray[np.float64], shape (dof,)
53
+ Initial generalized momenta.
54
+ total_time : float
55
+ Total integration time.
56
+ parameters : NDArray[np.float64]
57
+ Additional system parameters passed to `grad_T` and `grad_V`.
58
+ grad_T : Callable
59
+ Function returning the gradient of the kinetic energy with respect to `p`.
60
+ grad_V : Callable
61
+ Function returning the gradient of the potential energy with respect to `q`.
62
+ time_step : float
63
+ Integration step size.
64
+ integrator : Callable
65
+ Symplectic integrator function (e.g. `velocity_verlet_2nd_step`,
66
+ `yoshida_4th_step`).
67
+
68
+ Returns
69
+ -------
70
+ result : NDArray[np.float64], shape (num_steps+1, 2*dof+1)
71
+ Trajectory array:
72
+ - Column 0: time values.
73
+ - Columns 1..dof: generalized coordinates `q`.
74
+ - Columns dof+1..2*dof: generalized momenta `p`.
75
+ """
76
+ num_steps = round(total_time / time_step)
77
+ dof = len(q)
78
+ result = np.zeros((num_steps + 1, 2 * dof + 1))
79
+
80
+ result[0, 1 : dof + 1] = q
81
+ result[0, dof + 1 :] = p
82
+ for i in range(1, num_steps + 1):
83
+ q, p = integrator(q, p, time_step, grad_T, grad_V, parameters)
84
+ result[i, 0] = i * time_step
85
+ result[i, 1 : dof + 1] = q
86
+ result[i, dof + 1 :] = p
87
+
88
+ return result
89
+
90
+
91
+ @njit(parallel=True)
92
+ def ensemble_trajectories(
93
+ q: NDArray[np.float64],
94
+ p: NDArray[np.float64],
95
+ total_time: np.float64,
96
+ parameters: NDArray[np.float64],
97
+ grad_T: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
98
+ grad_V: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
99
+ time_step: np.float64,
100
+ integrator: Callable,
101
+ ) -> NDArray[np.float64]:
102
+ """
103
+ Generate an ensemble of trajectories from multiple initial conditions.
104
+
105
+ Parameters
106
+ ----------
107
+ q : NDArray[np.float64], shape (num_ic, dof)
108
+ Initial coordinates for each trajectory.
109
+ p : NDArray[np.float64], shape (num_ic, dof)
110
+ Initial momenta for each trajectory.
111
+ total_time : float
112
+ Total integration time.
113
+ parameters : NDArray[np.float64]
114
+ Additional system parameters passed to `grad_T` and `grad_V`.
115
+ grad_T : Callable
116
+ Function returning the gradient of the kinetic energy with respect to `p`.
117
+ grad_V : Callable
118
+ Function returning the gradient of the potential energy with respect to `q`.
119
+ time_step : float
120
+ Integration step size.
121
+ integrator : Callable
122
+ Symplectic integrator function.
123
+
124
+ Returns
125
+ -------
126
+ trajectories : NDArray[np.float64], shape (num_ic, num_steps+1, 2*dof+1)
127
+ Array of trajectories for all initial conditions.
128
+ """
129
+ num_steps = round(total_time / time_step)
130
+ num_ic, dof = q.shape
131
+
132
+ trajectories = np.zeros((num_ic, num_steps + 1, 2 * dof + 1), dtype=np.float64)
133
+
134
+ for i in prange(num_ic):
135
+ trajectory = generate_trajectory(
136
+ q[i], p[i], total_time, parameters, grad_T, grad_V, time_step, integrator
137
+ )
138
+ trajectories[i] = trajectory
139
+
140
+ return trajectories
141
+
142
+
143
+ @njit
144
+ def generate_poincare_section(
145
+ q: NDArray[np.float64],
146
+ p: NDArray[np.float64],
147
+ num_intersections: np.int32,
148
+ parameters: NDArray[np.float64],
149
+ grad_T: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
150
+ grad_V: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
151
+ time_step: np.float64,
152
+ integrator: Callable,
153
+ section_index: int = 0,
154
+ section_value: float = 0.0,
155
+ crossing: int = 1,
156
+ ) -> NDArray[np.float64]:
157
+ """
158
+ Generate a Poincaré section for a Hamiltonian system.
159
+
160
+ Parameters
161
+ ----------
162
+ q : NDArray[np.float64], shape (dof,)
163
+ Initial generalized coordinates.
164
+ p : NDArray[np.float64], shape (dof,)
165
+ Initial generalized momenta.
166
+ num_intersections : int
167
+ Total number of section crossings to record.
168
+ parameters : NDArray[np.float64]
169
+ Additional system parameters passed to `grad_T` and `grad_V`.
170
+ grad_T : Callable
171
+ Function returning the gradient of the kinetic energy with respect to `p`.
172
+ grad_V : Callable
173
+ Function returning the gradient of the potential energy with respect to `q`.
174
+ time_step : float
175
+ Integration step size.
176
+ integrator : Callable
177
+ Symplectic integrator function.
178
+ section_index : int, optional
179
+ Index of coordinate `q[i]` used to define the section (default 0).
180
+ section_value : float, optional
181
+ Value of `q[section_index]` defining the section (default 0.0).
182
+ crossing : int, optional
183
+ Crossing rule:
184
+ - +1 : only upward crossings (dq/dt > 0),
185
+ - -1 : only downward crossings (dq/dt < 0),
186
+ - 0 : count all crossings.
187
+
188
+ Returns
189
+ -------
190
+ section_points : NDArray[np.float64], shape (num_intersections, 2*dof)
191
+ Phase-space points `(q, p)` recorded at section crossings.
192
+ """
193
+ dof = len(q)
194
+ section_points = np.zeros((num_intersections, 2 * dof + 1))
195
+ count = 0
196
+ n_steps = 0
197
+ q_prev, p_prev = q.copy(), p.copy()
198
+ while count < num_intersections:
199
+ q_new, p_new = integrator(q_prev, p_prev, time_step, grad_T, grad_V, parameters)
200
+
201
+ # Check if crossing occurred
202
+ if (q_prev[section_index] - section_value) * (
203
+ q_new[section_index] - section_value
204
+ ) < 0.0:
205
+ lam = (section_value - q_prev[section_index]) / (
206
+ q_new[section_index] - q_prev[section_index]
207
+ )
208
+ q_cross = (1 - lam) * q_prev + lam * q_new
209
+ p_cross = (1 - lam) * p_prev + lam * p_new
210
+ t_cross = n_steps * time_step + lam * time_step
211
+
212
+ velocity = grad_T(p_cross, parameters)[section_index]
213
+
214
+ if crossing == 0 or np.sign(velocity) == crossing:
215
+ section_points[count, 0] = t_cross
216
+ section_points[count, 1 : dof + 1] = q_cross
217
+ section_points[count, dof + 1 :] = p_cross
218
+ count += 1
219
+
220
+ q_prev, p_prev = q_new, p_new
221
+ n_steps += 1
222
+
223
+ return section_points
224
+
225
+
226
+ @njit(parallel=True)
227
+ def ensemble_poincare_section(
228
+ q: NDArray[np.float64],
229
+ p: NDArray[np.float64],
230
+ num_intersections: int,
231
+ parameters: NDArray[np.float64],
232
+ grad_T: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
233
+ grad_V: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
234
+ time_step: np.float64,
235
+ integrator: Callable,
236
+ section_index: int = 0,
237
+ section_value: float = 0.0,
238
+ crossing: int = 1,
239
+ ) -> NDArray[np.float64]:
240
+ """
241
+ Generate Poincaré sections for an ensemble of initial conditions.
242
+
243
+ Parameters
244
+ ----------
245
+ q : NDArray[np.float64], shape (num_ic, dof)
246
+ Initial coordinates for each trajectory.
247
+ p : NDArray[np.float64], shape (num_ic, dof)
248
+ Initial momenta for each trajectory.
249
+ num_intersections : int
250
+ Number of section crossings to record per trajectory.
251
+ parameters : NDArray[np.float64]
252
+ Additional system parameters.
253
+ grad_T : Callable
254
+ Gradient of kinetic energy with respect to `p`.
255
+ grad_V : Callable
256
+ Gradient of potential energy with respect to `q`.
257
+ time_step : float
258
+ Integration step size.
259
+ integrator : Callable
260
+ Symplectic integrator function.
261
+ section_index : int, optional
262
+ Index of coordinate `q[i]` used for the section (default 0).
263
+ section_value : float, optional
264
+ Value of `q[section_index]` defining the section (default 0.0).
265
+ crossing : int, optional
266
+ Crossing rule:
267
+ - +1 : upward crossings,
268
+ - -1 : downward crossings,
269
+ - 0 : all crossings.
270
+
271
+ Returns
272
+ -------
273
+ section_points : NDArray[np.float64], shape (num_ic, num_intersections, 2*dof + 1)
274
+ Poincaré section points for each initial condition with the first column being the time at each crossing.
275
+ """
276
+ num_ic, dof = q.shape
277
+ section_points = np.zeros((num_ic, num_intersections, 2 * dof + 1))
278
+ for i in prange(num_ic):
279
+ section_points[i] = generate_poincare_section(
280
+ q[i],
281
+ p[i],
282
+ num_intersections,
283
+ parameters,
284
+ grad_T,
285
+ grad_V,
286
+ time_step,
287
+ integrator,
288
+ section_index,
289
+ section_value,
290
+ crossing,
291
+ )
292
+
293
+ return section_points
@@ -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) -> 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, degrees_of_freedom, 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) != degrees_of_freedom:
74
+ raise ValueError(
75
+ f"1D initial condition must have length {degrees_of_freedom}"
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] != degrees_of_freedom:
83
+ raise ValueError(
84
+ f"Each initial condition must have length {degrees_of_freedom}"
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