OptiLine-Py 0.1.7__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.
OptiLine/solvers.py ADDED
@@ -0,0 +1,2342 @@
1
+ import numpy as np
2
+ import math
3
+ import matplotlib.pyplot as plt
4
+ import quadprog
5
+ import time
6
+
7
+
8
+ def opt_min_curv(reftrack: np.ndarray,
9
+ normvectors: np.ndarray,
10
+ A: np.ndarray,
11
+ kappa_bound: float,
12
+ w_veh: float,
13
+ print_debug: bool = False,
14
+ plot_debug: bool = False,
15
+ closed: bool = True,
16
+ psi_s: float = None,
17
+ psi_e: float = None,
18
+ fix_s: bool = False,
19
+ fix_e: bool = False) -> tuple:
20
+ """
21
+
22
+ .. description::
23
+ This function uses a QP solver to minimize the summed curvature of a path by moving the path points along their
24
+ normal vectors within the track width. The function can be used for closed and unclosed tracks. For unclosed tracks
25
+ the heading psi_s and psi_e is enforced on the first and last point of the reftrack. Furthermore, in case of an
26
+ unclosed track, the first and last point of the reftrack are not subject to optimization and stay same.
27
+
28
+ Please refer to the paper for further information:
29
+ Heilmeier, Wischnewski, Hermansdorfer, Betz, Lienkamp, Lohmann
30
+ Minimum Curvature Trajectory Planning and Control for an Autonomous Racecar
31
+ DOI: 10.1080/00423114.2019.1631455
32
+
33
+ Hint: CVXOPT can be used as a solver instead of quadprog by uncommenting the import and corresponding code section.
34
+
35
+ .. inputs::
36
+ :param reftrack: array containing the reference track, i.e. a reference line and the according track widths to
37
+ the right and to the left [x, y, w_tr_right, w_tr_left] (unit is meter, must be unclosed!)
38
+ :type reftrack: np.ndarray
39
+ :param normvectors: normalized normal vectors for every point of the reference track [x_component, y_component]
40
+ (unit is meter, must be unclosed!)
41
+ :type normvectors: np.ndarray
42
+ :param A: linear equation system matrix for splines (applicable for both, x and y direction)
43
+ -> System matrices have the form a_i, b_i * t, c_i * t^2, d_i * t^3
44
+ -> see calc_splines.py for further information or to obtain this matrix
45
+ :type A: np.ndarray
46
+ :param kappa_bound: curvature boundary to consider during optimization.
47
+ :type kappa_bound: float
48
+ :param w_veh: vehicle width in m. It is considered during the calculation of the allowed deviations from the
49
+ reference line.
50
+ :type w_veh: float
51
+ :param print_debug: bool flag to print debug messages.
52
+ :type print_debug: bool
53
+ :param plot_debug: bool flag to plot the curvatures that are calculated based on the original linearization and on
54
+ a linearization around the solution.
55
+ :type plot_debug: bool
56
+ :param closed: bool flag specifying whether a closed or unclosed track should be assumed
57
+ :type closed: bool
58
+ :param psi_s: heading to be enforced at the first point for unclosed tracks
59
+ :type psi_s: float
60
+ :param psi_e: heading to be enforced at the last point for unclosed tracks
61
+ :type psi_e: float
62
+ :param fix_s: determines if start point is fixed to reference line for unclosed tracks
63
+ :type fix_s: bool
64
+ :param fix_e: determines if last point is fixed to reference line for unclosed tracks
65
+ :type fix_e: bool
66
+
67
+ .. outputs::
68
+ :return alpha_mincurv: solution vector of the opt. problem containing the lateral shift in m for every point.
69
+ :rtype alpha_mincurv: np.ndarray
70
+ :return curv_error_max: maximum curvature error when comparing the curvature calculated on the basis of the
71
+ linearization around the original refererence track and around the solution.
72
+ :rtype curv_error_max: float
73
+ """
74
+
75
+
76
+ no_points = reftrack.shape[0]
77
+
78
+ no_splines = no_points
79
+ if not closed:
80
+ no_splines -= 1
81
+
82
+ # check inputs
83
+ if no_points != normvectors.shape[0]:
84
+ raise RuntimeError("Array size of reftrack should be the same as normvectors!")
85
+
86
+ if (no_points * 4 != A.shape[0] and closed) or (no_splines * 4 != A.shape[0] and not closed)\
87
+ or A.shape[0] != A.shape[1]:
88
+ raise RuntimeError("Spline equation system matrix A has wrong dimensions!")
89
+
90
+ # create extraction matrix -> only b_i coefficients of the solved linear equation system are needed for gradient
91
+ # information
92
+ A_ex_b = np.zeros((no_points, no_splines * 4), dtype=int)
93
+
94
+ A_ex_b[np.arange(no_splines), np.arange(no_splines) * 4 + 1] = 1 # 1 * b_ix = E_x * x
95
+
96
+ # coefficients for end of spline (t = 1)
97
+ if not closed:
98
+ A_ex_b[-1, -4:] = np.array([0, 1, 2, 3])
99
+
100
+ # create extraction matrix -> only c_i coefficients of the solved linear equation system are needed for curvature
101
+ # information
102
+ A_ex_c = np.zeros((no_points, no_splines * 4), dtype=int)
103
+
104
+ A_ex_c[np.arange(no_splines), np.arange(no_splines) * 4 + 2] = 2 # 2 * c_ix = D_x * x
105
+
106
+ # coefficients for end of spline (t = 1)
107
+ if not closed:
108
+ A_ex_c[-1, -4:] = np.array([0, 0, 2, 6])
109
+
110
+ # invert matrix A resulting from the spline setup linear equation system and apply extraction matrix
111
+ A_inv = np.linalg.inv(A)
112
+ T_c = np.matmul(A_ex_c, A_inv)
113
+
114
+ # set up M_x and M_y matrices including the gradient information, i.e. bring normal vectors into matrix form
115
+ M_x = np.zeros((no_splines * 4, no_points))
116
+ M_y = np.zeros((no_splines * 4, no_points))
117
+
118
+ rows_0 = np.arange(no_splines) * 4
119
+ rows_1 = rows_0 + 1
120
+ cols_0 = np.arange(no_splines)
121
+ cols_1 = np.arange(1, no_splines + 1) % no_points # wraps last index to 0 for closed track
122
+
123
+ M_x[rows_0, cols_0] = normvectors[cols_0, 0]
124
+ M_x[rows_1, cols_1] = normvectors[cols_1, 0]
125
+ M_y[rows_0, cols_0] = normvectors[cols_0, 1]
126
+ M_y[rows_1, cols_1] = normvectors[cols_1, 1]
127
+
128
+ # set up q_x and q_y matrices including the point coordinate information
129
+ q_x = np.zeros((no_splines * 4, 1))
130
+ q_y = np.zeros((no_splines * 4, 1))
131
+
132
+ q_x[rows_0, 0] = reftrack[cols_0, 0]
133
+ q_x[rows_1, 0] = reftrack[cols_1, 0]
134
+ q_y[rows_0, 0] = reftrack[cols_0, 1]
135
+ q_y[rows_1, 0] = reftrack[cols_1, 1]
136
+
137
+ # for unclosed tracks, specify start- and end-heading constraints
138
+ if not closed:
139
+ q_x[-2, 0] = math.cos(psi_s + math.pi / 2)
140
+ q_y[-2, 0] = math.sin(psi_s + math.pi / 2)
141
+
142
+ q_x[-1, 0] = math.cos(psi_e + math.pi / 2)
143
+ q_y[-1, 0] = math.sin(psi_e + math.pi / 2)
144
+
145
+ # set up P_xx, P_xy, P_yy matrices
146
+ x_prime = np.eye(no_points, no_points) * np.matmul(np.matmul(A_ex_b, A_inv), q_x)
147
+ y_prime = np.eye(no_points, no_points) * np.matmul(np.matmul(A_ex_b, A_inv), q_y)
148
+
149
+ x_prime_sq = np.power(x_prime, 2)
150
+ y_prime_sq = np.power(y_prime, 2)
151
+ x_prime_y_prime = -2 * np.matmul(x_prime, y_prime)
152
+
153
+ curv_den = np.power(x_prime_sq + y_prime_sq, 1.5) # calculate curvature denominator
154
+ curv_part = np.divide(1, curv_den, out=np.zeros_like(curv_den),
155
+ where=curv_den != 0) # divide where not zero (diag elements)
156
+ curv_part_sq = np.power(curv_part, 2)
157
+
158
+ P_xx = np.matmul(curv_part_sq, y_prime_sq)
159
+ P_yy = np.matmul(curv_part_sq, x_prime_sq)
160
+ P_xy = np.matmul(curv_part_sq, x_prime_y_prime)
161
+
162
+ # ------------------------------------------------------------------------------------------------------------------
163
+ # SET UP FINAL MATRICES FOR SOLVER ---------------------------------------------------------------------------------
164
+ # ------------------------------------------------------------------------------------------------------------------
165
+
166
+ T_nx = np.matmul(T_c, M_x)
167
+ T_ny = np.matmul(T_c, M_y)
168
+
169
+ H_x = np.matmul(T_nx.T, np.matmul(P_xx, T_nx))
170
+ H_xy = np.matmul(T_ny.T, np.matmul(P_xy, T_nx))
171
+ H_y = np.matmul(T_ny.T, np.matmul(P_yy, T_ny))
172
+ H = H_x + H_xy + H_y
173
+ H = (H + H.T) / 2 # make H symmetric
174
+
175
+ f_x = 2 * np.matmul(np.matmul(q_x.T, T_c.T), np.matmul(P_xx, T_nx))
176
+ f_xy = np.matmul(np.matmul(q_x.T, T_c.T), np.matmul(P_xy, T_ny)) \
177
+ + np.matmul(np.matmul(q_y.T, T_c.T), np.matmul(P_xy, T_nx))
178
+ f_y = 2 * np.matmul(np.matmul(q_y.T, T_c.T), np.matmul(P_yy, T_ny))
179
+ f = f_x + f_xy + f_y
180
+ f = np.squeeze(f) # remove non-singleton dimensions
181
+
182
+ # ------------------------------------------------------------------------------------------------------------------
183
+ # KAPPA CONSTRAINTS ------------------------------------------------------------------------------------------------
184
+ # ------------------------------------------------------------------------------------------------------------------
185
+
186
+ Q_x = np.matmul(curv_part, y_prime)
187
+ Q_y = np.matmul(curv_part, x_prime)
188
+
189
+ # this part is multiplied by alpha within the optimization (variable part)
190
+ E_kappa = np.matmul(Q_y, T_ny) - np.matmul(Q_x, T_nx)
191
+
192
+ # original curvature part (static part)
193
+ k_kappa_ref = np.matmul(Q_y, np.matmul(T_c, q_y)) - np.matmul(Q_x, np.matmul(T_c, q_x))
194
+
195
+ con_ge = np.ones((no_points, 1)) * kappa_bound - k_kappa_ref
196
+ con_le = -(np.ones((no_points, 1)) * -kappa_bound - k_kappa_ref) # multiplied by -1 as only LE conditions are poss.
197
+ con_stack = np.append(con_ge, con_le)
198
+
199
+ # ------------------------------------------------------------------------------------------------------------------
200
+ # CALL QUADRATIC PROGRAMMING ALGORITHM -----------------------------------------------------------------------------
201
+ # ------------------------------------------------------------------------------------------------------------------
202
+
203
+ """
204
+ quadprog interface description taken from
205
+ https://github.com/stephane-caron/qpsolvers/blob/master/qpsolvers/quadprog_.py
206
+
207
+ Solve a Quadratic Program defined as:
208
+
209
+ minimize
210
+ (1/2) * alpha.T * H * alpha + f.T * alpha
211
+
212
+ subject to
213
+ G * alpha <= h
214
+ A * alpha == b
215
+
216
+ using quadprog <https://pypi.python.org/pypi/quadprog/>.
217
+
218
+ Parameters
219
+ ----------
220
+ H : numpy.array
221
+ Symmetric quadratic-cost matrix.
222
+ f : numpy.array
223
+ Quadratic-cost vector.
224
+ G : numpy.array
225
+ Linear inequality constraint matrix.
226
+ h : numpy.array
227
+ Linear inequality constraint vector.
228
+ A : numpy.array, optional
229
+ Linear equality constraint matrix.
230
+ b : numpy.array, optional
231
+ Linear equality constraint vector.
232
+ initvals : numpy.array, optional
233
+ Warm-start guess vector (not used).
234
+
235
+ Returns
236
+ -------
237
+ alpha : numpy.array
238
+ Solution to the QP, if found, otherwise ``None``.
239
+
240
+ Note
241
+ ----
242
+ The quadprog solver only considers the lower entries of `H`, therefore it
243
+ will use a wrong cost function if a non-symmetric matrix is provided.
244
+ """
245
+
246
+ # calculate allowed deviation from refline
247
+ dev_max_right = reftrack[:, 2] - w_veh / 2
248
+ dev_max_left = reftrack[:, 3] - w_veh / 2
249
+
250
+ # constrain resulting path to reference line at start- and end-point for open tracks
251
+ if not closed and fix_s:
252
+ dev_max_left[0] = 0.05
253
+ dev_max_right[0] = 0.05
254
+
255
+ if not closed and fix_e:
256
+ dev_max_left[-1] = 0.05
257
+ dev_max_right[-1] = 0.05
258
+
259
+ # check that there is space remaining between left and right maximum deviation (both can be negative as well!)
260
+ if np.any(-dev_max_right > dev_max_left) or np.any(-dev_max_left > dev_max_right):
261
+ raise RuntimeError("Problem not solvable, track might be too small to run with current safety distance!")
262
+
263
+ # consider value boundaries (-dev_max_left <= alpha <= dev_max_right)
264
+ G = np.vstack((np.eye(no_points), -np.eye(no_points), E_kappa, -E_kappa))
265
+ h = np.append(dev_max_right, dev_max_left)
266
+ h = np.append(h, con_stack)
267
+
268
+ # save start time
269
+ t_start = time.perf_counter()
270
+
271
+ # solve problem (CVXOPT) -------------------------------------------------------------------------------------------
272
+ # args = [cvxopt.matrix(H), cvxopt.matrix(f), cvxopt.matrix(G), cvxopt.matrix(h)]
273
+ # sol = cvxopt.solvers.qp(*args)
274
+ #
275
+ # if 'optimal' not in sol['status']:
276
+ # print("WARNING: Optimal solution not found!")
277
+ #
278
+ # alpha_mincurv = np.array(sol['x']).reshape((H.shape[1],))
279
+
280
+ # solve problem (quadprog) -----------------------------------------------------------------------------------------
281
+ alpha_mincurv = quadprog.solve_qp(H, -f, -G.T, -h, 0)[0]
282
+
283
+ # print runtime into console window
284
+ if print_debug:
285
+ print("Solver runtime opt_min_curv: " + "{:.3f}".format(time.perf_counter() - t_start) + "s")
286
+
287
+ # ------------------------------------------------------------------------------------------------------------------
288
+ # CALCULATE CURVATURE ERROR ----------------------------------------------------------------------------------------
289
+ # ------------------------------------------------------------------------------------------------------------------
290
+
291
+ # calculate curvature once based on original linearization and once based on a new linearization around the solution
292
+ q_x_tmp = q_x + np.matmul(M_x, np.expand_dims(alpha_mincurv, 1))
293
+ q_y_tmp = q_y + np.matmul(M_y, np.expand_dims(alpha_mincurv, 1))
294
+
295
+ x_prime_tmp = np.eye(no_points, no_points) * np.matmul(np.matmul(A_ex_b, A_inv), q_x_tmp)
296
+ y_prime_tmp = np.eye(no_points, no_points) * np.matmul(np.matmul(A_ex_b, A_inv), q_y_tmp)
297
+
298
+ x_prime_prime = np.squeeze(np.matmul(T_c, q_x) + np.matmul(T_nx, np.expand_dims(alpha_mincurv, 1)))
299
+ y_prime_prime = np.squeeze(np.matmul(T_c, q_y) + np.matmul(T_ny, np.expand_dims(alpha_mincurv, 1)))
300
+
301
+ xp_d = np.diag(x_prime)
302
+ yp_d = np.diag(y_prime)
303
+ xp_tmp_d = np.diag(x_prime_tmp)
304
+ yp_tmp_d = np.diag(y_prime_tmp)
305
+
306
+ curv_orig_lin = (xp_d * y_prime_prime - yp_d * x_prime_prime) / np.power(xp_d**2 + yp_d**2, 1.5)
307
+ curv_sol_lin = (xp_tmp_d * y_prime_prime - yp_tmp_d * x_prime_prime) / np.power(xp_tmp_d**2 + yp_tmp_d**2, 1.5)
308
+
309
+ if plot_debug:
310
+ plt.plot(curv_orig_lin)
311
+ plt.plot(curv_sol_lin)
312
+ plt.legend(("original linearization", "solution based linearization"))
313
+ plt.show()
314
+
315
+ # calculate maximum curvature error
316
+ curv_error_max = np.amax(np.abs(curv_sol_lin - curv_orig_lin))
317
+
318
+ return alpha_mincurv, curv_error_max
319
+
320
+
321
+ class ConstrainedCMAES_t:
322
+ """
323
+ CMAES optimizer.
324
+
325
+ Parameters:
326
+ - f_t: The objective function to minimize.
327
+ - mean: initial mean.
328
+ - sigma: Initial step size.
329
+ - popsize: Population size.
330
+ - bounds1: Upper bounds.
331
+ - bounds2: Lower bounds.
332
+ """
333
+ def __init__(self, f_t,mean, sigma, popsize, bounds1=None, bounds2=None):
334
+ self.mean = np.array(mean)
335
+ self.sigma = sigma
336
+ self.popsize = popsize
337
+ self.dim = len(mean)
338
+ self.cov_matrix = np.eye(self.dim)
339
+ self.bounds1 = bounds1
340
+ self.bounds2 = bounds2
341
+ self.ft = f_t
342
+ # CMA-ES parameters
343
+ self.weights = np.log(self.popsize/2 + 1) - np.log(np.arange(1, self.popsize + 1))
344
+ self.weights[self.weights < 0] = 0
345
+ self.weights /= np.sum(self.weights)
346
+ self.mu = int(self.popsize / 2)
347
+ self.mu_w = 1 / np.sum(self.weights[:self.mu]**2)
348
+
349
+ # Learning rates and constants
350
+ self.c_sigma = (self.mu_w + 2) / (self.dim + self.mu_w + 5)
351
+ self.d_sigma = 1 + 2 * max(0, np.sqrt((self.mu_w - 1) / (self.dim + 1)) - 1) + self.c_sigma
352
+ self.c_c = (4 + self.mu_w/self.dim) / (self.dim + 4 + 2*self.mu_w/self.dim)
353
+ self.c1 = 2 / ((self.dim + 1.3)**2 + self.mu_w)
354
+ self.cmu = min(1 - self.c1, 2 * (self.mu_w - 2 + 1/self.mu_w) / ((self.dim + 2)**2 + self.mu_w))
355
+
356
+ # Evolution paths
357
+ self.p_c = np.zeros(self.dim)
358
+ self.p_sigma = np.zeros(self.dim)
359
+
360
+ # Expected length of N(0,I)
361
+ self.chi_n = np.sqrt(self.dim) * (1 - 1/(4*self.dim) + 1/(21*self.dim**2))
362
+
363
+ def sample_population(self):
364
+ """
365
+
366
+ .. description::
367
+ Draws a new population of candidate solutions from the current multivariate Gaussian distribution and
368
+ clips them to the specified bounds if provided.
369
+
370
+ .. outputs::
371
+ :return samples: array of candidate solutions with shape (popsize, dim), clipped to [bounds2, bounds1].
372
+ :rtype samples: np.ndarray
373
+ """
374
+
375
+ samples = np.random.multivariate_normal(self.mean, self.sigma**2 * self.cov_matrix, self.popsize)
376
+ if self.bounds1 is not None:
377
+ samples = np.clip(samples, self.bounds2, self.bounds1)
378
+ return samples
379
+
380
+ def objective_function(self, ds):
381
+ return self.ft(ds)
382
+
383
+ def update(self, solutions, fitness):
384
+ """
385
+
386
+ .. description::
387
+ Updates the CMA-ES internal state (mean, evolution paths, covariance matrix, step size) using the
388
+ current population and their fitness values. Enforces positive semi-definiteness of the covariance matrix.
389
+
390
+ .. inputs::
391
+ :param solutions: array of candidate solutions evaluated in the current generation, shape (popsize, dim).
392
+ :type solutions: np.ndarray
393
+ :param fitness: array of objective function values corresponding to each solution, shape (popsize,).
394
+ :type fitness: np.ndarray
395
+ """
396
+
397
+ # Sort solutions
398
+ sorted_indices = np.argsort(fitness)
399
+ sorted_solutions = solutions[sorted_indices]
400
+
401
+ # Calculate weighted mean of the new population
402
+ old_mean = self.mean.copy()
403
+ self.mean = np.sum((self.weights[:self.mu, np.newaxis] * sorted_solutions[:self.mu]), axis=0)
404
+
405
+ # Update evolution paths
406
+ y = self.mean - old_mean
407
+ try:
408
+ C_2 = np.linalg.cholesky(self.cov_matrix)
409
+ except np.linalg.LinAlgError:
410
+ print("Warning: Cholesky decomposition failed. Using diagonal matrix instead.")
411
+ C_2 = np.diag(np.sqrt(np.abs(np.diag(self.cov_matrix))))
412
+
413
+ z = np.linalg.solve(C_2, y) / self.sigma
414
+
415
+ h_sigma = (np.linalg.norm(self.p_sigma) /
416
+ np.sqrt(1 - (1-self.c_sigma)**(2*(self.iterations+1))) / self.chi_n
417
+ < (1.4 + 2/(self.dim+1)))
418
+
419
+ self.p_sigma = (1 - self.c_sigma) * self.p_sigma + np.sqrt(self.c_sigma * (2 - self.c_sigma) * self.mu_w) * z
420
+ self.p_c = (1 - self.c_c) * self.p_c + h_sigma * np.sqrt(self.c_c * (2 - self.c_c) * self.mu_w) * y / self.sigma
421
+
422
+ # Adapt covariance matrix
423
+ c1a = self.c1 * (1 - (1-h_sigma**2) * self.c_c * (2-self.c_c))
424
+ self.cov_matrix = ((1 - c1a - self.cmu) * self.cov_matrix +
425
+ c1a * np.outer(self.p_c, self.p_c) +
426
+ self.cmu * np.sum(self.weights[:self.mu, np.newaxis, np.newaxis] *
427
+ (sorted_solutions[:self.mu] - old_mean)[:, :, np.newaxis] *
428
+ (sorted_solutions[:self.mu] - old_mean)[:, np.newaxis, :], axis=0) /
429
+ self.sigma**2)
430
+
431
+ # Adapt step size
432
+ self.sigma *= np.exp((self.c_sigma / self.d_sigma) * (np.linalg.norm(self.p_sigma) / self.chi_n - 1))
433
+
434
+ # Ensure positive semidefinite covariance matrix
435
+ epsilon = 1e-8
436
+ self.cov_matrix += epsilon * np.eye(self.dim)
437
+ eigvals, eigvecs = np.linalg.eigh(self.cov_matrix)
438
+ eigvals = np.maximum(eigvals, epsilon)
439
+ self.cov_matrix = np.dot(eigvecs, np.dot(np.diag(eigvals), eigvecs.T))
440
+
441
+ def optimize(self, iterations):
442
+ """
443
+
444
+ .. description::
445
+ Runs the CMA-ES optimization loop for a fixed number of generations. Each generation samples a new
446
+ population, evaluates the objective, and updates the distribution parameters.
447
+
448
+ .. inputs::
449
+ :param iterations: number of generations to run the optimization.
450
+ :type iterations: int
451
+
452
+ .. outputs::
453
+ :return mean: optimized mean vector representing the best solution estimate.
454
+ :rtype mean: np.ndarray
455
+ """
456
+
457
+ self.iterations = 0
458
+ for _ in range(iterations):
459
+ samples = self.sample_population()
460
+ fitness = np.array([self.objective_function(s) for s in samples])
461
+ self.update(samples, fitness)
462
+ self.iterations += 1
463
+ return self.mean
464
+
465
+
466
+
467
+ class ZORM:
468
+ def __init__(self, func, x0, num_iterations, mu=0.05, h=0.001, t=1, grad_type='noth', constraint_type='notc'):
469
+ """
470
+ ZORM optimizer.
471
+
472
+ Parameters:
473
+ - func: The objective function to minimize.
474
+ - mu: Smoothing parameter for gradient approximation.
475
+ - h: Step size.
476
+ - x0: initial guess
477
+ - num_iterations: number of iterations
478
+ - t: number of sampled directions for gradient estimation (averaged).
479
+ - grad_type: 'noth' for forward gradient, 'h' for symmetric (central difference).
480
+ - constraint_type: 'notc' for unconstrained, 'c' for constrained optimization.
481
+ """
482
+ self.func = func
483
+ self.mu = mu
484
+ self.h = h
485
+ self.x0=x0
486
+ self.T = num_iterations
487
+ self.t = t
488
+ self.grad_type = grad_type
489
+ self.constraint_type = constraint_type
490
+
491
+ def _sample_gaussian(self, d):
492
+ return np.random.normal(0, 1, size=d)
493
+
494
+ def _feasible_projection(self, x, min, max):
495
+ y = np.clip(x,min,max)
496
+ return y
497
+
498
+ def _grad(self, x):
499
+ grad_sum = np.zeros(len(x))
500
+ fx = self.func(x)
501
+ for _ in range(self.t):
502
+ u = self._sample_gaussian(len(x))
503
+ perturbation = self.mu * u
504
+ if self.grad_type == 'noth':
505
+ g = (self.func(x + perturbation) - fx) / self.mu
506
+ elif self.grad_type == 'h':
507
+ g = (self.func(x + perturbation) - self.func(x - perturbation)) / (2 * self.mu)
508
+ else:
509
+ raise ValueError("Invalid gradient type. Use 'noth' or 'h'.")
510
+ grad_sum += g * u
511
+ return grad_sum / self.t
512
+
513
+ def _step(self, x):
514
+ grad_est = self._grad(x)
515
+ return x - self.h * grad_est
516
+
517
+ def optimize(self, lower_bounds=None, upper_bounds=None):
518
+ """
519
+ Runs the ZORM optimization.
520
+
521
+ Parameters:
522
+ - x0: Initial guess (1D numpy array).
523
+ - T: Number of iterations.
524
+ - lower_bounds: Lower bounds matrix G for constraints (only needed if constraint_type='c').
525
+ - upper_bounds: Upper bounds vector h for constraints (only needed if constraint_type='c').
526
+
527
+ Returns:
528
+ - x: A (dim, T+1) array of iterates.
529
+ """
530
+ dim = len(self.x0)
531
+ x = np.zeros((dim, self.T + 1))
532
+ x[:, 0] = self.x0
533
+ if lower_bounds is None or upper_bounds is None:
534
+ raise ValueError("Bounds must be provided when constraint_type is 'c'.")
535
+ for k in range(self.T):
536
+ new_x = self._step(x[:, k])
537
+ if self.constraint_type == 'c':
538
+ new_x = self._feasible_projection(new_x, lower_bounds, upper_bounds)
539
+ x[:, k + 1] = new_x
540
+
541
+ return x
542
+
543
+ from OptiLine.utils import calc_splines,create_raceline, calc_head_curv_an, H_f, import_veh_dyn_info
544
+ from OptiLine.KinematicProfs import calc_vel_profile, calc_ax_profile, calc_t_profile , cumulative_distances
545
+
546
+ class Opt_min_CurvTime:
547
+ def __init__(self, reftrack, center, mu=0.05, h=0.001, kapb=0.7, sfty=1, t=1, si=0.8,
548
+ vm=22.88, m_veh=3, drag_coeff=0.0045, MC=1, min_s=None, max_s=None, sigma=0.001,
549
+ iterations_ZO=300, iterations_CMA=30, popsize=16,
550
+ ggv_import_path="maps/ggv.csv", ax_max_machines_import_path="maps/ax_max_machines.csv",
551
+ fw=3, refine_every=None, refine_subsample=1):
552
+ """
553
+ Min Curv and Time optimizer.
554
+
555
+ Parameters:
556
+ - reftrack: reference track array containing the reference line and track widths [x, y, w_tr_right, w_tr_left].
557
+ - center: array containing the center line and track widths [x, y, w_tr_right, w_tr_left].
558
+ - mu: Smoothing parameter for gradient approximation for ZO solver
559
+ - h: Step size for ZO solver
560
+ - iterations_ZO: number of iterations for ZO solver
561
+ - sfty: half of the vehicle width
562
+ - t = number of sampled directions for gradient estimation (averaged) for ZO solver
563
+ - kapb = bound on the maximum allowed curvature
564
+ - si: interpolation step size of the race line
565
+ - vm: maximum available velocity
566
+ - MC: Monte Carlo number of repetion for ZO solvers
567
+ - min_s: minimum curv length
568
+ - max_s: maximum curv length
569
+ - sigma: Initial covariance for CMA-es solver
570
+ - popsize: population size for CMA-es solver
571
+ - iterations_CMA: number of iterations for ZORM solver
572
+ - iterations_CMA: number of iterations for CMA-es solver
573
+ - ggv_import_path: Path for importing ggv file
574
+ - ax_max_machines_import_path: path for importing ax_max_machines file.
575
+ - fw: filter window lengths for convolution (moving average) filtering of velocity profile
576
+ - m_veh: vehicle mass
577
+ - drag_coeff: drag coefficient
578
+ - refine_every: int or None. When set to a positive integer q, CurveLenOpt rebuilds the
579
+ reference track from the current optimised raceline every q ZO iterations.
580
+ None (default) disables refinement and preserves the original behaviour.
581
+ Call reset() to restore self.reftrack to the original track at any time.
582
+ - refine_subsample: int. Sub-sampling stride applied to the new raceline before it
583
+ becomes the refined reference track. 1 = no sub-sampling (default).
584
+ """
585
+
586
+ if min_s is None and max_s is None:
587
+ el_lengths = np.sqrt(np.sum(np.power(np.diff(np.vstack((reftrack[:, 0:2], reftrack[0, 0:2])), axis=0), 2), axis=1))
588
+ min_s = np.min(el_lengths) *1
589
+ max_s = np.max(el_lengths) *1.7
590
+
591
+ self.reftrack = reftrack
592
+ self.mu = mu
593
+ self.h = h
594
+ self.t = t
595
+ self.ggv_import_path = ggv_import_path
596
+ self.ax_max_machines_import_path = ax_max_machines_import_path
597
+ self.sfty = sfty
598
+ self.kapb = kapb
599
+ self.si = si
600
+ self.vm = vm
601
+ self.MC = MC
602
+ self.min_s=min_s
603
+ self.max_s=max_s
604
+ self.iterations_ZO=iterations_ZO
605
+ self.iterations_CMA=iterations_CMA
606
+ self.sigma = sigma
607
+ self.popsize = popsize
608
+ self.fw = fw
609
+ self.m_veh=m_veh
610
+ self.drag_coeff=drag_coeff
611
+ self.center=center
612
+ lengths = np.sqrt(np.sum(np.power(np.diff(self.reftrack[:,0:2], axis=0), 2), axis=1))
613
+ lengths=np.append(lengths, lengths[0])
614
+ self.lengths=lengths
615
+ self.ggv,self.ax_max_machines =import_veh_dyn_info(ggv_import_path=self.ggv_import_path,ax_max_machines_import_path=self.ax_max_machines_import_path)
616
+ # Pre-built p_ggv cache keyed by kappa array size (size is approx constant across f_t calls)
617
+ self._p_ggv_cache = {}
618
+
619
+ # Refinement settings
620
+ self.refine_every = refine_every
621
+ self.refine_subsample = int(refine_subsample)
622
+ # Immutable copy of the original track; used by reset() and _build_refined_reftrack()
623
+ self._base_reftrack = reftrack.copy()
624
+
625
+ coeffs_x, coeffs_y, M, normvec_norm = calc_splines(path=np.vstack((center[:, 0:2], center[0, 0:2])),use_dist_scaling=True)
626
+
627
+ self.bound1 = center[:, 0:2] - normvec_norm * np.expand_dims(center[:, 2], axis=1)
628
+ self.bound2 = center[:, 0:2] + normvec_norm * np.expand_dims(center[:, 3], axis=1)
629
+
630
+
631
+ # ------------------------------------------------------------------
632
+ # Public helper
633
+ # ------------------------------------------------------------------
634
+
635
+ def reset(self):
636
+ """
637
+ .. description::
638
+ Restore self.reftrack to the original reference track supplied at construction
639
+ time, reverting any in-place refinements made during CurveLenOpt.
640
+ """
641
+ self._update_reftrack(self._base_reftrack.copy())
642
+
643
+ # ------------------------------------------------------------------
644
+ # Private helpers for path refinement
645
+ # ------------------------------------------------------------------
646
+
647
+ def _update_reftrack(self, reftrack_new: np.ndarray) -> None:
648
+ """
649
+ .. description::
650
+ Replace self.reftrack with reftrack_new and recompute all derived quantities:
651
+ segment lengths, optimisation bounds (min_s / max_s), and the p_ggv cache.
652
+
653
+ .. inputs::
654
+ :param reftrack_new: new reference track [x, y, w_right, w_left].
655
+ :type reftrack_new: np.ndarray
656
+ """
657
+ self.reftrack = reftrack_new
658
+ lengths = np.sqrt(np.sum(
659
+ np.power(np.diff(reftrack_new[:, 0:2], axis=0), 2), axis=1))
660
+ lengths = np.append(lengths, lengths[0])
661
+ self.lengths = lengths
662
+ self.min_s = float(np.min(lengths))
663
+ self.max_s = float(np.max(lengths)) * 1.7
664
+ self._p_ggv_cache = {} # kappa size may have changed — invalidate
665
+
666
+ def _build_refined_reftrack(self,
667
+ alpha_m: np.ndarray,
668
+ normvec_norm: np.ndarray) -> np.ndarray:
669
+ """
670
+ .. description::
671
+ Build a geometrically correct refined reference track from the QP solution on
672
+ the CURRENT self.reftrack.
673
+
674
+ The new centreline is the set of control points shifted laterally by alpha_m
675
+ along their unit normal vectors. The track half-widths are adjusted so that
676
+ the two physical boundary lines stay EXACTLY in place:
677
+
678
+ new_w_col2[i] = w_col2[i] + alpha_m[i]
679
+ new_w_col3[i] = w_col3[i] − alpha_m[i]
680
+
681
+ Derivation (QP constraint boundaries preserved):
682
+ The QP constraint is −(w_col3 − sfty) ≤ α ≤ (w_col2 − sfty),
683
+ so positive α moves the car toward B_A = center + n · w_col2
684
+ and negative α moves it toward B_B = center − n · w_col3.
685
+
686
+ After the shift, the remaining distances to each boundary are:
687
+ room toward B_A: w_col2 − alpha (moved closer → less room)
688
+ room toward B_B: w_col3 + alpha (moved away → more room)
689
+
690
+ From the new center the next-pass optimizer can reach at most:
691
+ new_center + (new_w_col2 − sfty)·n
692
+ = (center + alpha·n) + (w_col2 − alpha − sfty)·n
693
+ = center + (w_col2 − sfty)·n ✓
694
+ new_center − (new_w_col3 − sfty)·n
695
+ = (center + alpha·n) − (w_col3 + alpha − sfty)·n
696
+ = center − (w_col3 − sfty)·n ✓
697
+ i.e. the optimizer is confined to exactly the same physical region
698
+ as in the original pass, regardless of how many refinement steps
699
+ have been taken.
700
+
701
+ A minimum clearance of self.sfty is enforced on both sides so widths can
702
+ never fall below the vehicle half-width. The optional sub-sampling stride
703
+ self.refine_subsample is applied before returning.
704
+
705
+ .. inputs::
706
+ :param alpha_m: lateral shifts in m at every control point, shape (N,).
707
+ :type alpha_m: np.ndarray
708
+ :param normvec_norm: unit normal vectors at every control point, shape (N, 2).
709
+ :type normvec_norm: np.ndarray
710
+
711
+ .. outputs::
712
+ :return reftrack_new: refined reference track [x, y, w_col2, w_col3].
713
+ :rtype reftrack_new: np.ndarray
714
+ """
715
+ new_xy = self.reftrack[:, :2] + alpha_m[:, np.newaxis] * normvec_norm
716
+ new_w2 = self.reftrack[:, 2] - alpha_m
717
+ new_w3 = self.reftrack[:, 3] + alpha_m
718
+
719
+ reftrack_new = np.column_stack([new_xy, new_w2, new_w3])
720
+
721
+ # step = int(self.refine_subsample)
722
+ # if step > 1:
723
+ # nn = reftrack_new.shape[0]
724
+ # idx = np.arange(0, nn, step)
725
+ # idx = np.unique(np.concatenate([idx, [nn - 1]]))
726
+ # reftrack_new = reftrack_new[idx]
727
+
728
+ return reftrack_new
729
+
730
+ def _solve_alpha(self, ds: np.ndarray):
731
+ """
732
+ .. description::
733
+ Solve the minimum-curvature QP on self.reftrack for the given segment lengths
734
+ and return the lateral shift vector alpha_m together with the normal vectors.
735
+ Both arrays are at CONTROL-POINT resolution (one entry per track point), which
736
+ is what _build_refined_reftrack needs to compute physically correct widths.
737
+
738
+ .. inputs::
739
+ :param ds: spline segment lengths for the current reference track, shape (N,).
740
+ :type ds: np.ndarray
741
+
742
+ .. outputs::
743
+ :return alpha_m: lateral shift at every control point in m, shape (N,).
744
+ :rtype alpha_m: np.ndarray
745
+ :return normvec_norm: unit normal vectors at every control point, shape (N, 2).
746
+ :rtype normvec_norm: np.ndarray
747
+ """
748
+ coeffs_x, coeffs_y, M_mat, normvec_norm = calc_splines(
749
+ path=np.vstack((self.reftrack[:, 0:2], self.reftrack[0, 0:2])),
750
+ el_lengths=ds)
751
+ H, f, G, h = H_f(
752
+ reftrack=self.reftrack,
753
+ normvectors=normvec_norm,
754
+ A=M_mat,
755
+ kappa_bound=self.kapb,
756
+ w_veh=self.sfty,
757
+ closed=True)
758
+ alpha_m = quadprog.solve_qp(H, -f, -G.T, -h, 0)[0]
759
+ return alpha_m, normvec_norm
760
+
761
+ def _extract_raceline(self, ds: np.ndarray) -> np.ndarray:
762
+ """
763
+ .. description::
764
+ Convenience method: solve the QP and return the DENSE interpolated raceline
765
+ (one point every self.si metres). Useful for external inspection or plotting.
766
+ Refinement internally uses _solve_alpha + _build_refined_reftrack instead.
767
+
768
+ .. inputs::
769
+ :param ds: spline segment lengths for the current reference track, shape (N,).
770
+ :type ds: np.ndarray
771
+
772
+ .. outputs::
773
+ :return raceline_interp: interpolated raceline [x, y], shape (M, 2).
774
+ :rtype raceline_interp: np.ndarray
775
+ """
776
+ alpha_m, normvec_norm = self._solve_alpha(ds)
777
+ raceline_interp, *_ = create_raceline(
778
+ refline=self.reftrack[:, :2],
779
+ normvectors=normvec_norm,
780
+ alpha=alpha_m,
781
+ stepsize_interp=self.si)
782
+ return raceline_interp
783
+
784
+ # ------------------------------------------------------------------
785
+
786
+ def f_t(self,ds):
787
+ """
788
+
789
+ .. description::
790
+ Objective function for the curve-length optimization. Given a set of spline segment lengths ds, computes
791
+ the minimum-curvature raceline, interpolates the path, calculates the velocity and acceleration profiles,
792
+ and returns the estimated lap time.
793
+
794
+ .. inputs::
795
+ :param ds: array of spline segment lengths used to parameterize the reference track.
796
+ :type ds: np.ndarray
797
+
798
+ .. outputs::
799
+ :return laptime: estimated lap time in seconds for the raceline generated from the given segment lengths.
800
+ :rtype laptime: float
801
+ """
802
+
803
+ sfty = self.sfty
804
+ kapb = self.kapb
805
+
806
+ coeffs_x, coeffs_y, M, normvec_norm = calc_splines(path=np.vstack((self.reftrack[:, 0:2], self.reftrack[0, 0:2])),el_lengths=ds)
807
+ H, f, G , h = H_f(reftrack=self.reftrack,
808
+ normvectors=normvec_norm,
809
+ A=M,
810
+ kappa_bound=kapb,
811
+ w_veh=sfty,
812
+ closed=True)
813
+
814
+ alpha_m = quadprog.solve_qp(H, -f, -G.T,-h,0)[0]
815
+ si=self.si
816
+ raceline_interp, a_opt, coeffs_x_opt, coeffs_y_opt, spline_inds_opt_interp, t_vals_opt_interp, s_points_opt_interp,\
817
+ spline_lengths_opt, el_lengths_opt_interp = create_raceline(refline=self.reftrack[:, :2],
818
+ normvectors=normvec_norm,
819
+ alpha=alpha_m,
820
+ stepsize_interp=si,)
821
+
822
+
823
+ psi_vel_opt, kappa_opt =calc_head_curv_an(coeffs_x=coeffs_x_opt,
824
+ coeffs_y=coeffs_y_opt,
825
+ ind_spls=spline_inds_opt_interp,
826
+ t_spls=t_vals_opt_interp)
827
+
828
+ # s_splines = cumulative_distances(el_lengths_opt_interp)
829
+
830
+ vm =self.vm
831
+ fw = 3
832
+ n = kappa_opt.size
833
+ if n not in self._p_ggv_cache:
834
+ self._p_ggv_cache[n] = np.repeat(np.expand_dims(self.ggv, axis=0), n, axis=0)
835
+ vx_profile_opt = calc_vel_profile(ggv=self.ggv,
836
+ ax_max_machines=self.ax_max_machines,
837
+ v_max=vm,
838
+ kappa=kappa_opt,
839
+ el_lengths=el_lengths_opt_interp,
840
+ closed=True,
841
+ filt_window=fw,
842
+ dyn_model_exp=1.0,
843
+ drag_coeff=self.drag_coeff,
844
+ m_veh=self.m_veh,
845
+ v_start = 0.0,
846
+ p_ggv=self._p_ggv_cache[n])
847
+
848
+ # calculate longitudinal acceleration profile
849
+ vx_profile_opt_cl = np.append(vx_profile_opt, vx_profile_opt[0])
850
+ ax_profile_opt = calc_ax_profile(vx_profile=vx_profile_opt_cl,
851
+ el_lengths=el_lengths_opt_interp,
852
+ eq_length_output=False)
853
+
854
+ # calculate laptime
855
+ t_profile_cl = calc_t_profile(vx_profile=vx_profile_opt,
856
+ ax_profile=ax_profile_opt,
857
+ el_lengths=el_lengths_opt_interp)
858
+
859
+ return t_profile_cl[-1]
860
+
861
+ def CurveLenOpt(self, solver='ZO', refine_every=None):
862
+ """
863
+
864
+ .. description::
865
+ Optimizes the spline segment lengths of the reference track to minimize lap time.
866
+ Supports two solvers: zeroth-order random method (ZO) and CMA-ES (CMA).
867
+ Monte Carlo averaging is applied over self.MC runs when refinement is disabled.
868
+
869
+ When refine_every is set to a positive integer q the ZO loop is split into blocks
870
+ of q iterations. After each block (except the last) the current optimised raceline
871
+ is extracted, the reference track is rebuilt from that raceline via
872
+ _build_refined_reftrack, and optimisation continues on the refined track.
873
+ This updates self.reftrack as a side-effect; call reset() to restore the original.
874
+
875
+ .. inputs::
876
+ :param solver: optimization solver to use. 'ZO' for zeroth-order random
877
+ method, 'CMA' for CMA-ES.
878
+ :type solver: str
879
+ :param refine_every: per-call override for self.refine_every. Positive integer q
880
+ enables refinement every q iterations; 0 or None disables it.
881
+ :type refine_every: int or None
882
+
883
+ .. outputs::
884
+ :return ds_ff: optimized array of spline segment lengths for the current
885
+ self.reftrack (may be a refined track when refinement is used).
886
+ :rtype ds_ff: np.ndarray
887
+ """
888
+
889
+ # Resolve effective refinement cadence (per-call overrides instance default)
890
+ q = refine_every if refine_every is not None else self.refine_every
891
+ use_refinement = (q is not None and int(q) > 0)
892
+
893
+ if solver == 'ZO':
894
+ ds_0 = self.lengths.copy()
895
+
896
+ if not use_refinement:
897
+ # ----------------------------------------------------------------
898
+ # Original behaviour: MC runs, no path refinement
899
+ # ----------------------------------------------------------------
900
+ ds_ff = np.zeros_like(ds_0)
901
+ for _ in range(self.MC):
902
+ ZOs = ZORM(self.f_t, ds_0, self.iterations_ZO,
903
+ mu=self.mu, h=self.h, t=self.t, constraint_type='c')
904
+ ds = ZOs.optimize(lower_bounds=self.min_s, upper_bounds=self.max_s)
905
+ ds_ff += ds[:, -1]
906
+ return ds_ff / self.MC
907
+
908
+ else:
909
+ # ----------------------------------------------------------------
910
+ # Refinement-aware loop
911
+ # ----------------------------------------------------------------
912
+ if self.MC > 1:
913
+ print("WARNING [CurveLenOpt]: MC > 1 is not supported with path "
914
+ "refinement; running a single pass (MC=1).")
915
+
916
+ q = int(q)
917
+ if q > self.iterations_ZO:
918
+ print(f"WARNING [CurveLenOpt]: refine_every ({q}) > iterations_ZO "
919
+ f"({self.iterations_ZO}). No refinement will occur.")
920
+
921
+ # Partition total iterations into blocks of q (last block may be shorter).
922
+ # Refinement happens BETWEEN blocks, NOT after the final block, so we
923
+ # always end with an optimised ds on the current self.reftrack.
924
+ n_full = self.iterations_ZO // q
925
+ rem = self.iterations_ZO % q
926
+ all_blocks = [q] * n_full
927
+ if rem > 0:
928
+ all_blocks.append(rem)
929
+ n_blocks = len(all_blocks)
930
+ n_refinements = n_blocks - 1 # refine between every pair of blocks
931
+
932
+ ds_current = self.lengths.copy()
933
+
934
+ for blk_idx, blk_iters in enumerate(all_blocks):
935
+ ZOs = ZORM(self.f_t, ds_current, blk_iters,
936
+ mu=self.mu, h=self.h, t=self.t, constraint_type='c')
937
+ ds_opt = ZOs.optimize(
938
+ lower_bounds=self.min_s, upper_bounds=self.max_s)[:, -1]
939
+
940
+ is_last = (blk_idx == n_blocks - 1)
941
+
942
+ if not is_last:
943
+ # Solve QP → exact alpha + normals → geometrically correct reftrack
944
+ alpha_m, normvec = self._solve_alpha(ds_opt)
945
+ reftrack_refined = self._build_refined_reftrack(alpha_m, normvec)
946
+ self._update_reftrack(reftrack_refined)
947
+ ds_current = self.lengths.copy() # new initial ds for next block
948
+ print(f" [path refine {blk_idx + 1}/{n_refinements}] "
949
+ f"{reftrack_refined.shape[0]} ctrl pts | "
950
+ f"laptime ≈ {self.f_t(ds_current):.3f} s")
951
+ else:
952
+ ds_current = ds_opt # final optimised result on current track
953
+
954
+ return ds_current
955
+
956
+ if solver == 'CMA':
957
+ if not use_refinement:
958
+ # ----------------------------------------------------------------
959
+ # Original behaviour: MC runs, no path refinement
960
+ # ----------------------------------------------------------------
961
+ mean = self.lengths.copy()
962
+ s_cmaa = np.zeros_like(mean)
963
+ for _ in range(self.MC):
964
+ cma_es_s = ConstrainedCMAES_t(
965
+ self.f_t, mean, self.sigma, self.popsize,
966
+ bounds1=np.ones_like(self.lengths) * self.max_s,
967
+ bounds2=np.ones_like(self.lengths) * self.min_s)
968
+ s_cmai = cma_es_s.optimize(iterations=self.iterations_CMA)
969
+ s_cmaa += s_cmai
970
+ return s_cmaa / self.MC
971
+
972
+ else:
973
+ # ----------------------------------------------------------------
974
+ # Refinement-aware loop (mirrors the ZO logic)
975
+ # ----------------------------------------------------------------
976
+ if self.MC > 1:
977
+ print("WARNING [CurveLenOpt]: MC > 1 is not supported with path "
978
+ "refinement; running a single pass (MC=1).")
979
+
980
+ q = int(q)
981
+ if q > self.iterations_CMA:
982
+ print(f"WARNING [CurveLenOpt]: refine_every ({q}) > iterations_CMA "
983
+ f"({self.iterations_CMA}). No refinement will occur.")
984
+
985
+ # Partition total CMA iterations into blocks of q.
986
+ # Refinement happens BETWEEN blocks, not after the final block.
987
+ n_full = self.iterations_CMA // q
988
+ rem = self.iterations_CMA % q
989
+ all_blocks = [q] * n_full
990
+ if rem > 0:
991
+ all_blocks.append(rem)
992
+ n_blocks = len(all_blocks)
993
+ n_refinements = n_blocks - 1
994
+
995
+ ds_current = self.lengths.copy()
996
+
997
+ for blk_idx, blk_iters in enumerate(all_blocks):
998
+ cma_es_s = ConstrainedCMAES_t(
999
+ self.f_t, ds_current, self.sigma, self.popsize,
1000
+ bounds1=np.ones_like(ds_current) * self.max_s,
1001
+ bounds2=np.ones_like(ds_current) * self.min_s)
1002
+ ds_opt = cma_es_s.optimize(iterations=blk_iters)
1003
+
1004
+ is_last = (blk_idx == n_blocks - 1)
1005
+
1006
+ if not is_last:
1007
+ # Solve QP → exact alpha + normals → geometrically correct reftrack
1008
+ alpha_m, normvec = self._solve_alpha(ds_opt)
1009
+ reftrack_refined = self._build_refined_reftrack(alpha_m, normvec)
1010
+ self._update_reftrack(reftrack_refined)
1011
+ ds_current = self.lengths.copy() # new initial mean for next block
1012
+ print(f" [path refine {blk_idx + 1}/{n_refinements}] "
1013
+ f"{reftrack_refined.shape[0]} ctrl pts | "
1014
+ f"laptime ≈ {self.f_t(ds_current):.3f} s")
1015
+ else:
1016
+ ds_current = ds_opt # final optimised result on current track
1017
+
1018
+ return ds_current
1019
+
1020
+ def generate_raceline(self, ds=None, solver='ZO', refine_every=None):
1021
+ """
1022
+
1023
+ .. description::
1024
+ Generates the optimized raceline geometry for a given set of spline segment lengths.
1025
+ If no segment lengths are provided, runs CurveLenOpt first to obtain them.
1026
+
1027
+ When refine_every is set, CurveLenOpt is called with that cadence and self.reftrack
1028
+ may be updated as a side-effect (see CurveLenOpt docs). The returned raceline is
1029
+ computed on whatever self.reftrack is current after that call.
1030
+
1031
+ .. inputs::
1032
+ :param ds: array of spline segment lengths. If None, computed via
1033
+ CurveLenOpt(solver, refine_every).
1034
+ :type ds: np.ndarray
1035
+ :param solver: solver to use if ds is None. 'ZO' or 'CMA'.
1036
+ :type solver: str
1037
+ :param refine_every: per-call refinement cadence passed to CurveLenOpt when
1038
+ ds is None. None uses self.refine_every (default: no refinement).
1039
+ :type refine_every: int or None
1040
+
1041
+ .. outputs::
1042
+ :return raceline_interp: interpolated raceline coordinates [x, y].
1043
+ :rtype raceline_interp: np.ndarray
1044
+ :return ds: spline segment lengths used (either provided or optimized).
1045
+ :rtype ds: np.ndarray
1046
+ """
1047
+
1048
+ if ds is None:
1049
+ ds = self.CurveLenOpt(solver=solver, refine_every=refine_every)
1050
+
1051
+ coeffs_x, coeffs_y, M, normvec_norm = calc_splines(path=np.vstack((self.reftrack[:, 0:2], self.reftrack[0, 0:2])),el_lengths=ds)
1052
+ H, f, G , h = H_f(reftrack=self.reftrack,
1053
+ normvectors=normvec_norm,
1054
+ A=M,
1055
+ kappa_bound=self.kapb,
1056
+ w_veh=self.sfty,
1057
+ closed=True)
1058
+
1059
+
1060
+ alpha_m_s = quadprog.solve_qp(H, -f, -G.T,-h,0)[0]
1061
+ raceline_interp, a_opt, coeffs_x_opt, coeffs_y_opt, spline_inds_opt_interp, t_vals_opt_interp, s_points_opt_interp,\
1062
+ spline_lengths_opt, el_lengths_opt_interp = create_raceline(refline=self.reftrack[:, :2],
1063
+ normvectors=normvec_norm,
1064
+ alpha=alpha_m_s,
1065
+ stepsize_interp=self.si)
1066
+ return raceline_interp,ds
1067
+
1068
+ def generate_kinProfs(self, ds=None, solver='ZO', refine_every=None):
1069
+ """
1070
+
1071
+ .. description::
1072
+ Generates the full kinematic profiles (velocity, acceleration, curvature, time,
1073
+ raceline) for a given set of spline segment lengths. If no segment lengths are
1074
+ provided, runs CurveLenOpt first.
1075
+
1076
+ When refine_every is set, CurveLenOpt is called with that cadence and self.reftrack
1077
+ may be updated as a side-effect (see CurveLenOpt docs). All profiles are then
1078
+ computed on whatever self.reftrack is current after that call.
1079
+
1080
+ .. inputs::
1081
+ :param ds: array of spline segment lengths. If None, computed via
1082
+ CurveLenOpt(solver, refine_every).
1083
+ :type ds: np.ndarray
1084
+ :param solver: solver to use if ds is None. 'ZO' or 'CMA'.
1085
+ :type solver: str
1086
+ :param refine_every: per-call refinement cadence passed to CurveLenOpt when
1087
+ ds is None. None uses self.refine_every (default: no refinement).
1088
+ :type refine_every: int or None
1089
+
1090
+ .. outputs::
1091
+ :return s_splines: cumulative distance profile along the raceline in m.
1092
+ :rtype s_splines: np.ndarray
1093
+ :return vx_profile_opt: optimized velocity profile in m/s.
1094
+ :rtype vx_profile_opt: np.ndarray
1095
+ :return ax_profile_opt: longitudinal acceleration profile in m/s2.
1096
+ :rtype ax_profile_opt: np.ndarray
1097
+ :return kappa_opt: curvature profile of the raceline in rad/m.
1098
+ :rtype kappa_opt: np.ndarray
1099
+ :return t_profile_cl: lap time profile in seconds (cumulative).
1100
+ :rtype t_profile_cl: np.ndarray
1101
+ :return raceline_interp: interpolated raceline coordinates [x, y].
1102
+ :rtype raceline_interp: np.ndarray
1103
+ """
1104
+
1105
+ if ds is None:
1106
+ ds = self.CurveLenOpt(solver=solver, refine_every=refine_every)
1107
+ coeffs_x, coeffs_y, M, normvec_norm = calc_splines(path=np.vstack((self.reftrack[:, 0:2], self.reftrack[0, 0:2])),el_lengths=ds)
1108
+ H, f, G , h = H_f(reftrack=self.reftrack,
1109
+ normvectors=normvec_norm,
1110
+ A=M,
1111
+ kappa_bound=self.kapb,
1112
+ w_veh=self.sfty,
1113
+ closed=True)
1114
+
1115
+
1116
+ alpha_m_s = quadprog.solve_qp(H, -f, -G.T,-h,0)[0]
1117
+
1118
+ raceline_interp, a_opt, coeffs_x_opt, coeffs_y_opt, spline_inds_opt_interp, t_vals_opt_interp, s_points_opt_interp,\
1119
+ spline_lengths_opt, el_lengths_opt_interp = create_raceline(refline=self.reftrack[:, :2],
1120
+ normvectors=normvec_norm,
1121
+ alpha=alpha_m_s,
1122
+ stepsize_interp=self.si)
1123
+
1124
+ psi_vel_opt, kappa_opt =calc_head_curv_an(coeffs_x=coeffs_x_opt,
1125
+ coeffs_y=coeffs_y_opt,
1126
+ ind_spls=spline_inds_opt_interp,
1127
+ t_spls=t_vals_opt_interp)
1128
+
1129
+ s_splines = cumulative_distances(el_lengths_opt_interp)
1130
+ vx_profile_opt = calc_vel_profile(ggv=self.ggv,
1131
+ ax_max_machines=self.ax_max_machines,
1132
+ v_max=self.vm,
1133
+ kappa=kappa_opt,
1134
+ el_lengths=el_lengths_opt_interp,
1135
+ closed=True,
1136
+ filt_window=self.fw,
1137
+ dyn_model_exp=1.0,
1138
+ drag_coeff=self.drag_coeff,
1139
+ m_veh=self.m_veh,
1140
+ v_start = 0.0)
1141
+
1142
+ # calculate longitudinal acceleration profile
1143
+ vx_profile_opt_cl = np.append(vx_profile_opt, vx_profile_opt[0])
1144
+ ax_profile_opt = calc_ax_profile(vx_profile=vx_profile_opt_cl,
1145
+ el_lengths=el_lengths_opt_interp,
1146
+ eq_length_output=False)
1147
+
1148
+ # calculate laptime
1149
+ t_profile_cl = calc_t_profile(vx_profile=vx_profile_opt,
1150
+ ax_profile=ax_profile_opt,
1151
+ el_lengths=el_lengths_opt_interp)
1152
+
1153
+ return s_splines, vx_profile_opt, ax_profile_opt, kappa_opt, t_profile_cl, raceline_interp
1154
+
1155
+ def Comparison(self, ds_ZO=None, ds_CMA=None, plot='N', output='N', refine_every=None):
1156
+ """
1157
+
1158
+ .. description::
1159
+ Compares raceline kinematic profiles across four cases: ZO-optimized, CMA-ES-optimized,
1160
+ initial segment lengths, and the centerline. Prints lap times and optionally plots and
1161
+ returns all profiles.
1162
+
1163
+ Each solver is always run from the original base reference track so the comparison is
1164
+ fair regardless of whether path refinement is used for ZO. self.reftrack is reset to
1165
+ the base track when Comparison returns.
1166
+
1167
+ .. inputs::
1168
+ :param ds_ZO: optimized segment lengths from the ZO solver. If None, computed
1169
+ internally via CurveLenOpt('ZO', refine_every).
1170
+ :type ds_ZO: np.ndarray
1171
+ :param ds_CMA: optimized segment lengths from the CMA-ES solver. If None,
1172
+ computed internally via CurveLenOpt('CMA').
1173
+ :type ds_CMA: np.ndarray
1174
+ :param plot: 'Y' to display comparison plots, 'N' to skip.
1175
+ :type plot: str
1176
+ :param output: controls which profiles are returned. 'Y' returns all four,
1177
+ 'ZO'/'CMA'/'initial'/'center' returns the corresponding case
1178
+ only. 'N' returns nothing.
1179
+ :type output: str
1180
+ :param refine_every: path-refinement cadence forwarded to CurveLenOpt for the ZO
1181
+ solver only. None (default) uses self.refine_every.
1182
+ :type refine_every: int or None
1183
+
1184
+ .. outputs::
1185
+ :return profiles: kinematic profiles (s_splines, vx, ax, kappa, t_profile, raceline)
1186
+ for the selected output case(s). None if output is 'N'.
1187
+ :rtype profiles: tuple or None
1188
+ """
1189
+
1190
+ # ---- ZO (run from original base track; refinement optional) ----
1191
+ self._update_reftrack(self._base_reftrack.copy())
1192
+ if ds_ZO is None:
1193
+ ds_ZO = self.CurveLenOpt(solver='ZO', refine_every=refine_every)
1194
+ s_splines, vx_profile_opt, ax_profile_opt, kappa_opt, t_profile_cl, raceline_interp = \
1195
+ self.generate_kinProfs(ds=ds_ZO)
1196
+
1197
+ # ---- CMA (always from original base track, no refinement) ----
1198
+ self._update_reftrack(self._base_reftrack.copy())
1199
+ if ds_CMA is None:
1200
+ ds_CMA = self.CurveLenOpt(solver='CMA')
1201
+ s_splines1, vx_profile_opt1, ax_profile_opt1, kappa_opt1, t_profile_cl1, raceline_interp1 = \
1202
+ self.generate_kinProfs(ds=ds_CMA)
1203
+
1204
+ # ---- initial segment lengths (base track, no optimisation) ----
1205
+ self._update_reftrack(self._base_reftrack.copy())
1206
+ ds0 = self.lengths.copy()
1207
+ s_splines2, vx_profile_opt2, ax_profile_opt2, kappa_opt2, t_profile_cl2, raceline_interp2 = \
1208
+ self.generate_kinProfs(ds=ds0)
1209
+
1210
+
1211
+ ##Calculate the profiles for centerline
1212
+ lengths1 = np.sqrt(np.sum(np.power(np.diff(self.center[:,0:2], axis=0), 2), axis=1))
1213
+ lengths1=np.append(lengths1, lengths1[0])
1214
+ coeffs_x, coeffs_y, M, normvec_norm = calc_splines(path=np.vstack((self.center[:, 0:2], self.center[0, 0:2])),el_lengths=lengths1)
1215
+ H, f, G , h = H_f(reftrack=self.center,
1216
+ normvectors=normvec_norm,
1217
+ A=M,
1218
+ kappa_bound=self.kapb,
1219
+ w_veh=self.sfty,
1220
+ closed=True)
1221
+ alpha_m_0 = quadprog.solve_qp(H, -f, -G.T,-h,0)[0]
1222
+
1223
+ raceline_interp4, a_opt4, coeffs_x_opt4, coeffs_y_opt4, spline_inds_opt_interp4, t_vals_opt_interp4, s_points_opt_interp4,\
1224
+ spline_lengths_opt4, el_lengths_opt_interp4 = create_raceline(refline=self.center[:, :2],
1225
+ normvectors=normvec_norm,
1226
+ alpha=np.zeros_like(alpha_m_0),
1227
+ stepsize_interp=self.si)
1228
+
1229
+ psi_vel_opt4, kappa_opt4 =calc_head_curv_an(coeffs_x=coeffs_x_opt4,
1230
+ coeffs_y=coeffs_y_opt4,
1231
+ ind_spls=spline_inds_opt_interp4,
1232
+ t_spls=t_vals_opt_interp4)
1233
+
1234
+ s_splines4 = cumulative_distances(el_lengths_opt_interp4)
1235
+ vx_profile_opt4 = calc_vel_profile(ggv=self.ggv,
1236
+ ax_max_machines=self.ax_max_machines,
1237
+ v_max=self.vm,
1238
+ kappa=kappa_opt4,
1239
+ el_lengths=el_lengths_opt_interp4,
1240
+ closed=True,
1241
+ filt_window=self.fw,
1242
+ dyn_model_exp=1.0,
1243
+ drag_coeff=self.drag_coeff,
1244
+ m_veh=self.m_veh,
1245
+ v_start = 0.0)
1246
+
1247
+ # calculate longitudinal acceleration profile
1248
+ vx_profile_opt_cl4 = np.append(vx_profile_opt4, vx_profile_opt4[0])
1249
+ ax_profile_opt4 = calc_ax_profile(vx_profile=vx_profile_opt_cl4,
1250
+ el_lengths=el_lengths_opt_interp4,
1251
+ eq_length_output=False)
1252
+
1253
+ # calculate laptime
1254
+ t_profile_cl4 = calc_t_profile(vx_profile=vx_profile_opt4,
1255
+ ax_profile=ax_profile_opt4,
1256
+ el_lengths=el_lengths_opt_interp4)
1257
+
1258
+ print("INFO: Estimated laptime for ZO: %.2fs" % t_profile_cl[-1])
1259
+ print("INFO: Estimated laptime for CMA-ES: %.2fs" % t_profile_cl1[-1])
1260
+ print("INFO: Estimated laptime for initial: %.2fs" % t_profile_cl2[-1])
1261
+ print("INFO: Estimated laptime for centerline: %.2fs" % t_profile_cl4[-1])
1262
+
1263
+ if plot=='Y':
1264
+ plt.figure(figsize=(12, 6))
1265
+ plt.subplot(1,2,1)
1266
+ plt.plot(self.reftrack[:,0],self.reftrack[:,1],'b--',label='ref_line')
1267
+ plt.plot(self.bound1[:, 0], self.bound1[:, 1], 'k', label=' Track')
1268
+ plt.plot(self.bound2[:, 0], self.bound2[:, 1], 'k')
1269
+ plt.plot(raceline_interp[:,0], raceline_interp[:,1],'r.-' , label='Opt_ZO')
1270
+ plt.plot(raceline_interp2[:,0], raceline_interp2[:,1],'g-' , label='initial')
1271
+ plt.xlabel('X')
1272
+ plt.ylabel('Y')
1273
+ plt.legend()
1274
+ plt.grid(True)
1275
+ plt.subplot(1,2,2)
1276
+ plt.plot(self.reftrack[:,0],self.reftrack[:,1],'b--',label='ref_line')
1277
+ plt.plot(self.bound1[:, 0], self.bound1[:, 1], 'k', label=' Track')
1278
+ plt.plot(self.bound2[:, 0], self.bound2[:, 1], 'k')
1279
+ plt.plot(raceline_interp1[:,0], raceline_interp1[:,1],'r.-' , label='Opt_CMA')
1280
+ plt.plot(raceline_interp2[:,0], raceline_interp2[:,1],'g-' , label='initial')
1281
+ plt.xlabel('X')
1282
+ plt.ylabel('Y')
1283
+ plt.legend()
1284
+ plt.grid(True)
1285
+ plt.show()
1286
+
1287
+ plt.figure(figsize=(15, 15))
1288
+ plt.subplot(3,4,1)
1289
+ plt.plot(s_splines, vx_profile_opt,'b' , label='v_ZO')
1290
+ plt.plot(s_splines4, vx_profile_opt4,'r' , label='v_center')
1291
+ plt.plot(s_splines2, vx_profile_opt2,'g' , label='v_initial')
1292
+ plt.ylabel('v_profile')
1293
+ plt.xlabel('distance')
1294
+ plt.legend()
1295
+ plt.grid(True)
1296
+ plt.subplot(3,4,2)
1297
+ plt.plot(s_splines, ax_profile_opt,'b' , label='a_ZO')
1298
+ plt.plot(s_splines4, ax_profile_opt4,'r' , label='a_center')
1299
+ plt.plot(s_splines2, ax_profile_opt2,'g' , label='a_initial')
1300
+ plt.ylabel('a_profile')
1301
+ plt.xlabel('distance')
1302
+ plt.legend()
1303
+ plt.grid(True)
1304
+ plt.subplot(3,4,3)
1305
+ plt.plot(s_splines, kappa_opt,'b' , label='k_ZO')
1306
+ plt.plot(s_splines4, kappa_opt4,'r' , label='k_center')
1307
+ plt.plot(s_splines2, kappa_opt2,'g' , label='k_initial')
1308
+ plt.ylabel('curv_profile')
1309
+ plt.xlabel('distance')
1310
+ plt.legend()
1311
+ plt.grid(True)
1312
+ plt.subplot(3,4,4)
1313
+ plt.plot(s_splines, t_profile_cl[:-1],'b' , label='t_ZO')
1314
+ plt.plot(s_splines4, t_profile_cl4[:-1],'r' , label='t_center')
1315
+ plt.plot(s_splines2, t_profile_cl2[:-1],'g' , label='t_initial')
1316
+ plt.ylabel('time')
1317
+ plt.xlabel('distance')
1318
+ plt.legend()
1319
+ plt.grid(True)
1320
+ plt.subplot(3,4,5)
1321
+ plt.plot(s_splines1, vx_profile_opt1,'b' , label='v_cma')
1322
+ plt.plot(s_splines4, vx_profile_opt4,'r' , label='v_center')
1323
+ plt.plot(s_splines2, vx_profile_opt2,'g' , label='v_initial')
1324
+ plt.ylabel('v_profile')
1325
+ plt.xlabel('distance')
1326
+ plt.legend()
1327
+ plt.grid(True)
1328
+ plt.subplot(3,4,6)
1329
+ plt.plot(s_splines1, ax_profile_opt1,'b' , label='a_cma')
1330
+ plt.plot(s_splines4, ax_profile_opt4,'r' , label='a_center')
1331
+ plt.plot(s_splines2, ax_profile_opt2,'g' , label='a_initial')
1332
+ plt.ylabel('a_profile')
1333
+ plt.xlabel('distance')
1334
+ plt.legend()
1335
+ plt.grid(True)
1336
+ plt.subplot(3,4,7)
1337
+ plt.plot(s_splines1, kappa_opt1,'b' , label='k_cma')
1338
+ plt.plot(s_splines4, kappa_opt4,'r' , label='k_center')
1339
+ plt.plot(s_splines2, kappa_opt2,'g' , label='k_initial')
1340
+ plt.ylabel('curv_profile')
1341
+ plt.xlabel('distance')
1342
+ plt.legend()
1343
+ plt.grid(True)
1344
+ plt.subplot(3,4,8)
1345
+ plt.plot(s_splines1, t_profile_cl1[:-1],'b' , label='t_cma')
1346
+ plt.plot(s_splines4, t_profile_cl4[:-1],'r' , label='t_center')
1347
+ plt.plot(s_splines2, t_profile_cl2[:-1],'g' , label='t_initial')
1348
+ plt.ylabel('time')
1349
+ plt.xlabel('distance')
1350
+ plt.legend()
1351
+ plt.grid(True)
1352
+ plt.subplot(3,4,9)
1353
+ plt.plot(s_splines2, vx_profile_opt2,'b' , label='v_initial')
1354
+ plt.plot(s_splines4, vx_profile_opt4,'r' , label='v_center')
1355
+ plt.ylabel('v_profile')
1356
+ plt.xlabel('distance')
1357
+ plt.legend()
1358
+ plt.grid(True)
1359
+ plt.subplot(3,4,10)
1360
+ plt.plot(s_splines2, ax_profile_opt2,'b' , label='a_initial')
1361
+ plt.plot(s_splines4, ax_profile_opt4,'r' , label='a_center')
1362
+ plt.ylabel('a_profile')
1363
+ plt.xlabel('distance')
1364
+ plt.legend()
1365
+ plt.grid(True)
1366
+ plt.subplot(3,4,11)
1367
+ plt.plot(s_splines2, kappa_opt2,'b' , label='k_initial')
1368
+ plt.plot(s_splines4, kappa_opt4,'r' , label='k_center')
1369
+ plt.ylabel('curv_profile')
1370
+ plt.xlabel('distance')
1371
+ plt.legend()
1372
+ plt.grid(True)
1373
+ plt.subplot(3,4,12)
1374
+ plt.plot(s_splines2, t_profile_cl2[:-1],'b' , label='t_initial')
1375
+ plt.plot(s_splines4, t_profile_cl4[:-1],'r' , label='t_center')
1376
+ plt.ylabel('time')
1377
+ plt.xlabel('distance')
1378
+ plt.legend()
1379
+ plt.grid(True)
1380
+ plt.show()
1381
+
1382
+ # Always restore original track when Comparison exits
1383
+ self._update_reftrack(self._base_reftrack.copy())
1384
+
1385
+ if output == 'Y':
1386
+ return s_splines, vx_profile_opt, ax_profile_opt, kappa_opt, t_profile_cl, raceline_interp,\
1387
+ s_splines1, vx_profile_opt1, ax_profile_opt1, kappa_opt1, t_profile_cl1, raceline_interp1,\
1388
+ s_splines2, vx_profile_opt2, ax_profile_opt2, kappa_opt2, t_profile_cl2, raceline_interp2,\
1389
+ s_splines4, vx_profile_opt4, ax_profile_opt4, kappa_opt4, t_profile_cl4, raceline_interp4
1390
+ if output == 'ZO':
1391
+ return s_splines, vx_profile_opt, ax_profile_opt, kappa_opt, t_profile_cl, raceline_interp
1392
+ if output == 'CMA':
1393
+ return s_splines1, vx_profile_opt1, ax_profile_opt1, kappa_opt1, t_profile_cl1, raceline_interp1
1394
+ if output == 'initial':
1395
+ return s_splines2, vx_profile_opt2, ax_profile_opt2, kappa_opt2, t_profile_cl2, raceline_interp2
1396
+ if output == 'center':
1397
+ return s_splines4, vx_profile_opt4, ax_profile_opt4, kappa_opt4, t_profile_cl4, raceline_interp4
1398
+
1399
+
1400
+ # ===========================================================================
1401
+ # Blackbox_raceline
1402
+ # ===========================================================================
1403
+
1404
+ class Blackbox_raceline:
1405
+ """
1406
+ Direct zeroth-order (ZO) optimization of the lateral shift vector alpha
1407
+ for minimum lap time (or a user-supplied blackbox cost function).
1408
+
1409
+ Motivation
1410
+ ----------
1411
+ The existing ``Opt_min_CurvTime`` class uses a two-stage pipeline:
1412
+ ZO/CMA-ES optimizes spline segment lengths ds → QP solves for alpha that
1413
+ minimizes *curvature* (a proxy for lap time). The curvature proxy is
1414
+ inexact and couples geometry to the inner QP.
1415
+
1416
+ ``Blackbox_raceline`` bypasses both stages. Alpha is the optimization
1417
+ variable directly and the true lap time (from ``KinematicProfs``) is the
1418
+ cost function, so there is no proxy and no inner QP.
1419
+
1420
+ Formulation
1421
+ -----------
1422
+ * Variable : ``alpha`` ∈ R^N, one lateral shift per control point
1423
+ * Feasible set: box ``alpha[i] ∈ [-(w_left[i] - sfty), (w_right[i] - sfty)]``
1424
+ * Objective : ``minimize cost(alpha)`` [default: lap time]
1425
+ * Method : projected ZO stochastic gradient descent
1426
+
1427
+ The box constraint is the same as the QP constraint used inside
1428
+ ``Opt_min_CurvTime``, so the feasible region is identical.
1429
+
1430
+ Fixed normal vectors
1431
+ --------------------
1432
+ Spline normal vectors are computed once from the Euclidean segment lengths
1433
+ of the initial ``reftrack`` and held fixed throughout the run. Alpha is
1434
+ interpreted as displacement along these fixed normals, which keeps the
1435
+ box-projection exact and avoids recomputing splines at every iteration.
1436
+
1437
+ Gradient estimation
1438
+ -------------------
1439
+ Two oracles are supported for sampling the random direction u:
1440
+
1441
+ * ``'gaussian'`` : u ~ N(0, I_N) — standard Gaussian ZO estimator
1442
+ * ``'sphere'`` : u ~ Uniform(S^{N-1}) — normalized Gaussian; trades
1443
+ higher variance for uniform coverage of the search space
1444
+
1445
+ Two finite-difference schemes are supported:
1446
+
1447
+ * ``'noth'`` (forward) : ``g = (f(alpha + μu) - f(alpha)) / μ * u``
1448
+ — 1 extra function evaluation per direction
1449
+ * ``'h'`` (central) : ``g = (f(alpha + μu) - f(alpha - μu)) / 2μ * u``
1450
+ — 2 extra evaluations per direction, lower bias
1451
+
1452
+ Parameters
1453
+ ----------
1454
+ reftrack : np.ndarray, shape (N, 4)
1455
+ Reference track [x, y, w_right, w_left]. Unclosed.
1456
+ ggv : np.ndarray
1457
+ GGV table already loaded, shape (K, 3) → [vx, ax_max, ay_max].
1458
+ ax_max_machines : np.ndarray
1459
+ Machine longitudinal limits, shape (L, 2) → [vx, ax_max].
1460
+ sfty : float
1461
+ Vehicle safety half-width in m (box constraint clearance).
1462
+ v_max : float
1463
+ Maximum velocity [m/s].
1464
+ si : float
1465
+ Raceline interpolation stepsize [m].
1466
+ fw : int or None
1467
+ Velocity profile convolution filter window length. None = no filter.
1468
+ m_veh : float
1469
+ Vehicle mass [kg].
1470
+ drag_coeff : float
1471
+ Drag coefficient: 0.5 * c_w * A_front * rho_air.
1472
+ dyn_model_exp : float
1473
+ Dynamics model exponent in [1.0, 2.0].
1474
+ cost_fn : callable or None
1475
+ User-supplied cost function ``cost_fn(alpha) -> float``.
1476
+ If None the default lap-time oracle is used.
1477
+ init : str
1478
+ Initial alpha strategy.
1479
+
1480
+ ``'random'`` — alpha_0 sampled uniformly inside the feasible box.
1481
+ ``'mincurv'`` — alpha_0 = QP minimum-curvature solution on reftrack
1482
+ (warm start from the best geometric proxy).
1483
+ oracle : str
1484
+ ``'gaussian'`` or ``'sphere'`` (see above).
1485
+ mu : float
1486
+ ZO smoothing / perturbation magnitude μ.
1487
+ h : float
1488
+ Gradient-descent step size.
1489
+ t : int
1490
+ Number of random directions averaged per gradient estimate.
1491
+ grad_type : str
1492
+ ``'noth'`` (forward difference) or ``'h'`` (central difference).
1493
+ iterations : int
1494
+ Total number of gradient steps.
1495
+ kappa_bound : float
1496
+ Curvature bound used for the QP when ``init='mincurv'``.
1497
+ seed : int or None
1498
+ Global random seed set at construction time. Reapply with
1499
+ ``find_alpha(seed=...)`` for per-run reproducibility.
1500
+ """
1501
+
1502
+ def __init__(
1503
+ self,
1504
+ reftrack: np.ndarray,
1505
+ ggv: np.ndarray,
1506
+ ax_max_machines: np.ndarray,
1507
+ sfty: float = 1.0,
1508
+ v_max: float = 22.88,
1509
+ si: float = 0.8,
1510
+ fw: int = 3,
1511
+ m_veh: float = 1000.0,
1512
+ drag_coeff: float = 0.0,
1513
+ dyn_model_exp: float = 1.0,
1514
+ cost_fn=None,
1515
+ init: str = 'random',
1516
+ oracle: str = 'gaussian',
1517
+ mu: float = 0.05,
1518
+ h: float = 0.001,
1519
+ t: int = 1,
1520
+ grad_type: str = 'noth',
1521
+ iterations: int = 300,
1522
+ kappa_bound: float = 0.7,
1523
+ seed: int = None,
1524
+ ):
1525
+ # ---- store hyper-parameters -------------------------------------------
1526
+ self.reftrack = reftrack.copy()
1527
+ self.N = reftrack.shape[0]
1528
+ self.ggv = ggv
1529
+ self.ax_max_machines = ax_max_machines
1530
+ self.sfty = sfty
1531
+ self.v_max = v_max
1532
+ self.si = si
1533
+ self.fw = fw
1534
+ self.m_veh = m_veh
1535
+ self.drag_coeff = drag_coeff
1536
+ self.dyn_model_exp = dyn_model_exp
1537
+ self.cost_fn = cost_fn
1538
+ self.init = init
1539
+ self.oracle = oracle
1540
+ self.mu = mu
1541
+ self.h = h
1542
+ self.t = t
1543
+ self.grad_type = grad_type
1544
+ self.iterations = iterations
1545
+ self.kappa_bound = kappa_bound
1546
+
1547
+ if seed is not None:
1548
+ np.random.seed(seed)
1549
+
1550
+ # ---- box constraints (same convention as QP inside Opt_min_CurvTime) --
1551
+ # positive alpha → shift toward the right boundary (w_right side)
1552
+ # negative alpha → shift toward the left boundary (w_left side)
1553
+ self.lo = -(reftrack[:, 3] - sfty) # lower bound (max left shift)
1554
+ self.hi = reftrack[:, 2] - sfty # upper bound (max right shift)
1555
+
1556
+ bad = np.where(self.lo > self.hi)[0]
1557
+ if bad.size > 0:
1558
+ raise ValueError(
1559
+ f"Track is narrower than the safety margin at {bad.size} point(s) "
1560
+ f"(indices: {bad[:5]}{'...' if bad.size > 5 else ''}). "
1561
+ "Reduce sfty or check track widths.")
1562
+
1563
+ # ---- fixed normal vectors (computed once from initial Euclidean ds) ----
1564
+ ds_init = np.hypot(
1565
+ np.diff(np.r_[reftrack[:, 0], reftrack[0, 0]]),
1566
+ np.diff(np.r_[reftrack[:, 1], reftrack[0, 1]]))
1567
+ _, _, self._M, self._normvec = calc_splines(
1568
+ path=np.vstack((reftrack[:, :2], reftrack[0, :2])),
1569
+ el_lengths=ds_init,
1570
+ use_dist_scaling=True)
1571
+
1572
+ # ---- p_ggv cache (keyed by interpolated raceline length) ---------------
1573
+ self._p_ggv_cache: dict = {}
1574
+
1575
+ # ---- initial alpha + result placeholders --------------------------------
1576
+ self.alpha_0 = self._init_alpha()
1577
+ self.alpha_opt = self.alpha_0.copy()
1578
+ self.history = None
1579
+
1580
+ # ------------------------------------------------------------------
1581
+ # Private helpers
1582
+ # ------------------------------------------------------------------
1583
+
1584
+ def _init_alpha(self) -> np.ndarray:
1585
+ """Return the initial alpha vector according to self.init.
1586
+
1587
+ Supported modes
1588
+ ---------------
1589
+ 'zero'
1590
+ Start with zero shift (alpha = 0), i.e. the original reference track.
1591
+ 'random'
1592
+ Uniform sample inside the feasible box [lo, hi].
1593
+ 'mincurv'
1594
+ Solve the minimum-curvature QP (H_f) and use the result as the
1595
+ warm-start. This is the best geometric proxy for lap-time and
1596
+ typically the fastest starting point for further ZO/CMA refinement.
1597
+ 'shortest' (aliases: 'shortest_path', 'sp')
1598
+ Solve the Optimal Shortest Path (OSP) QP and use that alpha.
1599
+ The shortest path has maximum corner radii (less lateral movement
1600
+ overall) which sometimes beats mincurv as a starting point on
1601
+ tracks where sustained high speed on long straights matters more
1602
+ than corner apex curvature.
1603
+ """
1604
+ if self.init == 'random':
1605
+ return np.random.uniform(self.lo, self.hi)
1606
+ elif self.init == 'zero':
1607
+ return np.zeros(self.N)
1608
+
1609
+ elif self.init == 'mincurv':
1610
+ # Warm-start from the QP min-curvature solution on the original reftrack.
1611
+ # This is the best geometric proxy available and typically a better starting
1612
+ # point than a random interior point.
1613
+ H, f_vec, G, h_vec = H_f(
1614
+ reftrack=self.reftrack,
1615
+ normvectors=self._normvec,
1616
+ A=self._M,
1617
+ kappa_bound=self.kappa_bound,
1618
+ w_veh=self.sfty,
1619
+ closed=True)
1620
+ alpha_mc = quadprog.solve_qp(H, -f_vec, -G.T, -h_vec, 0)[0]
1621
+ # Clip in case QP and Blackbox box constraints differ by a rounding epsilon
1622
+ return np.clip(alpha_mc, self.lo, self.hi)
1623
+
1624
+ elif self.init in ('shortest', 'shortest_path', 'sp'):
1625
+ # Warm-start from the OSP (Optimal Shortest Path) QP solution.
1626
+ # OSP internally uses w_veh/2 as the per-side clearance; we pass
1627
+ # 2*sfty so that w_veh/2 = sfty, giving the same feasible box as
1628
+ # Blackbox_raceline uses for the gradient descent.
1629
+ H_sp, f_sp, G_sp, h_sp = OSP(
1630
+ reftrack=self.reftrack,
1631
+ normvectors=self._normvec,
1632
+ w_veh=2.0 * self.sfty)
1633
+ alpha_sp = quadprog.solve_qp(H_sp, -f_sp, -G_sp.T, -h_sp, 0)[0]
1634
+ # Clip to Blackbox box (OSP and H_f constraints can differ slightly
1635
+ # due to numerical tolerances or rounding in dev_max).
1636
+ return np.clip(alpha_sp, self.lo, self.hi)
1637
+
1638
+ else:
1639
+ raise ValueError(
1640
+ f"Unknown init mode '{self.init}'. "
1641
+ "Use 'random', 'mincurv', or 'shortest'.")
1642
+
1643
+ def _sample_direction(self) -> np.ndarray:
1644
+ """Sample a unit-scaled random direction u according to self.oracle."""
1645
+ u = np.random.normal(0.0, 1.0, size=self.N)
1646
+ if self.oracle == 'sphere':
1647
+ norm = np.linalg.norm(u)
1648
+ if norm > 1e-12:
1649
+ u /= norm # project onto N-sphere
1650
+ elif self.oracle != 'gaussian':
1651
+ raise ValueError(
1652
+ f"Unknown oracle '{self.oracle}'. Use 'gaussian' or 'sphere'.")
1653
+ return u
1654
+
1655
+ def _eval_cost(self, alpha: np.ndarray) -> float:
1656
+ """Evaluate cost at alpha (dispatch to user cost_fn or lap-time oracle)."""
1657
+ if self.cost_fn is not None:
1658
+ return float(self.cost_fn(alpha))
1659
+ return self._laptime_cost(alpha)
1660
+
1661
+ def _laptime_cost(self, alpha: np.ndarray) -> float:
1662
+ """
1663
+ Default cost oracle: build raceline from alpha and return the lap time
1664
+ computed by the KinematicProfs forward-backward velocity solver.
1665
+ """
1666
+ _, _, cx, cy, si_idx, tv, _, _, el = create_raceline(
1667
+ refline=self.reftrack[:, :2],
1668
+ normvectors=self._normvec,
1669
+ alpha=alpha,
1670
+ stepsize_interp=self.si)
1671
+ _, kappa = calc_head_curv_an(
1672
+ coeffs_x=cx, coeffs_y=cy, ind_spls=si_idx, t_spls=tv)
1673
+
1674
+ n = kappa.size
1675
+ if n not in self._p_ggv_cache:
1676
+ self._p_ggv_cache[n] = np.repeat(
1677
+ np.expand_dims(self.ggv, axis=0), n, axis=0)
1678
+
1679
+ vx = calc_vel_profile(
1680
+ ggv=self.ggv,
1681
+ ax_max_machines=self.ax_max_machines,
1682
+ v_max=self.v_max,
1683
+ kappa=kappa,
1684
+ el_lengths=el,
1685
+ closed=True,
1686
+ filt_window=self.fw,
1687
+ dyn_model_exp=self.dyn_model_exp,
1688
+ drag_coeff=self.drag_coeff,
1689
+ m_veh=self.m_veh,
1690
+ p_ggv=self._p_ggv_cache[n])
1691
+
1692
+ vx_cl = np.append(vx, vx[0])
1693
+ ax_prof = calc_ax_profile(
1694
+ vx_profile=vx_cl, el_lengths=el, eq_length_output=False)
1695
+ t_prof = calc_t_profile(
1696
+ vx_profile=vx, ax_profile=ax_prof, el_lengths=el)
1697
+ return float(t_prof[-1])
1698
+
1699
+ # ------------------------------------------------------------------
1700
+ # Public API
1701
+ # ------------------------------------------------------------------
1702
+
1703
+ def find_alpha(
1704
+ self,
1705
+ solver: str = 'ZO',
1706
+ n_iter: int = None,
1707
+ # ZO parameters
1708
+ step_size: float = None,
1709
+ mu: float = None,
1710
+ t: int = None,
1711
+ grad_type: str = None,
1712
+ # CMA-ES parameters
1713
+ sigma: float = None,
1714
+ popsize: int = None,
1715
+ # Common
1716
+ seed: int = None,
1717
+ verbose: bool = True,
1718
+ print_every: int = 50,
1719
+ ):
1720
+ """
1721
+ Minimize the cost over alpha using either ZO gradient descent or CMA-ES.
1722
+
1723
+ Both solvers respect the box constraint ``alpha ∈ [lo, hi]`` via
1724
+ projection (ZO) or clipping of the sampled population (CMA-ES).
1725
+ The best alpha seen across all evaluations is tracked and stored in
1726
+ ``self.alpha_opt``; ``self.history`` records the best-so-far cost at
1727
+ the end of each iteration/generation.
1728
+
1729
+ Parameters
1730
+ ----------
1731
+ solver : str
1732
+ ``'ZO'`` — projected zeroth-order stochastic gradient descent.
1733
+ ``'CMA'`` — CMA-ES evolutionary strategy.
1734
+ n_iter : int or None
1735
+ For ZO : number of gradient steps (default: ``self.iterations``).
1736
+ For CMA : number of generations (default: ``self.iterations``).
1737
+ One CMA generation = ``popsize`` function evaluations.
1738
+ step_size : float or None [ZO only]
1739
+ Gradient step size h. Defaults to ``self.h``.
1740
+ mu : float or None [ZO only]
1741
+ Perturbation magnitude μ. Defaults to ``self.mu``.
1742
+ t : int or None [ZO only]
1743
+ Directions averaged per gradient estimate. Defaults to ``self.t``.
1744
+ grad_type : str or None [ZO only]
1745
+ ``'noth'`` (forward difference) or ``'h'`` (central difference).
1746
+ Defaults to ``self.grad_type``.
1747
+ sigma : float or None [CMA only]
1748
+ Initial step-size (standard deviation) for CMA-ES.
1749
+ A good default is ~(1/3) × (hi - lo) range, but it is
1750
+ problem-specific. Defaults to ``self.h`` (used as a scale proxy).
1751
+ popsize : int or None [CMA only]
1752
+ Population size (number of candidate solutions per generation).
1753
+ Defaults to ``4 + int(3 * np.log(self.N))`` (CMA-ES heuristic).
1754
+ seed : int or None
1755
+ Random seed reset before the loop.
1756
+ verbose : bool
1757
+ Print progress every ``print_every`` iterations/generations.
1758
+ print_every : int
1759
+ Verbosity interval.
1760
+
1761
+ Returns
1762
+ -------
1763
+ best_alpha : np.ndarray, shape (N,)
1764
+ Alpha that achieved the lowest observed cost.
1765
+ history : np.ndarray, shape (n_iter + 1,)
1766
+ ``history[0]`` = cost of the initial alpha_0.
1767
+ ``history[k]`` = best-so-far cost after iteration/generation k.
1768
+ """
1769
+ if seed is not None:
1770
+ np.random.seed(seed)
1771
+
1772
+ n_iter = n_iter if n_iter is not None else self.iterations
1773
+
1774
+ # ---- evaluate starting point (common to both solvers) ------------------
1775
+ alpha_start = self.alpha_0.copy()
1776
+ f_start = self._eval_cost(alpha_start)
1777
+ best_alpha = alpha_start.copy()
1778
+ best_cost = f_start
1779
+ history = np.full(n_iter + 1, np.nan)
1780
+ history[0] = f_start
1781
+
1782
+ if verbose:
1783
+ print(f"[Blackbox_raceline/{solver}] init laptime = {f_start:.4f} s"
1784
+ f" (init='{self.init}', N={self.N})")
1785
+
1786
+ # ====================================================================
1787
+ # ZO — projected stochastic gradient descent
1788
+ # ====================================================================
1789
+ if solver == 'ZO':
1790
+ step_size = step_size if step_size is not None else self.h
1791
+ mu = mu if mu is not None else self.mu
1792
+ t_dirs = t if t is not None else self.t
1793
+ grad_type = grad_type if grad_type is not None else self.grad_type
1794
+
1795
+ alpha = alpha_start.copy()
1796
+ f_curr = f_start
1797
+
1798
+ for k in range(n_iter):
1799
+
1800
+ # gradient estimate averaged over t_dirs random directions
1801
+ grad_sum = np.zeros(self.N)
1802
+ for _ in range(t_dirs):
1803
+ u = self._sample_direction()
1804
+
1805
+ alpha_fwd = np.clip(alpha + mu * u, self.lo, self.hi)
1806
+ f_fwd = self._eval_cost(alpha_fwd)
1807
+
1808
+ if grad_type == 'noth':
1809
+ g = (f_fwd - f_curr) / mu * u
1810
+ else: # 'h' = central difference
1811
+ alpha_bwd = np.clip(alpha - mu * u, self.lo, self.hi)
1812
+ f_bwd = self._eval_cost(alpha_bwd)
1813
+ g = (f_fwd - f_bwd) / (2.0 * mu) * u
1814
+
1815
+ grad_sum += g
1816
+
1817
+ grad_est = grad_sum / t_dirs
1818
+
1819
+ # projected gradient step
1820
+ alpha = np.clip(alpha - step_size * grad_est, self.lo, self.hi)
1821
+ f_curr = self._eval_cost(alpha)
1822
+
1823
+ if f_curr < best_cost:
1824
+ best_cost = f_curr
1825
+ best_alpha = alpha.copy()
1826
+ history[k + 1] = best_cost
1827
+
1828
+ if verbose and (k + 1) % print_every == 0:
1829
+ print(f" iter {k + 1:4d}/{n_iter}: laptime = {f_curr:.4f} s"
1830
+ f" best = {best_cost:.4f} s")
1831
+
1832
+ # ====================================================================
1833
+ # CMA-ES — covariance matrix adaptation evolution strategy
1834
+ # ====================================================================
1835
+ elif solver == 'CMA':
1836
+ # Default sigma: 1/6 of the mean box half-width (explores ~1σ
1837
+ # within the feasible range with high probability)
1838
+ if sigma is None:
1839
+ sigma = float(np.mean(self.hi - self.lo)) / 6.0
1840
+ if popsize is None:
1841
+ popsize = 4 + int(3 * np.log(self.N)) # CMA-ES standard heuristic
1842
+
1843
+ # Wrap _eval_cost to track best-ever across all individual evaluations
1844
+ _best = {'alpha': best_alpha.copy(), 'cost': best_cost}
1845
+
1846
+ def _tracked_cost(a: np.ndarray) -> float:
1847
+ c = self._eval_cost(a)
1848
+ if c < _best['cost']:
1849
+ _best['cost'] = c
1850
+ _best['alpha'] = a.copy()
1851
+ return c
1852
+
1853
+ cma = ConstrainedCMAES_t(
1854
+ _tracked_cost,
1855
+ mean=alpha_start.copy(),
1856
+ sigma=sigma,
1857
+ popsize=popsize,
1858
+ bounds1=self.hi,
1859
+ bounds2=self.lo)
1860
+ # ConstrainedCMAES_t.update() references self.iterations (normally
1861
+ # set inside optimize()). We manage the loop ourselves for
1862
+ # best-tracking, so we must initialise it here.
1863
+ cma.iterations = 0
1864
+
1865
+ for gen in range(n_iter):
1866
+ samples = cma.sample_population()
1867
+ fitness = np.array([cma.objective_function(s) for s in samples])
1868
+ cma.update(samples, fitness)
1869
+ cma.iterations += 1
1870
+ history[gen + 1] = _best['cost'] # best-so-far after this generation
1871
+
1872
+ if verbose and (gen + 1) % print_every == 0:
1873
+ print(f" gen {gen + 1:4d}/{n_iter}: gen_best = {min(fitness):.4f} s"
1874
+ f" best = {_best['cost']:.4f} s")
1875
+
1876
+ best_alpha = _best['alpha']
1877
+ best_cost = _best['cost']
1878
+
1879
+ else:
1880
+ raise ValueError(f"Unknown solver '{solver}'. Use 'ZO' or 'CMA'.")
1881
+
1882
+ # ---- finalise ----------------------------------------------------------
1883
+ if verbose:
1884
+ print(f"[Blackbox_raceline/{solver}] done best laptime = {best_cost:.4f} s"
1885
+ f" (improvement = {f_start - best_cost:.4f} s)")
1886
+
1887
+ self.alpha_opt = best_alpha
1888
+ self.history = history
1889
+ return best_alpha, history
1890
+
1891
+ def generate_raceline(self, alpha: np.ndarray = None):
1892
+ """
1893
+ Build the full raceline and kinematic profiles for a given alpha.
1894
+
1895
+ Parameters
1896
+ ----------
1897
+ alpha : np.ndarray or None
1898
+ Lateral shift vector, shape (N,). If None, uses self.alpha_opt
1899
+ (the best alpha from the most recent find_alpha() call).
1900
+
1901
+ Returns
1902
+ -------
1903
+ s : np.ndarray
1904
+ Cumulative distance along the raceline [m], shape (M,).
1905
+ vx : np.ndarray
1906
+ Velocity profile [m/s], shape (M,) (unclosed).
1907
+ ax : np.ndarray
1908
+ Longitudinal acceleration [m/s²], shape (M,) (closed wrap-around).
1909
+ kappa : np.ndarray
1910
+ Curvature [rad/m], shape (M,).
1911
+ t_prof : np.ndarray
1912
+ Cumulative lap time [s], shape (M + 1,). t_prof[-1] = total lap time.
1913
+ raceline_xy : np.ndarray
1914
+ Interpolated raceline coordinates [x, y], shape (M, 2).
1915
+ """
1916
+ if alpha is None:
1917
+ if self.alpha_opt is None:
1918
+ raise RuntimeError(
1919
+ "No alpha available. Run find_alpha() first or pass alpha explicitly.")
1920
+ alpha = self.alpha_opt
1921
+
1922
+ raceline_xy, _, cx, cy, si_idx, tv, _, _, el = create_raceline(
1923
+ refline=self.reftrack[:, :2],
1924
+ normvectors=self._normvec,
1925
+ alpha=alpha,
1926
+ stepsize_interp=self.si)
1927
+ _, kappa = calc_head_curv_an(
1928
+ coeffs_x=cx, coeffs_y=cy, ind_spls=si_idx, t_spls=tv)
1929
+
1930
+ s = cumulative_distances(el)
1931
+
1932
+ n = kappa.size
1933
+ if n not in self._p_ggv_cache:
1934
+ self._p_ggv_cache[n] = np.repeat(
1935
+ np.expand_dims(self.ggv, axis=0), n, axis=0)
1936
+
1937
+ vx = calc_vel_profile(
1938
+ ggv=self.ggv,
1939
+ ax_max_machines=self.ax_max_machines,
1940
+ v_max=self.v_max,
1941
+ kappa=kappa,
1942
+ el_lengths=el,
1943
+ closed=True,
1944
+ filt_window=self.fw,
1945
+ dyn_model_exp=self.dyn_model_exp,
1946
+ drag_coeff=self.drag_coeff,
1947
+ m_veh=self.m_veh,
1948
+ p_ggv=self._p_ggv_cache[n])
1949
+
1950
+ vx_cl = np.append(vx, vx[0])
1951
+ ax = calc_ax_profile(
1952
+ vx_profile=vx_cl, el_lengths=el, eq_length_output=False)
1953
+ t_prof = calc_t_profile(
1954
+ vx_profile=vx, ax_profile=ax, el_lengths=el)
1955
+
1956
+ return s, vx, ax, kappa, t_prof, raceline_xy
1957
+
1958
+ def reset_alpha(self, new_init: str = None):
1959
+ """
1960
+ Re-initialize alpha_0 (and reset alpha_opt / history).
1961
+
1962
+ Useful for multi-start experiments: call reset_alpha() then find_alpha()
1963
+ to run a fresh trial from a new starting point.
1964
+
1965
+ Parameters
1966
+ ----------
1967
+ new_init : str or None
1968
+ Override init strategy ('random' or 'mincurv'). None keeps current.
1969
+ """
1970
+ if new_init is not None:
1971
+ self.init = new_init
1972
+ self.alpha_0 = self._init_alpha()
1973
+ self.alpha_opt = self.alpha_0.copy()
1974
+ self.history = None
1975
+
1976
+
1977
+ from scipy.integrate import quad
1978
+ from scipy.optimize import fsolve
1979
+
1980
+ class Clothoid_raceline:
1981
+ def __init__(self, k_0, s, x0, y0, th0, nump=10,a_max=5.3,a_min=12,ay_max=12,c0=0.00002,c1=0.0015,v_max=22.88,v0=0,vf=22.88):
1982
+ self.k_0 = k_0
1983
+ self.s = s
1984
+ self.x0 = x0
1985
+ self.y0 = y0
1986
+ self.th0 = th0
1987
+ self.nump = nump
1988
+ self.a_max = a_max
1989
+ self.a_min = a_min
1990
+ self.ay_max = ay_max
1991
+ self.c0 = c0
1992
+ self.c1 = c1
1993
+ self.v_max = v_max
1994
+ self.v0=v0
1995
+ self.vf=vf
1996
+
1997
+
1998
+ def X_0(self,a, b, c):
1999
+ """
2000
+
2001
+ .. description::
2002
+ Computes the normalized x-displacement integral of a clothoid segment by numerically integrating
2003
+ cos(a/2 * tau^2 + b * tau + c) over [0, 1].
2004
+
2005
+ .. inputs::
2006
+ :param a: curvature rate coefficient (k1 * s^2).
2007
+ :type a: float
2008
+ :param b: initial curvature coefficient (k0 * s).
2009
+ :type b: float
2010
+ :param c: initial heading angle theta_0 in radians.
2011
+ :type c: float
2012
+
2013
+ .. outputs::
2014
+ :return result: normalized x-displacement of the clothoid segment.
2015
+ :rtype result: float
2016
+ """
2017
+
2018
+ integrand = lambda tau: np.cos(a/2 * tau**2 + b * tau + c)
2019
+ result, _ = quad(integrand, 0, 1)
2020
+ return result
2021
+
2022
+ def Y_0(self,a, b, c):
2023
+ """
2024
+
2025
+ .. description::
2026
+ Computes the normalized y-displacement integral of a clothoid segment by numerically integrating
2027
+ sin(a/2 * tau^2 + b * tau + c) over [0, 1].
2028
+
2029
+ .. inputs::
2030
+ :param a: curvature rate coefficient (k1 * s^2).
2031
+ :type a: float
2032
+ :param b: initial curvature coefficient (k0 * s).
2033
+ :type b: float
2034
+ :param c: initial heading angle theta_0 in radians.
2035
+ :type c: float
2036
+
2037
+ .. outputs::
2038
+ :return result: normalized y-displacement of the clothoid segment.
2039
+ :rtype result: float
2040
+ """
2041
+
2042
+ integrand = lambda tau: np.sin(a/2 * tau**2 + b * tau + c)
2043
+ result, _ = quad(integrand, 0, 1)
2044
+ return result
2045
+
2046
+ def compute_clothoid_path(self):
2047
+ """
2048
+
2049
+ .. description::
2050
+ Computes the full x-y path, arc-length stations, and curvature profile of a piecewise clothoid curve
2051
+ defined by the curvature array k_0 and station array s. Each segment uses a linearly varying curvature
2052
+ (clothoid), and the path is reconstructed by numerically integrating X_0 and Y_0.
2053
+
2054
+ .. outputs::
2055
+ :return x_full: x-coordinates of all sampled path points in m.
2056
+ :rtype x_full: np.ndarray
2057
+ :return y_full: y-coordinates of all sampled path points in m.
2058
+ :rtype y_full: np.ndarray
2059
+ :return s_full: arc-length position of each sampled point along the path in m.
2060
+ :rtype s_full: np.ndarray
2061
+ :return curvature_full: curvature value at each sampled point in rad/m.
2062
+ :rtype curvature_full: np.ndarray
2063
+ """
2064
+
2065
+ x = [self.x0]
2066
+ y = [self.y0]
2067
+
2068
+ theta_0 = self.th0
2069
+
2070
+ x_full = []
2071
+ y_full = []
2072
+ s_full = []
2073
+ curvature_full = []
2074
+
2075
+ for i in range(1, len(self.s)):
2076
+
2077
+ L = self.s[i] - self.s[i - 1]
2078
+
2079
+ if L == 0:
2080
+ s_full.append(np.nan)
2081
+ curvature_full.append(np.nan)
2082
+ continue
2083
+
2084
+ k0 = self.k_0[i - 1]
2085
+ k1 = (self.k_0[i] - self.k_0[i - 1]) / L
2086
+
2087
+ s_values = np.linspace(0, L, self.nump)
2088
+
2089
+ for s_val in s_values:
2090
+
2091
+ X = s_val * self.X_0(k1 * s_val**2, k0 * s_val, theta_0)
2092
+ Y = s_val * self.Y_0(k1 * s_val**2, k0 * s_val, theta_0)
2093
+
2094
+ x_new = x[-1] + X
2095
+ y_new = y[-1] + Y
2096
+
2097
+ x_full.append(x_new)
2098
+ y_full.append(y_new)
2099
+
2100
+ s_full.append(self.s[i - 1] + s_val)
2101
+ curvature_full.append(k0 + k1 * s_val)
2102
+
2103
+ theta_0 = theta_0 + k0 * L + (k1 * L**2) / 2
2104
+
2105
+ x.append(x_full[-1])
2106
+ y.append(y_full[-1])
2107
+
2108
+ x_full = np.array(x_full)
2109
+ y_full = np.array(y_full)
2110
+ s_full = np.array(s_full)
2111
+ curvature_full = np.array(curvature_full)
2112
+
2113
+ return x_full, y_full, s_full, curvature_full
2114
+
2115
+
2116
+
2117
+
2118
+
2119
+
2120
+ def OSP(reftrack: np.ndarray,
2121
+ normvectors: np.ndarray,
2122
+ w_veh: float,
2123
+ print_debug: bool = False) -> np.ndarray:
2124
+ """
2125
+
2126
+ .. description::
2127
+ Builds the quadratic programming matrices H, f, G, h for the Optimal Shortest Path (OSP) problem.
2128
+ The objective minimizes the total squared lateral deviation between consecutive raceline points,
2129
+ subject to track-width bounds on the lateral shift alpha.
2130
+
2131
+ .. inputs::
2132
+ :param reftrack: reference track array [x, y, w_tr_right, w_tr_left] in m (unclosed).
2133
+ :type reftrack: np.ndarray
2134
+ :param normvectors: normalized normal vectors for every track point [x_component, y_component].
2135
+ :type normvectors: np.ndarray
2136
+ :param w_veh: vehicle width in m used to reduce the allowed lateral deviation.
2137
+ :type w_veh: float
2138
+ :param print_debug: flag to print debug information (currently unused).
2139
+ :type print_debug: bool
2140
+
2141
+ .. outputs::
2142
+ :return H: symmetric quadratic cost matrix for the QP.
2143
+ :rtype H: np.ndarray
2144
+ :return f: linear cost vector for the QP.
2145
+ :rtype f: np.ndarray
2146
+ :return G: inequality constraint matrix (stacked identity matrices).
2147
+ :rtype G: np.ndarray
2148
+ :return h: inequality constraint vector encoding the allowed lateral deviations.
2149
+ :rtype h: np.ndarray
2150
+ """
2151
+
2152
+ no_points = reftrack.shape[0]
2153
+
2154
+ # check inputs
2155
+ if no_points != normvectors.shape[0]:
2156
+ raise RuntimeError("Array size of reftrack should be the same as normvectors!")
2157
+
2158
+ # ------------------------------------------------------------------------------------------------------------------
2159
+ # SET UP FINAL MATRICES FOR SOLVER ---------------------------------------------------------------------------------
2160
+ # ------------------------------------------------------------------------------------------------------------------
2161
+
2162
+ H = np.zeros((no_points, no_points))
2163
+ f = np.zeros(no_points)
2164
+
2165
+ i_idx = np.arange(no_points)
2166
+ next_idx = (i_idx + 1) % no_points
2167
+ prev_idx = (i_idx - 1) % no_points
2168
+
2169
+ np.fill_diagonal(H, 4 * (normvectors[:, 0]**2 + normvectors[:, 1]**2))
2170
+ off_diag = -2 * (normvectors[i_idx, 0] * normvectors[next_idx, 0]
2171
+ + normvectors[i_idx, 1] * normvectors[next_idx, 1])
2172
+ H[i_idx, next_idx] = off_diag
2173
+ H[next_idx, i_idx] = off_diag
2174
+
2175
+ f = (2 * normvectors[:, 0] * (2 * reftrack[:, 0] - reftrack[prev_idx, 0] - reftrack[next_idx, 0])
2176
+ + 2 * normvectors[:, 1] * (2 * reftrack[:, 1] - reftrack[prev_idx, 1] - reftrack[next_idx, 1]))
2177
+
2178
+
2179
+
2180
+ # calculate allowed deviation from refline
2181
+ dev_max_right = reftrack[:, 2] - w_veh / 2
2182
+ dev_max_left = reftrack[:, 3] - w_veh / 2
2183
+
2184
+ # set minimum deviation to zero
2185
+ dev_max_right[dev_max_right < 0.001] = 0.001
2186
+ dev_max_left[dev_max_left < 0.001] = 0.001
2187
+
2188
+ # consider value boundaries (-dev_max <= alpha <= dev_max)
2189
+ G = np.vstack((np.eye(no_points), -np.eye(no_points)))
2190
+ h = np.ones(2 * no_points) * np.append(dev_max_right, dev_max_left)
2191
+
2192
+ return H,f,G,h
2193
+
2194
+ def ShortestPath(reftrack: np.ndarray,
2195
+ w_veh: float,
2196
+ stepsize: float,
2197
+ plot: bool,
2198
+ v_max = 22.88,
2199
+ ggv_import_path="maps/ggv.csv",ax_max_machines_import_path="maps/ax_max_machines.csv") -> np.ndarray:
2200
+ """
2201
+
2202
+ .. description::
2203
+ Computes the shortest feasible path through a closed reference track by solving a QP via the OSP formulation.
2204
+ After obtaining the optimal lateral shift, the function interpolates the raceline, computes kinematic profiles
2205
+ (velocity, acceleration, lap time), and optionally plots the results.
2206
+
2207
+ .. inputs::
2208
+ :param reftrack: reference track array [x, y, w_tr_right, w_tr_left] in m (unclosed).
2209
+ :type reftrack: np.ndarray
2210
+ :param w_veh: vehicle width in m used to reduce the allowed lateral deviation.
2211
+ :type w_veh: float
2212
+ :param stepsize: interpolation step size for the raceline in m (currently unused internally).
2213
+ :type stepsize: float
2214
+ :param v_max: maximum velocity in m/s for the velocity profile calculation.
2215
+ :type v_max: float
2216
+ :param plot: flag to enable plotting of the track and raceline.
2217
+ :type plot: bool
2218
+ :param ggv_import_path: file path to the ggv diagram CSV.
2219
+ :type ggv_import_path: str
2220
+ :param ax_max_machines_import_path: file path to the maximum machine acceleration CSV.
2221
+ :type ax_max_machines_import_path: str
2222
+
2223
+ .. outputs::
2224
+ :return raceline_interp: interpolated shortest-path raceline coordinates [x, y].
2225
+ :rtype raceline_interp: np.ndarray
2226
+ :return alpha_shpath: optimal lateral shift in m for every reference track point.
2227
+ :rtype alpha_shpath: np.ndarray
2228
+ :return s_splines: cumulative arc-length stations along the raceline in m.
2229
+ :rtype s_splines: np.ndarray
2230
+ :return vx_profile_opt_cl: closed velocity profile (last point appended) in m/s.
2231
+ :rtype vx_profile_opt_cl: np.ndarray
2232
+ :return ax_profile_opt: longitudinal acceleration profile in m/s2.
2233
+ :rtype ax_profile_opt: np.ndarray
2234
+ :return kappa_opt: curvature profile of the raceline in rad/m.
2235
+ :rtype kappa_opt: np.ndarray
2236
+ :return t_profile_cl: cumulative lap time profile in seconds.
2237
+ :rtype t_profile_cl: np.ndarray
2238
+ """
2239
+
2240
+ lengths = np.sqrt(np.sum(np.power(np.diff(reftrack[:,0:2], axis=0), 2), axis=1))
2241
+ lengths=np.append(lengths, lengths[0])
2242
+ coeffs_x, coeffs_y, M, normvec_norm = calc_splines(path=np.vstack((reftrack[:, 0:2], reftrack[0, 0:2])),el_lengths=lengths)
2243
+ H, f, G , h = OSP(reftrack=reftrack,
2244
+ normvectors=normvec_norm,
2245
+ w_veh=w_veh,)
2246
+ alpha_shpath = quadprog.solve_qp(H, -f, -G.T, -h, 0)[0]
2247
+ # sampled_pointss=np.zeros_like(reftrack[:,:2])
2248
+ # for i in range(len(alpha_shpath)):
2249
+ # sampled_pointss[i,:] = reftrack[i,:2]+alpha_shpath[i]*normvec_norm[i,:]
2250
+
2251
+
2252
+
2253
+
2254
+ raceline_interp, a_opt, coeffs_x_opt, coeffs_y_opt, spline_inds_opt_interp, t_vals_opt_interp, s_points_opt_interp,\
2255
+ spline_lengths_opt, el_lengths_opt_interp = create_raceline(refline=reftrack[:, :2],
2256
+ normvectors=normvec_norm,
2257
+ alpha=alpha_shpath,
2258
+ stepsize_interp=2.0,)
2259
+
2260
+
2261
+ psi_vel_opt, kappa_opt =calc_head_curv_an(coeffs_x=coeffs_x_opt,
2262
+ coeffs_y=coeffs_y_opt,
2263
+ ind_spls=spline_inds_opt_interp,
2264
+ t_spls=t_vals_opt_interp)
2265
+
2266
+ # s_splines = cumulative_distances(el_lengths_opt_interp)
2267
+
2268
+ vm = v_max
2269
+ fw = 3
2270
+
2271
+ ggv,ax_max_machines =import_veh_dyn_info(ggv_import_path=ggv_import_path,ax_max_machines_import_path=ax_max_machines_import_path)
2272
+ vx_profile_opt = calc_vel_profile(ggv=ggv,
2273
+ ax_max_machines=ax_max_machines,
2274
+ v_max=vm,
2275
+ kappa=kappa_opt,
2276
+ el_lengths=el_lengths_opt_interp,
2277
+ closed=True,
2278
+ filt_window=fw,
2279
+ dyn_model_exp=1.0,
2280
+ drag_coeff=0.75,
2281
+ m_veh=1000.0,
2282
+ v_start = 0.0)
2283
+
2284
+ # calculate longitudinal acceleration profile
2285
+ s_splines = cumulative_distances(el_lengths_opt_interp)
2286
+ vx_profile_opt_cl = np.append(vx_profile_opt, vx_profile_opt[0])
2287
+ ax_profile_opt = calc_ax_profile(vx_profile=vx_profile_opt_cl,
2288
+ el_lengths=el_lengths_opt_interp,
2289
+ eq_length_output=False)
2290
+
2291
+ # calculate laptime
2292
+ t_profile_cl = calc_t_profile(vx_profile=vx_profile_opt,
2293
+ ax_profile=ax_profile_opt,
2294
+ el_lengths=el_lengths_opt_interp)
2295
+
2296
+ if plot == True:
2297
+ bound1 = reftrack[:, 0:2] - normvec_norm * np.expand_dims(reftrack[:, 2], axis=1)
2298
+ bound2 = reftrack[:, 0:2] + normvec_norm * np.expand_dims(reftrack[:, 3], axis=1)
2299
+
2300
+ plt.figure(figsize=(10, 10))
2301
+ plt.plot(reftrack[:,0],reftrack[:,1],'b--',label='ref_line')
2302
+ plt.plot(bound1[:, 0], bound1[:, 1], 'k', label=' Track')
2303
+ plt.plot(bound2[:, 0], bound2[:, 1], 'k')
2304
+ plt.plot(raceline_interp[:,0], raceline_interp[:,1],'r.-' , label='Shortest Path')
2305
+ plt.xlabel('X')
2306
+ plt.ylabel('Y')
2307
+ plt.legend()
2308
+ plt.grid(True)
2309
+ plt.show()
2310
+
2311
+
2312
+ plt.figure(figsize=(15, 15))
2313
+ plt.subplot(2,2,1)
2314
+ plt.plot(s_splines, vx_profile_opt,'b' , label='v')
2315
+ plt.ylabel('v_profile')
2316
+ plt.xlabel('distance')
2317
+ plt.legend()
2318
+ plt.grid(True)
2319
+ plt.subplot(2,2,2)
2320
+ plt.plot(s_splines, ax_profile_opt,'b' , label='a')
2321
+ plt.ylabel('a_profile')
2322
+ plt.xlabel('distance')
2323
+ plt.legend()
2324
+ plt.grid(True)
2325
+ plt.subplot(2,2,3)
2326
+ plt.plot(s_splines, kappa_opt,'b' , label='curvature')
2327
+ plt.ylabel('curv_profile')
2328
+ plt.xlabel('distance')
2329
+ plt.legend()
2330
+ plt.grid(True)
2331
+ plt.subplot(2,2,4)
2332
+ plt.plot(s_splines, t_profile_cl[:-1],'b' , label='time')
2333
+ plt.ylabel('time')
2334
+ plt.xlabel('distance')
2335
+ plt.legend()
2336
+ plt.grid(True)
2337
+ plt.show()
2338
+
2339
+ print(t_profile_cl[-1])
2340
+
2341
+
2342
+ return raceline_interp,alpha_shpath,s_splines,vx_profile_opt_cl,ax_profile_opt,kappa_opt,t_profile_cl