InterpolatePy 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,580 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass
3
+ from typing import NamedTuple
4
+
5
+ import numpy as np
6
+
7
+
8
+ # Constants to avoid magic numbers
9
+ EPSILON = 1e-6 # Small value to avoid division by zero
10
+ MIN_GAMMA = 0.01 # Lower bound for gamma in binary search
11
+ MAX_ITERATIONS = 50 # Maximum iterations for binary search
12
+
13
+
14
+ @dataclass
15
+ class TrajectoryBounds:
16
+ """Bounds for trajectory planning.
17
+
18
+ Parameters
19
+ ----------
20
+ v_bound : float
21
+ Velocity bound (absolute value will be used for both min/max).
22
+ a_bound : float
23
+ Acceleration bound (absolute value will be used for both min/max).
24
+ j_bound : float
25
+ Jerk bound (absolute value will be used for both min/max).
26
+ """
27
+
28
+ v_bound: float
29
+ a_bound: float
30
+ j_bound: float
31
+
32
+ def __post_init__(self) -> None:
33
+ """Validate the bounds."""
34
+ if not all(isinstance(x, int | float) for x in [self.v_bound, self.a_bound, self.j_bound]):
35
+ raise TypeError("All bounds must be numeric values")
36
+
37
+ if self.v_bound <= 0 or self.a_bound <= 0 or self.j_bound <= 0:
38
+ raise ValueError("Bounds must be positive values")
39
+
40
+ # Convert to absolute values
41
+ self.v_bound = abs(self.v_bound)
42
+ self.a_bound = abs(self.a_bound)
43
+ self.j_bound = abs(self.j_bound)
44
+
45
+
46
+ class StateParams(NamedTuple):
47
+ """Parameters representing position and velocity state.
48
+
49
+ Parameters
50
+ ----------
51
+ q_0 : float
52
+ Start position.
53
+ q_1 : float
54
+ End position.
55
+ v_0 : float
56
+ Velocity at start of trajectory.
57
+ v_1 : float
58
+ Velocity at end of trajectory.
59
+ """
60
+
61
+ q_0: float
62
+ q_1: float
63
+ v_0: float
64
+ v_1: float
65
+
66
+
67
+ class DoubleSTrajectory:
68
+ """
69
+ Double S-Trajectory Planner Class
70
+
71
+ This class implements a trajectory planning algorithm that generates smooth motion
72
+ profiles with bounded velocity, acceleration, and jerk (double S-trajectory).
73
+ """
74
+
75
+ def __init__(self, state_params: StateParams, bounds: TrajectoryBounds) -> None:
76
+ """
77
+ Initialize the trajectory planner.
78
+
79
+ Parameters:
80
+ -----------
81
+ state_params : StateParams
82
+ Start and end states for trajectory planning
83
+ bounds : TrajectoryBounds
84
+ Velocity, acceleration, and jerk bounds for trajectory planning
85
+ """
86
+ # Input validation for state params
87
+ if not all(
88
+ isinstance(x, int | float)
89
+ for x in [state_params.q_0, state_params.q_1, state_params.v_0, state_params.v_1]
90
+ ):
91
+ raise TypeError("All state parameters must be numeric values")
92
+
93
+ # Store initial parameters
94
+ self.state = state_params
95
+ self.bounds = bounds
96
+
97
+ # Check if initial or final velocities exceed bounds
98
+ if abs(state_params.v_0) > bounds.v_bound or abs(state_params.v_1) > bounds.v_bound:
99
+ raise ValueError(
100
+ f"Initial or final velocities exceed the velocity bound of {bounds.v_bound}"
101
+ )
102
+
103
+ # Initialize trajectory parameters
104
+ self.T = 0.0 # Total trajectory duration
105
+ self.Ta = 0.0 # Acceleration phase duration
106
+ self.Tv = 0.0 # Constant velocity phase duration
107
+ self.Td = 0.0 # Deceleration phase duration
108
+ self.Tj_1 = 0.0 # Jerk time in acceleration phase
109
+ self.Tj_2 = 0.0 # Jerk time in deceleration phase
110
+ self.a_lim_a = 0.0 # Acceleration limit during acceleration phase
111
+ self.a_lim_d = 0.0 # Acceleration limit during deceleration phase
112
+ self.v_lim = 0.0 # Velocity limit during constant velocity phase
113
+ self.sigma = 1.0 # Direction coefficient
114
+
115
+ # Transformed parameters
116
+ self.q_0_transformed = 0.0
117
+ self.q_1_transformed = 0.0
118
+ self.v_0_transformed = 0.0
119
+ self.v_1_transformed = 0.0
120
+ self.v_max = 0.0
121
+ self.v_min = 0.0
122
+ self.a_max = 0.0
123
+ self.a_min = 0.0
124
+ self.j_max = 0.0
125
+ self.j_min = 0.0
126
+
127
+ # Plan the trajectory
128
+ self._plan_trajectory()
129
+
130
+ def _plan_trajectory(self) -> None:
131
+ """
132
+ Plan the trajectory based on the initialized parameters.
133
+ This internal method computes all necessary trajectory parameters.
134
+ """
135
+ qd_0, qd_1 = self.state.q_0, self.state.q_1
136
+ vd_0, vd_1 = self.state.v_0, self.state.v_1
137
+
138
+ # BLOCK 1: Given the initial conditions compute direction and transform parameters
139
+ # If positions are equal, handle specially
140
+ if np.isclose(qd_1, qd_0):
141
+ # For equal positions, create a minimal trajectory to match velocities if needed
142
+ if np.isclose(vd_1, vd_0):
143
+ # If velocities also match, return a static trajectory
144
+ self.T = 0.0
145
+ return
146
+
147
+ # Different velocities but same position requires a special trajectory
148
+ t_min = abs(vd_1 - vd_0) / self.bounds.a_bound
149
+ self.T = max(t_min * 1.5, 0.1) # Ensure minimum duration
150
+ return
151
+
152
+ # Normal case - different positions
153
+ self.sigma = np.sign(qd_1 - qd_0)
154
+
155
+ # Transform parameters based on direction
156
+ self.q_0_transformed = self.sigma * qd_0
157
+ self.q_1_transformed = self.sigma * qd_1
158
+ self.v_0_transformed = self.sigma * vd_0
159
+ self.v_1_transformed = self.sigma * vd_1
160
+
161
+ # Set limits based on direction
162
+ self.v_max = (self.sigma + 1) / 2 * self.bounds.v_bound + (self.sigma - 1) / 2 * (
163
+ -self.bounds.v_bound
164
+ )
165
+ self.v_min = (self.sigma + 1) / 2 * (-self.bounds.v_bound) + (
166
+ self.sigma - 1
167
+ ) / 2 * self.bounds.v_bound
168
+ self.a_max = (self.sigma + 1) / 2 * self.bounds.a_bound + (self.sigma - 1) / 2 * (
169
+ -self.bounds.a_bound
170
+ )
171
+ self.a_min = (self.sigma + 1) / 2 * (-self.bounds.a_bound) + (
172
+ self.sigma - 1
173
+ ) / 2 * self.bounds.a_bound
174
+ self.j_max = (self.sigma + 1) / 2 * self.bounds.j_bound + (self.sigma - 1) / 2 * (
175
+ -self.bounds.j_bound
176
+ )
177
+ self.j_min = (self.sigma + 1) / 2 * (-self.bounds.j_bound) + (
178
+ self.sigma - 1
179
+ ) / 2 * self.bounds.j_bound
180
+
181
+ # Compute time intervals assuming v_max and a_max are reached
182
+
183
+ # Acceleration part
184
+ if ((self.v_max - self.v_0_transformed) * self.j_max) < self.a_max**2:
185
+ self.Tj_1 = np.sqrt(
186
+ max((self.v_max - self.v_0_transformed) / self.j_max, 0)
187
+ ) # Prevent negative sqrt
188
+ self.Ta = 2 * self.Tj_1
189
+ else:
190
+ self.Tj_1 = self.a_max / self.j_max
191
+ self.Ta = self.Tj_1 + (self.v_max - self.v_0_transformed) / self.a_max
192
+
193
+ # Deceleration part
194
+ if ((self.v_max - self.v_1_transformed) * self.j_max) < self.a_max**2:
195
+ self.Tj_2 = np.sqrt(
196
+ max((self.v_max - self.v_1_transformed) / self.j_max, 0)
197
+ ) # Prevent negative sqrt
198
+ self.Td = 2 * self.Tj_2
199
+ else:
200
+ self.Tj_2 = self.a_max / self.j_max
201
+ self.Td = self.Tj_2 + (self.v_max - self.v_1_transformed) / self.a_max
202
+
203
+ # Determine the time duration of the constant velocity phase
204
+ if abs(self.v_max) < EPSILON: # Avoid division by zero
205
+ self.Tv = 0
206
+ else:
207
+ self.Tv = (
208
+ (self.q_1_transformed - self.q_0_transformed) / self.v_max
209
+ - self.Ta / 2 * (1 + self.v_0_transformed / self.v_max)
210
+ - self.Td / 2 * (1 + self.v_1_transformed / self.v_max)
211
+ )
212
+
213
+ # Check if Tv < 0 (v_max is not reached)
214
+ if self.Tv < 0:
215
+ # Set Tv = 0 (to prevent computation errors)
216
+ self.Tv = 0
217
+
218
+ # Iterate to find appropriate acceleration constraints using binary search
219
+ gamma_high = 1.0
220
+ gamma_low = MIN_GAMMA
221
+ gamma_mid = 0.5
222
+ iteration = 0
223
+
224
+ while iteration < MAX_ITERATIONS:
225
+ iteration += 1
226
+ gamma_mid = (gamma_high + gamma_low) / 2
227
+
228
+ # Test with current gamma
229
+ a_max_test = gamma_mid * self.bounds.a_bound
230
+ a_min_test = -gamma_mid * self.bounds.a_bound
231
+
232
+ # Recalculate time intervals
233
+ tj = a_max_test / self.j_max
234
+ delta = (
235
+ a_max_test**4 / self.j_max**2
236
+ + 2 * (self.v_0_transformed**2 + self.v_1_transformed**2)
237
+ + a_max_test
238
+ * (
239
+ 4 * (self.q_1_transformed - self.q_0_transformed)
240
+ - 2
241
+ * a_max_test
242
+ / self.j_max
243
+ * (self.v_0_transformed + self.v_1_transformed)
244
+ )
245
+ )
246
+
247
+ # Check if delta is negative (no solution with current gamma)
248
+ if delta < 0:
249
+ gamma_high = gamma_mid
250
+ continue
251
+
252
+ ta = (a_max_test**2 / self.j_max - 2 * self.v_0_transformed + np.sqrt(delta)) / (
253
+ 2 * a_max_test
254
+ )
255
+ td = (a_max_test**2 / self.j_max - 2 * self.v_1_transformed + np.sqrt(delta)) / (
256
+ 2 * a_max_test
257
+ )
258
+
259
+ if ta < 0:
260
+ if (
261
+ abs(self.v_1_transformed + self.v_0_transformed) < EPSILON
262
+ ): # Avoid division by zero
263
+ ta = 0
264
+ td = 0
265
+ tj_1 = 0
266
+ tj_2 = 0
267
+ break
268
+ ta = 0
269
+ td = (
270
+ 2
271
+ * (self.q_1_transformed - self.q_0_transformed)
272
+ / (self.v_1_transformed + self.v_0_transformed)
273
+ )
274
+ tj_2_arg = self.j_max * (self.q_1_transformed - self.q_0_transformed) - np.sqrt(
275
+ self.j_max
276
+ * (
277
+ self.j_max * (self.q_1_transformed - self.q_0_transformed) ** 2
278
+ + (self.v_1_transformed + self.v_0_transformed) ** 2
279
+ * (self.v_1_transformed - self.v_0_transformed)
280
+ )
281
+ )
282
+ tj_2 = (
283
+ tj_2_arg / (self.j_max * (self.v_1_transformed + self.v_0_transformed))
284
+ if abs(tj_2_arg) > EPSILON
285
+ else 0
286
+ )
287
+ elif td < 0:
288
+ if (
289
+ abs(self.v_1_transformed + self.v_0_transformed) < EPSILON
290
+ ): # Avoid division by zero
291
+ ta = 0
292
+ td = 0
293
+ tj_1 = 0
294
+ tj_2 = 0
295
+ break
296
+ td = 0
297
+ ta = (
298
+ 2
299
+ * (self.q_1_transformed - self.q_0_transformed)
300
+ / (self.v_1_transformed + self.v_0_transformed)
301
+ )
302
+ tj_1_arg = self.j_max * (self.q_1_transformed - self.q_0_transformed) - np.sqrt(
303
+ self.j_max
304
+ * (
305
+ self.j_max * (self.q_1_transformed - self.q_0_transformed) ** 2
306
+ - (self.v_1_transformed + self.v_0_transformed) ** 2
307
+ * (self.v_1_transformed - self.v_0_transformed)
308
+ )
309
+ )
310
+ tj_1 = (
311
+ tj_1_arg / (self.j_max * (self.v_1_transformed + self.v_0_transformed))
312
+ if abs(tj_1_arg) > EPSILON
313
+ else 0
314
+ )
315
+ elif (ta > 2 * tj) and (td > 2 * tj):
316
+ # Valid solution found
317
+ self.a_max = a_max_test
318
+ self.a_min = a_min_test
319
+ self.Tj_1 = tj
320
+ self.Tj_2 = tj
321
+ self.Ta = ta
322
+ self.Td = td
323
+ break
324
+ else:
325
+ # Need to reduce gamma further
326
+ gamma_high = gamma_mid
327
+ continue
328
+
329
+ # Check if solution is valid
330
+ if tj_1 >= 0 and tj_2 >= 0 and ta >= 0 and td >= 0:
331
+ self.a_max = a_max_test
332
+ self.a_min = a_min_test
333
+ self.Tj_1 = tj_1
334
+ self.Tj_2 = tj_2
335
+ self.Ta = ta
336
+ self.Td = td
337
+ break
338
+ gamma_high = gamma_mid
339
+
340
+ # Compute trajectory parameters
341
+ self.a_lim_a = self.j_max * self.Tj_1
342
+ self.a_lim_d = -self.j_max * self.Tj_2
343
+
344
+ # Ensure we don't divide by zero or have negative time periods
345
+ self.Ta = max(self.Ta, 0)
346
+ self.Td = max(self.Td, 0)
347
+ self.Tv = max(self.Tv, 0)
348
+ self.Tj_1 = max(self.Tj_1, 0)
349
+ self.Tj_2 = max(self.Tj_2, 0)
350
+
351
+ # Calculate v_lim safely
352
+ if self.Ta <= self.Tj_1:
353
+ self.v_lim = self.v_0_transformed + self.j_max * self.Ta**2 / 2
354
+ else:
355
+ self.v_lim = self.v_0_transformed + (self.Ta - self.Tj_1) * self.a_lim_a
356
+
357
+ # Total trajectory time
358
+ self.T = self.Ta + self.Tv + self.Td
359
+
360
+ # Round final time to discrete ticks (in milliseconds)
361
+ self.T = round(self.T * 1000) / 1000
362
+
363
+ def evaluate(self, t: float | np.ndarray) -> tuple[float | np.ndarray, ...]:
364
+ """
365
+ Evaluate the double-S trajectory at time t.
366
+
367
+ Parameters:
368
+ -----------
369
+ t : float or ndarray
370
+ Time(s) at which to evaluate the trajectory
371
+
372
+ Returns:
373
+ --------
374
+ q : float or ndarray
375
+ Position at time t
376
+ qp : float or ndarray
377
+ Velocity at time t
378
+ qpp : float or ndarray
379
+ Acceleration at time t
380
+ qppp : float or ndarray
381
+ Jerk at time t
382
+ """
383
+ # Handle array input
384
+ if isinstance(t, list | np.ndarray):
385
+ # Preallocate arrays for efficiency
386
+ q = np.zeros_like(t, dtype=float)
387
+ qp = np.zeros_like(t, dtype=float)
388
+ qpp = np.zeros_like(t, dtype=float)
389
+ qppp = np.zeros_like(t, dtype=float)
390
+
391
+ # Compute for each time point
392
+ for i, t_i in enumerate(t):
393
+ q[i], qp[i], qpp[i], qppp[i] = self.evaluate(float(t_i))
394
+ return q, qp, qpp, qppp
395
+
396
+ # Special case for equal positions with equal velocities
397
+ if np.isclose(self.state.q_0, self.state.q_1) and np.isclose(
398
+ self.state.v_0, self.state.v_1
399
+ ):
400
+ return self.state.q_0, self.state.v_0, 0.0, 0.0
401
+
402
+ # Special case for equal positions with different velocities
403
+ if np.isclose(self.state.q_0, self.state.q_1) and not np.isclose(
404
+ self.state.v_0, self.state.v_1
405
+ ):
406
+ t_norm = min(t / self.T, 1.0)
407
+ qp = self.state.v_0 + t_norm * (self.state.v_1 - self.state.v_0)
408
+
409
+ phase = 2 * np.pi * t_norm
410
+ amplitude = (self.state.v_1 - self.state.v_0) * self.T / (2 * np.pi)
411
+ q = self.state.q_0 + amplitude * np.sin(phase)
412
+
413
+ qpp = (self.state.v_1 - self.state.v_0) / self.T + amplitude * (
414
+ 2 * np.pi / self.T
415
+ ) * np.cos(phase)
416
+ qppp = -amplitude * (2 * np.pi / self.T) ** 2 * np.sin(phase)
417
+
418
+ return q, qp, qpp, qppp
419
+
420
+ # Ensure t is within bounds [0, T]
421
+ t = np.clip(t, 0, self.T)
422
+
423
+ # Handle zero or near-zero duration trajectory
424
+ if self.T < EPSILON:
425
+ return self.state.q_1, self.state.v_1, 0, 0
426
+
427
+ # Use transformed coordinates for calculation
428
+ q_0 = self.q_0_transformed
429
+ q_1 = self.q_1_transformed
430
+
431
+ # ACCELERATION PHASE
432
+ if t <= self.Tj_1 and self.Tj_1 > 0:
433
+ # t in [0, Tj_1]
434
+ q = q_0 + self.v_0_transformed * t + self.j_max * t**3 / 6
435
+ qp = self.v_0_transformed + self.j_max * (t**2) / 2
436
+ qpp = self.j_max * t
437
+ qppp = self.j_max
438
+
439
+ elif t <= (self.Ta - self.Tj_1) and self.Ta > self.Tj_1:
440
+ # t in [Tj_1, Ta - Tj_1]
441
+ q = (
442
+ q_0
443
+ + self.v_0_transformed * t
444
+ + self.a_lim_a / 6 * (3 * t**2 - 3 * self.Tj_1 * t + self.Tj_1**2)
445
+ )
446
+ qp = self.v_0_transformed + self.a_lim_a * (t - self.Tj_1 / 2)
447
+ qpp = self.a_lim_a
448
+ qppp = 0
449
+
450
+ elif t <= self.Ta and self.Ta > 0:
451
+ # t in [Ta-Tj_1, Ta]
452
+ q = (
453
+ q_0
454
+ + (self.v_lim + self.v_0_transformed) * self.Ta / 2
455
+ - self.v_lim * (self.Ta - t)
456
+ - self.j_min * (self.Ta - t) ** 3 / 6
457
+ )
458
+ qp = self.v_lim + self.j_min * (self.Ta - t) ** 2 / 2
459
+ qpp = -self.j_min * (self.Ta - t)
460
+ qppp = self.j_min
461
+
462
+ # CONSTANT VELOCITY PHASE
463
+ elif t <= (self.Ta + self.Tv) and self.Tv > 0:
464
+ # t in [Ta, Ta+Tv]
465
+ q = q_0 + (self.v_lim + self.v_0_transformed) * self.Ta / 2 + self.v_lim * (t - self.Ta)
466
+ qp = self.v_lim
467
+ qpp = 0
468
+ qppp = 0
469
+
470
+ # DECELERATION PHASE
471
+ elif t <= (self.Ta + self.Tv + self.Tj_2) and self.Tj_2 > 0:
472
+ # t in [Ta+Tv, Ta+Tv+Tj_2]
473
+ q = (
474
+ q_1
475
+ - (self.v_lim + self.v_1_transformed) * self.Td / 2
476
+ + self.v_lim * (t - self.T + self.Td)
477
+ - self.j_max * (t - self.T + self.Td) ** 3 / 6
478
+ )
479
+ qp = self.v_lim - self.j_max * (t - self.T + self.Td) ** 2 / 2
480
+ qpp = -self.j_max * (t - self.T + self.Td)
481
+ qppp = -self.j_max
482
+
483
+ elif t <= (self.Ta + self.Tv + (self.Td - self.Tj_2)) and self.Td > self.Tj_2:
484
+ # t in [Ta+Tv+Tj_2, Ta+Tv+(Td-Tj_2)]
485
+ q = (
486
+ q_1
487
+ - (self.v_lim + self.v_1_transformed) * self.Td / 2
488
+ + self.v_lim * (t - self.T + self.Td)
489
+ + self.a_lim_d
490
+ / 6
491
+ * (
492
+ 3 * (t - self.T + self.Td) ** 2
493
+ - 3 * self.Tj_2 * (t - self.T + self.Td)
494
+ + self.Tj_2**2
495
+ )
496
+ )
497
+ qp = self.v_lim + self.a_lim_d * (t - self.T + self.Td - self.Tj_2 / 2)
498
+ qpp = self.a_lim_d
499
+ qppp = 0
500
+
501
+ elif t <= self.T and self.Td > 0:
502
+ # t in [Ta+Tv+(Td-Tj_2), T]
503
+ q = q_1 - self.v_1_transformed * (self.T - t) - self.j_max * (self.T - t) ** 3 / 6
504
+ qp = self.v_1_transformed + self.j_max * (self.T - t) ** 2 / 2
505
+ qpp = -self.j_max * (self.T - t)
506
+ qppp = self.j_max
507
+
508
+ else:
509
+ # After end of trajectory or for empty phases
510
+ q = q_1
511
+ qp = self.v_1_transformed
512
+ qpp = 0
513
+ qppp = 0
514
+
515
+ # Transform back using sigma
516
+ q = self.sigma * q
517
+ qp = self.sigma * qp
518
+ qpp = self.sigma * qpp
519
+ qppp = self.sigma * qppp
520
+
521
+ return q, qp, qpp, qppp
522
+
523
+ def get_duration(self) -> float:
524
+ """
525
+ Returns the total duration of the trajectory.
526
+
527
+ Returns:
528
+ --------
529
+ T : float
530
+ Total trajectory duration in seconds
531
+ """
532
+ return self.T
533
+
534
+ def get_phase_durations(self) -> dict[str, float]:
535
+ """
536
+ Returns the durations of each phase in the trajectory.
537
+
538
+ Returns:
539
+ --------
540
+ phases : dict
541
+ Dictionary containing the durations of each phase
542
+ """
543
+ return {
544
+ "total": self.T,
545
+ "acceleration": self.Ta,
546
+ "constant_velocity": self.Tv,
547
+ "deceleration": self.Td,
548
+ "jerk_acceleration": self.Tj_1,
549
+ "jerk_deceleration": self.Tj_2,
550
+ }
551
+
552
+ @staticmethod
553
+ def create_trajectory(
554
+ state_params: StateParams, bounds: TrajectoryBounds
555
+ ) -> tuple[Callable[[float | np.ndarray], tuple[float | np.ndarray, ...]], float]:
556
+ """
557
+ Static factory method to create a trajectory function and return its duration.
558
+ This method provides an interface similar to the original function-based API.
559
+
560
+ Parameters:
561
+ -----------
562
+ state_params : StateParams
563
+ Start and end states for trajectory planning
564
+ bounds : TrajectoryBounds
565
+ Velocity, acceleration, and jerk bounds for trajectory planning
566
+
567
+ Returns:
568
+ --------
569
+ trajectory_function : Callable
570
+ A function that takes time t as input and returns position, velocity,
571
+ acceleration, and jerk at that time
572
+ T : float
573
+ Total trajectory duration
574
+ """
575
+ planner = DoubleSTrajectory(state_params, bounds)
576
+
577
+ def trajectory(t: float | np.ndarray) -> tuple[float | np.ndarray, ...]:
578
+ return planner.evaluate(t)
579
+
580
+ return trajectory, planner.get_duration()