58 engine_config_path: Path,
59 controller_config_path: Path |
None,
60 engine_lut_path: Path |
None =
None,
61 project_root: Path |
None =
None,
64 Load engine + controller configs and construct a RobustDDPController.
65 When engine_lut_path is set, controller uses EngineLUTWrapper for fast lookups.
76 load_config
as load_controller_config,
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))
84 controller_cfg = get_default_config()
86 if engine_lut_path
is not None:
89 if engine_lut_path.is_absolute()
90 else (project_root
or Path.cwd()) / engine_lut_path
93 controller_cfg.engine_lut_path = str(resolved)
97 warnings.warn(f
"Engine LUT not found: {resolved} — using physics")
99 controller = RobustDDPController(controller_cfg, engine_cfg, logger=
None)
100 return engine_cfg, controller, Measurement, NavState, Command, CommandType
148 """Map EngineEstimate to a single output value."""
149 value: float = np.nan
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:
173 value = float(est.diagnostics[out_name])
184 axis_names: List[str],
188) -> Tuple[float, float]:
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
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)
203 if eng_est
is None or not np.isfinite(eng_est.F)
or eng_est.F <= 0:
205 F_eng = float(eng_est.F)
207 float(eng_est.mdot_F)
208 if np.isfinite(eng_est.mdot_F)
and eng_est.mdot_F > 0
212 float(eng_est.mdot_O)
213 if np.isfinite(eng_est.mdot_O)
and eng_est.mdot_O > 0
216 mdot_total = mdot_F + mdot_O
217 Isp = F_eng / (mdot_total * G0)
if mdot_total > 0
else 280.0
220 if F_ref < F_eng * 0.80:
221 throttle = 0.06
if F_ref > 500
else 0.0
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))
226 deficit = F_ref - F_eng
227 throttle = 0.30 + 0.65 * (deficit / 7000.0)
228 throttle =
max(0.30,
min(0.95, throttle))
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
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))
242 return max(u_F, duty_F),
max(u_O, duty_O)
261 engine_config_path: str,
263 axis_names: List[str],
264 grids: List[np.ndarray],
265 axis_sizes: Tuple[int, ...],
266 output_names: List[str],
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)
274 _worker_axis_names = axis_names
275 _worker_grids = grids
276 _worker_axis_sizes = axis_sizes
277 _worker_output_names = output_names
282 axis_names: List[str],
283 grids: List[np.ndarray],
284 axis_sizes: Tuple[int, ...],
285 output_names: List[str],
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)
297 return (multi_idx, result)
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)
312 return (multi_idx, result)
316 engine_config_path: str,
317 controller_config_path: str,
318 engine_lut_path: str,
320 axis_names: List[str],
321 grids: List[np.ndarray],
322 axis_sizes: Tuple[int, ...],
323 output_names: List[str],
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)
331 engine_lut = Path(engine_lut_path)
if engine_lut_path
else None
340 Path(engine_config_path),
341 Path(controller_config_path),
342 engine_lut_path=engine_lut,
343 project_root=Path(project_root),
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
354 axis_names: List[str],
355 grids: List[np.ndarray],
356 axis_sizes: Tuple[int, ...],
357 output_names: List[str],
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))]
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)
392 command_type=CommandType.THRUST_DESIRED, thrust_desired=float(thrust_val)
394 elif "altitude_goal" in axis_names:
397 command_type=CommandType.ALTITUDE_GOAL, altitude_goal=float(alt_goal)
400 cmd = Command(command_type=CommandType.THRUST_DESIRED, thrust_desired=0.0)
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:
407 eng_est = engine_wrapper.estimate_from_pressures(P_u_fuel, P_u_ox)
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)
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":
445 u_safe, axis_names, coords, eng_est, 0, _get_axis_value
447 elif out_name ==
"duty_O":
449 u_safe, axis_names, coords, eng_est, 1, _get_axis_value
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":
461 u_safe, axis_names, coords, eng_est, 0, _get_axis_value
463 elif out_name ==
"u_safe_O":
465 u_safe, axis_names, coords, eng_est, 1, _get_axis_value
467 elif out_name
in (
"value_function",
"cost"):
468 if solution
is not None and hasattr(solution,
"objective"):
470 value = float(solution.objective)
475 float(diagnostics.get(
"last_cost", 0.0))
476 if "last_cost" in diagnostics
480 if out_name
in diagnostics:
482 val = diagnostics[out_name]
483 if isinstance(val, (int, float, np.floating)):
489 and eng_est.diagnostics
is not None
490 and out_name
in eng_est.diagnostics
493 value = float(eng_est.diagnostics[out_name])
496 result[out_name] = value
497 return (multi_idx, result)
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
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)
529 if "thrust_desired" in axis_names:
530 thrust_val =
_get_axis_value(axis_names, coords,
"thrust_desired", 0.0)
532 command_type=_worker_CommandType.THRUST_DESIRED,
533 thrust_desired=float(thrust_val),
535 elif "altitude_goal" in axis_names:
538 command_type=_worker_CommandType.ALTITUDE_GOAL,
539 altitude_goal=float(alt_goal),
543 command_type=_worker_CommandType.THRUST_DESIRED,
547 _worker_controller.reset()
548 actuation_cmd, diagnostics = _worker_controller.step(meas, nav, cmd)
550 eng_est = diagnostics.get(
"eng_est")
551 if eng_est
is None and _worker_engine_wrapper
is not None:
553 eng_est = _worker_engine_wrapper.estimate_from_pressures(P_u_fuel, P_u_ox)
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)
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":
592 u_safe, axis_names, coords, eng_est, 0, _get_axis_value
594 elif out_name ==
"duty_O":
596 u_safe, axis_names, coords, eng_est, 1, _get_axis_value
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":
608 u_safe, axis_names, coords, eng_est, 0, _get_axis_value
610 elif out_name ==
"u_safe_O":
612 u_safe, axis_names, coords, eng_est, 1, _get_axis_value
614 elif out_name
in (
"value_function",
"cost"):
615 if solution
is not None and hasattr(solution,
"objective"):
617 value = float(solution.objective)
622 float(diagnostics.get(
"last_cost", 0.0))
623 if "last_cost" in diagnostics
627 if out_name
in diagnostics:
629 val = diagnostics[out_name]
630 if isinstance(val, (int, float, np.floating)):
636 and eng_est.diagnostics
is not None
637 and out_name
in eng_est.diagnostics
640 value = float(eng_est.diagnostics[out_name])
643 result[out_name] = value
644 return (multi_idx, result)
648 lut_config_path: Path,
654 Generate a high-dimensional LUT.
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.).
663 lut_cfg = ControllerLUTConfig.from_yaml(lut_config_path)
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
676 axis_sizes = [len(g)
for g
in grids]
677 total_points = int(np.prod(axis_sizes))
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}")
685 print(f
"[LUT] Parallel: {jobs} workers")
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)
699 for flat_idx
in range(total_points):
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}%)")
717 str(engine_config_path),
722 list(lut_cfg.outputs),
725 with ProcessPoolExecutor(
726 max_workers=jobs, initializer=_init_engine_worker, initargs=init_args
729 ex.submit(_compute_engine_point, i): i
for i
in range(total_points)
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
736 if done %
max(1, total_points // 50) == 0
or done == total_points:
738 f
"[LUT] {done:,}/{total_points:,} points ({100.0*done/total_points:5.1f}%)"
745 Path(lut_cfg.engine_lut_path)
if lut_cfg.engine_lut_path
else None
757 controller_config_path,
758 engine_lut_path=engine_lut_path,
759 project_root=project_root,
761 engine_wrapper = controller.engine_wrapper
762 for flat_idx
in range(total_points):
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
783 f
"[LUT/DDP] {flat_idx+1:,}/{total_points:,} points ({pct:5.1f}%)"
787 str(engine_config_path),
788 str(controller_config_path),
789 str(engine_lut_path)
if engine_lut_path
else "",
794 list(lut_cfg.outputs),
797 with ProcessPoolExecutor(
798 max_workers=jobs, initializer=_init_ddp_worker, initargs=init_args
801 ex.submit(_compute_ddp_point, i): i
for i
in range(total_points)
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
808 if done %
max(1, total_points // 50) == 0
or done == total_points:
810 f
"[LUT/DDP] {done:,}/{total_points:,} points ({100.0*done/total_points:5.1f}%)"
814 axes_meta: List[Dict[str, Any]] = [
818 "description": axis.description,
820 for axis
in lut_cfg.axes
823 meta: Dict[str, Any] = {
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,
831 npz_kwargs: Dict[str, Any] = {}
833 for axis, grid
in zip(lut_cfg.axes, grids):
834 npz_kwargs[f
"axes/{axis.name}"] = grid
836 for out_name, arr
in data_arrays.items():
837 npz_kwargs[f
"data/{out_name}"] = arr
839 npz_kwargs[
"meta"] = np.array(json.dumps(meta))
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)
846 print(f
"[LUT] Saved LUT to {output_path}")
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)