Skip to content

Commit f8908c6

Browse files
authored
Merge pull request #1 from shaoanlu/feature/support-ltv
support ltv and add an unicycle example
2 parents 3a37e4a + bf232c0 commit f8908c6

File tree

6 files changed

+470
-58
lines changed

6 files changed

+470
-58
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ pip install -e .
1111
```
1212

1313
## Usage
14-
Refer to [examples](examples/).
14+
Refer to [examples](examples/):
15+
- `examples/afti16_example.py` (linear AFTI-16 aircraft model)
16+
- `examples/unicycle_nmpc_example.py` (nonlinear unicycle with time-varying linearization)
1517

1618
Create a model, configure with `Modelsetup()`, and solve with `Model.solve()`.
1719

@@ -65,6 +67,8 @@ where:
6567

6668
![](assets/example_afti16.png)
6769

70+
![](assets/example_unicycle.png)
71+
6872
## Testing
6973
```bash
7074
pytest tests/test_pimpc.py

assets/example_unicycle.png

44.1 KB
Loading

examples/benchmark_cpu_gpu.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import argparse
2+
import time
3+
4+
import numpy as np
5+
6+
from pimpc import Model
7+
8+
9+
def build_afti16(ts=0.05):
10+
As = np.array(
11+
[
12+
[-0.0151, -60.5651, 0.0, -32.174],
13+
[-0.0001, -1.3411, 0.9929, 0.0],
14+
[0.00018, 43.2541, -0.86939, 0.0],
15+
[0.0, 0.0, 1.0, 0.0],
16+
]
17+
)
18+
Bs = np.array(
19+
[
20+
[-2.516, -13.136],
21+
[-0.1689, -0.2514],
22+
[-17.251, -1.5766],
23+
[0.0, 0.0],
24+
]
25+
)
26+
27+
nx, nu = 4, 2
28+
try:
29+
from scipy.linalg import expm
30+
except ImportError as exc:
31+
raise RuntimeError("SciPy is required for the AFTI-16 benchmark.") from exc
32+
33+
M = expm(np.block([[As, Bs], [np.zeros((nu, nx)), np.zeros((nu, nu))]]) * ts)
34+
A = M[:nx, :nx]
35+
B = M[:nx, nx:]
36+
C = np.array(
37+
[
38+
[0.0, 1.0, 0.0, 0.0],
39+
[0.0, 0.0, 0.0, 1.0],
40+
]
41+
)
42+
return A, B, C
43+
44+
45+
def build_random_system(nx=8, nu=3, ny=4, seed=0):
46+
rng = np.random.default_rng(seed)
47+
A = rng.standard_normal((nx, nx)) * 0.1
48+
radius = np.max(np.abs(np.linalg.eigvals(A)))
49+
if radius > 0:
50+
A = A * min(0.95 / radius, 1.0)
51+
B = rng.standard_normal((nx, nu)) * 0.1
52+
C = rng.standard_normal((ny, nx))
53+
return A, B, C
54+
55+
56+
def setup_model(A, B, C, device, Np):
57+
nx, nu = B.shape
58+
ny = C.shape[0]
59+
model = Model()
60+
model.setup(
61+
A=A,
62+
B=B,
63+
C=C,
64+
Np=Np,
65+
Wy=10.0 * np.eye(ny),
66+
Wu=0.1 * np.eye(nu),
67+
Wdu=0.1 * np.eye(nu),
68+
rho=1.0,
69+
tol=1e-6,
70+
maxiter=200,
71+
precond=True,
72+
accel=True,
73+
device=device,
74+
)
75+
return model
76+
77+
78+
def run_benchmark(model, A, B, repeats, warmup):
79+
nx, nu = B.shape
80+
ny = model.ny
81+
x0 = np.zeros(nx)
82+
u0 = np.zeros(nu)
83+
yref = np.zeros(ny)
84+
uref = np.zeros(nu)
85+
w = np.zeros(nx)
86+
87+
for _ in range(warmup):
88+
results = model.solve(x0, u0, yref, uref, w, verbose=False)
89+
u0 = results.u[:, 0]
90+
x0 = A @ x0 + B @ u0
91+
92+
times_ms = []
93+
for _ in range(repeats):
94+
t0 = time.perf_counter()
95+
results = model.solve(x0, u0, yref, uref, w, verbose=False)
96+
t1 = time.perf_counter()
97+
times_ms.append((t1 - t0) * 1000.0)
98+
u0 = results.u[:, 0]
99+
x0 = A @ x0 + B @ u0
100+
101+
return np.array(times_ms, dtype=float)
102+
103+
104+
def summarize(times_ms):
105+
return {
106+
"mean": float(np.mean(times_ms)),
107+
"median": float(np.median(times_ms)),
108+
"std": float(np.std(times_ms)),
109+
"min": float(np.min(times_ms)),
110+
"max": float(np.max(times_ms)),
111+
}
112+
113+
114+
def main():
115+
parser = argparse.ArgumentParser(description="Benchmark PiMPC CPU vs GPU solver.")
116+
parser.add_argument("--system", choices=["afti16", "random"], default="afti16")
117+
parser.add_argument("--repeats", type=int, default=50)
118+
parser.add_argument("--warmup", type=int, default=5)
119+
parser.add_argument("--Np", type=int, default=5)
120+
args = parser.parse_args()
121+
122+
if args.system == "afti16":
123+
A, B, C = build_afti16()
124+
else:
125+
A, B, C = build_random_system()
126+
127+
cpu_model = setup_model(A, B, C, device="cpu", Np=args.Np)
128+
cpu_times = run_benchmark(cpu_model, A, B, repeats=args.repeats, warmup=args.warmup)
129+
cpu_stats = summarize(cpu_times)
130+
131+
print("CPU benchmark")
132+
print(f" mean: {cpu_stats['mean']:.3f} ms")
133+
print(f" median: {cpu_stats['median']:.3f} ms")
134+
print(f" std: {cpu_stats['std']:.3f} ms")
135+
print(f" min: {cpu_stats['min']:.3f} ms")
136+
print(f" max: {cpu_stats['max']:.3f} ms")
137+
138+
gpu_model = setup_model(A, B, C, device="gpu", Np=args.Np)
139+
if gpu_model.device != "gpu":
140+
print("GPU benchmark skipped (GPU not available or CuPy missing).")
141+
return
142+
143+
gpu_times = run_benchmark(gpu_model, A, B, repeats=args.repeats, warmup=args.warmup)
144+
gpu_stats = summarize(gpu_times)
145+
146+
print("GPU benchmark")
147+
print(f" mean: {gpu_stats['mean']:.3f} ms")
148+
print(f" median: {gpu_stats['median']:.3f} ms")
149+
print(f" std: {gpu_stats['std']:.3f} ms")
150+
print(f" min: {gpu_stats['min']:.3f} ms")
151+
print(f" max: {gpu_stats['max']:.3f} ms")
152+
153+
speedup = cpu_stats["mean"] / gpu_stats["mean"]
154+
print(f"Speedup (mean CPU / mean GPU): {speedup:.2f}x")
155+
156+
157+
if __name__ == "__main__":
158+
main()

examples/unicycle_nmpc_example.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import os
2+
import sys
3+
4+
import numpy as np
5+
6+
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
7+
if ROOT not in sys.path:
8+
sys.path.insert(0, ROOT)
9+
10+
from pimpc import Model
11+
12+
13+
def unicycle_dynamics(x, u):
14+
px, py, theta = x
15+
v, omega = u
16+
return np.array([v * np.cos(theta), v * np.sin(theta), omega])
17+
18+
19+
def linearize_discrete_unicycle(x, u, dt):
20+
theta = x[2]
21+
v = u[0]
22+
23+
A = np.eye(3)
24+
A[0, 2] = -dt * v * np.sin(theta)
25+
A[1, 2] = dt * v * np.cos(theta)
26+
27+
B = np.zeros((3, 2))
28+
B[0, 0] = dt * np.cos(theta)
29+
B[1, 0] = dt * np.sin(theta)
30+
B[2, 1] = dt
31+
32+
x_next = x + dt * unicycle_dynamics(x, u)
33+
c = x_next - A @ x - B @ u
34+
return A, B, c
35+
36+
37+
def build_ltv_matrices(x0, u_nom, dt):
38+
nx = x0.shape[0]
39+
nu, Np = u_nom.shape
40+
x_nom = np.zeros((nx, Np + 1))
41+
x_nom[:, 0] = x0
42+
43+
for k in range(Np):
44+
x_nom[:, k + 1] = x_nom[:, k] + dt * unicycle_dynamics(x_nom[:, k], u_nom[:, k])
45+
46+
A_seq = np.zeros((Np, nx, nx))
47+
B_seq = np.zeros((Np, nx, nu))
48+
c_seq = np.zeros((nx, Np))
49+
for k in range(Np):
50+
A_k, B_k, c_k = linearize_discrete_unicycle(x_nom[:, k], u_nom[:, k], dt)
51+
A_seq[k] = A_k
52+
B_seq[k] = B_k
53+
c_seq[:, k] = c_k
54+
55+
return A_seq, B_seq, c_seq
56+
57+
58+
def main():
59+
dt = 0.1
60+
Np = 25
61+
nsim = 60
62+
63+
nx, nu = 3, 2
64+
x0 = np.array([0.0, 0.0, 0.0])
65+
u_prev = np.zeros(nu)
66+
u_nom = np.zeros((nu, Np))
67+
68+
yref = np.array([2.0, 2.0, 0.0])
69+
uref = np.zeros(nu)
70+
71+
model = Model()
72+
73+
x_hist = np.zeros((nx, nsim + 1))
74+
u_hist = np.zeros((nu, nsim))
75+
x_hist[:, 0] = x0
76+
x_current = x0.copy()
77+
78+
print("=" * 50)
79+
print(" PiMPC - Unicycle NMPC (LTV Linearization)")
80+
print("=" * 50)
81+
82+
for k in range(nsim):
83+
A_seq, B_seq, c_seq = build_ltv_matrices(x_current, u_nom, dt)
84+
85+
model.setup(
86+
A=A_seq,
87+
B=B_seq,
88+
C=np.eye(nx),
89+
Np=Np,
90+
Wy=np.diag([20.0, 20.0, 0.0]),
91+
Wu=0.1 * np.eye(nu),
92+
Wdu=0.1 * np.eye(nu),
93+
umin=np.array([0.0, -1.0]),
94+
umax=np.array([1.5, 1.0]),
95+
rho=1.0,
96+
tol=1e-5,
97+
maxiter=200,
98+
precond=False,
99+
accel=True,
100+
device="cpu",
101+
)
102+
103+
results = model.solve(x_current, u_prev, yref, uref, c_seq, verbose=False)
104+
u_apply = results.u[:, 0]
105+
106+
x_next = x_current + dt * unicycle_dynamics(x_current, u_apply)
107+
x_hist[:, k + 1] = x_next
108+
u_hist[:, k] = u_apply
109+
110+
x_current = x_next
111+
u_prev = u_apply
112+
113+
u_nom = np.hstack([results.u[:, 1:], results.u[:, -1:]])
114+
115+
try:
116+
import matplotlib.pyplot as plt
117+
except ImportError as exc:
118+
raise RuntimeError("matplotlib is required to plot results.") from exc
119+
120+
t = np.arange(nsim + 1) * dt
121+
fig, axes = plt.subplots(3, 1, figsize=(8, 8), sharex=False)
122+
123+
axes[0].plot(x_hist[0, :], x_hist[1, :], color="blue", label="Trajectory")
124+
axes[0].scatter([yref[0]], [yref[1]], color="red", label="Goal")
125+
axes[0].set_ylabel("y (m)")
126+
axes[0].set_xlabel("x (m)")
127+
axes[0].legend(loc="best")
128+
axes[0].set_title("Unicycle Path")
129+
130+
axes[1].plot(t[:-1], u_hist[0, :], color="green")
131+
axes[1].set_ylabel("v (m/s)")
132+
133+
axes[2].plot(t[:-1], u_hist[1, :], color="purple")
134+
axes[2].set_ylabel("omega (rad/s)")
135+
axes[2].set_xlabel("Time (s)")
136+
137+
plt.tight_layout()
138+
plt.show()
139+
140+
141+
if __name__ == "__main__":
142+
main()

pimpc/model.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def __init__(self, dtype=np.float64):
4242

4343
# State
4444
self.is_setup = False
45+
self.is_time_varying = False
4546
self.warm_vars = None
4647

4748
def setup(
@@ -72,11 +73,25 @@ def setup(
7273
):
7374
A = np.asarray(A, dtype=self.dtype)
7475
B = np.asarray(B, dtype=self.dtype)
75-
if A.ndim != 2 or B.ndim != 2:
76-
raise ValueError("A and B must be 2D arrays.")
77-
nx, nu = B.shape
78-
if A.shape != (nx, nx):
79-
raise ValueError("A must be square and compatible with B.")
76+
if A.ndim not in (2, 3) or B.ndim not in (2, 3):
77+
raise ValueError("A and B must be 2D or 3D arrays.")
78+
if A.ndim != B.ndim:
79+
raise ValueError("A and B must have the same number of dimensions.")
80+
if A.ndim == 2:
81+
nx, nu = B.shape
82+
if A.shape != (nx, nx):
83+
raise ValueError("A must be square and compatible with B.")
84+
else:
85+
nsteps, nx, nx2 = A.shape
86+
if nx != nx2:
87+
raise ValueError("A must be square for each time step.")
88+
if B.shape[0] != nsteps or B.shape[1] != nx:
89+
raise ValueError("B must have shape (Np, nx, nu) to match A.")
90+
nu = B.shape[2]
91+
92+
Np = int(Np)
93+
if A.ndim == 3 and A.shape[0] != Np:
94+
raise ValueError("A and B must have Np time steps.")
8095

8196
if C is None:
8297
C = np.eye(nx, dtype=self.dtype)
@@ -133,7 +148,8 @@ def _vec_or_fill(val, size, fill, name):
133148
self.nx = nx
134149
self.nu = nu
135150
self.ny = ny
136-
self.Np = int(Np)
151+
self.Np = Np
152+
self.is_time_varying = A.ndim == 3
137153

138154
self.Wy = Wy
139155
self.Wu = Wu

0 commit comments

Comments
 (0)