Skip to content

Commit 6813678

Browse files
committed
linear polynomials with parabolic blends
1 parent 745a00e commit 6813678

File tree

4 files changed

+365
-1
lines changed

4 files changed

+365
-1
lines changed

examples/lin_poly_parabolic_ex.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
from collections.abc import Callable
2+
from collections.abc import Sequence
3+
4+
import matplotlib.pyplot as plt
5+
import numpy as np
6+
7+
from interpolatepy.lin_poly_parabolic import ParabolicBlendTrajectory
8+
9+
10+
def plot_trajectory_with_waypoints(
11+
traj: ParabolicBlendTrajectory,
12+
q: Sequence[float],
13+
t: Sequence[float],
14+
) -> None:
15+
"""Plot the trajectory along with waypoints highlighted.
16+
17+
The function generates position, velocity, and acceleration profiles
18+
from the provided trajectory and displays them in three subplots.
19+
A dashed linear interpolation between waypoints is also plotted.
20+
21+
Parameters
22+
----------
23+
traj : ParabolicBlendTrajectory
24+
Trajectory object created with waypoints and blend durations.
25+
q : Sequence[float]
26+
List or array of position waypoints.
27+
t : Sequence[float]
28+
List or array of times corresponding to each waypoint.
29+
30+
Returns
31+
-------
32+
None
33+
Displays the plot directly.
34+
"""
35+
traj_func, duration = traj.generate()
36+
times = np.arange(0.0, duration + traj.dt, traj.dt)
37+
38+
# Evaluate trajectory at each time point
39+
positions = np.empty_like(times)
40+
velocities = np.empty_like(times)
41+
accelerations = np.empty_like(times)
42+
43+
for i, time in enumerate(times):
44+
positions[i], velocities[i], accelerations[i] = traj_func(time)
45+
46+
# Adjust waypoint times to align with trajectory time scale
47+
adjusted_t = [time + traj.dt_blend[0] / 2 for time in t]
48+
49+
# Create the figure with three subplots
50+
fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(10, 8), sharex=True)
51+
52+
# Plot position trajectory with waypoints
53+
ax1.plot(times, positions, label="Position Trajectory")
54+
linear_positions = np.interp(times, adjusted_t, q)
55+
ax1.plot(times, linear_positions, "--", label="Linear Interpolation")
56+
ax1.scatter(adjusted_t, q, color="red", s=50, zorder=5, label="Via Points")
57+
ax1.set_ylabel("Position")
58+
ax1.legend()
59+
ax1.grid(True)
60+
61+
# Plot velocity profile
62+
ax2.plot(times, velocities, label="Velocity")
63+
ax2.set_ylabel("Velocity")
64+
ax2.axhline(0, linestyle="--", alpha=0.3)
65+
ax2.legend()
66+
ax2.grid(True)
67+
68+
# Plot acceleration profile
69+
ax3.plot(times, accelerations, label="Acceleration")
70+
ax3.set_ylabel("Acceleration")
71+
ax3.set_xlabel("Time [s]")
72+
ax3.axhline(0, linestyle="--", alpha=0.3)
73+
ax3.legend()
74+
ax3.grid(True)
75+
76+
fig.tight_layout()
77+
plt.show()
78+
79+
80+
if __name__ == "__main__":
81+
# Define waypoints and blend durations
82+
q: list[float] = [0, 2 * np.pi, np.pi / 2, np.pi]
83+
t: list[float] = [0, 2, 3, 5]
84+
dt_blend: np.ndarray = np.full(len(t), 0.6)
85+
86+
print("Creating parabolic blend trajectory with:")
87+
print(f" Positions: {q}")
88+
print(f" Times: {t}")
89+
print(f" Blend durations: {dt_blend}")
90+
91+
traj: ParabolicBlendTrajectory = ParabolicBlendTrajectory(q, t, dt_blend)
92+
93+
# Option 1: Use the built-in plot method
94+
print("\nPlotting trajectory using built-in plot method...")
95+
traj.plot()
96+
97+
# Option 2: Custom plotting with waypoints highlighted
98+
print("\nPlotting trajectory with highlighted waypoints...")
99+
plot_trajectory_with_waypoints(traj, q, t)
100+
101+
# Option 3: Direct usage of the trajectory function
102+
print("\nDirect usage of trajectory function:")
103+
traj_func: Callable[[float], tuple[float, float, float]]
104+
traj_func, duration = traj.generate()
105+
106+
# Evaluate at specific times
107+
evaluation_times: list[float] = [0.5, 2.1, 3.5, 4.8]
108+
print(f"\nTotal trajectory duration: {duration:.2f} seconds")
109+
print("\nEvaluating trajectory at specific time points:")
110+
111+
for time_point in evaluation_times:
112+
position, velocity, acceleration = traj_func(time_point)
113+
print(
114+
f"At t={time_point:.2f}s: position={position:.4f}, velocity={velocity:.4f}, acceleration={acceleration:.4f}" # noqa: E501
115+
)
116+
117+
# Demonstrate out-of-bounds handling
118+
print("\nDemonstrating out-of-bounds handling:")
119+
out_of_bounds_time: float = duration + 1.0
120+
position, velocity, acceleration = traj_func(out_of_bounds_time)
121+
print(
122+
f"At t={out_of_bounds_time:.2f}s (beyond duration): position={position:.4f}, velocity={velocity:.4f}, acceleration={acceleration:.4f}" # noqa: E501
123+
)

interpolatepy/lin_poly_parabolic.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
from collections.abc import Callable # noqa: EXE002
2+
3+
import matplotlib.pyplot as plt
4+
import numpy as np
5+
6+
7+
class ParabolicBlendTrajectory:
8+
"""
9+
Class to generate trajectories composed of linear segments with parabolic blends at via points.
10+
11+
The trajectory duration is extended:
12+
total_duration = t[-1] - t[0] + (dt_blend[0] + dt_blend[-1]) / 2
13+
14+
Initial and final velocities are set to zero (q̇0,1 = q̇N,N+1 = 0).
15+
16+
Parameters
17+
----------
18+
q : list | np.ndarray
19+
Positions of via points (length N).
20+
t : list | np.ndarray
21+
Nominal times at which via points are reached (length N).
22+
dt_blend : list | np.ndarray
23+
Blend durations at via points (length N).
24+
dt : float, optional
25+
Sampling interval for plotting trajectory. Default is 0.01.
26+
"""
27+
28+
def __init__(
29+
self,
30+
q: list | np.ndarray,
31+
t: list | np.ndarray,
32+
dt_blend: list | np.ndarray,
33+
dt: float = 0.01,
34+
) -> None:
35+
self.q = np.asarray(q, dtype=float)
36+
self.t = np.asarray(t, dtype=float)
37+
self.dt_blend = np.asarray(dt_blend, dtype=float)
38+
self.dt = dt
39+
40+
if not (len(self.q) == len(self.t) == len(self.dt_blend)):
41+
raise ValueError("Lengths of q, t, and dt_blend must match.")
42+
43+
def generate(self) -> tuple[Callable[[float], tuple[float, float, float]], float]:
44+
"""
45+
Generate the parabolic blend trajectory function.
46+
47+
Returns
48+
-------
49+
trajectory_function : callable
50+
Function that takes a time value and returns position, velocity, and acceleration.
51+
total_duration : float
52+
The total duration of the trajectory.
53+
"""
54+
# Use lowercase for count variable per style
55+
n = len(self.q)
56+
57+
# Vectorized computation of segment velocities with zero initial and final
58+
v_before = np.zeros(n)
59+
# Calculate differences in positions and times
60+
dq = np.diff(self.q)
61+
dt = np.diff(self.t)
62+
# Vectorized velocity calculation
63+
v_before[1:] = dq / dt
64+
65+
# Shift velocities for v_after
66+
v_after = np.zeros(n)
67+
v_after[:-1] = v_before[1:]
68+
69+
# Accelerations for parabolic blends
70+
a = (v_after - v_before) / self.dt_blend
71+
72+
# Preallocate arrays for region data with structure of arrays (SoA)
73+
# Estimating 2*n-1 regions (initial blend + n-1 pairs of linear+blend)
74+
num_regions = 2 * n - 1
75+
reg_t0 = np.zeros(num_regions)
76+
reg_t1 = np.zeros(num_regions)
77+
reg_q0 = np.zeros(num_regions)
78+
reg_v0 = np.zeros(num_regions)
79+
reg_a = np.zeros(num_regions)
80+
81+
# Initial blend
82+
reg_idx = 0
83+
t0 = self.t[0] - self.dt_blend[0] / 2
84+
t1 = self.t[0] + self.dt_blend[0] / 2
85+
reg_t0[reg_idx] = t0
86+
reg_t1[reg_idx] = t1
87+
reg_q0[reg_idx] = self.q[0]
88+
reg_v0[reg_idx] = v_before[0]
89+
reg_a[reg_idx] = a[0]
90+
reg_idx += 1
91+
92+
# Build remaining regions efficiently
93+
for k in range(n - 1):
94+
# Constant-velocity segment
95+
t0_c = reg_t1[reg_idx - 1]
96+
t1_c = self.t[k + 1] - self.dt_blend[k + 1] / 2
97+
98+
# Calculate position at start of constant velocity segment
99+
dt0 = t0_c - reg_t0[reg_idx - 1]
100+
q0_c = (
101+
reg_q0[reg_idx - 1] + reg_v0[reg_idx - 1] * dt0 + 0.5 * reg_a[reg_idx - 1] * dt0**2
102+
)
103+
104+
# Store constant velocity segment
105+
reg_t0[reg_idx] = t0_c
106+
reg_t1[reg_idx] = t1_c
107+
reg_q0[reg_idx] = q0_c
108+
reg_v0[reg_idx] = v_after[k]
109+
reg_a[reg_idx] = 0.0
110+
reg_idx += 1
111+
112+
# Parabolic blend
113+
t0_b = t1_c
114+
t1_b = self.t[k + 1] + self.dt_blend[k + 1] / 2
115+
116+
# Calculate position at start of blend
117+
dt0b = t0_b - reg_t0[reg_idx - 1]
118+
q0_b = reg_q0[reg_idx - 1] + reg_v0[reg_idx - 1] * dt0b
119+
120+
# Store parabolic blend
121+
reg_t0[reg_idx] = t0_b
122+
reg_t1[reg_idx] = t1_b
123+
reg_q0[reg_idx] = q0_b
124+
reg_v0[reg_idx] = v_before[k + 1]
125+
reg_a[reg_idx] = a[k + 1]
126+
reg_idx += 1
127+
128+
# Trim unused regions if we overestimated
129+
if reg_idx < num_regions:
130+
reg_t0 = reg_t0[:reg_idx]
131+
reg_t1 = reg_t1[:reg_idx]
132+
reg_q0 = reg_q0[:reg_idx]
133+
reg_v0 = reg_v0[:reg_idx]
134+
reg_a = reg_a[:reg_idx]
135+
136+
# Determine overall duration
137+
t_start = reg_t0[0]
138+
t_end = reg_t1[-1]
139+
total_duration = t_end - t_start
140+
141+
# Prepare binary search for region lookup
142+
region_boundaries = np.append(reg_t0[0], reg_t1)
143+
144+
# Function to evaluate trajectory at any time t
145+
def trajectory_function(t: float) -> tuple[float, float, float]:
146+
"""
147+
Evaluate the trajectory at time t.
148+
149+
Parameters
150+
----------
151+
t : float
152+
Time at which to evaluate the trajectory
153+
154+
Returns
155+
-------
156+
tuple[float, float, float]
157+
Tuple containing position, velocity, and acceleration at time t
158+
"""
159+
# Clip time to valid range
160+
t = np.clip(t, 0.0, total_duration)
161+
162+
# Convert to absolute time
163+
t_abs = t + t_start
164+
165+
# Find region using binary search
166+
region_idx = np.searchsorted(region_boundaries, t_abs, side="right") - 1
167+
region_idx = min(region_idx, len(reg_t0) - 1)
168+
169+
# Calculate values
170+
u = t_abs - reg_t0[region_idx]
171+
pos = reg_q0[region_idx] + reg_v0[region_idx] * u + 0.5 * reg_a[region_idx] * u**2
172+
vel = reg_v0[region_idx] + reg_a[region_idx] * u
173+
acc = reg_a[region_idx]
174+
175+
return pos, vel, acc
176+
177+
return trajectory_function, total_duration
178+
179+
def plot(
180+
self,
181+
times: np.ndarray | None = None,
182+
pos: np.ndarray | None = None,
183+
vel: np.ndarray | None = None,
184+
acc: np.ndarray | None = None,
185+
) -> None:
186+
"""
187+
Plot the trajectory's position, velocity, and acceleration.
188+
189+
If trajectory data is not provided, it will be generated.
190+
191+
Parameters
192+
----------
193+
times : ndarray, optional
194+
Time samples; if None, generated from trajectory function.
195+
pos : ndarray, optional
196+
Position samples; if None, generated from trajectory function.
197+
vel : ndarray, optional
198+
Velocity samples; if None, generated from trajectory function.
199+
acc : ndarray, optional
200+
Acceleration samples; if None, generated from trajectory function.
201+
"""
202+
if times is None or pos is None or vel is None or acc is None:
203+
traj_func, total_duration = self.generate()
204+
times = np.arange(0.0, total_duration + self.dt, self.dt)
205+
pos = np.zeros_like(times)
206+
vel = np.zeros_like(times)
207+
acc = np.zeros_like(times)
208+
209+
for i, t in enumerate(times):
210+
pos[i], vel[i], acc[i] = traj_func(t)
211+
212+
fig, (ax1, ax2, ax3) = plt.subplots(3, 1, sharex=True)
213+
ax1.plot(times, pos)
214+
ax1.set_ylabel("Position")
215+
ax2.plot(times, vel)
216+
ax2.set_ylabel("Velocity")
217+
ax3.plot(times, acc)
218+
ax3.set_ylabel("Acceleration")
219+
ax3.set_xlabel("Time")
220+
fig.tight_layout()
221+
plt.show()

interpolatepy/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
__version__ file.
33
"""
44

5-
__version__ = "1.0.3"
5+
__version__ = "1.1.0"

requirements-dev.txt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,23 @@ pyright>=1.1.335
1717
pytest>=7.4.3
1818
pytest-benchmark>=4.0.0
1919
lark>=0.0.0
20+
21+
# Documentation
22+
mkdocs>=1.5.3
23+
mkdocstrings>=0.23.0
24+
mkdocstrings-python>=1.7.3
25+
mkdocs-material>=9.4.8
26+
Pygments>=2.16.1
27+
28+
# Resolve existing dependency-conflicts
29+
jinja2>=3.0,<4.0
30+
markupsafe>=2.0.1
31+
typeguard>=2.0.0
32+
colorama~=0.4
33+
pytz>=2020.1
34+
lxml>=4.9.0
35+
beautifulsoup4>=4.12.2
36+
webencodings>=0.5.1
37+
psutil>=5.9.5
38+
decorator>=5.1.1
39+
pexpect>=4.8.0

0 commit comments

Comments
 (0)