STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
generate_controller_lut.py
Go to the documentation of this file.
1from __future__ import annotations
2
3import argparse
4import json
5import os
6import sys
7from concurrent.futures import ProcessPoolExecutor, as_completed
8from pathlib import Path
9from typing import Any, Dict, List, Tuple
10
11import numpy as np
12
13from .config import ControllerLUTConfig
14
15# Worker globals (set by initializer, used by _compute_*_point)
16_worker_controller = None
17_worker_engine_wrapper = None
18_worker_axis_names = None
19_worker_grids = None
20_worker_axis_sizes = None
21_worker_output_names = None
22_worker_Measurement = None
23_worker_NavState = None
24_worker_Command = None
25_worker_CommandType = None
26
27
28def _add_engine_sim_to_path(project_root: Path) -> None:
29 """
30 Ensure the engine_sim submodule is importable as a Python package.
31
32 We do not modify any files inside engine_sim; we only import and use its
33 public APIs.
34 """
35 engine_sim_root = project_root / "engine_sim"
36 if str(engine_sim_root) not in sys.path:
37 sys.path.insert(0, str(engine_sim_root))
38
39
40def _load_engine_only(engine_config_path: Path):
41 """
42 Import engine_sim components and load the engine configuration
43 for pure engine-performance LUTs (no DDP).
44
45 Returns
46 -------
47 (engine_config, EngineWrapper)
48 """
49 from engine.pipeline.io import load_config as load_engine_config
50 from engine.control.robust_ddp.engine_wrapper import EngineWrapper
51
52 engine_cfg = load_engine_config(str(engine_config_path))
53 wrapper = EngineWrapper(engine_cfg)
54 return engine_cfg, wrapper
55
56
58 engine_config_path: Path,
59 controller_config_path: Path | None,
60 engine_lut_path: Path | None = None,
61 project_root: Path | None = None,
62):
63 """
64 Load engine + controller configs and construct a RobustDDPController.
65 When engine_lut_path is set, controller uses EngineLUTWrapper for fast lookups.
66 """
67 from engine.pipeline.io import load_config as load_engine_config
68 from engine.control.robust_ddp import (
69 RobustDDPController,
70 Measurement,
71 NavState,
72 Command,
73 CommandType,
74 )
76 load_config as load_controller_config,
77 get_default_config,
78 )
79
80 engine_cfg = load_engine_config(str(engine_config_path))
81 if controller_config_path is not None:
82 controller_cfg = load_controller_config(str(controller_config_path))
83 else:
84 controller_cfg = get_default_config()
85
86 if engine_lut_path is not None:
87 resolved = (
88 engine_lut_path
89 if engine_lut_path.is_absolute()
90 else (project_root or Path.cwd()) / engine_lut_path
91 )
92 if resolved.exists():
93 controller_cfg.engine_lut_path = str(resolved)
94 else:
95 import warnings
96
97 warnings.warn(f"Engine LUT not found: {resolved} — using physics")
98
99 controller = RobustDDPController(controller_cfg, engine_cfg, logger=None)
100 return engine_cfg, controller, Measurement, NavState, Command, CommandType
101
102
103def _build_axes(config: ControllerLUTConfig) -> Tuple[List[str], List[np.ndarray]]:
104 axis_names: List[str] = []
105 grids: List[np.ndarray] = []
106 for axis in config.axes:
107 values = np.asarray(axis.values, dtype=float)
108 if values.ndim != 1:
109 raise ValueError(f"Axis '{axis.name}' values must be 1D")
110 if values.size < 2:
111 raise ValueError(
112 f"Axis '{axis.name}' must have at least 2 points for interpolation"
113 )
114 axis_names.append(axis.name)
115 grids.append(values)
116 return axis_names, grids
117
118
119def _find_required_pressure_axes(axis_names: List[str]) -> Tuple[int, int]:
120 """
121 Locate indices of P_u_fuel and P_u_ox axes.
122
123 These are required because EngineWrapper.estimate_from_pressures expects
124 tank/ullage pressures for fuel and oxidizer.
125 """
126 try:
127 idx_fuel = axis_names.index("P_u_fuel")
128 idx_ox = axis_names.index("P_u_ox")
129 except ValueError as exc:
130 raise ValueError(
131 "LUT config must define axes named 'P_u_fuel' and 'P_u_ox' "
132 "to drive EngineWrapper.estimate_from_pressures."
133 ) from exc
134 return idx_fuel, idx_ox
135
136
138 axis_names: List[str], coords: List[float], name: str, default: float
139) -> float:
140 """Helper to read a coordinate for a named axis with a default."""
141 if name in axis_names:
142 idx = axis_names.index(name)
143 return float(coords[idx])
144 return float(default)
145
146
147def _map_engine_output(est: Any, out_name: str) -> float:
148 """Map EngineEstimate to a single output value."""
149 value: float = np.nan
150 if out_name == "F":
151 value = float(est.F)
152 elif out_name == "MR":
153 value = float(est.MR)
154 elif out_name == "P_ch":
155 value = float(est.P_ch)
156 elif out_name == "mdot_F":
157 value = float(est.mdot_F)
158 elif out_name == "mdot_O":
159 value = float(est.mdot_O)
160 elif out_name == "injector_dp_F":
161 value = float(est.injector_dp_F)
162 elif out_name == "injector_dp_O":
163 value = float(est.injector_dp_O)
164 elif out_name == "stability_score":
165 if est.stability_metrics is not None:
166 value = float(est.stability_metrics.get("stability_score", np.nan))
167 elif out_name == "injector_stiffness_ok":
168 if est.stability_metrics is not None:
169 ok = est.stability_metrics.get("injector_stiffness_ok")
170 value = 1.0 if ok is True else (0.0 if ok is False else np.nan)
171 elif est.diagnostics is not None and out_name in est.diagnostics:
172 try:
173 value = float(est.diagnostics[out_name])
174 except Exception:
175 value = np.nan
176 return value
177
178
179G0 = 9.80665 # m/s^2
180
181
183 u_safe: np.ndarray,
184 axis_names: List[str],
185 coords: List[float],
186 eng_est: Any,
187 get_axis: Any,
188) -> Tuple[float, float]:
189 """
190 Compute duty_F and duty_O from (F_ref, MR_ref, F_engine) for MR-aware control.
191 Uses mass-flow scaling: duty_i = throttle * min(1, mdot_i_des / mdot_i_engine).
192 - F_ref=0: duty_F=duty_O=0
193 - F_ref < F_engine*0.80: throttle=0 (or 0.06 baseline if F_ref>500)
194 - F_ref in [F_engine*0.80, F_engine]: throttle 0.10..0.30 (maintenance)
195 - F_ref > F_engine: throttle 0.30 + 0.65*(deficit/7000), cap 0.95
196 """
197 u_F = float(u_safe[0]) if len(u_safe) > 0 else 0.0
198 u_O = float(u_safe[1]) if len(u_safe) > 1 else 0.0
199 F_ref = get_axis(axis_names, coords, "thrust_desired", 0.0)
200 MR_ref = get_axis(axis_names, coords, "MR_ref", 2.2)
201 if F_ref <= 0:
202 return 0.0, 0.0
203 if eng_est is None or not np.isfinite(eng_est.F) or eng_est.F <= 0:
204 return u_F, u_O
205 F_eng = float(eng_est.F)
206 mdot_F = (
207 float(eng_est.mdot_F)
208 if np.isfinite(eng_est.mdot_F) and eng_est.mdot_F > 0
209 else 1e-9
210 )
211 mdot_O = (
212 float(eng_est.mdot_O)
213 if np.isfinite(eng_est.mdot_O) and eng_est.mdot_O > 0
214 else 1e-9
215 )
216 mdot_total = mdot_F + mdot_O
217 Isp = F_eng / (mdot_total * G0) if mdot_total > 0 else 280.0
218
219 # Throttle level from thrust demand
220 if F_ref < F_eng * 0.80:
221 throttle = 0.06 if F_ref > 500 else 0.0
222 elif F_ref <= F_eng:
223 t = (F_ref - F_eng * 0.80) / (F_eng * 0.20)
224 throttle = max(0.10, min(0.30, 0.10 + 0.20 * t))
225 else:
226 deficit = F_ref - F_eng
227 throttle = 0.30 + 0.65 * (deficit / 7000.0)
228 throttle = max(0.30, min(0.95, throttle))
229
230 # Desired mass flows for (F_ref, MR_ref)
231 mdot_total_des = F_ref / (Isp * G0)
232 mdot_F_des = mdot_total_des / (1.0 + MR_ref)
233 mdot_O_des = MR_ref * mdot_F_des
234
235 duty_F_raw = mdot_F_des / mdot_F
236 duty_O_raw = mdot_O_des / mdot_O
237 duty_F = throttle * min(1.0, duty_F_raw)
238 duty_O = throttle * min(1.0, duty_O_raw)
239 duty_F = max(0.0, min(0.95, duty_F))
240 duty_O = max(0.0, min(0.95, duty_O))
241
242 return max(u_F, duty_F), max(u_O, duty_O)
243
244
246 u_safe: np.ndarray,
247 axis_names: List[str],
248 coords: List[float],
249 eng_est: Any,
250 idx: int,
251 get_axis: Any,
252) -> float:
253 """Legacy: return duty_F or duty_O from MR-aware computation."""
254 duty_F, duty_O = _duty_from_thrust_and_mr(
255 u_safe, axis_names, coords, eng_est, get_axis
256 )
257 return duty_F if idx == 0 else duty_O
258
259
261 engine_config_path: str,
262 project_root: str,
263 axis_names: List[str],
264 grids: List[np.ndarray],
265 axis_sizes: Tuple[int, ...],
266 output_names: List[str],
267) -> None:
268 """Initialize worker for engine-only LUT generation."""
269 global _worker_engine_wrapper, _worker_axis_names, _worker_grids
270 global _worker_axis_sizes, _worker_output_names
271 os.chdir(project_root)
272 _add_engine_sim_to_path(Path(project_root))
273 _, _worker_engine_wrapper = _load_engine_only(Path(engine_config_path))
274 _worker_axis_names = axis_names
275 _worker_grids = grids
276 _worker_axis_sizes = axis_sizes
277 _worker_output_names = output_names
278
279
281 flat_idx: int,
282 axis_names: List[str],
283 grids: List[np.ndarray],
284 axis_sizes: Tuple[int, ...],
285 output_names: List[str],
286 engine_wrapper: Any,
287) -> Tuple[Tuple[int, ...], Dict[str, float]]:
288 """Compute a single engine-only grid point (main process, no worker)."""
289 multi_idx = np.unravel_index(flat_idx, axis_sizes)
290 coords = [grids[i][multi_idx[i]] for i in range(len(axis_names))]
291 idx_fuel = axis_names.index("P_u_fuel")
292 idx_ox = axis_names.index("P_u_ox")
293 P_u_fuel = float(coords[idx_fuel])
294 P_u_ox = float(coords[idx_ox])
295 est = engine_wrapper.estimate_from_pressures(P_u_fuel, P_u_ox)
296 result = {out_name: _map_engine_output(est, out_name) for out_name in output_names}
297 return (multi_idx, result)
298
299
300def _compute_engine_point(flat_idx: int) -> Tuple[Tuple[int, ...], Dict[str, float]]:
301 """Compute a single engine-only grid point. Uses worker globals."""
302 multi_idx = np.unravel_index(flat_idx, _worker_axis_sizes)
303 coords = [_worker_grids[i][multi_idx[i]] for i in range(len(_worker_axis_names))]
304 idx_fuel = _worker_axis_names.index("P_u_fuel")
305 idx_ox = _worker_axis_names.index("P_u_ox")
306 P_u_fuel = float(coords[idx_fuel])
307 P_u_ox = float(coords[idx_ox])
308 est = _worker_engine_wrapper.estimate_from_pressures(P_u_fuel, P_u_ox)
309 result = {
310 out_name: _map_engine_output(est, out_name) for out_name in _worker_output_names
311 }
312 return (multi_idx, result)
313
314
316 engine_config_path: str,
317 controller_config_path: str,
318 engine_lut_path: str,
319 project_root: str,
320 axis_names: List[str],
321 grids: List[np.ndarray],
322 axis_sizes: Tuple[int, ...],
323 output_names: List[str],
324) -> None:
325 """Initialize worker for DDP LUT generation."""
326 global _worker_controller, _worker_engine_wrapper, _worker_axis_names
327 global _worker_grids, _worker_axis_sizes, _worker_output_names
328 global _worker_Measurement, _worker_NavState, _worker_Command, _worker_CommandType
329 os.chdir(project_root)
330 _add_engine_sim_to_path(Path(project_root))
331 engine_lut = Path(engine_lut_path) if engine_lut_path else None
332 (
333 _,
334 _worker_controller,
335 _worker_Measurement,
336 _worker_NavState,
337 _worker_Command,
338 _worker_CommandType,
340 Path(engine_config_path),
341 Path(controller_config_path),
342 engine_lut_path=engine_lut,
343 project_root=Path(project_root),
344 )
345 _worker_engine_wrapper = _worker_controller.engine_wrapper
346 _worker_axis_names = axis_names
347 _worker_grids = grids
348 _worker_axis_sizes = axis_sizes
349 _worker_output_names = output_names
350
351
353 flat_idx: int,
354 axis_names: List[str],
355 grids: List[np.ndarray],
356 axis_sizes: Tuple[int, ...],
357 output_names: List[str],
358 controller: Any,
359 engine_wrapper: Any,
360 Measurement: Any,
361 NavState: Any,
362 Command: Any,
363 CommandType: Any,
364) -> Tuple[Tuple[int, ...], Dict[str, float]]:
365 """Compute a single DDP grid point (main process, no worker)."""
366 multi_idx = np.unravel_index(flat_idx, axis_sizes)
367 coords = [grids[i][multi_idx[i]] for i in range(len(axis_names))]
368
369 P_copv = _get_axis_value(axis_names, coords, "P_copv", 18.96e6)
370 P_reg = _get_axis_value(axis_names, coords, "P_reg", 6.89e6)
371 P_u_fuel = _get_axis_value(axis_names, coords, "P_u_fuel", 6.89e6)
372 P_u_ox = _get_axis_value(axis_names, coords, "P_u_ox", 6.89e6)
373 P_d_fuel = _get_axis_value(axis_names, coords, "P_d_fuel", P_u_fuel)
374 P_d_ox = _get_axis_value(axis_names, coords, "P_d_ox", P_u_ox)
375 meas = Measurement(
376 P_copv=P_copv,
377 P_reg=P_reg,
378 P_u_fuel=P_u_fuel,
379 P_u_ox=P_u_ox,
380 P_d_fuel=P_d_fuel,
381 P_d_ox=P_d_ox,
382 timestamp=0.0,
383 )
384 h = _get_axis_value(axis_names, coords, "h", 0.0)
385 vz = _get_axis_value(axis_names, coords, "vz", 0.0)
386 theta = _get_axis_value(axis_names, coords, "theta", 0.0)
387 mass_estimate = _get_axis_value(axis_names, coords, "mass_estimate", 100.0)
388 nav = NavState(h=h, vz=vz, theta=theta, mass_estimate=mass_estimate)
389 if "thrust_desired" in axis_names:
390 thrust_val = _get_axis_value(axis_names, coords, "thrust_desired", 0.0)
391 cmd = Command(
392 command_type=CommandType.THRUST_DESIRED, thrust_desired=float(thrust_val)
393 )
394 elif "altitude_goal" in axis_names:
395 alt_goal = _get_axis_value(axis_names, coords, "altitude_goal", 0.0)
396 cmd = Command(
397 command_type=CommandType.ALTITUDE_GOAL, altitude_goal=float(alt_goal)
398 )
399 else:
400 cmd = Command(command_type=CommandType.THRUST_DESIRED, thrust_desired=0.0)
401
402 controller.reset()
403 actuation_cmd, diagnostics = controller.step(meas, nav, cmd)
404 eng_est = diagnostics.get("eng_est")
405 if eng_est is None and engine_wrapper is not None:
406 try:
407 eng_est = engine_wrapper.estimate_from_pressures(P_u_fuel, P_u_ox)
408 except Exception:
409 eng_est = None
410 u_relaxed = diagnostics.get("u_relaxed", np.array([0.0, 0.0]))
411 u_safe = diagnostics.get("u_safe", np.array([0.0, 0.0]))
412 solution = diagnostics.get("solution", None)
413
414 result = {}
415 for out_name in output_names:
416 value: float = np.nan
417 if out_name in ("F", "F_hat"):
418 if diagnostics.get("F_hat") is not None:
419 value = float(diagnostics["F_hat"])
420 elif eng_est is not None:
421 value = float(eng_est.F)
422 elif out_name in ("MR", "MR_hat"):
423 if diagnostics.get("MR_hat") is not None:
424 value = float(diagnostics["MR_hat"])
425 elif eng_est is not None:
426 value = float(eng_est.MR)
427 elif out_name in ("P_ch", "Pc"):
428 if diagnostics.get("P_ch") is not None:
429 value = float(diagnostics["P_ch"])
430 elif eng_est is not None:
431 value = float(eng_est.P_ch)
432 elif out_name == "mdot_F":
433 value = float(eng_est.mdot_F) if eng_est is not None else np.nan
434 elif out_name == "mdot_O":
435 value = float(eng_est.mdot_O) if eng_est is not None else np.nan
436 elif out_name == "stability_score":
437 if eng_est is not None and eng_est.stability_metrics is not None:
438 value = float(eng_est.stability_metrics.get("stability_score", np.nan))
439 elif out_name == "injector_stiffness_ok":
440 if eng_est is not None and eng_est.stability_metrics is not None:
441 ok = eng_est.stability_metrics.get("injector_stiffness_ok")
442 value = 1.0 if ok is True else (0.0 if ok is False else np.nan)
443 elif out_name == "duty_F":
444 value = _duty_from_thrust(
445 u_safe, axis_names, coords, eng_est, 0, _get_axis_value
446 )
447 elif out_name == "duty_O":
448 value = _duty_from_thrust(
449 u_safe, axis_names, coords, eng_est, 1, _get_axis_value
450 )
451 elif out_name == "u_F_onoff":
452 value = 1.0 if bool(getattr(actuation_cmd, "u_F_onoff", False)) else 0.0
453 elif out_name == "u_O_onoff":
454 value = 1.0 if bool(getattr(actuation_cmd, "u_O_onoff", False)) else 0.0
455 elif out_name == "u_relaxed_F":
456 value = float(u_relaxed[0]) if len(u_relaxed) > 0 else 0.0
457 elif out_name == "u_relaxed_O":
458 value = float(u_relaxed[1]) if len(u_relaxed) > 1 else 0.0
459 elif out_name == "u_safe_F":
460 value = _duty_from_thrust(
461 u_safe, axis_names, coords, eng_est, 0, _get_axis_value
462 )
463 elif out_name == "u_safe_O":
464 value = _duty_from_thrust(
465 u_safe, axis_names, coords, eng_est, 1, _get_axis_value
466 )
467 elif out_name in ("value_function", "cost"):
468 if solution is not None and hasattr(solution, "objective"):
469 try:
470 value = float(solution.objective)
471 except Exception:
472 value = np.nan
473 else:
474 value = (
475 float(diagnostics.get("last_cost", 0.0))
476 if "last_cost" in diagnostics
477 else np.nan
478 )
479 else:
480 if out_name in diagnostics:
481 try:
482 val = diagnostics[out_name]
483 if isinstance(val, (int, float, np.floating)):
484 value = float(val)
485 except Exception:
486 value = np.nan
487 elif (
488 eng_est is not None
489 and eng_est.diagnostics is not None
490 and out_name in eng_est.diagnostics
491 ):
492 try:
493 value = float(eng_est.diagnostics[out_name])
494 except Exception:
495 value = np.nan
496 result[out_name] = value
497 return (multi_idx, result)
498
499
500def _compute_ddp_point(flat_idx: int) -> Tuple[Tuple[int, ...], Dict[str, float]]:
501 """Compute a single DDP grid point. Uses worker globals."""
502 multi_idx = np.unravel_index(flat_idx, _worker_axis_sizes)
503 coords = [_worker_grids[i][multi_idx[i]] for i in range(len(_worker_axis_names))]
504 axis_names = _worker_axis_names
505
506 P_copv = _get_axis_value(axis_names, coords, "P_copv", 18.96e6)
507 P_reg = _get_axis_value(axis_names, coords, "P_reg", 6.89e6)
508 P_u_fuel = _get_axis_value(axis_names, coords, "P_u_fuel", 6.89e6)
509 P_u_ox = _get_axis_value(axis_names, coords, "P_u_ox", 6.89e6)
510 P_d_fuel = _get_axis_value(axis_names, coords, "P_d_fuel", P_u_fuel)
511 P_d_ox = _get_axis_value(axis_names, coords, "P_d_ox", P_u_ox)
512
513 meas = _worker_Measurement(
514 P_copv=P_copv,
515 P_reg=P_reg,
516 P_u_fuel=P_u_fuel,
517 P_u_ox=P_u_ox,
518 P_d_fuel=P_d_fuel,
519 P_d_ox=P_d_ox,
520 timestamp=0.0,
521 )
522
523 h = _get_axis_value(axis_names, coords, "h", 0.0)
524 vz = _get_axis_value(axis_names, coords, "vz", 0.0)
525 theta = _get_axis_value(axis_names, coords, "theta", 0.0)
526 mass_estimate = _get_axis_value(axis_names, coords, "mass_estimate", 100.0)
527 nav = _worker_NavState(h=h, vz=vz, theta=theta, mass_estimate=mass_estimate)
528
529 if "thrust_desired" in axis_names:
530 thrust_val = _get_axis_value(axis_names, coords, "thrust_desired", 0.0)
531 cmd = _worker_Command(
532 command_type=_worker_CommandType.THRUST_DESIRED,
533 thrust_desired=float(thrust_val),
534 )
535 elif "altitude_goal" in axis_names:
536 alt_goal = _get_axis_value(axis_names, coords, "altitude_goal", 0.0)
537 cmd = _worker_Command(
538 command_type=_worker_CommandType.ALTITUDE_GOAL,
539 altitude_goal=float(alt_goal),
540 )
541 else:
542 cmd = _worker_Command(
543 command_type=_worker_CommandType.THRUST_DESIRED,
544 thrust_desired=0.0,
545 )
546
547 _worker_controller.reset()
548 actuation_cmd, diagnostics = _worker_controller.step(meas, nav, cmd)
549
550 eng_est = diagnostics.get("eng_est")
551 if eng_est is None and _worker_engine_wrapper is not None:
552 try:
553 eng_est = _worker_engine_wrapper.estimate_from_pressures(P_u_fuel, P_u_ox)
554 except Exception:
555 eng_est = None
556
557 u_relaxed = diagnostics.get("u_relaxed", np.array([0.0, 0.0]))
558 u_safe = diagnostics.get("u_safe", np.array([0.0, 0.0]))
559 solution = diagnostics.get("solution", None)
560
561 result = {}
562 for out_name in _worker_output_names:
563 value: float = np.nan
564 if out_name in ("F", "F_hat"):
565 if diagnostics.get("F_hat") is not None:
566 value = float(diagnostics["F_hat"])
567 elif eng_est is not None:
568 value = float(eng_est.F)
569 elif out_name in ("MR", "MR_hat"):
570 if diagnostics.get("MR_hat") is not None:
571 value = float(diagnostics["MR_hat"])
572 elif eng_est is not None:
573 value = float(eng_est.MR)
574 elif out_name in ("P_ch", "Pc"):
575 if diagnostics.get("P_ch") is not None:
576 value = float(diagnostics["P_ch"])
577 elif eng_est is not None:
578 value = float(eng_est.P_ch)
579 elif out_name == "mdot_F":
580 value = float(eng_est.mdot_F) if eng_est is not None else np.nan
581 elif out_name == "mdot_O":
582 value = float(eng_est.mdot_O) if eng_est is not None else np.nan
583 elif out_name == "stability_score":
584 if eng_est is not None and eng_est.stability_metrics is not None:
585 value = float(eng_est.stability_metrics.get("stability_score", np.nan))
586 elif out_name == "injector_stiffness_ok":
587 if eng_est is not None and eng_est.stability_metrics is not None:
588 ok = eng_est.stability_metrics.get("injector_stiffness_ok")
589 value = 1.0 if ok is True else (0.0 if ok is False else np.nan)
590 elif out_name == "duty_F":
591 value = _duty_from_thrust(
592 u_safe, axis_names, coords, eng_est, 0, _get_axis_value
593 )
594 elif out_name == "duty_O":
595 value = _duty_from_thrust(
596 u_safe, axis_names, coords, eng_est, 1, _get_axis_value
597 )
598 elif out_name == "u_F_onoff":
599 value = 1.0 if bool(getattr(actuation_cmd, "u_F_onoff", False)) else 0.0
600 elif out_name == "u_O_onoff":
601 value = 1.0 if bool(getattr(actuation_cmd, "u_O_onoff", False)) else 0.0
602 elif out_name == "u_relaxed_F":
603 value = float(u_relaxed[0]) if len(u_relaxed) > 0 else 0.0
604 elif out_name == "u_relaxed_O":
605 value = float(u_relaxed[1]) if len(u_relaxed) > 1 else 0.0
606 elif out_name == "u_safe_F":
607 value = _duty_from_thrust(
608 u_safe, axis_names, coords, eng_est, 0, _get_axis_value
609 )
610 elif out_name == "u_safe_O":
611 value = _duty_from_thrust(
612 u_safe, axis_names, coords, eng_est, 1, _get_axis_value
613 )
614 elif out_name in ("value_function", "cost"):
615 if solution is not None and hasattr(solution, "objective"):
616 try:
617 value = float(solution.objective)
618 except Exception:
619 value = np.nan
620 else:
621 value = (
622 float(diagnostics.get("last_cost", 0.0))
623 if "last_cost" in diagnostics
624 else np.nan
625 )
626 else:
627 if out_name in diagnostics:
628 try:
629 val = diagnostics[out_name]
630 if isinstance(val, (int, float, np.floating)):
631 value = float(val)
632 except Exception:
633 value = np.nan
634 elif (
635 eng_est is not None
636 and eng_est.diagnostics is not None
637 and out_name in eng_est.diagnostics
638 ):
639 try:
640 value = float(eng_est.diagnostics[out_name])
641 except Exception:
642 value = np.nan
643 result[out_name] = value
644 return (multi_idx, result)
645
646
648 lut_config_path: Path,
649 output_path: Path,
650 project_root: Path,
651 jobs: int = 1,
652) -> None:
653 """
654 Generate a high-dimensional LUT.
655
656 Modes:
657 - Engine-only mode (no controller_config_path): map tank pressures to
658 engine performance (F, MR, Pc, mdots, stability, etc.) using EngineWrapper.
659 - DDP mode (controller_config_path set): run RobustDDPController for each
660 grid point and record both engine predictions and optimal control
661 quantities (duty cycles, relaxed controls, etc.).
662 """
663 lut_cfg = ControllerLUTConfig.from_yaml(lut_config_path)
664
665 _add_engine_sim_to_path(project_root)
666
667 use_ddp = lut_cfg.controller_config_path is not None
668 engine_config_path = project_root / lut_cfg.engine_config_path
669 controller_config_path = (
670 project_root / lut_cfg.controller_config_path
671 if lut_cfg.controller_config_path is not None
672 else None
673 )
674
675 axis_names, grids = _build_axes(lut_cfg)
676 axis_sizes = [len(g) for g in grids]
677 total_points = int(np.prod(axis_sizes))
678
679 print(f"[LUT] Axes: {axis_names}")
680 print(f"[LUT] Axis sizes: {axis_sizes} (total points = {total_points:,})")
681 print(f"[LUT] Engine config: {lut_cfg.engine_config_path}")
682 print(f"[LUT] Controller config: {lut_cfg.controller_config_path or '<default>'}")
683 print(f"[LUT] Outputs: {lut_cfg.outputs}")
684 if jobs > 1:
685 print(f"[LUT] Parallel: {jobs} workers")
686
687 # Allocate arrays for each requested output
688 data_arrays: Dict[str, np.ndarray] = {}
689 for out_name in lut_cfg.outputs:
690 data_arrays[out_name] = np.full(axis_sizes, np.nan, dtype=float)
691
692 if not use_ddp:
693 # ------------------------------------------------------------------
694 # Engine-only LUT: map tank pressures -> engine performance
695 # ------------------------------------------------------------------
696 _find_required_pressure_axes(axis_names) # validate axes
697 if jobs <= 1:
698 engine_cfg, engine_wrapper = _load_engine_only(engine_config_path)
699 for flat_idx in range(total_points):
700 multi_idx, result = _compute_engine_point_sequential(
701 flat_idx,
702 axis_names,
703 grids,
704 axis_sizes,
705 lut_cfg.outputs,
706 engine_wrapper,
707 )
708 for out_name, val in result.items():
709 data_arrays[out_name][multi_idx] = val
710 if (flat_idx + 1) % max(
711 1, total_points // 50
712 ) == 0 or flat_idx == total_points - 1:
713 pct = 100.0 * (flat_idx + 1) / total_points
714 print(f"[LUT] {flat_idx+1:,}/{total_points:,} points ({pct:5.1f}%)")
715 else:
716 init_args = (
717 str(engine_config_path),
718 str(project_root),
719 axis_names,
720 grids,
721 tuple(axis_sizes),
722 list(lut_cfg.outputs),
723 )
724 done = 0
725 with ProcessPoolExecutor(
726 max_workers=jobs, initializer=_init_engine_worker, initargs=init_args
727 ) as ex:
728 futures = {
729 ex.submit(_compute_engine_point, i): i for i in range(total_points)
730 }
731 for fut in as_completed(futures):
732 multi_idx, result = fut.result()
733 for out_name, val in result.items():
734 data_arrays[out_name][multi_idx] = val
735 done += 1
736 if done % max(1, total_points // 50) == 0 or done == total_points:
737 print(
738 f"[LUT] {done:,}/{total_points:,} points ({100.0*done/total_points:5.1f}%)"
739 )
740 else:
741 # ------------------------------------------------------------------
742 # DDP-based LUT: map full state/command -> optimal control + engine
743 # ------------------------------------------------------------------
744 engine_lut_path = (
745 Path(lut_cfg.engine_lut_path) if lut_cfg.engine_lut_path else None
746 )
747 if jobs <= 1:
748 (
749 engine_cfg,
750 controller,
751 Measurement,
752 NavState,
753 Command,
754 CommandType,
756 engine_config_path,
757 controller_config_path,
758 engine_lut_path=engine_lut_path,
759 project_root=project_root,
760 )
761 engine_wrapper = controller.engine_wrapper
762 for flat_idx in range(total_points):
763 multi_idx, result = _compute_ddp_point_sequential(
764 flat_idx,
765 axis_names,
766 grids,
767 axis_sizes,
768 lut_cfg.outputs,
769 controller,
770 engine_wrapper,
771 Measurement,
772 NavState,
773 Command,
774 CommandType,
775 )
776 for out_name, val in result.items():
777 data_arrays[out_name][multi_idx] = val
778 if (flat_idx + 1) % max(
779 1, total_points // 50
780 ) == 0 or flat_idx == total_points - 1:
781 pct = 100.0 * (flat_idx + 1) / total_points
782 print(
783 f"[LUT/DDP] {flat_idx+1:,}/{total_points:,} points ({pct:5.1f}%)"
784 )
785 else:
786 init_args = (
787 str(engine_config_path),
788 str(controller_config_path),
789 str(engine_lut_path) if engine_lut_path else "",
790 str(project_root),
791 axis_names,
792 grids,
793 tuple(axis_sizes),
794 list(lut_cfg.outputs),
795 )
796 done = 0
797 with ProcessPoolExecutor(
798 max_workers=jobs, initializer=_init_ddp_worker, initargs=init_args
799 ) as ex:
800 futures = {
801 ex.submit(_compute_ddp_point, i): i for i in range(total_points)
802 }
803 for fut in as_completed(futures):
804 multi_idx, result = fut.result()
805 for out_name, val in result.items():
806 data_arrays[out_name][multi_idx] = val
807 done += 1
808 if done % max(1, total_points // 50) == 0 or done == total_points:
809 print(
810 f"[LUT/DDP] {done:,}/{total_points:,} points ({100.0*done/total_points:5.1f}%)"
811 )
812
813 # Prepare metadata for saving
814 axes_meta: List[Dict[str, Any]] = [
815 {
816 "name": axis.name,
817 "units": axis.units,
818 "description": axis.description,
819 }
820 for axis in lut_cfg.axes
821 ]
822
823 meta: Dict[str, Any] = {
824 "axes": axes_meta,
825 "outputs": list(lut_cfg.outputs),
826 "engine_config_path": lut_cfg.engine_config_path,
827 "controller_config_path": lut_cfg.controller_config_path,
828 "metadata": lut_cfg.metadata,
829 }
830
831 npz_kwargs: Dict[str, Any] = {}
832 # Axes
833 for axis, grid in zip(lut_cfg.axes, grids):
834 npz_kwargs[f"axes/{axis.name}"] = grid
835 # Data
836 for out_name, arr in data_arrays.items():
837 npz_kwargs[f"data/{out_name}"] = arr
838 # Meta
839 npz_kwargs["meta"] = np.array(json.dumps(meta))
840
841 output_path = output_path.resolve()
842 output_path.parent.mkdir(parents=True, exist_ok=True)
843 tmp_path = output_path.parent / (output_path.stem + ".tmp.npz")
844 np.savez_compressed(str(tmp_path), **npz_kwargs)
845 tmp_path.replace(output_path) # atomic on same filesystem
846 print(f"[LUT] Saved LUT to {output_path}")
847
848
849def main(argv: list[str] | None = None) -> None:
850 parser = argparse.ArgumentParser(
851 description="Generate high-dimensional controller LUT using engine_sim."
852 )
853 parser.add_argument(
854 "--lut-config",
855 type=Path,
856 required=True,
857 help="Path to LUT configuration YAML file.",
858 )
859 parser.add_argument(
860 "--output",
861 type=Path,
862 required=True,
863 help="Path to output .npz file for the LUT.",
864 )
865 parser.add_argument(
866 "--project-root",
867 type=Path,
868 default=Path(__file__).resolve().parents[2],
869 help="Path to sensor_system project root (used to locate engine_sim).",
870 )
871 parser.add_argument(
872 "--jobs",
873 type=int,
874 default=os.cpu_count() or 1,
875 help="Number of parallel workers (default: CPU count). Use 1 for sequential.",
876 )
877
878 args = parser.parse_args(argv)
879 generate_lut(args.lut_config, args.output, args.project_root, jobs=args.jobs)
880
881
882if __name__ == "__main__":
883 main()
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
Tuple[Tuple[int,...], Dict[str, float]] _compute_ddp_point_sequential(int flat_idx, List[str] axis_names, List[np.ndarray] grids, Tuple[int,...] axis_sizes, List[str] output_names, Any controller, Any engine_wrapper, Any Measurement, Any NavState, Any Command, Any CommandType)
None _init_ddp_worker(str engine_config_path, str controller_config_path, str engine_lut_path, str project_root, List[str] axis_names, List[np.ndarray] grids, Tuple[int,...] axis_sizes, List[str] output_names)
float _duty_from_thrust(np.ndarray u_safe, List[str] axis_names, List[float] coords, Any eng_est, int idx, Any get_axis)
Tuple[int, int] _find_required_pressure_axes(List[str] axis_names)
Tuple[List[str], List[np.ndarray]] _build_axes(ControllerLUTConfig config)
None _init_engine_worker(str engine_config_path, str project_root, List[str] axis_names, List[np.ndarray] grids, Tuple[int,...] axis_sizes, List[str] output_names)
None generate_lut(Path lut_config_path, Path output_path, Path project_root, int jobs=1)
float _get_axis_value(List[str] axis_names, List[float] coords, str name, float default)
float _map_engine_output(Any est, str out_name)
Tuple[Tuple[int,...], Dict[str, float]] _compute_ddp_point(int flat_idx)
Tuple[float, float] _duty_from_thrust_and_mr(np.ndarray u_safe, List[str] axis_names, List[float] coords, Any eng_est, Any get_axis)
_load_engine_and_controller(Path engine_config_path, Path|None controller_config_path, Path|None engine_lut_path=None, Path|None project_root=None)
Tuple[Tuple[int,...], Dict[str, float]] _compute_engine_point_sequential(int flat_idx, List[str] axis_names, List[np.ndarray] grids, Tuple[int,...] axis_sizes, List[str] output_names, Any engine_wrapper)
Tuple[Tuple[int,...], Dict[str, float]] _compute_engine_point(int flat_idx)
int main()