STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
data_models.py
Go to the documentation of this file.
1"""Data models for robust DDP controller."""
2
3from __future__ import annotations
4
5from dataclasses import dataclass, field, asdict
6from enum import Enum
7from typing import Optional, Union, List, Dict, Any
8import json
9import yaml
10from datetime import datetime
11import numpy as np
12
13# Import N_STATE for type hints (lazy import to avoid circular dependency)
14# N_STATE is defined in dynamics.py, but dynamics.py imports ControllerConfig from here
15# So we use a fallback and import it lazily when needed
16N_STATE = 11 # Default/fallback (expanded to include gas masses: 8 original + 3 gas masses)
17
18
19class CommandType(Enum):
20 """Type of control command."""
21 THRUST_DESIRED = "thrust_desired"
22 ALTITUDE_GOAL = "altitude_goal"
23
24
25@dataclass
27 """Sensor measurement data.
28
29 Contains pressure transducer readings and timestamp.
30 All pressures in Pascals [Pa].
31 """
32 # COPV (Composite Overwrapped Pressure Vessel) pressure
33 P_copv: float
34
35 # Regulator pressure (downstream of regulator)
36 P_reg: float
37
38 # Upstream pressures (tank side of injector)
39 P_u_fuel: float # Fuel upstream pressure
40 P_u_ox: float # Oxidizer upstream pressure
41
42 # Downstream pressures (chamber side of injector)
43 P_d_fuel: float # Fuel downstream pressure
44 P_d_ox: float # Oxidizer downstream pressure
45
46 # Timestamp (Unix time in seconds, or datetime object)
47 timestamp: Union[float, datetime] = field(default_factory=datetime.now)
48
49 def __post_init__(self):
50 """Validate measurement data."""
51 # Convert datetime to float if needed
52 if isinstance(self.timestamp, datetime):
53 self.timestamp = self.timestamp.timestamp()
54
55 # Validate pressures are non-negative
56 pressures = [
57 self.P_copv, self.P_reg,
58 self.P_u_fuel, self.P_u_ox,
59 self.P_d_fuel, self.P_d_ox
60 ]
61 for i, p in enumerate(pressures):
62 if p < 0:
63 raise ValueError(f"Pressure {i} is negative: {p} Pa")
64 if not np.isfinite(p):
65 raise ValueError(f"Pressure {i} is not finite: {p}")
66
67 def to_dict(self) -> Dict[str, Any]:
68 """Convert to dictionary for serialization."""
69 return {
70 "P_copv": float(self.P_copv),
71 "P_reg": float(self.P_reg),
72 "P_u_fuel": float(self.P_u_fuel),
73 "P_u_ox": float(self.P_u_ox),
74 "P_d_fuel": float(self.P_d_fuel),
75 "P_d_ox": float(self.P_d_ox),
76 "timestamp": float(self.timestamp),
77 }
78
79 @classmethod
80 def from_dict(cls, data: Dict[str, Any]) -> Measurement:
81 """Create from dictionary."""
82 return cls(
83 P_copv=float(data["P_copv"]),
84 P_reg=float(data["P_reg"]),
85 P_u_fuel=float(data["P_u_fuel"]),
86 P_u_ox=float(data["P_u_ox"]),
87 P_d_fuel=float(data["P_d_fuel"]),
88 P_d_ox=float(data["P_d_ox"]),
89 timestamp=float(data.get("timestamp", datetime.now().timestamp())),
90 )
91
92
93@dataclass
95 """Navigation state (vehicle state).
96
97 Contains altitude, velocity, attitude, and mass estimate.
98 """
99 # Altitude above ground level [m]
100 h: float
101
102 # Vertical velocity [m/s] (positive = upward)
103 vz: float
104
105 # Tilt angle [rad] (0 = vertical, positive = leaning forward)
106 # Alternative: use quaternion for full 3D attitude
107 theta: float = 0.0
108
109 # Optional: quaternion for 3D attitude [w, x, y, z]
110 quaternion: Optional[List[float]] = None
111
112 # Mass estimate [kg] (optional, for adaptive control)
113 mass_estimate: Optional[float] = None
114
115 def __post_init__(self):
116 """Validate navigation state."""
117 if self.h < 0:
118 raise ValueError(f"Altitude h is negative: {self.h} m")
119 if not np.isfinite(self.h):
120 raise ValueError(f"Altitude h is not finite: {self.h}")
121 if not np.isfinite(self.vz):
122 raise ValueError(f"Vertical velocity vz is not finite: {self.vz}")
123 if not np.isfinite(self.theta):
124 raise ValueError(f"Tilt angle theta is not finite: {self.theta}")
125
126 # Validate quaternion if provided
127 if self.quaternion is not None:
128 if len(self.quaternion) != 4:
129 raise ValueError(f"Quaternion must have 4 elements, got {len(self.quaternion)}")
130 q_norm = np.linalg.norm(self.quaternion)
131 if not np.isclose(q_norm, 1.0, atol=1e-3):
132 raise ValueError(f"Quaternion must be normalized, got norm={q_norm}")
133
134 # Validate mass if provided
135 if self.mass_estimate is not None:
136 if self.mass_estimate <= 0:
137 raise ValueError(f"Mass estimate must be positive, got {self.mass_estimate} kg")
138 if not np.isfinite(self.mass_estimate):
139 raise ValueError(f"Mass estimate is not finite: {self.mass_estimate}")
140
141 def to_dict(self) -> Dict[str, Any]:
142 """Convert to dictionary for serialization."""
143 result = {
144 "h": float(self.h),
145 "vz": float(self.vz),
146 "theta": float(self.theta),
147 }
148 if self.quaternion is not None:
149 result["quaternion"] = [float(q) for q in self.quaternion]
150 if self.mass_estimate is not None:
151 result["mass_estimate"] = float(self.mass_estimate)
152 return result
153
154 @classmethod
155 def from_dict(cls, data: Dict[str, Any]) -> NavState:
156 """Create from dictionary."""
157 return cls(
158 h=float(data["h"]),
159 vz=float(data.get("vz", 0.0)),
160 theta=float(data.get("theta", 0.0)),
161 quaternion=data.get("quaternion"),
162 mass_estimate=data.get("mass_estimate"),
163 )
164
165
166@dataclass
168 """Control command.
169
170 Supports either thrust desired (time-varying) or altitude goal.
171 """
172 # Command type
173 command_type: CommandType
174
175 # For THRUST_DESIRED: thrust profile as function of time
176 # Stored as list of (time, thrust) pairs, or constant value
177 thrust_desired: Optional[Union[float, List[tuple]]] = None
178
179 # For ALTITUDE_GOAL: target altitude [m]
180 altitude_goal: Optional[float] = None
181
182 def __post_init__(self):
183 """Validate command."""
184 if self.command_type == CommandType.THRUST_DESIRED:
185 if self.thrust_desired is None:
186 raise ValueError("thrust_desired must be provided for THRUST_DESIRED command")
187 if isinstance(self.thrust_desired, float):
188 if self.thrust_desired < 0:
189 raise ValueError(f"Thrust desired must be non-negative, got {self.thrust_desired} N")
190 elif isinstance(self.thrust_desired, list):
191 for t, F in self.thrust_desired:
192 if F < 0:
193 raise ValueError(f"Thrust at t={t} is negative: {F} N")
194 elif self.command_type == CommandType.ALTITUDE_GOAL:
195 if self.altitude_goal is None:
196 raise ValueError("altitude_goal must be provided for ALTITUDE_GOAL command")
197 if self.altitude_goal < 0:
198 raise ValueError(f"Altitude goal must be non-negative, got {self.altitude_goal} m")
199
200 def to_dict(self) -> Dict[str, Any]:
201 """Convert to dictionary for serialization."""
202 result = {
203 "command_type": self.command_type.value,
204 }
205 if self.thrust_desired is not None:
206 result["thrust_desired"] = self.thrust_desired
207 if self.altitude_goal is not None:
208 result["altitude_goal"] = self.altitude_goal
209 return result
210
211 @classmethod
212 def from_dict(cls, data: Dict[str, Any]) -> Command:
213 """Create from dictionary."""
214 command_type = CommandType(data["command_type"])
215 return cls(
216 command_type=command_type,
217 thrust_desired=data.get("thrust_desired"),
218 altitude_goal=data.get("altitude_goal"),
219 )
220
221
222@dataclass
224 """Controller configuration parameters.
225
226 Contains all tunable parameters for the robust DDP controller.
227 """
228 # Prediction horizon
229 N: int = 50 # Number of time steps in horizon
230 dt: float = 0.01 # Time step [s] (10 ms = 100 Hz)
231
232 # Dwell time (minimum time between control changes) [s]
233 dwell_time: float = 0.05 # 50 ms = 20 Hz max control rate
234
235 # Duty quantization (for PWM solenoids)
236 duty_quantization: float = 0.01 # 1% duty cycle steps
237
238 # Cost function weights
239 qF: float = 1000.0 # Thrust tracking weight (VERY high priority - controller MUST track thrust)
240 qMR: float = 10.0 # Mixture ratio weight
241 qGas: float = 0.001 # Gas consumption weight (minimal - allow controller to use gas freely)
242 qSwitch: float = 0.0001 # Control switching cost (minimal - allow very aggressive control changes)
243
244 # Constraints
245 MR_min: float = 1.5 # Minimum mixture ratio (O/F)
246 MR_max: float = 3.0 # Maximum mixture ratio (O/F)
247 injector_dp_frac: float = 0.1 # Minimum injector pressure drop fraction
248 eps_i: float = 1e-3 # Constraint violation tolerance
249 P_u_max: float = 10e6 # Maximum upstream pressure [Pa] (~1450 psi)
250 P_copv_min: float = 1e6 # Minimum COPV pressure [Pa] (~145 psi)
251 headroom_dp_min: float = 0.05e6 # Minimum headroom pressure drop [Pa] (~7.25 psi)
252
253 # Robustness parameters
254 rho: float = 0.1 # Robustness parameter (disturbance bound)
255 eta: float = 0.01 # Robustness margin
256
257 # Additional parameters
258 max_iterations: int = 20 # Max DDP iterations (increased for better convergence)
259 convergence_tol: float = 1e-3 # Convergence tolerance (relaxed to allow more exploration)
260
261 # Dynamics parameters
262 # COPV blowdown model: P_copv[k+1] = P_copv[k] - dt*(cF*u_F + cO*u_O + loss)
263 copv_cF: float = 1e5 # COPV consumption coefficient for fuel [Pa/s per unit u_F]
264 copv_cO: float = 1e5 # COPV consumption coefficient for oxidizer [Pa/s per unit u_O]
265 copv_loss: float = 1e3 # COPV leakage/heat loss [Pa/s]
266
267 # Regulator model
268 reg_setpoint: Optional[float] = None # Regulator setpoint [Pa] (None = derived from COPV)
269 reg_ratio: float = 0.8 # P_reg / P_copv ratio if setpoint not specified
270 # Pressurisation-path hardware. Previously literals inside dynamics.step(); surfaced here so
271 # a vehicle with a different regulator or solenoid can be modelled without editing code.
272 # See docs/adr/0001 -- this chain is one of the feed models lib/feedtwin absorbs.
273 gamma_gas: float = 1.4 # Pressurant specific heat ratio (N2)
274 Cd_regulator: float = 0.7 # Discharge coefficient, regulator orifice
275 Cd_valve: float = 0.65 # Discharge coefficient, solenoid valve orifice
276 A_regulator: float = 2e-5 # Regulator orifice area [m^2]
277 A_valve_F: float = 5e-5 # Fuel solenoid valve flow area [m^2]
278 A_valve_O: float = 5e-5 # Oxidiser solenoid valve flow area [m^2]
279
280 # Ullage pressurization flow coefficients [1/s]
281 alpha_F: float = 10.0 # Fuel pressurization flow coefficient
282 alpha_O: float = 10.0 # Oxidizer pressurization flow coefficient
283
284 # Propellant densities [kg/m³]
285 rho_F: float = 800.0 # Fuel density (RP-1)
286 rho_O: float = 1140.0 # Oxidizer density (LOX)
287
288 # Feed line time constants [s]
289 tau_line_F: float = 0.01 # Fuel feed line time constant
290 tau_line_O: float = 0.01 # Oxidizer feed line time constant
291
292 # Initial ullage volumes [m³] (if not provided, computed from initial conditions)
293 V_u_F_init: Optional[float] = None
294 V_u_O_init: Optional[float] = None
295
296 # Policy LUT (use precomputed LUT instead of online DDP when True)
297 use_policy_lut: bool = False
298 policy_lut_path: Optional[str] = None
299
300 # Engine LUT (use precomputed engine performance instead of physics; stems from engine config + tank pressure range)
301 engine_lut_path: Optional[str] = None
302
303 def __post_init__(self):
304 """Validate configuration."""
305 if self.N <= 0:
306 raise ValueError(f"N must be positive, got {self.N}")
307 if self.dt <= 0:
308 raise ValueError(f"dt must be positive, got {self.dt} s")
309 if self.dwell_time < 0:
310 raise ValueError(f"dwell_time must be non-negative, got {self.dwell_time} s")
311 if self.duty_quantization <= 0 or self.duty_quantization > 1:
312 raise ValueError(f"duty_quantization must be in (0, 1], got {self.duty_quantization}")
313 if self.MR_min >= self.MR_max:
314 raise ValueError(f"MR_min ({self.MR_min}) must be < MR_max ({self.MR_max})")
315 if self.P_u_max <= 0:
316 raise ValueError(f"P_u_max must be positive, got {self.P_u_max} Pa")
317 if self.P_copv_min <= 0:
318 raise ValueError(f"P_copv_min must be positive, got {self.P_copv_min} Pa")
319 if self.rho < 0:
320 raise ValueError(f"rho must be non-negative, got {self.rho}")
321 if self.eta < 0:
322 raise ValueError(f"eta must be non-negative, got {self.eta}")
323
324 # Validate dynamics parameters
325 if self.copv_cF < 0:
326 raise ValueError(f"copv_cF must be non-negative, got {self.copv_cF}")
327 if self.copv_cO < 0:
328 raise ValueError(f"copv_cO must be non-negative, got {self.copv_cO}")
329 if self.copv_loss < 0:
330 raise ValueError(f"copv_loss must be non-negative, got {self.copv_loss}")
331 if self.reg_ratio <= 0 or self.reg_ratio > 1:
332 raise ValueError(f"reg_ratio must be in (0, 1], got {self.reg_ratio}")
333 if self.alpha_F <= 0:
334 raise ValueError(f"alpha_F must be positive, got {self.alpha_F}")
335 if self.alpha_O <= 0:
336 raise ValueError(f"alpha_O must be positive, got {self.alpha_O}")
337 if self.rho_F <= 0:
338 raise ValueError(f"rho_F must be positive, got {self.rho_F}")
339 if self.rho_O <= 0:
340 raise ValueError(f"rho_O must be positive, got {self.rho_O}")
341 if self.tau_line_F <= 0:
342 raise ValueError(f"tau_line_F must be positive, got {self.tau_line_F}")
343 if self.tau_line_O <= 0:
344 raise ValueError(f"tau_line_O must be positive, got {self.tau_line_O}")
345
346 def to_dict(self) -> Dict[str, Any]:
347 """Convert to dictionary for serialization."""
348 return asdict(self)
349
350 @classmethod
351 def from_dict(cls, data: Dict[str, Any]) -> ControllerConfig:
352 """Create from dictionary."""
353 return cls(**data)
354
355 def to_json(self, filepath: str) -> None:
356 """Save to JSON file."""
357 with open(filepath, 'w') as f:
358 json.dump(self.to_dict(), f, indent=2)
359
360 @classmethod
361 def from_json(cls, filepath: str) -> ControllerConfig:
362 """Load from JSON file."""
363 with open(filepath, 'r') as f:
364 data = json.load(f)
365 return cls.from_dict(data)
366
367 def to_yaml(self, filepath: str) -> None:
368 """Save to YAML file."""
369 with open(filepath, 'w') as f:
370 yaml.dump(self.to_dict(), f, default_flow_style=False, indent=2)
371
372 @classmethod
373 def from_yaml(cls, filepath: str) -> ControllerConfig:
374 """Load from YAML file."""
375 with open(filepath, 'r') as f:
376 data = yaml.safe_load(f)
377 return cls.from_dict(data)
378
379
380@dataclass
382 """Persistent controller state.
383
384 Maintains state across control ticks for robust DDP controller.
385 """
386 # Last control action (upstream pressures) [Pa]
387 u_prev: Dict[str, float] = field(default_factory=lambda: {"P_u_fuel": 0.0, "P_u_ox": 0.0})
388
389 # Dwell timers (time since last control change) [s]
390 dwell_timers: Dict[str, float] = field(default_factory=lambda: {"P_u_fuel": 0.0, "P_u_ox": 0.0})
391
392 # Last predicted trajectory (for warm start)
393 last_trajectory: Optional[List[Dict[str, Any]]] = None
394
395 # Residual bounds (for robustness)
396 # Can be dict (legacy) or array (per-state-component)
397 w_bar: Dict[str, float] = field(default_factory=lambda: {"thrust": 0.0, "MR": 0.0})
398 w_bar_array: Optional[np.ndarray] = None # Per-state-component bounds [N_STATE]
399
400 # Disturbance estimate (bias)
401 beta: float = 0.0
402
403 # Additional state
404 iteration_count: int = 0
405 last_cost: float = float('inf')
406
407 def reset(self) -> None:
408 """Reset controller state to initial values."""
409 self.u_prev = {"P_u_fuel": 0.0, "P_u_ox": 0.0}
410 self.dwell_timers = {"P_u_fuel": 0.0, "P_u_ox": 0.0}
411 self.last_trajectory = None
412 self.w_bar = {"thrust": 0.0, "MR": 0.0}
413 self.w_bar_array = None
414 self.beta = 0.0
415 self.iteration_count = 0
416 self.last_cost = float('inf')
417
418 def to_dict(self) -> Dict[str, Any]:
419 """Convert to dictionary for serialization."""
420 result = {
421 "u_prev": self.u_prev.copy(),
422 "dwell_timers": self.dwell_timers.copy(),
423 "last_trajectory": self.last_trajectory,
424 "w_bar": self.w_bar.copy(),
425 "beta": float(self.beta),
426 "iteration_count": int(self.iteration_count),
427 "last_cost": float(self.last_cost) if np.isfinite(self.last_cost) else None,
428 }
429 if self.w_bar_array is not None:
430 result["w_bar_array"] = self.w_bar_array.tolist()
431 return result
432
433 @classmethod
434 def from_dict(cls, data: Dict[str, Any]) -> ControllerState:
435 """Create from dictionary."""
436 state = cls(
437 u_prev=data.get("u_prev", {"P_u_fuel": 0.0, "P_u_ox": 0.0}),
438 dwell_timers=data.get("dwell_timers", {"P_u_fuel": 0.0, "P_u_ox": 0.0}),
439 last_trajectory=data.get("last_trajectory"),
440 w_bar=data.get("w_bar", {"thrust": 0.0, "MR": 0.0}),
441 beta=float(data.get("beta", 0.0)),
442 iteration_count=int(data.get("iteration_count", 0)),
443 last_cost=float(data.get("last_cost", float('inf'))) if data.get("last_cost") is not None else float('inf'),
444 )
445 if "w_bar_array" in data and data["w_bar_array"] is not None:
446 state.w_bar_array = np.array(data["w_bar_array"], dtype=np.float64)
447 return state
448
Command from_dict(cls, Dict[str, Any] data)
ControllerConfig from_json(cls, str filepath)
ControllerConfig from_dict(cls, Dict[str, Any] data)
ControllerConfig from_yaml(cls, str filepath)
ControllerState from_dict(cls, Dict[str, Any] data)
Measurement from_dict(cls, Dict[str, Any] data)
NavState from_dict(cls, Dict[str, Any] data)