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,515 @@
1
+ import matplotlib.pyplot as plt
2
+ import numpy as np
3
+ from scipy.linalg import solve
4
+
5
+ from interpolatepy.b_spline import BSpline
6
+
7
+
8
+ CUBIC_DEGREE = 3
9
+ QUARTIC_DEGREE = 4
10
+ QUINTIC_DEGREE = 5
11
+ VALID_DEGREES = {CUBIC_DEGREE, QUARTIC_DEGREE, QUINTIC_DEGREE}
12
+ MAX_POINTS_FOR_LABELS = 10
13
+ TWO_DIMENSIONAL = 2
14
+ THREE_DIMENSIONAL = 3
15
+ EPS_P = 1e12
16
+ EPS_N = 1e-10
17
+
18
+
19
+ class BSplineInterpolator(BSpline):
20
+ """A B-spline that interpolates a set of points with specified degrees of continuity.
21
+
22
+ This class inherits from BSpline and computes the knot vector and control points
23
+ required to interpolate given data points at specified times, while maintaining
24
+ desired continuity constraints.
25
+
26
+ The implementation follows section 4.5 of the document, supporting:
27
+ - Cubic splines (degree 3) with C² continuity
28
+ - Quartic splines (degree 4) with C³ continuity (continuous jerk)
29
+ - Quintic splines (degree 5) with C⁴ continuity (continuous snap)
30
+
31
+ Points can be of any dimension, including 2D and 3D.
32
+ """
33
+
34
+ def __init__( # noqa: PLR0913, PLR0917
35
+ self,
36
+ degree: int,
37
+ points: list | np.ndarray,
38
+ times: list | np.ndarray | None = None,
39
+ initial_velocity: list | np.ndarray | None = None,
40
+ final_velocity: list | np.ndarray | None = None,
41
+ initial_acceleration: list | np.ndarray | None = None,
42
+ final_acceleration: list | np.ndarray | None = None,
43
+ cyclic: bool = False,
44
+ ) -> None:
45
+ """Initialize a B-spline interpolator.
46
+
47
+ Parameters
48
+ ----------
49
+ degree : int
50
+ The degree of the B-spline (3, 4, or 5).
51
+ points : list or numpy.ndarray
52
+ The points to be interpolated.
53
+ times : list or numpy.ndarray or None, optional
54
+ The time instants for each point. If None, uses uniform spacing.
55
+ initial_velocity : list or numpy.ndarray or None, optional
56
+ Initial velocity constraint.
57
+ final_velocity : list or numpy.ndarray or None, optional
58
+ Final velocity constraint.
59
+ initial_acceleration : list or numpy.ndarray or None, optional
60
+ Initial acceleration constraint.
61
+ final_acceleration : list or numpy.ndarray or None, optional
62
+ Final acceleration constraint.
63
+ cyclic : bool, default=False
64
+ Whether to use cyclic (periodic) conditions.
65
+
66
+ Raises
67
+ ------
68
+ ValueError
69
+ If the degree is not 3, 4, or 5.
70
+ ValueError
71
+ If there are not enough points for the specified degree.
72
+ """
73
+ # Validate inputs
74
+ if degree not in VALID_DEGREES:
75
+ raise ValueError(f"Degree must be 3, 4, or 5, got {degree}")
76
+
77
+ # Convert inputs to numpy arrays
78
+ if not isinstance(points, np.ndarray):
79
+ points = np.array(points, dtype=np.float64)
80
+
81
+ # Ensure points are 2D
82
+ if points.ndim == 1:
83
+ # For 1D points, reshape to column vector
84
+ points = points.reshape(-1, 1)
85
+
86
+ # Validate number of points relative to degree
87
+ num_points = len(points)
88
+ min_points = degree + 1
89
+ if num_points < min_points:
90
+ raise ValueError(
91
+ f"Not enough points for degree {degree} B-spline interpolation. "
92
+ f"Need at least {min_points} points, but got {num_points}. "
93
+ f"Either reduce the degree or provide more points."
94
+ )
95
+
96
+ # Set up time sequence if not provided
97
+ if times is None:
98
+ times = np.arange(len(points), dtype=np.float64)
99
+ elif not isinstance(times, np.ndarray):
100
+ times = np.array(times, dtype=np.float64)
101
+
102
+ # Store attributes used for interpolation
103
+ self.interp_points = points.copy()
104
+ self.times = times
105
+ self.initial_velocity = initial_velocity
106
+ self.final_velocity = final_velocity
107
+ self.initial_acceleration = initial_acceleration
108
+ self.final_acceleration = final_acceleration
109
+ self.cyclic = cyclic
110
+
111
+ # Compute knots and control points for interpolation
112
+ knots = self._create_knot_vector(degree, points, times)
113
+
114
+ # Create a temporary BSpline to use its methods for basis function calculations
115
+ # Use a single control point since we only need it for basis functions
116
+ temp_control_points = np.zeros((len(knots) - degree - 1, 1))
117
+ self.temp_spline = BSpline(degree, knots, temp_control_points)
118
+
119
+ # Compute control points using the temporary BSpline
120
+ control_points = self._compute_control_points(degree, points, times)
121
+
122
+ # Initialize the base BSpline class with computed values
123
+ super().__init__(degree, knots, control_points)
124
+
125
+ @staticmethod
126
+ def _create_knot_vector(degree: int, points: np.ndarray, times: np.ndarray) -> np.ndarray:
127
+ """Create the knot vector based on the degree.
128
+
129
+ - For odd degrees (3, 5): knots at interpolation points (eq. 4.42)
130
+ - For even degrees (4): knots at midpoints (eq. 4.43)
131
+
132
+ Parameters
133
+ ----------
134
+ degree : int
135
+ The degree of the B-spline.
136
+ points : numpy.ndarray
137
+ The points to be interpolated.
138
+ times : numpy.ndarray
139
+ The time instants for each point.
140
+
141
+ Returns
142
+ -------
143
+ numpy.ndarray
144
+ The computed knot vector.
145
+ """
146
+ n = len(points) - 1 # n segments (n+1 points)
147
+ p = degree
148
+
149
+ if p % 2 == 1: # Odd degree (3, 5): knots at points
150
+ # Using equation 4.42 from the document
151
+ # u = [t0, ..., t0, t1, ..., tn-1, tn, ..., tn]
152
+ # p+1 times p+1 times
153
+
154
+ knots = np.zeros(n + 2 * p + 1) # Total knots: n + 2p + 1
155
+
156
+ # Set first p+1 knots to t0
157
+ knots[: p + 1] = times[0]
158
+
159
+ # Set internal knots to interpolation points
160
+ knots[p + 1 : p + 1 + n - 1] = times[1:-1]
161
+
162
+ # Set last p+1 knots to tn
163
+ knots[p + n :] = times[-1]
164
+ else: # Even degree (4): knots at midpoints
165
+ # Using equation 4.43 from the document
166
+ # u = [t0, ..., t0, (t0+t1)/2, ..., (tn-1+tn)/2, tn, ..., tn]
167
+ # p+1 times p+1 times
168
+
169
+ knots = np.zeros(n + 2 * p + 2) # Total knots: n + 2p + 2
170
+
171
+ # Set first p+1 knots to t0
172
+ knots[: p + 1] = times[0]
173
+
174
+ # Set internal knots to midpoints between interpolation points
175
+ for i in range(n):
176
+ knots[p + 1 + i] = (times[i] + times[i + 1]) / 2.0
177
+
178
+ # Set last p+1 knots to tn
179
+ knots[p + 1 + n :] = times[-1]
180
+
181
+ return knots
182
+
183
+ def _compute_control_points(
184
+ self, degree: int, points: np.ndarray, times: np.ndarray
185
+ ) -> np.ndarray:
186
+ """Compute the control points by solving the linear system.
187
+
188
+ The system includes:
189
+ - Interpolation conditions (curve passes through each point)
190
+ - Boundary conditions (velocity/acceleration or cyclic conditions)
191
+
192
+ Parameters
193
+ ----------
194
+ degree : int
195
+ The degree of the B-spline.
196
+ points : numpy.ndarray
197
+ The points to be interpolated.
198
+ times : numpy.ndarray
199
+ The time instants for each point.
200
+
201
+ Returns
202
+ -------
203
+ numpy.ndarray
204
+ The computed control points.
205
+
206
+ Raises
207
+ ------
208
+ ValueError
209
+ If the linear system cannot be solved or is ill-conditioned.
210
+ """
211
+ n = len(points) - 1 # Number of segments
212
+ p = degree
213
+
214
+ # Determine number of control points based on degree
215
+ num_control_points = (n + 1) + p - 1 if p % 2 == 1 else (n + 1) + p
216
+
217
+ # Determine number of additional conditions needed
218
+ num_additional = p if p % 2 == 0 else p - 1
219
+
220
+ # Create the linear system: A * P = b
221
+ a_matrix = np.zeros((n + 1 + num_additional, num_control_points))
222
+ b = np.zeros((n + 1 + num_additional, points.shape[1]))
223
+
224
+ # Fill the interpolation conditions (points must lie on the curve)
225
+ for i in range(n + 1):
226
+ t = times[i]
227
+
228
+ # Find which basis functions are non-zero at this point
229
+ span = self.temp_spline.find_knot_span(t)
230
+ basis_values = self.temp_spline.basis_functions(t, span)
231
+
232
+ for j in range(p + 1):
233
+ col = span - p + j
234
+ if 0 <= col < num_control_points:
235
+ a_matrix[i, col] = basis_values[j]
236
+
237
+ # The right side is the point to interpolate
238
+ b[i] = points[i]
239
+
240
+ # Add boundary conditions
241
+ row = n + 1 # Start adding boundary conditions after interpolation
242
+
243
+ if self.cyclic:
244
+ # Add cyclic conditions: derivatives at start = derivatives at end
245
+ for k in range(1, num_additional + 1):
246
+ t0 = times[0]
247
+ tn = times[-1]
248
+
249
+ span0 = self.temp_spline.find_knot_span(t0)
250
+ spann = self.temp_spline.find_knot_span(tn)
251
+
252
+ # Get derivative basis functions
253
+ ders0 = self.temp_spline.basis_function_derivatives(t0, span0, k)
254
+ dersn = self.temp_spline.basis_function_derivatives(tn, spann, k)
255
+
256
+ # Fill the derivative constraint: s^(k)(t0) - s^(k)(tn) = 0
257
+ for j in range(p + 1):
258
+ col0 = span0 - p + j
259
+ if 0 <= col0 < num_control_points:
260
+ a_matrix[row, col0] = ders0[k, j]
261
+
262
+ coln = spann - p + j
263
+ if 0 <= coln < num_control_points:
264
+ a_matrix[row, coln] = -dersn[k, j]
265
+
266
+ # Right side is zero for cyclic conditions
267
+ # b[row] already initialized to zero
268
+
269
+ row += 1
270
+ if row >= n + 1 + num_additional:
271
+ break
272
+ else:
273
+ # Add velocity constraints if provided
274
+ if self.initial_velocity is not None and row < n + 1 + num_additional:
275
+ t = times[0]
276
+ span = self.temp_spline.find_knot_span(t)
277
+ ders = self.temp_spline.basis_function_derivatives(t, span, 1)
278
+
279
+ for j in range(p + 1):
280
+ col = span - p + j
281
+ if 0 <= col < num_control_points:
282
+ a_matrix[row, col] = ders[1, j]
283
+
284
+ b[row] = self.initial_velocity
285
+ row += 1
286
+
287
+ if self.final_velocity is not None and row < n + 1 + num_additional:
288
+ t = times[-1]
289
+ span = self.temp_spline.find_knot_span(t)
290
+ ders = self.temp_spline.basis_function_derivatives(t, span, 1)
291
+
292
+ for j in range(p + 1):
293
+ col = span - p + j
294
+ if 0 <= col < num_control_points:
295
+ a_matrix[row, col] = ders[1, j]
296
+
297
+ b[row] = self.final_velocity
298
+ row += 1
299
+
300
+ # Add acceleration constraints if provided
301
+ if self.initial_acceleration is not None and row < n + 1 + num_additional:
302
+ t = times[0]
303
+ span = self.temp_spline.find_knot_span(t)
304
+ ders = self.temp_spline.basis_function_derivatives(t, span, 2)
305
+
306
+ for j in range(p + 1):
307
+ col = span - p + j
308
+ if 0 <= col < num_control_points:
309
+ a_matrix[row, col] = ders[2, j]
310
+
311
+ b[row] = self.initial_acceleration
312
+ row += 1
313
+
314
+ if self.final_acceleration is not None and row < n + 1 + num_additional:
315
+ t = times[-1]
316
+ span = self.temp_spline.find_knot_span(t)
317
+ ders = self.temp_spline.basis_function_derivatives(t, span, 2)
318
+
319
+ for j in range(p + 1):
320
+ col = span - p + j
321
+ if 0 <= col < num_control_points:
322
+ a_matrix[row, col] = ders[2, j]
323
+
324
+ b[row] = self.final_acceleration
325
+ row += 1
326
+
327
+ # If we still need more constraints, add natural spline conditions
328
+ # (zero second derivatives at endpoints)
329
+ while row < n + 1 + num_additional:
330
+ # For cubic splines, use zero second derivatives
331
+ # For higher degree, can use higher derivatives
332
+ deriv_order = min(p - 1, 2)
333
+
334
+ # Alternate between initial and final endpoints
335
+ t = times[0] if row % 2 == 0 else times[-1]
336
+
337
+ span = self.temp_spline.find_knot_span(t)
338
+ ders = self.temp_spline.basis_function_derivatives(t, span, deriv_order)
339
+
340
+ for j in range(p + 1):
341
+ col = span - p + j
342
+ if 0 <= col < num_control_points:
343
+ a_matrix[row, col] = ders[deriv_order, j]
344
+
345
+ # Right side is zero (natural spline condition)
346
+ # b[row] already initialized to zero
347
+
348
+ row += 1
349
+
350
+ # Check if the system is well-posed
351
+ if np.linalg.matrix_rank(a_matrix) < min(a_matrix.shape):
352
+ raise ValueError(
353
+ "Linear system is rank-deficient. This typically occurs when there "
354
+ "are too few points for the specified degree and constraints. "
355
+ "Add more points or reduce the polynomial degree."
356
+ )
357
+
358
+ # Solve the linear system for each coordinate
359
+ try:
360
+ # Check if the system is well-conditioned
361
+ condition_number = np.linalg.cond(a_matrix)
362
+ if condition_number > EPS_P:
363
+ print(
364
+ f"Warning: The linear system is ill-conditioned "
365
+ f"(condition number: {condition_number:.2e})"
366
+ )
367
+ print("This may lead to numerical inaccuracies in the spline interpolation.")
368
+ print(
369
+ "Consider adding more points, using a lower degree, "
370
+ "or adjusting the time distribution."
371
+ )
372
+
373
+ # Add a small regularization term for stability
374
+ if condition_number > EPS_P:
375
+ epsilon = EPS_N
376
+ a_matrix += epsilon * np.eye(a_matrix.shape[0], a_matrix.shape[1])
377
+ print(
378
+ f"Adding regularization (epsilon={epsilon}) to improve numerical stability."
379
+ )
380
+
381
+ # Solve for control points
382
+ control_points = np.zeros((num_control_points, points.shape[1]))
383
+ for dim in range(points.shape[1]):
384
+ control_points[:, dim] = solve(a_matrix, b[:, dim])
385
+
386
+ return control_points # noqa: TRY300
387
+
388
+ except np.linalg.LinAlgError as e:
389
+ recommended_points = degree + 2 # Safe minimum
390
+ current_points = len(points)
391
+
392
+ error_msg = f"Failed to solve for control points: {e}\n"
393
+ error_msg += "This is likely due to an ill-posed interpolation problem.\n"
394
+ error_msg += (
395
+ f"For degree {degree} B-splines, you should have at least "
396
+ f"{recommended_points} points "
397
+ )
398
+ error_msg += f"(you provided {current_points}).\n"
399
+
400
+ if self.cyclic:
401
+ error_msg += "When using cyclic conditions, you may need even more points.\n"
402
+
403
+ if (
404
+ self.initial_velocity is not None
405
+ or self.final_velocity is not None
406
+ or self.initial_acceleration is not None
407
+ or self.final_acceleration is not None
408
+ ):
409
+ error_msg += (
410
+ "When specifying velocity or acceleration constraints, "
411
+ "you may need more points.\n"
412
+ )
413
+
414
+ if degree in {QUARTIC_DEGREE, QUINTIC_DEGREE}:
415
+ error_msg += (
416
+ f"Consider using a lower degree (e.g., degree=3) "
417
+ f"with {current_points} points.\n"
418
+ )
419
+
420
+ raise ValueError(error_msg) from e
421
+
422
+ def plot_with_points(
423
+ self, num_points: int = 100, show_control_polygon: bool = True, ax: plt.Axes | None = None
424
+ ) -> plt.Axes:
425
+ """Plot the 2D B-spline curve along with the interpolation points.
426
+
427
+ Parameters
428
+ ----------
429
+ num_points : int, default=100
430
+ Number of points to generate for the curve.
431
+ show_control_polygon : bool, default=True
432
+ Whether to show the control polygon.
433
+ ax : matplotlib.axes.Axes or None, optional
434
+ Optional matplotlib axis to use.
435
+
436
+ Returns
437
+ -------
438
+ matplotlib.axes.Axes
439
+ The matplotlib axis object.
440
+
441
+ Raises
442
+ ------
443
+ ValueError
444
+ If points are not 2D.
445
+ """
446
+ if self.interp_points.shape[1] != TWO_DIMENSIONAL:
447
+ raise ValueError(f"Points must be 2D for this plot, got {self.interp_points.shape[1]}D")
448
+
449
+ # Plot the B-spline using the parent class method
450
+ ax = self.plot_2d(num_points=num_points, show_control_polygon=show_control_polygon, ax=ax)
451
+
452
+ # Add interpolation points
453
+ ax.plot(
454
+ self.interp_points[:, 0],
455
+ self.interp_points[:, 1],
456
+ "go",
457
+ markersize=8,
458
+ label="Interpolation points",
459
+ )
460
+
461
+ # Add time labels if not too many points
462
+ if len(self.interp_points) <= MAX_POINTS_FOR_LABELS:
463
+ for i, (x, y) in enumerate(self.interp_points):
464
+ ax.text(x, y + 0.1, f"t={self.times[i]:.1f}", horizontalalignment="center")
465
+
466
+ ax.legend()
467
+ return ax
468
+
469
+ def plot_with_points_3d(
470
+ self, num_points: int = 100, show_control_polygon: bool = True, ax: plt.Axes | None = None
471
+ ) -> plt.Axes:
472
+ """Plot the 3D B-spline curve along with the interpolation points.
473
+
474
+ Parameters
475
+ ----------
476
+ num_points : int, default=100
477
+ Number of points to generate for the curve.
478
+ show_control_polygon : bool, default=True
479
+ Whether to show the control polygon.
480
+ ax : matplotlib.axes.Axes or None, optional
481
+ Optional matplotlib 3D axis to use.
482
+
483
+ Returns
484
+ -------
485
+ matplotlib.axes.Axes
486
+ The matplotlib 3D axis object.
487
+
488
+ Raises
489
+ ------
490
+ ValueError
491
+ If points are not 3D.
492
+ """
493
+ if self.interp_points.shape[1] != THREE_DIMENSIONAL:
494
+ raise ValueError(f"Points must be 3D for this plot, got {self.interp_points.shape[1]}D")
495
+
496
+ # Plot the B-spline using the parent class method
497
+ ax = self.plot_3d(num_points=num_points, show_control_polygon=show_control_polygon, ax=ax)
498
+
499
+ # Add interpolation points
500
+ ax.scatter(
501
+ self.interp_points[:, 0],
502
+ self.interp_points[:, 1],
503
+ self.interp_points[:, 2],
504
+ color="g",
505
+ s=64,
506
+ label="Interpolation points",
507
+ )
508
+
509
+ # Add time labels if not too many points
510
+ if len(self.interp_points) <= MAX_POINTS_FOR_LABELS:
511
+ for i, (x, y, z) in enumerate(self.interp_points):
512
+ ax.text(x, y, z, f"t={self.times[i]:.1f}", horizontalalignment="center")
513
+
514
+ ax.legend()
515
+ return ax