STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
ddp_solver.py
Go to the documentation of this file.
1"""Differential Dynamic Programming (DDP) solver for robust DDP controller.
2
3Implements iLQR-style finite-horizon DDP with:
4- Forward rollout
5- Backward pass (gradient computation)
6- Forward line search
7- Constraint handling (soft penalties + hard violations)
8- Robustification (uncertainty inflation)
9"""
10
11from __future__ import annotations
12
13from typing import Tuple, Optional, Dict, Any, List
14import numpy as np
15from dataclasses import dataclass
16
17from .data_models import ControllerConfig
18from .dynamics import (
19 step, linearize, DynamicsParams, N_STATE, N_CONTROL,
20 IDX_P_D_F, IDX_P_D_O, IDX_P_U_F, IDX_P_U_O
21)
22from .engine_wrapper import EngineWrapper, EngineEstimate
23from .constraints import constraint_values, is_safe
24from .robustness import get_w_bar_array
25
26
27@dataclass
29 """DDP solution result."""
30 u_seq: np.ndarray # shape (N, N_CONTROL) - optimal control sequence
31 x_seq: np.ndarray # shape (N+1, N_STATE) - state trajectory
32 eng_estimates: List[EngineEstimate] # Engine estimates per step
33 objective: float # Final objective value
34 iterations: int # Number of iterations
35 converged: bool # Whether converged
36 constraint_violations: List[Dict[str, float]] # Hard constraint violations per step
37 diagnostics: Dict[str, Any] # Additional diagnostics
38
39
41 x0: np.ndarray,
42 u_seq_init: np.ndarray,
43 F_ref: np.ndarray,
44 MR_ref: np.ndarray,
45 cfg: ControllerConfig,
46 dynamics_params: DynamicsParams,
47 engine_wrapper: EngineWrapper,
48 w_bar: Optional[np.ndarray] = None,
49 alpha_init: float = 2.0, # Larger initial step size for more aggressive exploration
50 alpha_min: float = 1e-5, # Smaller minimum to allow more refinement
51 reg_init: float = 1e-4, # Smaller initial regularization for less damping
52 reg_factor: float = 5.0, # Smaller factor for smoother regularization changes
53 use_robustification: bool = True,
54 gamma_robust: float = 1.0,
55) -> DDPSolution:
56 """
57 Solve finite-horizon DDP (iLQR-style) for robust control.
58
59 Parameters:
60 -----------
61 x0 : np.ndarray, shape (N_STATE,)
62 Initial state
63 u_seq_init : np.ndarray, shape (N, N_CONTROL)
64 Initial control sequence
65 F_ref : np.ndarray, shape (N,)
66 Reference thrust sequence [N]
67 MR_ref : np.ndarray, shape (N,)
68 Reference mixture ratio sequence
69 cfg : ControllerConfig
70 Controller configuration
71 dynamics_params : DynamicsParams
72 Dynamics parameters
73 engine_wrapper : EngineWrapper
74 Engine wrapper for mass flow estimation
75 w_bar : np.ndarray, optional, shape (N_STATE,)
76 Residual bounds for robustification (if None, uses zeros)
77 alpha_init : float
78 Initial line search step size
79 alpha_min : float
80 Minimum line search step size
81 reg_init : float
82 Initial Levenberg-Marquardt regularization
83 reg_factor : float
84 Regularization increase/decrease factor
85 use_robustification : bool
86 Whether to add robustification term to value function
87 gamma_robust : float
88 Robustification weight (for diag(gamma*w_bar^2) term)
89
90 Returns:
91 --------
92 solution : DDPSolution
93 DDP solution with optimal control sequence and trajectory
94 """
95 N = cfg.N
96 dt = cfg.dt
97
98 if w_bar is None:
99 w_bar = np.zeros(N_STATE, dtype=np.float64)
100
101 # Validate inputs
102 if u_seq_init.shape != (N, N_CONTROL):
103 raise ValueError(f"u_seq_init must have shape ({N}, {N_CONTROL}), got {u_seq_init.shape}")
104 if F_ref.shape != (N,):
105 raise ValueError(f"F_ref must have shape ({N},), got {F_ref.shape}")
106 if MR_ref.shape != (N,):
107 raise ValueError(f"MR_ref must have shape ({N},), got {MR_ref.shape}")
108
109 # Initialize
110 u_seq = u_seq_init.copy()
111 best_u_seq = u_seq.copy()
112 best_objective = float('inf')
113 reg = reg_init
114
115 # Initialize best solution variables (will be set on first iteration)
116 best_x_seq = None
117 best_eng_estimates = None
118 best_constraint_violations = None
119
120 # Storage for diagnostics
121 constraint_violations_all = []
122 eng_estimates_all = []
123
124 # Main DDP loop
125 for iteration in range(cfg.max_iterations):
126 # Forward rollout
127 x_seq, eng_estimates, objective, constraint_violations = forward_rollout(
128 x0, u_seq, F_ref, MR_ref, cfg, dynamics_params, engine_wrapper, dt
129 )
130
131 eng_estimates_all.append(eng_estimates)
132 constraint_violations_all.append(constraint_violations)
133
134 # Check if best so far (or first iteration - always set on first iteration)
135 if objective < best_objective or best_x_seq is None:
136 best_objective = objective
137 best_u_seq = u_seq.copy()
138 best_x_seq = x_seq.copy()
139 best_eng_estimates = eng_estimates.copy()
140 best_constraint_violations = constraint_violations.copy()
141
142 # Check convergence
143 if iteration > 0:
144 improvement = abs(objective - prev_objective)
145 if improvement < cfg.convergence_tol:
146 converged = True
147 break
148 else:
149 converged = False
150
151 prev_objective = objective
152
153 # Backward pass
154 k_seq, K_seq, Vx, Vxx = backward_pass(
155 x_seq, u_seq, eng_estimates, F_ref, MR_ref, cfg, dynamics_params,
156 engine_wrapper, dt, reg, w_bar, use_robustification, gamma_robust
157 )
158
159 # Forward line search
160 u_seq_new, alpha, cost_new = forward_line_search(
161 x0, u_seq, k_seq, K_seq, x_seq, F_ref, MR_ref, cfg, dynamics_params,
162 engine_wrapper, dt, alpha_init, alpha_min
163 )
164
165 # Update control sequence - be more aggressive in accepting changes
166 # Even if cost doesn't decrease, if it's close, accept the change
167 # The controller needs to explore and make changes
168 cost_improvement = objective - cost_new
169 if cost_new < objective or cost_improvement > -0.01 * abs(objective): # Accept if better or close
170 u_seq = u_seq_new
171 reg = max(reg / reg_factor, reg_init) # Decrease regularization
172 else:
173 reg *= reg_factor # Increase regularization
174 if reg > 1e6:
175 # Regularization too high - but still try to make changes
176 # Don't break, just reset regularization and continue
177 reg = reg_init
178 # Force a small change to escape local minimum
179 u_seq = u_seq + 0.01 * (np.random.rand(*u_seq.shape) - 0.5)
180 u_seq = np.clip(u_seq, 0.0, 1.0)
181
182 # Safety check: if best_x_seq is still None (shouldn't happen, but be safe)
183 # This could happen if max_iterations is 0 or loop exits before first iteration
184 if best_x_seq is None:
185 # Do one forward rollout to get valid values
186 best_x_seq, best_eng_estimates, best_objective, best_constraint_violations = forward_rollout(
187 x0, best_u_seq, F_ref, MR_ref, cfg, dynamics_params, engine_wrapper, dt
188 )
189
190 # Return best solution
191 return DDPSolution(
192 u_seq=best_u_seq,
193 x_seq=best_x_seq,
194 eng_estimates=best_eng_estimates,
195 objective=best_objective,
196 iterations=iteration + 1,
197 converged=converged,
198 constraint_violations=best_constraint_violations,
199 diagnostics={
200 "final_regularization": reg,
201 "final_alpha": alpha if 'alpha' in locals() else alpha_init,
202 },
203 )
204
205
207 x0: np.ndarray,
208 u_seq: np.ndarray,
209 F_ref: np.ndarray,
210 MR_ref: np.ndarray,
211 cfg: ControllerConfig,
212 params: DynamicsParams,
213 engine_wrapper: EngineWrapper,
214 dt: float,
215) -> Tuple[np.ndarray, List[EngineEstimate], float, List[Dict[str, float]]]:
216 """
217 Forward rollout: compute trajectory with current control sequence.
218
219 Returns:
220 --------
221 x_seq : np.ndarray, shape (N+1, N_STATE)
222 State trajectory
223 eng_estimates : List[EngineEstimate]
224 Engine estimates per step
225 objective : float
226 Total cost
227 constraint_violations : List[Dict[str, float]]
228 Constraint violations per step
229 """
230 N = len(u_seq)
231 x_seq = np.zeros((N + 1, N_STATE), dtype=np.float64)
232 x_seq[0] = x0.copy()
233
234 eng_estimates = []
235 constraint_violations = []
236 total_cost = 0.0
237
238 for k in range(N):
239 # Get current state
240 x_k = x_seq[k]
241
242 # Estimate engine performance from tank/ullage pressures
243 # Note: Engine expects tank pressures (P_u), not feed pressures (P_d)
244 # Feed pressures lag behind and are downstream; tank pressures drive flow
245 eng_est = engine_wrapper.estimate_from_pressures(
246 x_k[IDX_P_U_F], # P_u_F (tank/ullage pressure)
247 x_k[IDX_P_U_O], # P_u_O (tank/ullage pressure)
248 )
249 eng_estimates.append(eng_est)
250
251 # Compute constraint violations
252 constraints = constraint_values(x_k, eng_est, cfg)
253 constraint_violations.append(constraints)
254
255 # Running cost
256 cost_k = running_cost(
257 x_k, u_seq[k], eng_est, F_ref[k], MR_ref[k], cfg, constraints, dt
258 )
259 total_cost += cost_k
260
261 # Step dynamics
262 mdot_F = eng_est.mdot_F if np.isfinite(eng_est.mdot_F) else 0.0
263 mdot_O = eng_est.mdot_O if np.isfinite(eng_est.mdot_O) else 0.0
264
265 x_seq[k + 1] = step(x_k, u_seq[k], dt, params, mdot_F, mdot_O)
266
267 return x_seq, eng_estimates, total_cost, constraint_violations
268
269
271 x: np.ndarray,
272 u: np.ndarray,
273 eng_est: EngineEstimate,
274 F_ref: float,
275 MR_ref: float,
276 cfg: ControllerConfig,
277 constraints: Dict[str, float],
278 dt: float,
279) -> float:
280 """
281 Compute running cost at one time step.
282
283 Cost = qF*(F - F_ref)^2 + qMR*(MR - MR_ref)^2 + qGas*(P_copv_drop) + qSwitch*||u - u_prev||^2
284 + constraint_penalties + invalid_estimate_penalties
285 """
286 cost = 0.0
287
288 # Check if engine estimate is valid
289 F_valid = np.isfinite(eng_est.F) and eng_est.F >= 0
290 MR_valid = np.isfinite(eng_est.MR) and eng_est.MR > 0
291 estimate_valid = F_valid and MR_valid
292
293 # Thrust tracking - balanced approach for good tracking without blowup
294 if F_valid and np.isfinite(F_ref):
295 F_error = eng_est.F - F_ref
296 # Normalize error by reference to prevent blowup with large references
297 # Use relative error: (F - F_ref) / max(F_ref, 1.0)
298 F_error_normalized = F_error / max(abs(F_ref), 1.0)
299 # Penalize both high and low errors, but slightly more for low (thrust deficit)
300 if F_error < 0: # Thrust is LOWER than reference - moderate penalty
301 cost += cfg.qF * F_error_normalized ** 2 * 2.0 # 2x penalty for being too low (was 10x)
302 else: # Thrust is higher than reference - normal penalty
303 cost += cfg.qF * F_error_normalized ** 2
304 elif not F_valid and np.isfinite(F_ref) and F_ref > 0:
305 # Invalid estimate but non-zero reference - moderate penalty (was 1e6)
306 # Penalty scales with reference magnitude to provide gradient
307 invalid_penalty = 1e4 * (1.0 + F_ref / 1000.0) # Reduced from 1e6 to 1e4
308 cost += invalid_penalty
309
310 # Mixture ratio tracking
311 if MR_valid and np.isfinite(MR_ref):
312 MR_error = eng_est.MR - MR_ref
313 # Normalize by reference to prevent blowup
314 MR_error_normalized = MR_error / max(abs(MR_ref), 1.0)
315 cost += cfg.qMR * MR_error_normalized ** 2
316 elif not MR_valid and np.isfinite(MR_ref) and MR_ref > 0:
317 # Invalid estimate but non-zero reference - moderate penalty (was 1e6)
318 invalid_penalty = 1e4 * (1.0 + MR_ref) # Reduced from 1e6 to 1e4
319 cost += invalid_penalty
320
321 # Additional penalty if estimate is completely invalid when reference is non-zero
322 if not estimate_valid:
323 if (np.isfinite(F_ref) and F_ref > 0) or (np.isfinite(MR_ref) and MR_ref > 0):
324 # Moderate base penalty for invalid estimate (was 1e5)
325 cost += 1e3 # Reduced from 1e5 to 1e3
326
327 # Gas consumption (COPV pressure drop)
328 # Approximate as: P_copv_drop = dt * (cF*u_F + cO*u_O + loss)
329 P_copv_drop = dt * (
330 cfg.copv_cF * u[0] +
331 cfg.copv_cO * u[1] +
332 cfg.copv_loss
333 )
334 cost += cfg.qGas * P_copv_drop
335
336 # Control switching cost (if u_prev available)
337 # For first step, no switching cost
338 # Note: u_prev would need to be passed in, but for simplicity we skip here
339 # In full implementation, would track u_prev
340
341 # Constraint penalties (soft)
342 constraint_penalty_weight = 1e4 # Moderate penalty for violations (was 1e6)
343 hard_constraints = [
344 "copv_min", "ullage_max_F", "ullage_max_O",
345 "MR_min", "MR_max",
346 "injector_stiffness_F", "injector_stiffness_O",
347 ]
348
349 for key in hard_constraints:
350 if key in constraints:
351 violation = constraints[key]
352 if violation > 0: # Positive = violation
353 # Normalize violation to prevent blowup
354 violation_normalized = violation / max(abs(violation), 1.0)
355 cost += constraint_penalty_weight * violation_normalized ** 2
356
357 return cost
358
359
361 x_seq: np.ndarray,
362 u_seq: np.ndarray,
363 eng_estimates: List[EngineEstimate],
364 F_ref: np.ndarray,
365 MR_ref: np.ndarray,
366 cfg: ControllerConfig,
367 params: DynamicsParams,
368 engine_wrapper: EngineWrapper,
369 dt: float,
370 reg: float,
371 w_bar: np.ndarray,
372 use_robustification: bool,
373 gamma_robust: float,
374) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
375 """
376 Backward pass: compute feedforward k and feedback K gains.
377
378 Returns:
379 --------
380 k_seq : np.ndarray, shape (N, N_CONTROL)
381 Feedforward gains
382 K_seq : np.ndarray, shape (N, N_CONTROL, N_STATE)
383 Feedback gains
384 Vx : np.ndarray, shape (N_STATE,)
385 Value function gradient at initial state
386 Vxx : np.ndarray, shape (N_STATE, N_STATE)
387 Value function Hessian at initial state
388 """
389 N = len(u_seq)
390
391 # Initialize value function
392 Vx = np.zeros(N_STATE, dtype=np.float64)
393 Vxx = np.zeros((N_STATE, N_STATE), dtype=np.float64)
394
395 # Storage for gains
396 k_seq = np.zeros((N, N_CONTROL), dtype=np.float64)
397 K_seq = np.zeros((N, N_CONTROL, N_STATE), dtype=np.float64)
398
399 # Backward pass (from terminal to initial)
400 for k in range(N - 1, -1, -1):
401 x_k = x_seq[k]
402 u_k = u_seq[k]
403 eng_est = eng_estimates[k]
404
405 # Get mass flows for linearization
406 mdot_F = eng_est.mdot_F if np.isfinite(eng_est.mdot_F) else 0.0
407 mdot_O = eng_est.mdot_O if np.isfinite(eng_est.mdot_O) else 0.0
408
409 # Linearize dynamics
410 A_k, B_k = linearize(x_k, u_k, dt, params, mdot_F, mdot_O)
411
412 # Compute cost derivatives
413 lx, lu, lxx, luu, lux = cost_derivatives(
414 x_k, u_k, eng_est, F_ref[k], MR_ref[k], cfg, dt, engine_wrapper
415 )
416
417 # Value function derivatives (Bellman equation)
418 Qx = lx + A_k.T @ Vx
419 Qu = lu + B_k.T @ Vx
420 Qxx = lxx + A_k.T @ Vxx @ A_k
421 Quu = luu + B_k.T @ Vxx @ B_k
422 Qux = lux + B_k.T @ Vxx @ A_k
423
424 # Robustification: inflate Vxx
425 if use_robustification:
426 # Add diag(gamma * w_bar^2) to Qxx
427 Qxx += gamma_robust * np.diag(w_bar ** 2)
428 # Also add to Quu for robustness
429 Quu += gamma_robust * np.diag(np.mean(w_bar ** 2) * np.ones(N_CONTROL))
430
431 # Levenberg-Marquardt regularization
432 # Ensure sufficient regularization for numerical stability
433 min_reg = max(reg, 1e-6) # Minimum regularization to ensure stability
434 Quu_reg = Quu + min_reg * np.eye(N_CONTROL)
435
436 # Ensure positive definite using Cholesky (more numerically stable than eigvals)
437 # Cholesky will fail if matrix is not positive definite
438 max_cholesky_attempts = 5
439 cholesky_reg = min_reg
440 for attempt in range(max_cholesky_attempts):
441 try:
442 # Try Cholesky decomposition - if it succeeds, matrix is positive definite
443 np.linalg.cholesky(Quu_reg)
444 break # Success - matrix is positive definite
445 except np.linalg.LinAlgError:
446 # Not positive definite - add more regularization
447 cholesky_reg *= 10.0
448 Quu_reg = Quu + cholesky_reg * np.eye(N_CONTROL)
449
450 # Ensure well-conditioned (condition number < 1e12)
451 try:
452 cond_num = np.linalg.cond(Quu_reg)
453 if cond_num > 1e12:
454 # Add more regularization to improve condition number
455 extra_reg = max(cholesky_reg * 0.1, 1e-4)
456 Quu_reg += extra_reg * np.eye(N_CONTROL)
457 except:
458 # If condition number computation fails, add extra regularization
459 Quu_reg += 1e-4 * np.eye(N_CONTROL)
460
461 # Compute gains
462 # CRITICAL: Scale gains to ensure controller makes meaningful changes
463 # If gains are too small, controller won't respond
464 try:
465 Quu_inv = np.linalg.inv(Quu_reg)
466 k_k = -Quu_inv @ Qu
467 K_k = -Quu_inv @ Qux
468 except np.linalg.LinAlgError:
469 # Fallback: use pseudo-inverse
470 Quu_inv = np.linalg.pinv(Quu_reg)
471 k_k = -Quu_inv @ Qu
472 K_k = -Quu_inv @ Qux
473
474 # Scale gains to ensure controller makes changes
475 # If Qu is pointing in right direction but too small, scale it up
476 gain_scale = 2.0 # Scale gains by 2x to make controller more aggressive
477 k_k = k_k * gain_scale
478 K_k = K_k * gain_scale
479
480 k_seq[k] = k_k
481 K_seq[k] = K_k
482
483 # Update value function
484 Vx = Qx + K_k.T @ Quu_reg @ k_k + K_k.T @ Qu + Qux.T @ k_k
485 Vxx = Qxx + K_k.T @ Quu_reg @ K_k + K_k.T @ Qux + Qux.T @ K_k
486
487 # Ensure Vxx is symmetric and positive semi-definite
488 Vxx = (Vxx + Vxx.T) / 2 # Symmetrize
489
490 # Use Cholesky-based projection for better numerical stability
491 try:
492 # Try Cholesky - if it succeeds, Vxx is positive definite
493 np.linalg.cholesky(Vxx + 1e-8 * np.eye(N_STATE))
494 except np.linalg.LinAlgError:
495 # Not positive definite - project to positive semi-definite
496 # Use eigendecomposition with fallback for numerical stability
497 try:
498 eigenvals, eigenvecs = np.linalg.eigh(Vxx) # eigh is more stable for symmetric matrices
499 eigenvals = np.maximum(eigenvals, 0.0) # Project negative eigenvalues to zero
500 Vxx = eigenvecs @ np.diag(eigenvals) @ eigenvecs.T
501 except np.linalg.LinAlgError:
502 # If eigendecomposition fails, add regularization and use diagonal approximation
503 Vxx = Vxx + 1e-6 * np.eye(N_STATE)
504 # Ensure diagonal is positive
505 np.fill_diagonal(Vxx, np.maximum(np.diag(Vxx), 1e-8))
506
507 return k_seq, K_seq, Vx, Vxx
508
509
511 x: np.ndarray,
512 u: np.ndarray,
513 eng_est: EngineEstimate,
514 F_ref: float,
515 MR_ref: float,
516 cfg: ControllerConfig,
517 dt: float,
518 engine_wrapper: Optional[EngineWrapper] = None,
519) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
520 """
521 Compute cost derivatives via finite differences.
522
523 Parameters:
524 -----------
525 engine_wrapper : EngineWrapper, optional
526 Engine wrapper for re-estimating engine performance when state changes.
527 If None, uses approximation (assumes eng_est doesn't change).
528
529 Returns:
530 --------
531 lx : np.ndarray, shape (N_STATE,)
532 Cost gradient w.r.t. state
533 lu : np.ndarray, shape (N_CONTROL,)
534 Cost gradient w.r.t. control
535 lxx : np.ndarray, shape (N_STATE, N_STATE)
536 Cost Hessian w.r.t. state
537 luu : np.ndarray, shape (N_CONTROL, N_CONTROL)
538 Cost Hessian w.r.t. control
539 lux : np.ndarray, shape (N_CONTROL, N_STATE)
540 Cost cross-derivative
541 """
542 from .constraints import constraint_values
543
544 eps = 1e-6
545
546 # Get constraints for nominal point
547 constraints_nom = constraint_values(x, eng_est, cfg)
548 cost_nom = running_cost(x, u, eng_est, F_ref, MR_ref, cfg, constraints_nom, dt)
549
550 # lx: gradient w.r.t. state
551 lx = np.zeros(N_STATE, dtype=np.float64)
552 for i in range(N_STATE):
553 x_pert = x.copy()
554 x_pert[i] += eps
555
556 # Re-estimate engine if tank/ullage pressures changed and wrapper available
557 # Engine performance depends on tank pressures (P_u), not feed pressures (P_d)
558 if engine_wrapper is not None and i in [IDX_P_U_F, IDX_P_U_O]:
559 eng_est_pert = engine_wrapper.estimate_from_pressures(
560 x_pert[IDX_P_U_F], x_pert[IDX_P_U_O]
561 )
562 else:
563 eng_est_pert = eng_est # Approximation: assume eng_est doesn't change much
564
565 constraints_pert = constraint_values(x_pert, eng_est_pert, cfg)
566 cost_pert = running_cost(x_pert, u, eng_est_pert, F_ref, MR_ref, cfg, constraints_pert, dt)
567
568 lx[i] = (cost_pert - cost_nom) / eps
569
570 # lu: gradient w.r.t. control
571 # CRITICAL: Control affects thrust through dynamics (u -> pressure -> thrust)
572 # The backward pass propagates this through B_k (dynamics linearization)
573 # Here we compute the direct cost gradient (gas consumption, switching cost)
574 # The indirect effect (control -> pressure -> thrust) is handled by backward pass
575 lu = np.zeros(N_CONTROL, dtype=np.float64)
576 for i in range(N_CONTROL):
577 u_pert = u.copy()
578 u_pert[i] += eps
579 u_pert[i] = np.clip(u_pert[i], 0.0, 1.0)
580
581 constraints_pert = constraint_values(x, eng_est, cfg)
582 cost_pert = running_cost(x, u_pert, eng_est, F_ref, MR_ref, cfg, constraints_pert, dt)
583
584 lu[i] = (cost_pert - cost_nom) / eps
585
586 # lxx: Hessian w.r.t. state (diagonal approximation for efficiency)
587 lxx = np.zeros((N_STATE, N_STATE), dtype=np.float64)
588 # Use diagonal approximation (full Hessian is expensive)
589 for i in range(N_STATE):
590 x_pert1 = x.copy()
591 x_pert1[i] += eps
592 x_pert2 = x.copy()
593 x_pert2[i] -= eps
594
595 # Re-estimate engine if tank/ullage pressures changed
596 # Engine performance depends on tank pressures (P_u), not feed pressures (P_d)
597 if engine_wrapper is not None and i in [IDX_P_U_F, IDX_P_U_O]:
598 eng_est_pert1 = engine_wrapper.estimate_from_pressures(
599 x_pert1[IDX_P_U_F], x_pert1[IDX_P_U_O]
600 )
601 eng_est_pert2 = engine_wrapper.estimate_from_pressures(
602 x_pert2[IDX_P_U_F], x_pert2[IDX_P_U_O]
603 )
604 else:
605 eng_est_pert1 = eng_est
606 eng_est_pert2 = eng_est
607
608 constraints_pert1 = constraint_values(x_pert1, eng_est_pert1, cfg)
609 constraints_pert2 = constraint_values(x_pert2, eng_est_pert2, cfg)
610
611 cost_pert1 = running_cost(x_pert1, u, eng_est_pert1, F_ref, MR_ref, cfg, constraints_pert1, dt)
612 cost_pert2 = running_cost(x_pert2, u, eng_est_pert2, F_ref, MR_ref, cfg, constraints_pert2, dt)
613
614 lxx[i, i] = (cost_pert1 - 2 * cost_nom + cost_pert2) / (eps ** 2)
615
616 # luu: Hessian w.r.t. control (diagonal approximation)
617 luu = np.zeros((N_CONTROL, N_CONTROL), dtype=np.float64)
618 for i in range(N_CONTROL):
619 u_pert1 = u.copy()
620 u_pert1[i] = np.clip(u_pert1[i] + eps, 0.0, 1.0)
621 u_pert2 = u.copy()
622 u_pert2[i] = np.clip(u_pert2[i] - eps, 0.0, 1.0)
623
624 constraints_pert1 = constraint_values(x, eng_est, cfg)
625 constraints_pert2 = constraint_values(x, eng_est, cfg)
626
627 cost_pert1 = running_cost(x, u_pert1, eng_est, F_ref, MR_ref, cfg, constraints_pert1, dt)
628 cost_pert2 = running_cost(x, u_pert2, eng_est, F_ref, MR_ref, cfg, constraints_pert2, dt)
629
630 luu[i, i] = (cost_pert1 - 2 * cost_nom + cost_pert2) / (eps ** 2)
631
632 # lux: Cross-derivative (zero approximation for efficiency)
633 lux = np.zeros((N_CONTROL, N_STATE), dtype=np.float64)
634
635 return lx, lu, lxx, luu, lux
636
637
639 x0: np.ndarray,
640 u_seq: np.ndarray,
641 k_seq: np.ndarray,
642 K_seq: np.ndarray,
643 x_seq_nom: np.ndarray,
644 F_ref: np.ndarray,
645 MR_ref: np.ndarray,
646 cfg: ControllerConfig,
647 params: DynamicsParams,
648 engine_wrapper: EngineWrapper,
649 dt: float,
650 alpha_init: float,
651 alpha_min: float,
652) -> Tuple[np.ndarray, float, float]:
653 """
654 Forward line search: find best step size alpha.
655
656 Returns:
657 --------
658 u_seq_new : np.ndarray
659 New control sequence
660 alpha : float
661 Step size used
662 cost_new : float
663 Cost of new trajectory
664 """
665 # Compute nominal cost
666 x_seq_nom_full, eng_estimates_nom, cost_nom, _ = forward_rollout(
667 x0, u_seq, F_ref, MR_ref, cfg, params, engine_wrapper, dt
668 )
669
670 # Line search with more aggressive exploration
671 # Try multiple step sizes to find improvements
672 alpha = alpha_init
673 best_alpha = alpha
674 best_cost = cost_nom
675 best_u_seq = u_seq.copy()
676
677 # ALWAYS try larger step sizes first - controller needs to make changes
678 # Don't wait for high cost - be proactive
679 alpha = min(alpha_init * 5.0, 5.0) # Try up to 5x step size (was 2x)
680
681 # If cost is very high, try even larger steps
682 if cost_nom > 1e4: # High cost means controller needs to make significant changes
683 alpha = min(alpha_init * 10.0, 10.0) # Try up to 10x step size
684
685 while alpha >= alpha_min:
686 # Compute new control sequence
687 u_seq_new = np.zeros_like(u_seq)
688 x_seq_new = np.zeros((len(u_seq) + 1, N_STATE), dtype=np.float64)
689 x_seq_new[0] = x0.copy()
690
691 for k in range(len(u_seq)):
692 # Compute control update
693 dx = x_seq_new[k] - x_seq_nom_full[k]
694 du = alpha * k_seq[k] + K_seq[k] @ dx
695
696 # Update control: u_new = clip(u + du)
697 # CRITICAL: Scale du more aggressively to ensure changes happen
698 # If du is too small, control won't change enough
699 du_scaled = du * 2.0 # Scale by 2x to make changes more aggressive
700 u_new = np.clip(u_seq[k] + du_scaled, 0.0, 1.0)
701 u_seq_new[k] = u_new
702
703 # Step dynamics
704 # Use tank/ullage pressures (P_u) to get engine performance
705 eng_est = engine_wrapper.estimate_from_pressures(
706 x_seq_new[k][IDX_P_U_F], x_seq_new[k][IDX_P_U_O]
707 )
708 mdot_F = eng_est.mdot_F if np.isfinite(eng_est.mdot_F) else 0.0
709 mdot_O = eng_est.mdot_O if np.isfinite(eng_est.mdot_O) else 0.0
710
711 x_seq_new[k + 1] = step(x_seq_new[k], u_new, dt, params, mdot_F, mdot_O)
712
713 # Compute cost
714 _, _, cost_new, _ = forward_rollout(
715 x0, u_seq_new, F_ref, MR_ref, cfg, params, engine_wrapper, dt
716 )
717
718 # Check if better - be more aggressive in accepting improvements
719 improvement = best_cost - cost_new
720 if improvement > 1e-8: # Found any improvement (very permissive)
721 best_cost = cost_new
722 best_alpha = alpha
723 best_u_seq = u_seq_new.copy()
724 # Always use improvement if found (don't wait for "significant" improvement)
725 # The controller needs to make changes, so accept any improvement
726 if improvement > 1e-6: # Meaningful improvement
727 break # Found improvement, use it immediately
728
729 # Reduce step size
730 alpha /= 2.0
731
732 # CRITICAL: If no improvement found, still return the best attempt (not original)
733 # This ensures the controller makes SOME change even if line search struggles
734 return best_u_seq, best_alpha, best_cost
735
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
Tuple[np.ndarray, List[EngineEstimate], float, List[Dict[str, float]]] forward_rollout(np.ndarray x0, np.ndarray u_seq, np.ndarray F_ref, np.ndarray MR_ref, ControllerConfig cfg, DynamicsParams params, EngineWrapper engine_wrapper, float dt)
Tuple[np.ndarray, float, float] forward_line_search(np.ndarray x0, np.ndarray u_seq, np.ndarray k_seq, np.ndarray K_seq, np.ndarray x_seq_nom, np.ndarray F_ref, np.ndarray MR_ref, ControllerConfig cfg, DynamicsParams params, EngineWrapper engine_wrapper, float dt, float alpha_init, float alpha_min)
DDPSolution solve_ddp(np.ndarray x0, np.ndarray u_seq_init, np.ndarray F_ref, np.ndarray MR_ref, ControllerConfig cfg, DynamicsParams dynamics_params, EngineWrapper engine_wrapper, Optional[np.ndarray] w_bar=None, float alpha_init=2.0, float alpha_min=1e-5, float reg_init=1e-4, float reg_factor=5.0, bool use_robustification=True, float gamma_robust=1.0)
Definition ddp_solver.py:55
float running_cost(np.ndarray x, np.ndarray u, EngineEstimate eng_est, float F_ref, float MR_ref, ControllerConfig cfg, Dict[str, float] constraints, float dt)
Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray] cost_derivatives(np.ndarray x, np.ndarray u, EngineEstimate eng_est, float F_ref, float MR_ref, ControllerConfig cfg, float dt, Optional[EngineWrapper] engine_wrapper=None)
Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] backward_pass(np.ndarray x_seq, np.ndarray u_seq, List[EngineEstimate] eng_estimates, np.ndarray F_ref, np.ndarray MR_ref, ControllerConfig cfg, DynamicsParams params, EngineWrapper engine_wrapper, float dt, float reg, np.ndarray w_bar, bool use_robustification, float gamma_robust)