STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
controller.py
Go to the documentation of this file.
1"""Main robust DDP controller class.
2
3Integrates all components: reference generation, DDP solver, actuation, and safety filter.
4"""
5
6from __future__ import annotations
7
8from typing import Optional
9import numpy as np
10
11from .data_models import (
12 ControllerConfig,
13 ControllerState,
14 Measurement,
15 NavState,
16 Command,
17)
18from .dynamics import (
19 DynamicsParams,
20 N_STATE,
21 N_CONTROL,
22 IDX_P_COPV,
23 IDX_P_REG,
24 IDX_P_U_F,
25 IDX_P_U_O,
26 IDX_P_D_F,
27 IDX_P_D_O,
28 IDX_V_U_F,
29 IDX_V_U_O,
30)
31from .reference import build_reference
32from .robustness import update_bounds, get_w_bar_array
33from .ddp_solver import solve_ddp
34from .actuation import compute_actuation, update_state_dwell_timers, ExecutionBackend
35from .safety_filter import filter_action
36from .engine_wrapper import EngineWrapper
37from .logging import ControllerLogger
38from .constraints import constraint_values
39from .policy_lut import PolicyLUT
40from .engine_lut_wrapper import EngineLUTWrapper
41from engine.pipeline.config_schemas import PintleEngineConfig
42
43
45 """Robust DDP controller for thrust regulation."""
46
48 self,
49 cfg: ControllerConfig,
50 engine_config: Optional[PintleEngineConfig] = None,
51 logger: Optional[ControllerLogger] = None,
52 ):
53 """
54 Initialize robust DDP controller.
55
56 Parameters:
57 -----------
58 cfg : ControllerConfig
59 Controller configuration
60 engine_config : PintleEngineConfig, optional
61 Engine configuration (required for engine wrapper)
62 logger : ControllerLogger, optional
63 Logger instance. If None, no logging.
64 """
65 self.cfg = cfg
67 self.logger = logger
68
69 # Initialize engine wrapper: prefer EngineLUT (fast) when path provided
70 self.engine_wrapper = None
71 if cfg.engine_lut_path:
72 try:
73 self.engine_wrapper = EngineLUTWrapper(cfg.engine_lut_path)
74 except Exception as e:
75 import warnings
76 warnings.warn(f"Failed to load engine LUT from {cfg.engine_lut_path}: {e}")
77 if self.engine_wrapper is None and engine_config is not None:
78 self.engine_wrapper = EngineWrapper(engine_config)
79
80 # Dynamics parameters
81 self.dynamics_params = DynamicsParams.from_config(cfg)
82
83 # Previous state and control (for robustness update)
84 self.x_prev: Optional[np.ndarray] = None
85 self.u_prev_applied: Optional[np.ndarray] = None
86
87 # Previous DDP solution (for warm start)
88 self.u_seq_prev: Optional[np.ndarray] = None
89
90 # Initialize ullage volumes (if not in config, will be estimated)
91 self.V_u_F = cfg.V_u_F_init if cfg.V_u_F_init is not None else 0.01
92 self.V_u_O = cfg.V_u_O_init if cfg.V_u_O_init is not None else 0.01
93
94 # Tick counter for logging
95 self.tick = 0
96
97 # Policy LUT (lazy load when use_policy_lut is True)
98 self._policy_lut: Optional[PolicyLUT] = None
99 if cfg.use_policy_lut and cfg.policy_lut_path:
100 try:
101 self._policy_lut = PolicyLUT.load(cfg.policy_lut_path)
102 except Exception as e:
103 import warnings
104 warnings.warn(f"Failed to load policy LUT from {cfg.policy_lut_path}: {e}")
105
106 def reset(self) -> None:
107 """Reset controller state to initial values."""
108 self.state.reset()
109 self.x_prev = None
110 self.u_prev_applied = None
111 self.u_seq_prev = None
112 self.V_u_F = self.cfg.V_u_F_init if self.cfg.V_u_F_init is not None else 0.01
113 self.V_u_O = self.cfg.V_u_O_init if self.cfg.V_u_O_init is not None else 0.01
114 self.tick = 0
115
116 def step(
117 self,
118 meas: Measurement,
119 nav: NavState,
120 cmd: Command,
121 backend: ExecutionBackend = ExecutionBackend.PWM,
122 ) -> tuple:
123 """
124 Execute one control step.
125
126 Parameters:
127 -----------
128 meas : Measurement
129 Current sensor measurements
130 nav : NavState
131 Current navigation state
132 cmd : Command
133 Control command (thrust desired or altitude goal)
134 backend : ExecutionBackend
135 Actuation backend (PWM or BINARY)
136
137 Returns:
138 --------
139 actuation_cmd : ActuationCommand
140 Actuation command for solenoids
141 diagnostics : dict
142 Additional diagnostics (DDP solution, reference, etc.)
143 """
144 # Step 1: Build full state x from measurements + internal ullage volumes
145 x = self._build_state(meas)
146
147 # Step 2: Build reference (F_ref, MR_ref) for horizon
148 ref = build_reference(
149 nav=nav,
150 meas=meas,
151 cmd=cmd,
152 cfg=self.cfg,
153 horizon_N=self.cfg.N,
154 engine_wrapper=self.engine_wrapper,
155 F_ref_prev=self._get_previous_f_ref(),
156 dt=self.cfg.dt,
157 )
158
159 # Step 3: Update robustness bounds using previous x and applied u
160 if self.x_prev is not None and self.u_prev_applied is not None:
161 update_bounds(
162 state=self.state,
163 x_prev=self.x_prev,
164 x_meas=x,
165 u_prev=self.u_prev_applied,
166 cfg=self.cfg,
167 engine_wrapper=self.engine_wrapper,
168 dt=self.cfg.dt,
169 )
170
171 # Step 4: Check engine estimate validity before DDP
172 # Get a preliminary engine estimate to check if we can compute valid thrust/MR
173 eng_est_prelim = None
174 engine_valid = False
175 if self.engine_wrapper is not None:
176 try:
177 eng_est_prelim = self.engine_wrapper.estimate_from_pressures(
178 meas.P_u_fuel, meas.P_u_ox
179 )
180 # Check if estimate is valid (both F and MR are finite and positive)
181 engine_valid = (
182 np.isfinite(eng_est_prelim.F) and eng_est_prelim.F >= 0 and
183 np.isfinite(eng_est_prelim.MR) and eng_est_prelim.MR > 0
184 )
185 except Exception:
186 engine_valid = False
187
188 # Check if we have a non-zero reference but invalid engine estimate
189 F_ref_current = ref.F_ref[0] if len(ref.F_ref) > 0 else 0.0
190 MR_ref_current = ref.MR_ref[0] if len(ref.MR_ref) > 0 else 0.0
191 has_nonzero_ref = (np.isfinite(F_ref_current) and F_ref_current > 0) or \
192 (np.isfinite(MR_ref_current) and MR_ref_current > 0)
193
194 if has_nonzero_ref and not engine_valid:
195 # Log warning: we want output but engine estimate is invalid
196 # This could happen if pressures are too low, engine can't operate, etc.
197 # DDP will still run but will use large penalties for invalid estimates
198 import warnings
199 warnings.warn(
200 f"Controller step {self.tick}: Non-zero reference (F_ref={F_ref_current:.1f} N, "
201 f"MR_ref={MR_ref_current:.3f}) but engine estimate is invalid. "
202 f"Pressures: P_u_F={meas.P_u_fuel/1e6:.2f} MPa, P_u_O={meas.P_u_ox/1e6:.2f} MPa. "
203 f"DDP will use penalty-based optimization.",
204 RuntimeWarning
205 )
206
207 w_bar = get_w_bar_array(self.state)
208
209 # Step 5 & 6: Get u_relaxed from LUT or DDP
210 if self._policy_lut is not None:
211 u_relaxed = self._policy_lut.lookup(
212 x[IDX_P_U_F], x[IDX_P_U_O], F_ref_current, MR_ref_current
213 )
214 u_seq = np.tile(u_relaxed, (self.cfg.N, 1))
215 from .ddp_solver import DDPSolution
216 solution = DDPSolution(
217 u_seq=u_seq,
218 x_seq=np.tile(x, (self.cfg.N + 1, 1)),
219 eng_estimates=[],
220 objective=0.0,
221 iterations=0,
222 converged=True,
223 constraint_violations=[{}] * self.cfg.N,
224 diagnostics={"source": "policy_lut"},
225 )
226 else:
227 u_seq_init = self._get_warm_start_control_sequence()
228 solution = solve_ddp(
229 x0=x,
230 u_seq_init=u_seq_init,
231 F_ref=ref.F_ref,
232 MR_ref=ref.MR_ref,
233 cfg=self.cfg,
234 dynamics_params=self.dynamics_params,
235 engine_wrapper=self.engine_wrapper,
236 w_bar=w_bar,
237 use_robustification=True,
238 )
239 u_relaxed = solution.u_seq[0].copy()
240
241 # CRITICAL: If controller isn't making changes, force it to respond to thrust errors
242 # Check if we have a thrust deficit and controller isn't responding
243 eng_est_current = None
244 if self.engine_wrapper is not None:
245 try:
246 eng_est_current = self.engine_wrapper.estimate_from_pressures(
247 meas.P_u_fuel, meas.P_u_ox
248 )
249 except Exception:
250 pass
251
252 F_ref_current = ref.F_ref[0] if len(ref.F_ref) > 0 else 0.0
253 if eng_est_current is not None and np.isfinite(eng_est_current.F) and F_ref_current > 0:
254 F_error = eng_est_current.F - F_ref_current
255 # If thrust is too low and control is low, force increase
256 if F_error < -500.0 and np.max(u_relaxed) < 0.3: # Thrust deficit > 500N and control < 30%
257 # Force controller to increase control to raise pressure
258 u_relaxed = np.clip(u_relaxed + 0.2, 0.0, 1.0) # Increase by 20%
259
260 # Step 7: Convert to actuation via actuation.py
261 actuation_cmd = compute_actuation(
262 u_relaxed=u_relaxed,
263 state=self.state,
264 cfg=self.cfg,
265 backend=backend,
266 dt=self.cfg.dt,
267 )
268
269 # Step 8: Apply safety filter
270 u_safe = filter_action(
271 x=x,
272 proposed=u_relaxed,
273 state=self.state,
274 cfg=self.cfg,
275 engine_wrapper=self.engine_wrapper,
276 F_ref=ref.F_ref[0] if len(ref.F_ref) > 0 else None,
277 MR_ref=ref.MR_ref[0] if len(ref.MR_ref) > 0 else None,
278 num_steps=2,
279 dt=self.cfg.dt,
280 )
281
282 # If safety filter changed action, recompute actuation
283 if not np.allclose(u_safe, u_relaxed):
284 actuation_cmd = compute_actuation(
285 u_relaxed=u_safe,
286 state=self.state,
287 cfg=self.cfg,
288 backend=backend,
289 dt=self.cfg.dt,
290 )
291
292 # Update dwell timers in state
293 update_state_dwell_timers(self.state, actuation_cmd)
294
295 # Step 9: Store state for next tick
296 self._update_state(x, u_safe, solution, ref)
297
298 # Get engine estimate once for both logging and diagnostics
299 eng_est = None
300 if self.engine_wrapper is not None:
301 try:
302 # Use tank/ullage pressures (P_u) to get engine performance
303 # Engine expects tank pressures, not feed pressures
304 eng_est = self.engine_wrapper.estimate_from_pressures(
305 meas.P_u_fuel, meas.P_u_ox
306 )
307 except Exception:
308 pass
309
310 # Get constraint margins
311 constraints = {}
312 if eng_est is not None:
313 constraints = constraint_values(x, eng_est, self.cfg)
314
315 # Log tick data
316 if self.logger is not None:
317 # Log
318 self.logger.log_tick(
319 tick=self.tick,
320 timestamp=meas.timestamp if hasattr(meas, 'timestamp') else self.tick * self.cfg.dt,
321 meas=meas,
322 eng_est=eng_est,
323 constraints=constraints,
324 u_proposed=u_relaxed,
325 u_filtered=u_safe,
326 actuation_cmd=actuation_cmd,
327 w_bar=w_bar,
328 F_ref=ref.F_ref[0] if len(ref.F_ref) > 0 else None,
329 MR_ref=ref.MR_ref[0] if len(ref.MR_ref) > 0 else None,
330 )
331
332 # Increment tick counter
333 self.tick += 1
334
335 # Build diagnostics
336 diagnostics = {
337 "x": x.copy(),
338 "u_relaxed": u_relaxed.copy(),
339 "u_safe": u_safe.copy(),
340 "solution": solution,
341 "ref": ref,
342 "w_bar": w_bar.copy(),
343 "constraint_violations": solution.constraint_violations[0] if solution.constraint_violations else {},
344 "eng_est": eng_est,
345 "F_hat": float(eng_est.F) if eng_est is not None and np.isfinite(eng_est.F) else 0.0,
346 "MR_hat": float(eng_est.MR) if eng_est is not None and np.isfinite(eng_est.MR) else 0.0,
347 "P_ch": float(eng_est.P_ch) if eng_est is not None and np.isfinite(eng_est.P_ch) else 0.0,
348 "F_ref": float(ref.F_ref[0]) if len(ref.F_ref) > 0 else 0.0,
349 "MR_ref": float(ref.MR_ref[0]) if len(ref.MR_ref) > 0 else 0.0,
350 }
351
352 return actuation_cmd, diagnostics
353
354 def _build_state(self, meas: Measurement) -> np.ndarray:
355 """
356 Build full state vector from measurements and internal ullage volumes.
357
358 Parameters:
359 -----------
360 meas : Measurement
361 Sensor measurements
362
363 Returns:
364 --------
365 x : np.ndarray, shape (N_STATE,)
366 Full state vector including gas masses
367 """
368 from .dynamics import IDX_M_GAS_COPV, IDX_M_GAS_F, IDX_M_GAS_O
369
370 x = np.zeros(N_STATE, dtype=np.float64)
371
372 x[IDX_P_COPV] = meas.P_copv
373 x[IDX_P_REG] = meas.P_reg
374 x[IDX_P_U_F] = meas.P_u_fuel
375 x[IDX_P_U_O] = meas.P_u_ox
376 x[IDX_P_D_F] = meas.P_d_fuel
377 x[IDX_P_D_O] = meas.P_d_ox
378 x[IDX_V_U_F] = self.V_u_F
379 x[IDX_V_U_O] = self.V_u_O
380
381 # Initialize gas masses from pressures using ideal gas law
382 # P = (m * R * T) / (V * Z), so m = (P * V * Z) / (R * T)
383 R_gas = 296.8 # N2 gas constant [J/(kg·K)]
384 T_gas = 293.0 # Gas temperature [K]
385 Z_gas = 1.0 # Compressibility factor
386 V_copv = getattr(self.dynamics_params, 'V_copv', 0.006) # Default 6L
387
388 x[IDX_M_GAS_COPV] = (meas.P_copv * V_copv * Z_gas) / (R_gas * T_gas)
389 x[IDX_M_GAS_F] = (meas.P_u_fuel * self.V_u_F * Z_gas) / (R_gas * T_gas) if self.V_u_F > 1e-10 else 0.0
390 x[IDX_M_GAS_O] = (meas.P_u_ox * self.V_u_O * Z_gas) / (R_gas * T_gas) if self.V_u_O > 1e-10 else 0.0
391
392 return x
393
394 def _get_warm_start_control_sequence(self) -> np.ndarray:
395 """
396 Get warm start control sequence from previous solution.
397
398 Returns:
399 --------
400 u_seq_init : np.ndarray, shape (N, N_CONTROL)
401 Initial control sequence (shifted previous or small non-zero values)
402 """
403 if self.u_seq_prev is not None:
404 # Shift previous solution: u[k] = u_prev[k+1], last = last
405 u_seq_init = np.zeros((self.cfg.N, N_CONTROL), dtype=np.float64)
406 u_seq_init[:-1] = self.u_seq_prev[1:]
407 u_seq_init[-1] = self.u_seq_prev[-1]
408 return u_seq_init
409 else:
410 # No previous solution: use small non-zero initial values
411 # This helps when engine estimates are initially invalid by providing
412 # a starting point that allows the solver to explore the control space
413 # Small value (0.1) is better than zero when gradients are undefined
414 return np.full((self.cfg.N, N_CONTROL), 0.1, dtype=np.float64)
415
416 def _get_previous_f_ref(self) -> Optional[float]:
417 """Get previous reference thrust for slew rate limiting."""
418 # Could store previous reference, for now return None
419 return None
420
422 self,
423 x: np.ndarray,
424 u_applied: np.ndarray,
425 solution,
426 ref,
427 ) -> None:
428 """
429 Update controller state for next tick.
430
431 Parameters:
432 -----------
433 x : np.ndarray
434 Current state
435 u_applied : np.ndarray
436 Applied control (after safety filter)
437 solution : DDPSolution
438 DDP solution
439 ref : Reference
440 Reference trajectory
441 """
442 # Store previous state and control
443 self.x_prev = x.copy()
444 self.u_prev_applied = u_applied.copy()
445
446 # Store previous DDP solution for warm start
447 self.u_seq_prev = solution.u_seq.copy()
448
449 # Update ullage volumes (from state)
450 self.V_u_F = x[IDX_V_U_F]
451 self.V_u_O = x[IDX_V_U_O]
452
None _update_state(self, np.ndarray x, np.ndarray u_applied, solution, ref)
np.ndarray _build_state(self, Measurement meas)
tuple step(self, Measurement meas, NavState nav, Command cmd, ExecutionBackend backend=ExecutionBackend.PWM)
__init__(self, ControllerConfig cfg, Optional[PintleEngineConfig] engine_config=None, Optional[ControllerLogger] logger=None)
Definition controller.py:52