STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
control.py
Go to the documentation of this file.
1"""Controller endpoints for robust DDP thrust control."""
2
3from fastapi import APIRouter, HTTPException, UploadFile, File, Depends
4from fastapi.responses import StreamingResponse, JSONResponse
5from pydantic import BaseModel, Field
6from typing import Optional, List, Dict, Any, Literal
7import numpy as np
8import json
9import asyncio
10import logging
11from pathlib import Path
12
13import yaml
14
15from starlette.concurrency import run_in_threadpool
16
17from backend.session import UserSession, get_session, gated_stream, run_compute
18from engine.control.robust_ddp import (
19 RobustDDPController,
20 ControllerConfig,
21 ControllerLogger,
22 Measurement,
23 NavState,
24 Command,
25 CommandType,
26 ActuationCommand,
27)
28from engine.control.robust_ddp.config_loader import load_config as load_controller_config, get_default_config
29from engine.pipeline.io import load_config as load_engine_config
30
31router = APIRouter(prefix="/api/control", tags=["control"])
32_log = logging.getLogger(__name__)
33
34
35# A request names a YAML to read by path (an engine config for the controller runs, a controller
36# config). Read as given, that was any file the server could open -- another user's upload under
37# output/user_configs/<them>/ run through the controller past design sharing, and the 500 echoed a
38# traceback with server paths. A path must now resolve to a .yaml under configs/ (or, for an engine
39# config, the caller's own uploads).
40_PROJECT_ROOT = Path(__file__).resolve().parents[2]
41_CONFIGS_DIR = _PROJECT_ROOT / "configs"
42
43
44def _engine_config_roots(session: UserSession) -> tuple:
45 return (_CONFIGS_DIR, _PROJECT_ROOT / "output" / "user_configs" / session.user)
46
47
48def _confined_yaml(path: str, *roots: Path, base: Path = _PROJECT_ROOT) -> Path:
49 """``path`` (relative to ``base``) resolved, if it is a .yaml file under one of ``roots``; else 400/404."""
50 candidate = Path(path)
51 if not candidate.is_absolute():
52 candidate = base / candidate
53 candidate = candidate.resolve()
54 allowed = [r.resolve() for r in roots]
55 if candidate.suffix.lower() not in (".yaml", ".yml") or not any(candidate.is_relative_to(r) for r in allowed):
56 raise HTTPException(status_code=400, detail="The path must name a .yaml file under configs/ (or your own uploads).")
57 if not candidate.is_file():
58 raise HTTPException(status_code=404, detail=f"No such config: {path}")
59 return candidate
60
61# Off the loop: the solve endpoints below are thin ``async def`` shells around a plain ``def``.
62# The controller's DDP solve and the engine evaluations it drives are seconds of CPU; inline in
63# an ``async def`` they ran on the event loop and froze every other request -- /api/health
64# included -- for as long (backend/session.py, run_compute). ``run_compute`` puts them in the
65# thread pool under the session's compute lock, so one user's controller (session.controller)
66# is still stepped by one request at a time, as it was on the loop. That holds for every touch
67# of it: the SSE streams build and step it through run_compute too (one step per lock hold), and
68# /reset resets it there. On a bare pool thread a stream's step ran beside a /simulate or /reset
69# of the same controller from another tab -- mid-step, where on the loop they could only
70# interleave between steps.
71
72
73def _fresh_controller(session: UserSession, cfg, engine_config) -> RobustDDPController:
74 """A new controller for this session, reset; called through run_compute (under the lock)."""
75 controller = RobustDDPController(cfg, engine_config, logger=None)
76 controller.reset()
77 session.controller.controller = controller
78 return controller
79
80# The controller instance is per-user now, held on session.controller
81# (backend/session.py). It used to be a module global, so two users shared one
82# controller: B's /step ran against the controller and engine config A had
83# initialized. Each endpoint below reaches it through session.controller.*.
84
85
86def safe_float(val):
87 """Convert value to float, replacing NaN/Inf with None."""
88 if val is None:
89 return None
90 try:
91 fval = float(val)
92 if np.isnan(fval) or np.isinf(fval):
93 return None
94 return fval
95 except (ValueError, TypeError):
96 return None
97
98
100 """Recursively convert numpy types to Python native types, handling NaN and Inf."""
101 # Handle dataclasses and objects with __dict__
102 if hasattr(obj, '__dict__') and not isinstance(obj, (dict, list, tuple, str, bytes)):
103 try:
104 # Try to convert to dict first
105 if hasattr(obj, '__dataclass_fields__'):
106 # It's a dataclass
107 return {k: convert_numpy(getattr(obj, k, None)) for k in obj.__dataclass_fields__.keys()}
108 else:
109 # Regular object with __dict__
110 return {k: convert_numpy(v) for k, v in obj.__dict__.items()}
111 except Exception:
112 # If conversion fails, return string representation
113 return str(obj)
114
115 if isinstance(obj, dict):
116 return {k: convert_numpy(v) for k, v in obj.items()}
117 elif isinstance(obj, (list, tuple)):
118 return [convert_numpy(item) for item in obj]
119 elif isinstance(obj, np.ndarray):
120 # Convert array, replacing NaN/Inf with None
121 try:
122 arr_list = obj.tolist()
123 return convert_numpy(arr_list) # Recursively handle NaN in list
124 except Exception:
125 return str(obj)
126 elif isinstance(obj, (np.integer, np.floating)):
127 val = obj.item()
128 # Replace NaN and Inf with None (JSON-safe)
129 if isinstance(val, float) and (np.isnan(val) or np.isinf(val)):
130 return None
131 return val
132 elif isinstance(obj, np.bool_):
133 return bool(obj)
134 elif isinstance(obj, float):
135 # Handle Python float NaN/Inf
136 if np.isnan(obj) or np.isinf(obj):
137 return None
138 return obj
139 elif isinstance(obj, (int, str, bool, type(None))):
140 return obj
141 else:
142 # For any other type, try to convert to string
143 try:
144 return str(obj)
145 except Exception:
146 return None
147
148
149# ============================================================================
150# Request/Response Models
151# ============================================================================
152
153class ControllerInitRequest(BaseModel):
154 """Request to initialize controller."""
155 controller_config_path: Optional[str] = Field(
156 default=None,
157 description="Path to controller config YAML (relative to configs/). If None, uses default."
158 )
159 use_engine_config: bool = Field(
160 default=True,
161 description="Use the caller's currently loaded engine config"
162 )
163
164
165class MeasurementRequest(BaseModel):
166 """Sensor measurement data."""
167 P_copv: float = Field(..., description="COPV pressure [Pa]")
168 P_reg: float = Field(..., description="Regulator pressure [Pa]")
169 P_u_fuel: float = Field(..., description="Fuel ullage/tank pressure [Pa]")
170 P_u_ox: float = Field(..., description="Oxidizer ullage/tank pressure [Pa]")
171 P_d_fuel: float = Field(..., description="Fuel feed pressure [Pa]")
172 P_d_ox: float = Field(..., description="Oxidizer feed pressure [Pa]")
173 timestamp: Optional[float] = Field(default=None, description="Timestamp [s]")
174
175
176class NavStateRequest(BaseModel):
177 """Navigation state data."""
178 h: float = Field(..., description="Altitude [m]")
179 vz: float = Field(..., description="Vertical velocity [m/s]")
180 theta: float = Field(default=0.0, description="Tilt angle [rad]")
181 mass_estimate: Optional[float] = Field(default=None, description="Vehicle mass estimate [kg]")
182
183
184class CommandRequest(BaseModel):
185 """Control command."""
186 command_type: Literal["thrust_desired", "altitude_goal"] = Field(
187 ...,
188 description="Command type: 'thrust_desired' or 'altitude_goal'"
189 )
190 thrust_desired: Optional[float] = Field(
191 default=None,
192 description="Desired thrust [N] (for thrust_desired mode) or piecewise schedule"
193 )
194 altitude_goal: Optional[float] = Field(
195 default=None,
196 description="Target altitude [m] (for altitude_goal mode)"
197 )
198
199
200class ControllerStepRequest(BaseModel):
201 """Request for one controller step."""
202 meas: MeasurementRequest
203 nav: NavStateRequest
204 cmd: CommandRequest
205
206
208 """Request to simulate controller over time series."""
209 # Initial conditions
210 initial_meas: MeasurementRequest
211 initial_nav: NavStateRequest
212
213 # Command
214 cmd: CommandRequest
215
216 # Simulation parameters
217 duration: float = Field(..., gt=0, description="Simulation duration [s]")
218 dt: float = Field(default=0.01, gt=0, description="Time step [s]")
219
220 # Optional: provide thrust curve for tracking
221 thrust_curve: Optional[List[float]] = Field(
222 default=None,
223 description="Target thrust curve [N] (if provided, used for thrust_desired mode)"
224 )
225 time_array: Optional[List[float]] = Field(
226 default=None,
227 description="Time array for thrust curve [s]"
228 )
229
230 # Controller config
231 controller_config_path: Optional[str] = None
232
233
235 """Request to run controller on Layer 2 optimized thrust curve."""
236 thrust_curve_time: List[float] = Field(..., description="Time array from Layer 2 [s]")
237 thrust_curve_values: List[float] = Field(..., description="Thrust values from Layer 2 [N]")
238 initial_meas: Optional[MeasurementRequest] = Field(
239 default=None,
240 description="Initial measurements. If None, uses defaults from Layer 2 results."
241 )
242 initial_nav: Optional[NavStateRequest] = Field(
243 default=None,
244 description="Initial navigation state. If None, uses defaults."
245 )
246 dt: float = Field(default=0.01, description="Controller time step [s]")
247
248
250 """Request to run controller from Layer 2 config file."""
251 config_path: Optional[str] = Field(
252 default=None,
253 description="Path to Layer 2 config YAML file (relative to project root). If None, uses currently loaded config."
254 )
255 initial_meas: Optional[MeasurementRequest] = Field(
256 default=None,
257 description="Initial measurements. If None, uses defaults from config."
258 )
259 initial_nav: Optional[NavStateRequest] = Field(
260 default=None,
261 description="Initial navigation state. If None, uses defaults."
262 )
263 dt: float = Field(default=0.01, description="Controller time step [s]")
264
265
266# ============================================================================
267# Endpoints
268# ============================================================================
269
270@router.post("/init")
271async def init_controller(request: ControllerInitRequest, session: UserSession = Depends(get_session)):
272 """Initialize the robust DDP controller.
273
274 Off the event loop, one at a time per session: see "Off the loop" at the top.
275 """
276 return await run_compute(session, _init_controller, request, session)
277
278
279def _init_controller(request: ControllerInitRequest, session: UserSession):
280
281 try:
282 # Load controller config
283 if request.controller_config_path:
284 cfg = load_controller_config(str(_confined_yaml(request.controller_config_path, _CONFIGS_DIR, base=_CONFIGS_DIR)))
285 else:
286 cfg = get_default_config()
287
288 # Get engine config (REQUIRED for controller - needed for chamber design, injector geometry, etc.)
289 if not session.app_state.has_config():
290 raise HTTPException(
291 status_code=400,
292 detail="No engine config loaded. The controller requires an engine config to formulate the control pipeline (chamber design, injector geometry, etc.). Please load a config first."
293 )
294 engine_config = session.app_state.config
295
296 # Create logger (optional, for now just in-memory)
297 # session.controller.controller_logger = ControllerLogger("controller_run.json", format="json")
298
299 # Create controller (engine_config is required)
300 session.controller.controller = RobustDDPController(cfg, engine_config, logger=None)
301
302 return {
303 "status": "initialized",
304 "config": convert_numpy(cfg.to_dict()),
305 }
306 except HTTPException:
307 raise
308 except Exception as e:
309 raise HTTPException(status_code=500, detail=str(e))
310
311
312@router.post("/step")
313async def controller_step(request: ControllerStepRequest, session: UserSession = Depends(get_session)):
314 """Execute one controller step.
315
316 Off the event loop, one at a time per session: see "Off the loop" at the top.
317 """
318 return await run_compute(session, _controller_step, request, session)
319
320
321def _controller_step(request: ControllerStepRequest, session: UserSession):
322
323 if session.controller.controller is None:
324 raise HTTPException(status_code=400, detail="Controller not initialized. Call /init first.")
325
326 try:
327 # Convert requests to data models
328 measurement = Measurement(
329 P_copv=request.meas.P_copv,
330 P_reg=request.meas.P_reg,
331 P_u_fuel=request.meas.P_u_fuel,
332 P_u_ox=request.meas.P_u_ox,
333 P_d_fuel=request.meas.P_d_fuel,
334 P_d_ox=request.meas.P_d_ox,
335 timestamp=request.meas.timestamp,
336 )
337
338 nav_state = NavState(
339 h=request.nav.h,
340 vz=request.nav.vz,
341 theta=request.nav.theta,
342 mass_estimate=request.nav.mass_estimate,
343 )
344
345 command = Command(
346 command_type=CommandType(request.cmd.command_type),
347 thrust_desired=request.cmd.thrust_desired,
348 altitude_goal=request.cmd.altitude_goal,
349 )
350
351 # Run controller step
352 actuation_cmd, diagnostics = session.controller.controller.step(measurement, nav_state, command)
353
354 # Extract only primitive values - avoid any numpy arrays or complex objects
355 def safe_float_val(val, default=0.0):
356 try:
357 if isinstance(val, (int, float)):
358 fval = float(val)
359 return fval if not (np.isnan(fval) or np.isinf(fval)) else default
360 elif isinstance(val, np.number):
361 fval = float(val)
362 return fval if not (np.isnan(fval) or np.isinf(fval)) else default
363 elif isinstance(val, np.ndarray):
364 # Skip arrays entirely
365 return default
366 return default
367 except Exception:
368 return default
369
370 # Build diagnostics dict with ONLY primitive types
371 diagnostics_dict = {}
372 try:
373 diagnostics_dict["F_ref"] = safe_float_val(diagnostics.get("F_ref", 0.0))
374 diagnostics_dict["MR_ref"] = safe_float_val(diagnostics.get("MR_ref", 0.0))
375 diagnostics_dict["F_estimated"] = safe_float_val(diagnostics.get("F_hat", 0.0))
376 diagnostics_dict["MR_estimated"] = safe_float_val(diagnostics.get("MR_hat", 0.0))
377 diagnostics_dict["P_ch"] = safe_float_val(diagnostics.get("P_ch", 0.0))
378 diagnostics_dict["cost"] = 0.0
379 diagnostics_dict["solver_iters"] = 0
380 diagnostics_dict["safety_filtered"] = False
381 diagnostics_dict["cutoff_active"] = False
382
383 # Try to add solution info if available (safely)
384 solution = diagnostics.get("solution")
385 if solution:
386 if hasattr(solution, 'objective'):
387 try:
388 obj_val = solution.objective
389 if isinstance(obj_val, (int, float)) and not isinstance(obj_val, np.ndarray):
390 diagnostics_dict["cost"] = safe_float_val(obj_val)
391 except Exception:
392 pass
393 if hasattr(solution, 'iterations'):
394 try:
395 iter_val = solution.iterations
396 if isinstance(iter_val, (int, np.integer)):
397 diagnostics_dict["solver_iters"] = int(iter_val)
398 except Exception:
399 pass
400 except Exception as e:
401 # If diagnostics extraction fails, use defaults
402 diagnostics_dict = {
403 "F_ref": 0.0, "MR_ref": 0.0, "F_estimated": 0.0, "MR_estimated": 0.0,
404 "P_ch": 0.0, "cost": 0.0, "solver_iters": 0, "safety_filtered": False, "cutoff_active": False
405 }
406
407 # Extract primitive values from actuation_cmd
408 actuation_dict = {
409 "duty_F": float(getattr(actuation_cmd, 'duty_F', 0.0)),
410 "duty_O": float(getattr(actuation_cmd, 'duty_O', 0.0)),
411 "u_F_onoff": bool(getattr(actuation_cmd, 'u_F_onoff', False)),
412 "u_O_onoff": bool(getattr(actuation_cmd, 'u_O_onoff', False)),
413 }
414
415 # Return plain dict - FastAPI will serialize it
416 return {
417 "actuation": actuation_dict,
418 "diagnostics": diagnostics_dict,
419 }
420 except Exception as e:
421 raise HTTPException(status_code=500, detail=str(e))
422
423
424@router.post("/simulate")
425async def simulate_controller(request: ControllerSimulateRequest, session: UserSession = Depends(get_session)):
426 """Simulate controller over a time series.
427
428 Off the event loop, one at a time per session: see "Off the loop" at the top.
429 """
430 return await run_compute(session, _simulate_controller, request, session)
431
432
433def _simulate_controller(request: ControllerSimulateRequest, session: UserSession):
434
435 # Initialize controller if needed
436 if session.controller.controller is None:
437 try:
438 cfg = get_default_config()
439 if not session.app_state.has_config():
440 raise HTTPException(
441 status_code=400,
442 detail="No engine config loaded. Load a config first."
443 )
444 engine_config = session.app_state.config
445 session.controller.controller = RobustDDPController(cfg, engine_config, logger=None)
446 except Exception as e:
447 raise HTTPException(status_code=500, detail=f"Failed to initialize controller: {e}")
448
449 try:
450 # Build time array
451 n_steps = int(request.duration / request.dt)
452 time_array = np.linspace(0, request.duration, n_steps + 1)
453
454 # Initialize state
455 meas = Measurement(
456 P_copv=request.initial_meas.P_copv,
457 P_reg=request.initial_meas.P_reg,
458 P_u_fuel=request.initial_meas.P_u_fuel,
459 P_u_ox=request.initial_meas.P_u_ox,
460 P_d_fuel=request.initial_meas.P_d_fuel,
461 P_d_ox=request.initial_meas.P_d_ox,
462 )
463
464 nav = NavState(
465 h=request.initial_nav.h,
466 vz=request.initial_nav.vz,
467 theta=request.initial_nav.theta,
468 mass_estimate=request.initial_nav.mass_estimate,
469 )
470
471 # Build command (handle thrust curve if provided)
472 if request.cmd.command_type == "thrust_desired" and request.thrust_curve:
473 # Piecewise thrust schedule
474 if request.time_array:
475 thrust_schedule = list(zip(request.time_array, request.thrust_curve))
476 else:
477 # Use simulation time array
478 thrust_schedule = list(zip(time_array.tolist(), request.thrust_curve))
479 cmd = Command(
480 command_type=CommandType.THRUST_DESIRED,
481 thrust_desired=thrust_schedule,
482 )
483 else:
484 cmd = Command(
485 command_type=CommandType(request.cmd.command_type),
486 thrust_desired=request.cmd.thrust_desired,
487 altitude_goal=request.cmd.altitude_goal,
488 )
489
490 # Import dynamics for state propagation
491 from engine.control.robust_ddp.dynamics import step as dynamics_step, DynamicsParams, N_STATE
492 from engine.control.robust_ddp.dynamics import IDX_P_COPV, IDX_P_REG, IDX_P_U_F, IDX_P_U_O, IDX_P_D_F, IDX_P_D_O, IDX_V_U_F, IDX_V_U_O
493
494 # Get dynamics params
495 dynamics_params = DynamicsParams.from_config(session.controller.controller.cfg)
496
497 # Build initial state vector
498 x = session.controller.controller._build_state(meas)
499
500 # Validate initial state size
501 if len(x) != N_STATE:
502 raise ValueError(f"Initial state vector has wrong size: expected {N_STATE}, got {len(x)}. "
503 f"State: {x}")
504
505 # NOTE: Controller is already integrated with engine physics pipeline:
506 # - Controller calls engine_wrapper.estimate_from_pressures(P_u_F, P_u_O)
507 # - Engine wrapper calls PintleEngineRunner.evaluate(P_tank_F, P_tank_O)
508 # - PintleEngineRunner uses full physics: chamber solver, nozzle, stability, etc.
509 # - This gives actual thrust, mdot, MR from tank pressures
510 # - Flight dynamics are integrated below using actual thrust from engine
511
512 # Simulate
513 results = {
514 "time": [],
515 "thrust_ref": [],
516 "thrust_actual": [],
517 "MR": [],
518 "P_copv": [],
519 "P_reg": [],
520 "P_u_fuel": [],
521 "P_u_ox": [],
522 "P_d_fuel": [],
523 "P_d_ox": [],
524 "P_ch": [],
525 "duty_F": [],
526 "duty_O": [],
527 "altitude": [],
528 "velocity": [],
529 "value_function": [], # DDP objective/cost
530 "control_effort": [], # ||u|| or ||u - u_prev||
531 "V_u_fuel": [], # Fuel ullage volume
532 "V_u_ox": [], # Oxidizer ullage volume
533 "mdot_F": [],
534 "mdot_O": [],
535 }
536
537 u_prev = None
538
539 for i, t in enumerate(time_array):
540 # Update command if using thrust curve
541 if request.cmd.command_type == "thrust_desired" and request.thrust_curve:
542 # Interpolate thrust from curve
543 if request.time_array and len(request.time_array) == len(request.thrust_curve):
544 # Interpolate
545 thrust_val = np.interp(t, request.time_array, request.thrust_curve)
546 cmd = Command(
547 command_type=CommandType.THRUST_DESIRED,
548 thrust_desired=float(thrust_val),
549 )
550
551 # Controller step
552 actuation, diagnostics = session.controller.controller.step(meas, nav, cmd)
553
554 # Get DDP solution for value function
555 solution = diagnostics.get("solution")
556 value_func = solution.objective if solution and hasattr(solution, 'objective') else 0.0
557
558 # Get control effort
559 u_relaxed = diagnostics.get("u_relaxed", np.array([0.0, 0.0]))
560 if u_prev is not None:
561 control_effort = float(np.linalg.norm(u_relaxed - u_prev))
562 else:
563 control_effort = float(np.linalg.norm(u_relaxed))
564 u_prev = u_relaxed.copy()
565
566 # Get engine estimate from diagnostics (computed once in controller)
567 eng_est = diagnostics.get("eng_est")
568
569 # Get state from diagnostics if available, otherwise use current x
570 x_current = diagnostics.get("x", x)
571
572 # Store results
573 results["time"].append(float(t))
574 results["thrust_ref"].append(diagnostics.get("F_ref", 0.0) if isinstance(diagnostics.get("F_ref"), (int, float)) else 0.0)
575 results["thrust_actual"].append(diagnostics.get("F_hat", 0.0) if isinstance(diagnostics.get("F_hat"), (int, float)) else 0.0)
576 results["MR"].append(diagnostics.get("MR_hat", 0.0) if isinstance(diagnostics.get("MR_hat"), (int, float)) else 0.0)
577 results["P_copv"].append(float(meas.P_copv))
578 results["P_reg"].append(float(meas.P_reg))
579 results["P_u_fuel"].append(float(meas.P_u_fuel))
580 results["P_u_ox"].append(float(meas.P_u_ox))
581 results["P_d_fuel"].append(float(meas.P_d_fuel))
582 results["P_d_ox"].append(float(meas.P_d_ox))
583 results["P_ch"].append(diagnostics.get("P_ch", 0.0) if isinstance(diagnostics.get("P_ch"), (int, float)) else 0.0)
584 results["duty_F"].append(float(actuation.duty_F))
585 results["duty_O"].append(float(actuation.duty_O))
586 results["altitude"].append(float(nav.h))
587 results["velocity"].append(float(nav.vz))
588 results["value_function"].append(value_func)
589 results["control_effort"].append(control_effort)
590 results["V_u_fuel"].append(float(x_current[IDX_V_U_F]))
591 results["V_u_ox"].append(float(x_current[IDX_V_U_O]))
592 results["mdot_F"].append(float(eng_est.mdot_F) if eng_est and hasattr(eng_est, 'mdot_F') and np.isfinite(eng_est.mdot_F) else 0.0)
593 results["mdot_O"].append(float(eng_est.mdot_O) if eng_est and hasattr(eng_est, 'mdot_O') and np.isfinite(eng_est.mdot_O) else 0.0)
594
595 # Update state using actual dynamics
596 # Get mass flows from engine (pressure-dependent, NOT gated by control)
597 # CRITICAL: Mass flow to chamber is ALWAYS happening (pressure-dependent)
598 # Control input (u) only affects gas flow INTO tanks, not propellant flow OUT
599 # Propellant flows out based on feed pressure and injector characteristics
600 mdot_F = float(eng_est.mdot_F) if eng_est and hasattr(eng_est, 'mdot_F') and np.isfinite(eng_est.mdot_F) else 0.0
601 mdot_O = float(eng_est.mdot_O) if eng_est and hasattr(eng_est, 'mdot_O') and np.isfinite(eng_est.mdot_O) else 0.0
602
603 # CRITICAL: Use ACTUAL actuation state (duty cycle or on/off), not relaxed control
604 # PWM duty cycle means valve is either fully open or fully closed, switching at high frequency
605 # For dynamics, we need to model the discrete on/off behavior, not continuous flow
606 # Use duty cycle as the "fraction of time valve is open" for the time step
607 # This creates the spikes/ripple when valves open and close
608
609 # Get actuation command (has duty cycles and on/off states)
610 duty_F = float(actuation.duty_F) # Duty cycle [0, 1] - fraction of time valve is open
611 duty_O = float(actuation.duty_O) # Duty cycle [0, 1]
612
613 # For discrete modeling: duty cycle represents fraction of dt that valve is open
614 # During that time, valve is fully open (u=1), otherwise closed (u=0)
615 # Average effect over dt: u_effective = duty_cycle
616 # But for realistic spikes, we should model the on/off switching
617 # For now, use duty cycle as effective control (will create pressure changes)
618 u_effective = np.array([duty_F, duty_O], dtype=np.float64)
619
620 # Step dynamics (ensure dt is always defined)
621 # Dynamics model handles:
622 # - Gas flow from COPV->regulator->tanks when valves open (duty > 0)
623 # - Blowdown when valves closed (pressure decreases as propellant consumed)
624 # - Mass flow to chamber is always happening (pressure-dependent)
625 dt_step = float(request.dt) if request.dt is not None else 0.01
626
627 # Validate state before stepping
628 if len(x) != N_STATE:
629 raise ValueError(f"State vector has wrong size before step: expected {N_STATE}, got {len(x)}")
630
631 # Use effective control (duty cycle) for dynamics
632 x_next = dynamics_step(x, u_effective, dt_step, dynamics_params, mdot_F, mdot_O)
633
634 # Validate state after stepping
635 if len(x_next) != N_STATE:
636 raise ValueError(f"State vector has wrong size after step: expected {N_STATE}, got {len(x_next)}")
637
638 # Update measurements from state
639 meas.P_copv = x_next[IDX_P_COPV]
640 meas.P_reg = x_next[IDX_P_REG]
641 meas.P_u_fuel = x_next[IDX_P_U_F]
642 meas.P_u_ox = x_next[IDX_P_U_O]
643 meas.P_d_fuel = x_next[IDX_P_D_F]
644 meas.P_d_ox = x_next[IDX_P_D_O]
645
646 # Update internal ullage volumes
647 session.controller.controller.V_u_F = x_next[IDX_V_U_F]
648 session.controller.controller.V_u_O = x_next[IDX_V_U_O]
649
650 # Update nav using flight dynamics integrated with engine physics
651 # Use actual thrust from engine to compute acceleration
652 F_actual = diagnostics.get("F_hat", 0.0)
653 if isinstance(F_actual, (int, float)) and F_actual > 0:
654 # Flight dynamics: a = F/m - g
655 # Account for propellant consumption
656 mass = nav.mass_estimate or 100.0
657 if eng_est:
658 mdot_total = (eng_est.mdot_F or 0.0) + (eng_est.mdot_O or 0.0)
659 mass -= mdot_total * dt_step
660 nav.mass_estimate = max(mass, 50.0) # Minimum mass
661
662 # Compute acceleration (accounting for gravity and drag if needed)
663 # For now, simple vertical flight: a = F/m - g
664 # In full implementation, would integrate with RocketPy for drag, etc.
665 acceleration = (F_actual / nav.mass_estimate) - 9.81
666 nav.vz += acceleration * dt_step
667 nav.h += nav.vz * dt_step
668 else:
669 # No thrust - ballistic (gravity only)
670 nav.vz -= 9.81 * dt_step
671 nav.h += nav.vz * dt_step
672
673 # Update state for next iteration
674 # CRITICAL: Use x_next directly - it contains all updated values including gas masses
675 # Don't rebuild from measurements, as gas masses aren't in measurements
676 x = x_next.copy() # Make a copy to avoid reference issues
677
678 return convert_numpy(results)
679
680 except HTTPException:
681 raise
682 except Exception as e:
683 _log.exception("controller simulation failed")
684 raise HTTPException(status_code=500, detail=f"Controller simulation failed: {e}")
685
686
687@router.post("/reset")
688async def reset_controller(session: UserSession = Depends(get_session)):
689 """Reset controller state."""
690 if session.controller.controller is None:
691 raise HTTPException(status_code=400, detail="Controller not initialized")
692 # Under the compute lock: a /simulate or a stream's step may be using this controller.
693 await run_compute(session, session.controller.controller.reset)
694 return {"status": "reset"}
695
696
697@router.post("/simulate-layer2")
698async def simulate_layer2_controller(request: Layer2ControllerSimulateRequest, session: UserSession = Depends(get_session)):
699 """Run controller simulation using Layer 2 optimized thrust curve as reference.
700
701 Off the event loop, one at a time per session: see "Off the loop" at the top.
702 """
703 return await run_compute(session, _simulate_layer2_controller, request, session)
704
705
706def _simulate_layer2_controller(request: Layer2ControllerSimulateRequest, session: UserSession):
707
708 # Initialize dt as function-level variable (always defined)
709 dt = float(request.dt) if request.dt is not None else 0.01
710
711 # Initialize controller if needed
712 if session.controller.controller is None:
713 try:
714 cfg = get_default_config()
715 if not session.app_state.has_config():
716 raise HTTPException(
717 status_code=400,
718 detail="No engine config loaded. Load a config first."
719 )
720 engine_config = session.app_state.config
721 session.controller.controller = RobustDDPController(cfg, engine_config, logger=None)
722 except Exception as e:
723 raise HTTPException(status_code=500, detail=f"Failed to initialize controller: {e}")
724
725 try:
726 # Use Layer 2 thrust curve as reference
727 time_array = np.array(request.thrust_curve_time)
728 thrust_curve = np.array(request.thrust_curve_values)
729
730 # Validate inputs
731 if len(time_array) == 0:
732 raise HTTPException(status_code=400, detail="Time array is empty")
733 if len(thrust_curve) == 0:
734 raise HTTPException(status_code=400, detail="Thrust curve is empty")
735 if len(time_array) != len(thrust_curve):
736 raise HTTPException(status_code=400, detail=f"Time array length ({len(time_array)}) doesn't match thrust curve length ({len(thrust_curve)})")
737
738 duration = float(time_array[-1])
739
740 # Compute dt from time array (use differences, fallback to function-level dt)
741 # Override default if we have multiple time points
742 if len(time_array) > 1:
743 try:
744 dt = float(np.mean(np.diff(time_array)))
745 except (ValueError, TypeError):
746 # Fallback to default if computation fails
747 dt = float(request.dt) if request.dt is not None else 0.01
748
749 # Default initial conditions if not provided
750 if request.initial_meas is None:
751 # Use reasonable defaults based on typical Layer 2 results
752 meas = Measurement(
753 P_copv=18.96e6, # ~2750 psi
754 P_reg=6.89e6, # ~1000 psi
755 P_u_fuel=6.89e6, # ~1000 psi (regulated)
756 P_u_ox=6.89e6, # ~1000 psi (regulated)
757 P_d_fuel=6.89e6, # Start at ullage pressure
758 P_d_ox=6.89e6, # Start at ullage pressure
759 )
760 else:
761 meas = Measurement(
762 P_copv=request.initial_meas.P_copv,
763 P_reg=request.initial_meas.P_reg,
764 P_u_fuel=request.initial_meas.P_u_fuel,
765 P_u_ox=request.initial_meas.P_u_ox,
766 P_d_fuel=request.initial_meas.P_d_fuel,
767 P_d_ox=request.initial_meas.P_d_ox,
768 )
769
770 if request.initial_nav is None:
771 nav = NavState(h=0.0, vz=0.0, theta=0.0, mass_estimate=100.0)
772 else:
773 nav = NavState(
774 h=request.initial_nav.h,
775 vz=request.initial_nav.vz,
776 theta=request.initial_nav.theta,
777 mass_estimate=request.initial_nav.mass_estimate,
778 )
779
780 # Build piecewise thrust command from Layer 2 curve
781 thrust_schedule = list(zip(time_array.tolist(), thrust_curve.tolist()))
782 cmd = Command(
783 command_type=CommandType.THRUST_DESIRED,
784 thrust_desired=thrust_schedule,
785 )
786
787 # Import dynamics for state propagation
788 from engine.control.robust_ddp.dynamics import step as dynamics_step, DynamicsParams
789 from engine.control.robust_ddp.dynamics import IDX_P_COPV, IDX_P_REG, IDX_P_U_F, IDX_P_U_O, IDX_P_D_F, IDX_P_D_O, IDX_V_U_F, IDX_V_U_O
790
791 # Get dynamics params
792 dynamics_params = DynamicsParams.from_config(session.controller.controller.cfg)
793
794 # Build initial state vector
795 x = session.controller.controller._build_state(meas)
796
797 # Simulate over Layer 2 time array
798 n_steps = len(time_array)
799 results = {
800 "time": [],
801 "thrust_ref": [],
802 "thrust_actual": [],
803 "MR": [],
804 "P_copv": [],
805 "P_reg": [],
806 "P_u_fuel": [],
807 "P_u_ox": [],
808 "P_d_fuel": [],
809 "P_d_ox": [],
810 "P_ch": [],
811 "duty_F": [],
812 "duty_O": [],
813 "altitude": [],
814 "velocity": [],
815 "value_function": [],
816 "control_effort": [],
817 "V_u_fuel": [],
818 "V_u_ox": [],
819 "mdot_F": [],
820 "mdot_O": [],
821 "w_bar": [], # Robustness bounds
822 "constraint_margins": [], # Constraint safety margins
823 }
824
825 u_prev = None
826
827 for i, t in enumerate(time_array):
828 # Interpolate thrust reference from Layer 2 curve
829 if i < len(thrust_curve):
830 thrust_ref = float(thrust_curve[i])
831 else:
832 thrust_ref = float(thrust_curve[-1]) if len(thrust_curve) > 0 else 0.0
833
834 # Update command with current reference
835 cmd = Command(
836 command_type=CommandType.THRUST_DESIRED,
837 thrust_desired=thrust_ref,
838 )
839
840 # Controller step
841 actuation, diagnostics = session.controller.controller.step(meas, nav, cmd)
842
843 # Get diagnostics
844 eng_est = diagnostics.get("eng_est")
845 solution = diagnostics.get("solution")
846 value_func = solution.objective if solution and hasattr(solution, 'objective') else 0.0
847
848 # Get control effort
849 u_relaxed = diagnostics.get("u_relaxed", np.array([0.0, 0.0]))
850 if u_prev is not None:
851 control_effort = float(np.linalg.norm(u_relaxed - u_prev))
852 else:
853 control_effort = float(np.linalg.norm(u_relaxed))
854 u_prev = u_relaxed.copy()
855
856 # Get robustness bounds
857 from engine.control.robust_ddp.robustness import get_w_bar_array
858 w_bar = get_w_bar_array(session.controller.controller.state).tolist()
859
860 # Get constraint margins
861 constraint_margins = diagnostics.get("constraint_margins", {})
862
863 # Store results
864 results["time"].append(float(t))
865 results["thrust_ref"].append(thrust_ref)
866 results["thrust_actual"].append(float(diagnostics.get("F_hat", 0.0)))
867 results["MR"].append(float(eng_est.MR) if eng_est and eng_est.MR else 0.0)
868 results["P_copv"].append(float(meas.P_copv))
869 results["P_reg"].append(float(meas.P_reg))
870 results["P_u_fuel"].append(float(meas.P_u_fuel))
871 results["P_u_ox"].append(float(meas.P_u_ox))
872 results["P_d_fuel"].append(float(meas.P_d_fuel))
873 results["P_d_ox"].append(float(meas.P_d_ox))
874 results["P_ch"].append(float(eng_est.P_ch) if eng_est and eng_est.P_ch else 0.0)
875 results["duty_F"].append(float(actuation.duty_F))
876 results["duty_O"].append(float(actuation.duty_O))
877 results["altitude"].append(float(nav.h))
878 results["velocity"].append(float(nav.vz))
879 results["value_function"].append(value_func)
880 results["control_effort"].append(control_effort)
881 results["V_u_fuel"].append(float(session.controller.controller.V_u_F))
882 results["V_u_ox"].append(float(session.controller.controller.V_u_O))
883 results["mdot_F"].append(float(eng_est.mdot_F) if eng_est and eng_est.mdot_F else 0.0)
884 results["mdot_O"].append(float(eng_est.mdot_O) if eng_est and eng_est.mdot_O else 0.0)
885 results["w_bar"].append(w_bar)
886 results["constraint_margins"].append(constraint_margins)
887
888 # Propagate state using dynamics
889 # Use actual time step from array (may vary)
890 if i < len(time_array) - 1:
891 dt_step = float(time_array[i+1] - time_array[i])
892 else:
893 # Last iteration - use average dt (always defined at start of try block)
894 dt_step = dt
895
896 u_relaxed_array = np.array([actuation.duty_F, actuation.duty_O])
897
898 # Get mass flows from engine estimate
899 mdot_F = float(eng_est.mdot_F) if eng_est and hasattr(eng_est, 'mdot_F') and eng_est.mdot_F is not None else 0.0
900 mdot_O = float(eng_est.mdot_O) if eng_est and hasattr(eng_est, 'mdot_O') and eng_est.mdot_O is not None else 0.0
901
902 x_next = dynamics_step(x, u_relaxed_array, dt_step, dynamics_params, mdot_F, mdot_O)
903
904 # Update measurements from state
905 meas.P_copv = x_next[IDX_P_COPV]
906 meas.P_reg = x_next[IDX_P_REG]
907 meas.P_u_fuel = x_next[IDX_P_U_F]
908 meas.P_u_ox = x_next[IDX_P_U_O]
909 meas.P_d_fuel = x_next[IDX_P_D_F]
910 meas.P_d_ox = x_next[IDX_P_D_O]
911
912 # Update internal ullage volumes
913 session.controller.controller.V_u_F = x_next[IDX_V_U_F]
914 session.controller.controller.V_u_O = x_next[IDX_V_U_O]
915
916 # Update nav using flight dynamics
917 F_actual = diagnostics.get("F_hat", 0.0)
918 if isinstance(F_actual, (int, float)) and F_actual > 0:
919 mass = nav.mass_estimate or 100.0
920 if eng_est:
921 mdot_total = (eng_est.mdot_F or 0.0) + (eng_est.mdot_O or 0.0)
922 mass -= mdot_total * dt_step
923 nav.mass_estimate = max(mass, 50.0)
924
925 acceleration = (F_actual / nav.mass_estimate) - 9.81
926 nav.vz += acceleration * dt_step
927 nav.h += nav.vz * dt_step
928 else:
929 nav.vz -= 9.81 * dt_step
930 nav.h += nav.vz * dt_step
931
932 # Update state for next iteration
933 # CRITICAL: Use x_next directly - it contains all updated values including gas masses
934 # Don't rebuild from measurements, as gas masses aren't in measurements
935 x = x_next.copy() # Make a copy to avoid reference issues
936
937 return convert_numpy(results)
938
939 except HTTPException:
940 raise
941 except Exception as e:
942 _log.exception("controller simulation failed")
943 raise HTTPException(status_code=500, detail=f"Controller simulation failed: {e}")
944
945
946@router.post("/simulate-layer2-stream")
947async def simulate_layer2_controller_stream(request: Layer2ControllerSimulateRequest, session: UserSession = Depends(get_session)):
948 """Stream controller simulation in real-time using Layer 2 optimized thrust curve."""
949
950 def safe_json_dumps(obj):
951 """Safely serialize to JSON, handling non-serializable types."""
952 return json.dumps(convert_numpy(obj), allow_nan=False)
953
954 async def event_generator():
955
956 # Initialize dt as function-level variable (always defined)
957 dt = float(request.dt) if request.dt is not None else 0.01
958
959 # Always reinitialize controller with fresh config to ensure latest settings
960 try:
961 cfg = get_default_config()
962 if not session.app_state.has_config():
963 yield f"data: {safe_json_dumps({'type': 'error', 'error': 'No engine config loaded. Load a config first.'})}\n\n"
964 return
965 engine_config = session.app_state.config
966 await run_compute(session, _fresh_controller, session, cfg, engine_config) # fresh state
967 except Exception as e:
968 yield f"data: {safe_json_dumps({'type': 'error', 'error': f'Failed to initialize controller: {e}'})}\n\n"
969 return
970
971 try:
972 # Use Layer 2 thrust curve as reference
973 time_array = np.array(request.thrust_curve_time)
974 thrust_curve = np.array(request.thrust_curve_values)
975
976 # Validate inputs
977 if len(time_array) == 0:
978 yield f"data: {safe_json_dumps({'type': 'error', 'error': 'Time array is empty'})}\n\n"
979 return
980 if len(thrust_curve) == 0:
981 yield f"data: {safe_json_dumps({'type': 'error', 'error': 'Thrust curve is empty'})}\n\n"
982 return
983 if len(time_array) != len(thrust_curve):
984 error_msg = f"Time array length ({len(time_array)}) doesn't match thrust curve length ({len(thrust_curve)})"
985 yield f"data: {safe_json_dumps({'type': 'error', 'error': error_msg})}\n\n"
986 return
987
988 duration = float(time_array[-1])
989 n_steps = len(time_array)
990
991 # Compute dt from time array
992 if len(time_array) > 1:
993 try:
994 dt = float(np.mean(np.diff(time_array)))
995 except (ValueError, TypeError):
996 dt = float(request.dt) if request.dt is not None else 0.01
997
998 # Send initial status
999 yield f"data: {safe_json_dumps({'type': 'status', 'progress': 0.0, 'stage': 'Initializing', 'message': f'Starting simulation for {n_steps} steps...'})}\n\n"
1000
1001 # Default initial conditions
1002 if request.initial_meas is None:
1003 meas = Measurement(
1004 P_copv=18.96e6, P_reg=6.89e6,
1005 P_u_fuel=6.89e6, P_u_ox=6.89e6,
1006 P_d_fuel=6.89e6, P_d_ox=6.89e6,
1007 )
1008 else:
1009 meas = Measurement(
1010 P_copv=request.initial_meas.P_copv,
1011 P_reg=request.initial_meas.P_reg,
1012 P_u_fuel=request.initial_meas.P_u_fuel,
1013 P_u_ox=request.initial_meas.P_u_ox,
1014 P_d_fuel=request.initial_meas.P_d_fuel,
1015 P_d_ox=request.initial_meas.P_d_ox,
1016 )
1017
1018 if request.initial_nav is None:
1019 nav = NavState(h=0.0, vz=0.0, theta=0.0, mass_estimate=100.0)
1020 else:
1021 nav = NavState(
1022 h=request.initial_nav.h,
1023 vz=request.initial_nav.vz,
1024 theta=request.initial_nav.theta,
1025 mass_estimate=request.initial_nav.mass_estimate,
1026 )
1027
1028 # Import dynamics
1029 from engine.control.robust_ddp.dynamics import step as dynamics_step, DynamicsParams
1030 from engine.control.robust_ddp.dynamics import IDX_P_COPV, IDX_P_REG, IDX_P_U_F, IDX_P_U_O, IDX_P_D_F, IDX_P_D_O, IDX_V_U_F, IDX_V_U_O
1031
1032 dynamics_params = DynamicsParams.from_config(session.controller.controller.cfg)
1033 x = session.controller.controller._build_state(meas)
1034 u_prev = None
1035
1036 # Simulate and stream results
1037 for i, t in enumerate(time_array):
1038 progress = (i + 1) / n_steps
1039
1040 # Interpolate thrust reference
1041 if i < len(thrust_curve):
1042 thrust_ref = float(thrust_curve[i])
1043 else:
1044 thrust_ref = float(thrust_curve[-1]) if len(thrust_curve) > 0 else 0.0
1045
1046 cmd = Command(
1047 command_type=CommandType.THRUST_DESIRED,
1048 thrust_desired=thrust_ref,
1049 )
1050
1051 # Controller step
1052 actuation, diagnostics = await run_compute(session, session.controller.controller.step, meas, nav, cmd)
1053
1054 # Get diagnostics
1055 eng_est = diagnostics.get("eng_est")
1056 solution = diagnostics.get("solution")
1057 value_func = solution.objective if solution and hasattr(solution, 'objective') else 0.0
1058
1059 u_relaxed = diagnostics.get("u_relaxed", np.array([0.0, 0.0]))
1060 if u_prev is not None:
1061 control_effort = float(np.linalg.norm(u_relaxed - u_prev))
1062 else:
1063 control_effort = float(np.linalg.norm(u_relaxed))
1064 u_prev = u_relaxed.copy()
1065
1066 # Get robustness bounds
1067 from engine.control.robust_ddp.robustness import get_w_bar_array
1068 w_bar = get_w_bar_array(session.controller.controller.state).tolist()
1069 constraint_margins = diagnostics.get("constraint_margins", {})
1070
1071 # Stream data point (sanitize all float values to handle NaN/Inf)
1072 data_point = {
1073 "type": "data",
1074 "time": safe_float(t),
1075 "thrust_ref": safe_float(thrust_ref),
1076 "thrust_actual": safe_float(diagnostics.get("F_hat", 0.0)),
1077 "MR": safe_float(eng_est.MR if eng_est and eng_est.MR else 0.0),
1078 "P_copv": safe_float(meas.P_copv),
1079 "P_reg": safe_float(meas.P_reg),
1080 "P_u_fuel": safe_float(meas.P_u_fuel),
1081 "P_u_ox": safe_float(meas.P_u_ox),
1082 "P_d_fuel": safe_float(meas.P_d_fuel),
1083 "P_d_ox": safe_float(meas.P_d_ox),
1084 "P_ch": safe_float(eng_est.P_ch if eng_est and eng_est.P_ch else 0.0),
1085 "duty_F": safe_float(actuation.duty_F),
1086 "duty_O": safe_float(actuation.duty_O),
1087 "altitude": safe_float(nav.h),
1088 "velocity": safe_float(nav.vz),
1089 "value_function": safe_float(value_func),
1090 "control_effort": safe_float(control_effort),
1091 "V_u_fuel": safe_float(session.controller.controller.V_u_F),
1092 "V_u_ox": safe_float(session.controller.controller.V_u_O),
1093 "mdot_F": safe_float(eng_est.mdot_F if eng_est and eng_est.mdot_F else 0.0),
1094 "mdot_O": safe_float(eng_est.mdot_O if eng_est and eng_est.mdot_O else 0.0),
1095 "w_bar": convert_numpy(w_bar), # Already handles NaN recursively
1096 "constraint_margins": convert_numpy(constraint_margins), # Already handles NaN recursively
1097 }
1098 yield f"data: {safe_json_dumps(data_point)}\n\n"
1099
1100 # Update progress every 10 steps or at end
1101 if (i + 1) % 10 == 0 or i == n_steps - 1:
1102 yield f"data: {safe_json_dumps({'type': 'progress', 'progress': progress, 'stage': 'Simulating', 'message': f'Step {i+1}/{n_steps}'})}\n\n"
1103
1104 # Propagate state
1105 if i < len(time_array) - 1:
1106 dt_step = float(time_array[i+1] - time_array[i])
1107 else:
1108 dt_step = dt
1109
1110 u_relaxed_array = np.array([actuation.duty_F, actuation.duty_O])
1111 mdot_F = float(eng_est.mdot_F) if eng_est and hasattr(eng_est, 'mdot_F') and eng_est.mdot_F is not None else 0.0
1112 mdot_O = float(eng_est.mdot_O) if eng_est and hasattr(eng_est, 'mdot_O') and eng_est.mdot_O is not None else 0.0
1113
1114 x_next = dynamics_step(x, u_relaxed_array, dt_step, dynamics_params, mdot_F, mdot_O)
1115
1116 # Update measurements
1117 meas.P_copv = x_next[IDX_P_COPV]
1118 meas.P_reg = x_next[IDX_P_REG]
1119 meas.P_u_fuel = x_next[IDX_P_U_F]
1120 meas.P_u_ox = x_next[IDX_P_U_O]
1121 meas.P_d_fuel = x_next[IDX_P_D_F]
1122 meas.P_d_ox = x_next[IDX_P_D_O]
1123
1124 session.controller.controller.V_u_F = x_next[IDX_V_U_F]
1125 session.controller.controller.V_u_O = x_next[IDX_V_U_O]
1126
1127 # Update nav
1128 F_actual = diagnostics.get("F_hat", 0.0)
1129 if isinstance(F_actual, (int, float)) and F_actual > 0:
1130 mass = nav.mass_estimate or 100.0
1131 if eng_est:
1132 mdot_total = (eng_est.mdot_F or 0.0) + (eng_est.mdot_O or 0.0)
1133 mass -= mdot_total * dt_step
1134 nav.mass_estimate = max(mass, 50.0)
1135
1136 acceleration = (F_actual / nav.mass_estimate) - 9.81
1137 nav.vz += acceleration * dt_step
1138 nav.h += nav.vz * dt_step
1139 else:
1140 nav.vz -= 9.81 * dt_step
1141 nav.h += nav.vz * dt_step
1142
1143 x = x_next
1144
1145 # Small delay to allow UI to update (optional, can be removed for faster simulation)
1146 await asyncio.sleep(0.01)
1147
1148 # Send completion
1149 yield f"data: {safe_json_dumps({'type': 'complete', 'progress': 1.0, 'stage': 'Complete', 'message': 'Simulation complete'})}\n\n"
1150
1151 except Exception as e:
1152 _log.exception("controller simulation failed")
1153 error_detail = f"Controller simulation failed: {e}"
1154 yield f"data: {safe_json_dumps({'type': 'error', 'error': error_detail})}\n\n"
1155
1156 return StreamingResponse(
1157 gated_stream(event_generator()),
1158 media_type="text/event-stream",
1159 headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}
1160 )
1161
1162
1163@router.post("/simulate-from-config")
1164async def simulate_from_layer2_config(request: Layer2ConfigSimulateRequest, session: UserSession = Depends(get_session)):
1165 """Run controller simulation from a Layer 2 config file by extracting thrust curve.
1166
1167 Off the event loop, one at a time per session: see "Off the loop" at the top.
1168 """
1169 return await run_compute(session, _simulate_from_layer2_config, request, session)
1170
1171
1172def _simulate_from_layer2_config(request: Layer2ConfigSimulateRequest, session: UserSession):
1173
1174 try:
1175 # Load config
1176 if request.config_path:
1177 config = load_engine_config(str(_confined_yaml(request.config_path, *_engine_config_roots(session))))
1178 else:
1179 # Use currently loaded config
1180 if not session.app_state.has_config():
1181 raise HTTPException(
1182 status_code=400,
1183 detail="No config loaded. Provide config_path or load a config first."
1184 )
1185 config = session.app_state.config
1186
1187 # Check if config has pressure curves
1188 if not config.pressure_curves:
1189 raise HTTPException(
1190 status_code=400,
1191 detail="Config does not have pressure_curves. This must be a Layer 2 optimized config."
1192 )
1193
1194 # Import functions to generate pressure curves and evaluate engine
1195 from engine.optimizer.layers.layer2_pressure import generate_pressure_curve_from_segments
1196 from engine.core.runner import PintleEngineRunner
1197
1198 # Generate pressure curves from segments
1199 pc = config.pressure_curves
1200 n_points = pc.n_points
1201 burn_time = pc.target_burn_time_s
1202
1203 # Convert segments to dict format
1204 lox_segments = [
1205 {
1206 "length_ratio": seg.length_ratio,
1207 "type": seg.type,
1208 "start_pressure": seg.start_pressure_pa,
1209 "end_pressure": seg.end_pressure_pa,
1210 "k": seg.k,
1211 }
1212 for seg in pc.lox_segments
1213 ]
1214 fuel_segments = [
1215 {
1216 "length_ratio": seg.length_ratio,
1217 "type": seg.type,
1218 "start_pressure": seg.start_pressure_pa,
1219 "end_pressure": seg.end_pressure_pa,
1220 "k": seg.k,
1221 }
1222 for seg in pc.fuel_segments
1223 ]
1224
1225 # Generate pressure arrays
1226 P_lox = generate_pressure_curve_from_segments(lox_segments, n_points)
1227 P_fuel = generate_pressure_curve_from_segments(fuel_segments, n_points)
1228
1229 # Generate time array
1230 time_array = np.linspace(0, burn_time, n_points)
1231
1232 # Evaluate engine at each time point to get thrust
1233 runner = PintleEngineRunner(config)
1234 thrust_curve = []
1235
1236 for i in range(n_points):
1237 try:
1238 results = runner.evaluate(
1239 P_tank_O=P_lox[i],
1240 P_tank_F=P_fuel[i],
1241 silent=True,
1242 )
1243 thrust_curve.append(float(results.get("F", 0.0)))
1244 except Exception as e:
1245 # If evaluation fails, use previous value or 0
1246 thrust_curve.append(thrust_curve[-1] if thrust_curve else 0.0)
1247
1248 # Now run controller simulation with extracted thrust curve
1249 # Convert to Layer2ControllerSimulateRequest format
1250 layer2_request = Layer2ControllerSimulateRequest(
1251 thrust_curve_time=time_array.tolist(),
1252 thrust_curve_values=thrust_curve,
1253 initial_meas=request.initial_meas,
1254 initial_nav=request.initial_nav,
1255 dt=request.dt,
1256 )
1257
1258 # Call the existing simulate endpoint. Pass session explicitly: this is a
1259 # direct function call, not a FastAPI-resolved dependency, so the default
1260 # Depends(get_session) would not be filled in.
1261 # Already under this session's compute lock: the sync body, not the endpoint again.
1262 return _simulate_layer2_controller(layer2_request, session)
1263
1264 except HTTPException:
1265 raise
1266 except Exception as e:
1267 _log.exception("simulate from config failed")
1268 raise HTTPException(status_code=500, detail=f"Failed to simulate from config: {e}")
1269
1270
1271@router.post("/simulate-from-config-stream")
1272async def simulate_from_layer2_config_stream(request: Layer2ConfigSimulateRequest, session: UserSession = Depends(get_session)):
1273 """Stream controller simulation from a Layer 2 config file in real-time."""
1274
1275 def safe_json_dumps(obj):
1276 """Safely serialize to JSON, handling non-serializable types."""
1277 return json.dumps(convert_numpy(obj), allow_nan=False)
1278
1279 async def event_generator():
1280
1281 try:
1282 # Load config
1283 if request.config_path:
1284 try:
1285 path = _confined_yaml(request.config_path, *_engine_config_roots(session))
1286 except HTTPException as e:
1287 yield f"data: {safe_json_dumps({'type': 'error', 'error': e.detail})}\n\n"
1288 return
1289 config = load_engine_config(str(path))
1290 else:
1291 # Use currently loaded config
1292 if not session.app_state.has_config():
1293 yield f"data: {safe_json_dumps({'type': 'error', 'error': 'No config loaded. Provide config_path or load a config first.'})}\n\n"
1294 return
1295 config = session.app_state.config
1296
1297 # Check if config has pressure curves
1298 if not config.pressure_curves:
1299 yield f"data: {safe_json_dumps({'type': 'error', 'error': 'Config does not have pressure_curves. This must be a Layer 2 optimized config.'})}\n\n"
1300 return
1301
1302 yield f"data: {safe_json_dumps({'type': 'status', 'progress': 0.0, 'stage': 'Extracting Thrust Curve', 'message': 'Generating pressure curves from config...'})}\n\n"
1303
1304 # Import functions
1305 from engine.optimizer.layers.layer2_pressure import generate_pressure_curve_from_segments
1306 from engine.core.runner import PintleEngineRunner
1307
1308 # Generate pressure curves
1309 pc = config.pressure_curves
1310 n_points = pc.n_points
1311 burn_time = pc.target_burn_time_s
1312
1313 lox_segments = [
1314 {
1315 "length_ratio": seg.length_ratio,
1316 "type": seg.type,
1317 "start_pressure": seg.start_pressure_pa,
1318 "end_pressure": seg.end_pressure_pa,
1319 "k": seg.k,
1320 }
1321 for seg in pc.lox_segments
1322 ]
1323 fuel_segments = [
1324 {
1325 "length_ratio": seg.length_ratio,
1326 "type": seg.type,
1327 "start_pressure": seg.start_pressure_pa,
1328 "end_pressure": seg.end_pressure_pa,
1329 "k": seg.k,
1330 }
1331 for seg in pc.fuel_segments
1332 ]
1333
1334 P_lox = generate_pressure_curve_from_segments(lox_segments, n_points)
1335 P_fuel = generate_pressure_curve_from_segments(fuel_segments, n_points)
1336 time_array = np.linspace(0, burn_time, n_points)
1337
1338 yield f"data: {safe_json_dumps({'type': 'status', 'progress': 0.2, 'stage': 'Evaluating Engine', 'message': 'Computing thrust curve from pressure curves...'})}\n\n"
1339
1340 # Evaluate engine to get thrust (runner build and each point in the thread pool)
1341 runner = await run_in_threadpool(PintleEngineRunner, config)
1342 thrust_curve = []
1343
1344 for i in range(n_points):
1345 try:
1346 results = await run_in_threadpool(
1347 runner.evaluate,
1348 P_tank_O=P_lox[i],
1349 P_tank_F=P_fuel[i],
1350 silent=True,
1351 )
1352 thrust_curve.append(float(results.get("F", 0.0)))
1353 except Exception:
1354 thrust_curve.append(thrust_curve[-1] if thrust_curve else 0.0)
1355
1356 # Progress update every 20 points
1357 if (i + 1) % 20 == 0:
1358 progress = 0.2 + 0.3 * ((i + 1) / n_points)
1359 yield f"data: {safe_json_dumps({'type': 'progress', 'progress': progress, 'stage': 'Evaluating Engine', 'message': f'Evaluated {i+1}/{n_points} points'})}\n\n"
1360
1361 yield f"data: {safe_json_dumps({'type': 'status', 'progress': 0.5, 'stage': 'Starting Controller', 'message': 'Thrust curve extracted. Starting controller simulation...'})}\n\n"
1362
1363 # Now run controller simulation
1364 layer2_request = Layer2ControllerSimulateRequest(
1365 thrust_curve_time=time_array.tolist(),
1366 thrust_curve_values=thrust_curve,
1367 initial_meas=request.initial_meas,
1368 initial_nav=request.initial_nav,
1369 dt=request.dt,
1370 )
1371
1372 # Reuse the streaming logic from simulate_layer2_controller_stream
1373 # Initialize controller
1374 dt = float(request.dt) if request.dt is not None else 0.01
1375
1376 # Always reinitialize controller with fresh config to ensure latest settings
1377 # This ensures controller uses the most aggressive settings for responsiveness
1378 cfg = get_default_config()
1379 if not session.app_state.has_config():
1380 yield f"data: {safe_json_dumps({'type': 'error', 'error': 'No engine config loaded. Load a config first.'})}\n\n"
1381 return
1382 engine_config = session.app_state.config
1383 await run_compute(session, _fresh_controller, session, cfg, engine_config) # fresh state
1384
1385 # Continue with simulation (reuse code from simulate_layer2_controller_stream)
1386 # ... (rest of the simulation code)
1387 # For now, just call the existing stream endpoint logic
1388 # Actually, let me just forward to the existing endpoint
1389 # But we can't easily do that with streaming, so let me inline it
1390
1391 # Use the extracted data
1392 time_array_np = np.array(layer2_request.thrust_curve_time)
1393 thrust_curve_np = np.array(layer2_request.thrust_curve_values)
1394 n_steps = len(time_array_np)
1395
1396 # Default initial conditions
1397 if request.initial_meas is None:
1398 meas = Measurement(
1399 P_copv=18.96e6, P_reg=6.89e6,
1400 P_u_fuel=pc.initial_fuel_pressure_pa,
1401 P_u_ox=pc.initial_lox_pressure_pa,
1402 P_d_fuel=pc.initial_fuel_pressure_pa,
1403 P_d_ox=pc.initial_lox_pressure_pa,
1404 )
1405 else:
1406 meas = Measurement(
1407 P_copv=request.initial_meas.P_copv,
1408 P_reg=request.initial_meas.P_reg,
1409 P_u_fuel=request.initial_meas.P_u_fuel,
1410 P_u_ox=request.initial_meas.P_u_ox,
1411 P_d_fuel=request.initial_meas.P_d_fuel,
1412 P_d_ox=request.initial_meas.P_d_ox,
1413 )
1414
1415 if request.initial_nav is None:
1416 nav = NavState(h=0.0, vz=0.0, theta=0.0, mass_estimate=100.0)
1417 else:
1418 nav = NavState(
1419 h=request.initial_nav.h,
1420 vz=request.initial_nav.vz,
1421 theta=request.initial_nav.theta,
1422 mass_estimate=request.initial_nav.mass_estimate,
1423 )
1424
1425 from engine.control.robust_ddp.dynamics import step as dynamics_step, DynamicsParams
1426 from engine.control.robust_ddp.dynamics import IDX_P_COPV, IDX_P_REG, IDX_P_U_F, IDX_P_U_O, IDX_P_D_F, IDX_P_D_O, IDX_V_U_F, IDX_V_U_O
1427
1428 dynamics_params = DynamicsParams.from_config(session.controller.controller.cfg)
1429 x = session.controller.controller._build_state(meas)
1430 u_prev = None
1431
1432 # Simulate and stream
1433 for i, t in enumerate(time_array_np):
1434 progress = 0.5 + 0.5 * ((i + 1) / n_steps)
1435
1436 if i < len(thrust_curve_np):
1437 thrust_ref = float(thrust_curve_np[i])
1438 else:
1439 thrust_ref = float(thrust_curve_np[-1]) if len(thrust_curve_np) > 0 else 0.0
1440
1441 cmd = Command(
1442 command_type=CommandType.THRUST_DESIRED,
1443 thrust_desired=thrust_ref,
1444 )
1445
1446 actuation, diagnostics = await run_compute(session, session.controller.controller.step, meas, nav, cmd)
1447
1448 eng_est = diagnostics.get("eng_est")
1449 solution = diagnostics.get("solution")
1450 value_func = solution.objective if solution and hasattr(solution, 'objective') else 0.0
1451
1452 u_relaxed = diagnostics.get("u_relaxed", np.array([0.0, 0.0]))
1453 if u_prev is not None:
1454 control_effort = float(np.linalg.norm(u_relaxed - u_prev))
1455 else:
1456 control_effort = float(np.linalg.norm(u_relaxed))
1457 u_prev = u_relaxed.copy()
1458
1459 # Get robustness bounds
1460 from engine.control.robust_ddp.robustness import get_w_bar_array
1461 w_bar = get_w_bar_array(session.controller.controller.state).tolist()
1462 constraint_margins = diagnostics.get("constraint_margins", {})
1463
1464 # Stream data point
1465 # Stream data point (sanitize all float values to handle NaN/Inf)
1466 data_point = {
1467 "type": "data",
1468 "time": safe_float(t),
1469 "thrust_ref": safe_float(thrust_ref),
1470 "thrust_actual": safe_float(diagnostics.get("F_hat", 0.0)),
1471 "MR": safe_float(eng_est.MR if eng_est and eng_est.MR else 0.0),
1472 "P_copv": safe_float(meas.P_copv),
1473 "P_reg": safe_float(meas.P_reg),
1474 "P_u_fuel": safe_float(meas.P_u_fuel),
1475 "P_u_ox": safe_float(meas.P_u_ox),
1476 "P_d_fuel": safe_float(meas.P_d_fuel),
1477 "P_d_ox": safe_float(meas.P_d_ox),
1478 "P_ch": safe_float(eng_est.P_ch if eng_est and eng_est.P_ch else 0.0),
1479 "duty_F": safe_float(actuation.duty_F),
1480 "duty_O": safe_float(actuation.duty_O),
1481 "altitude": safe_float(nav.h),
1482 "velocity": safe_float(nav.vz),
1483 "value_function": safe_float(value_func),
1484 "control_effort": safe_float(control_effort),
1485 "V_u_fuel": safe_float(session.controller.controller.V_u_F),
1486 "V_u_ox": safe_float(session.controller.controller.V_u_O),
1487 "mdot_F": safe_float(eng_est.mdot_F if eng_est and eng_est.mdot_F else 0.0),
1488 "mdot_O": safe_float(eng_est.mdot_O if eng_est and eng_est.mdot_O else 0.0),
1489 "w_bar": convert_numpy(w_bar), # Already handles NaN recursively
1490 "constraint_margins": convert_numpy(constraint_margins), # Already handles NaN recursively
1491 }
1492 yield f"data: {safe_json_dumps(data_point)}\n\n"
1493
1494 if (i + 1) % 10 == 0 or i == n_steps - 1:
1495 yield f"data: {safe_json_dumps({'type': 'progress', 'progress': progress, 'stage': 'Simulating', 'message': f'Step {i+1}/{n_steps}'})}\n\n"
1496
1497 # Propagate state
1498 if i < len(time_array_np) - 1:
1499 dt_step = float(time_array_np[i+1] - time_array_np[i])
1500 else:
1501 dt_step = dt
1502
1503 u_relaxed_array = np.array([actuation.duty_F, actuation.duty_O])
1504 mdot_F = float(eng_est.mdot_F) if eng_est and hasattr(eng_est, 'mdot_F') and eng_est.mdot_F is not None else 0.0
1505 mdot_O = float(eng_est.mdot_O) if eng_est and hasattr(eng_est, 'mdot_O') and eng_est.mdot_O is not None else 0.0
1506
1507 x_next = dynamics_step(x, u_relaxed_array, dt_step, dynamics_params, mdot_F, mdot_O)
1508
1509 meas.P_copv = x_next[IDX_P_COPV]
1510 meas.P_reg = x_next[IDX_P_REG]
1511 meas.P_u_fuel = x_next[IDX_P_U_F]
1512 meas.P_u_ox = x_next[IDX_P_U_O]
1513 meas.P_d_fuel = x_next[IDX_P_D_F]
1514 meas.P_d_ox = x_next[IDX_P_D_O]
1515
1516 session.controller.controller.V_u_F = x_next[IDX_V_U_F]
1517 session.controller.controller.V_u_O = x_next[IDX_V_U_O]
1518
1519 F_actual = diagnostics.get("F_hat", 0.0)
1520 if isinstance(F_actual, (int, float)) and F_actual > 0:
1521 mass = nav.mass_estimate or 100.0
1522 if eng_est:
1523 mdot_total = (eng_est.mdot_F or 0.0) + (eng_est.mdot_O or 0.0)
1524 mass -= mdot_total * dt_step
1525 nav.mass_estimate = max(mass, 50.0)
1526
1527 acceleration = (F_actual / nav.mass_estimate) - 9.81
1528 nav.vz += acceleration * dt_step
1529 nav.h += nav.vz * dt_step
1530 else:
1531 nav.vz -= 9.81 * dt_step
1532 nav.h += nav.vz * dt_step
1533
1534 x = x_next
1535 await asyncio.sleep(0.01)
1536
1537 yield f"data: {safe_json_dumps({'type': 'complete', 'progress': 1.0, 'stage': 'Complete', 'message': 'Simulation complete'})}\n\n"
1538
1539 except Exception as e:
1540 _log.exception("simulate from config failed")
1541 error_detail = f"Failed to simulate from config: {e}"
1542 yield f"data: {safe_json_dumps({'type': 'error', 'error': error_detail})}\n\n"
1543
1544 return StreamingResponse(
1545 gated_stream(event_generator()),
1546 media_type="text/event-stream",
1547 headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}
1548 )
1549
1550
1551@router.post("/upload-config-and-simulate")
1553 file: UploadFile = File(...),
1554 dt: float = 0.01,
1555 session: UserSession = Depends(get_session),
1556):
1557 """Upload a Layer 2 config file and immediately run controller simulation."""
1558 def safe_json_dumps(obj):
1559 """Safely serialize to JSON, handling non-serializable types."""
1560 return json.dumps(convert_numpy(obj), allow_nan=False)
1561
1562 async def event_generator():
1563
1564 try:
1565 # Read uploaded file
1566 contents = await file.read()
1567 config_dict = yaml.safe_load(contents.decode("utf-8"))
1568
1569 # Load config
1570 from engine.pipeline.config_schemas import PintleEngineConfig
1571 config = PintleEngineConfig(**config_dict)
1572
1573 # Check if config has pressure curves
1574 if not config.pressure_curves:
1575 yield f"data: {safe_json_dumps({'type': 'error', 'error': 'Config does not have pressure_curves. This must be a Layer 2 optimized config.'})}\n\n"
1576 return
1577
1578 yield f"data: {safe_json_dumps({'type': 'status', 'progress': 0.0, 'stage': 'Extracting Thrust Curve', 'message': 'Generating pressure curves from config...'})}\n\n"
1579
1580 # Import functions
1581 from engine.optimizer.layers.layer2_pressure import generate_pressure_curve_from_segments
1582 from engine.core.runner import PintleEngineRunner
1583
1584 # Generate pressure curves
1585 pc = config.pressure_curves
1586 n_points = pc.n_points
1587 burn_time = pc.target_burn_time_s
1588
1589 lox_segments = [
1590 {
1591 "length_ratio": seg.length_ratio,
1592 "type": seg.type,
1593 "start_pressure": seg.start_pressure_pa,
1594 "end_pressure": seg.end_pressure_pa,
1595 "k": seg.k,
1596 }
1597 for seg in pc.lox_segments
1598 ]
1599 fuel_segments = [
1600 {
1601 "length_ratio": seg.length_ratio,
1602 "type": seg.type,
1603 "start_pressure": seg.start_pressure_pa,
1604 "end_pressure": seg.end_pressure_pa,
1605 "k": seg.k,
1606 }
1607 for seg in pc.fuel_segments
1608 ]
1609
1610 P_lox = generate_pressure_curve_from_segments(lox_segments, n_points)
1611 P_fuel = generate_pressure_curve_from_segments(fuel_segments, n_points)
1612 time_array = np.linspace(0, burn_time, n_points)
1613
1614 yield f"data: {safe_json_dumps({'type': 'status', 'progress': 0.2, 'stage': 'Evaluating Engine', 'message': 'Computing thrust curve from pressure curves...'})}\n\n"
1615
1616 # The session config is left alone. This used to set_config the uploaded engine for the
1617 # length of the run (inline on the event loop) and put the old one back after; nothing
1618 # here reads it -- the thrust curve comes from the runner built below, the controller
1619 # from `config` -- but the design bar's autosave does (GET /api/config), and with the
1620 # load token unchanged it saved the uploaded engine into the open design mid-run.
1621 # Evaluate engine to get thrust (runner build and each point in the thread pool)
1622 runner = await run_in_threadpool(PintleEngineRunner, config)
1623 thrust_curve = []
1624
1625 for i in range(n_points):
1626 try:
1627 results = await run_in_threadpool(
1628 runner.evaluate,
1629 P_tank_O=P_lox[i],
1630 P_tank_F=P_fuel[i],
1631 silent=True,
1632 )
1633 thrust_curve.append(float(results.get("F", 0.0)))
1634 except Exception:
1635 thrust_curve.append(thrust_curve[-1] if thrust_curve else 0.0)
1636
1637 # Progress update every 20 points
1638 if (i + 1) % 20 == 0:
1639 progress = 0.2 + 0.3 * ((i + 1) / n_points)
1640 yield f"data: {safe_json_dumps({'type': 'progress', 'progress': progress, 'stage': 'Evaluating Engine', 'message': f'Evaluated {i+1}/{n_points} points'})}\n\n"
1641
1642 yield f"data: {safe_json_dumps({'type': 'status', 'progress': 0.5, 'stage': 'Starting Controller', 'message': 'Thrust curve extracted. Starting controller simulation...'})}\n\n"
1643
1644 # Initialize controller
1645 dt_val = float(dt)
1646
1647 if session.controller.controller is None:
1648 cfg = get_default_config()
1649 engine_config = config # Use the uploaded config
1650 session.controller.controller = await run_compute(session, RobustDDPController, cfg, engine_config, logger=None)
1651
1652 # Default initial conditions from config
1653 meas = Measurement(
1654 P_copv=18.96e6, P_reg=6.89e6,
1655 P_u_fuel=pc.initial_fuel_pressure_pa,
1656 P_u_ox=pc.initial_lox_pressure_pa,
1657 P_d_fuel=pc.initial_fuel_pressure_pa,
1658 P_d_ox=pc.initial_lox_pressure_pa,
1659 )
1660
1661 nav = NavState(h=0.0, vz=0.0, theta=0.0, mass_estimate=100.0)
1662
1663 from engine.control.robust_ddp.dynamics import step as dynamics_step, DynamicsParams
1664 from engine.control.robust_ddp.dynamics import IDX_P_COPV, IDX_P_REG, IDX_P_U_F, IDX_P_U_O, IDX_P_D_F, IDX_P_D_O, IDX_V_U_F, IDX_V_U_O
1665
1666 dynamics_params = DynamicsParams.from_config(session.controller.controller.cfg)
1667 x = session.controller.controller._build_state(meas)
1668 u_prev = None
1669
1670 # Simulate and stream
1671 for i, t in enumerate(time_array):
1672 progress = 0.5 + 0.5 * ((i + 1) / n_points)
1673
1674 if i < len(thrust_curve):
1675 thrust_ref = float(thrust_curve[i])
1676 else:
1677 thrust_ref = float(thrust_curve[-1]) if len(thrust_curve) > 0 else 0.0
1678
1679 cmd = Command(
1680 command_type=CommandType.THRUST_DESIRED,
1681 thrust_desired=thrust_ref,
1682 )
1683
1684 actuation, diagnostics = await run_compute(session, session.controller.controller.step, meas, nav, cmd)
1685
1686 eng_est = diagnostics.get("eng_est")
1687 solution = diagnostics.get("solution")
1688 value_func = solution.objective if solution and hasattr(solution, 'objective') else 0.0
1689
1690 u_relaxed = diagnostics.get("u_relaxed", np.array([0.0, 0.0]))
1691 if u_prev is not None:
1692 control_effort = float(np.linalg.norm(u_relaxed - u_prev))
1693 else:
1694 control_effort = float(np.linalg.norm(u_relaxed))
1695 u_prev = u_relaxed.copy()
1696
1697 # Get robustness bounds
1698 from engine.control.robust_ddp.robustness import get_w_bar_array
1699 w_bar = get_w_bar_array(session.controller.controller.state).tolist()
1700 constraint_margins = diagnostics.get("constraint_margins", {})
1701
1702 # Stream data point (sanitize all float values to handle NaN/Inf)
1703 data_point = {
1704 "type": "data",
1705 "time": safe_float(t),
1706 "thrust_ref": safe_float(thrust_ref),
1707 "thrust_actual": safe_float(diagnostics.get("F_hat", 0.0)),
1708 "MR": safe_float(eng_est.MR if eng_est and eng_est.MR else 0.0),
1709 "P_copv": safe_float(meas.P_copv),
1710 "P_reg": safe_float(meas.P_reg),
1711 "P_u_fuel": safe_float(meas.P_u_fuel),
1712 "P_u_ox": safe_float(meas.P_u_ox),
1713 "P_d_fuel": safe_float(meas.P_d_fuel),
1714 "P_d_ox": safe_float(meas.P_d_ox),
1715 "P_ch": safe_float(eng_est.P_ch if eng_est and eng_est.P_ch else 0.0),
1716 "duty_F": safe_float(actuation.duty_F),
1717 "duty_O": safe_float(actuation.duty_O),
1718 "altitude": safe_float(nav.h),
1719 "velocity": safe_float(nav.vz),
1720 "value_function": safe_float(value_func),
1721 "control_effort": safe_float(control_effort),
1722 "V_u_fuel": safe_float(session.controller.controller.V_u_F),
1723 "V_u_ox": safe_float(session.controller.controller.V_u_O),
1724 "mdot_F": safe_float(eng_est.mdot_F if eng_est and eng_est.mdot_F else 0.0),
1725 "mdot_O": safe_float(eng_est.mdot_O if eng_est and eng_est.mdot_O else 0.0),
1726 "w_bar": convert_numpy(w_bar), # Already handles NaN recursively
1727 "constraint_margins": convert_numpy(constraint_margins), # Already handles NaN recursively
1728 }
1729 yield f"data: {safe_json_dumps(data_point)}\n\n"
1730
1731 if (i + 1) % 10 == 0 or i == n_points - 1:
1732 yield f"data: {safe_json_dumps({'type': 'progress', 'progress': progress, 'stage': 'Simulating', 'message': f'Step {i+1}/{n_points}'})}\n\n"
1733
1734 # Propagate state
1735 if i < len(time_array) - 1:
1736 dt_step = float(time_array[i+1] - time_array[i])
1737 else:
1738 dt_step = dt_val
1739
1740 u_relaxed_array = np.array([actuation.duty_F, actuation.duty_O])
1741 mdot_F = float(eng_est.mdot_F) if eng_est and hasattr(eng_est, 'mdot_F') and eng_est.mdot_F is not None else 0.0
1742 mdot_O = float(eng_est.mdot_O) if eng_est and hasattr(eng_est, 'mdot_O') and eng_est.mdot_O is not None else 0.0
1743
1744 x_next = dynamics_step(x, u_relaxed_array, dt_step, dynamics_params, mdot_F, mdot_O)
1745
1746 meas.P_copv = x_next[IDX_P_COPV]
1747 meas.P_reg = x_next[IDX_P_REG]
1748 meas.P_u_fuel = x_next[IDX_P_U_F]
1749 meas.P_u_ox = x_next[IDX_P_U_O]
1750 meas.P_d_fuel = x_next[IDX_P_D_F]
1751 meas.P_d_ox = x_next[IDX_P_D_O]
1752
1753 session.controller.controller.V_u_F = x_next[IDX_V_U_F]
1754 session.controller.controller.V_u_O = x_next[IDX_V_U_O]
1755
1756 F_actual = diagnostics.get("F_hat", 0.0)
1757 if isinstance(F_actual, (int, float)) and F_actual > 0:
1758 mass = nav.mass_estimate or 100.0
1759 if eng_est:
1760 mdot_total = (eng_est.mdot_F or 0.0) + (eng_est.mdot_O or 0.0)
1761 mass -= mdot_total * dt_step
1762 nav.mass_estimate = max(mass, 50.0)
1763
1764 acceleration = (F_actual / nav.mass_estimate) - 9.81
1765 nav.vz += acceleration * dt_step
1766 nav.h += nav.vz * dt_step
1767 else:
1768 nav.vz -= 9.81 * dt_step
1769 nav.h += nav.vz * dt_step
1770
1771 x = x_next
1772 await asyncio.sleep(0.01)
1773
1774 yield f"data: {safe_json_dumps({'type': 'complete', 'progress': 1.0, 'stage': 'Complete', 'message': 'Simulation complete'})}\n\n"
1775
1776
1777 except Exception as e:
1778 _log.exception("upload and simulate failed")
1779 error_detail = f"Failed to upload and simulate: {e}"
1780 yield f"data: {safe_json_dumps({'type': 'error', 'error': error_detail})}\n\n"
1781
1782 return StreamingResponse(
1783 gated_stream(event_generator()),
1784 media_type="text/event-stream",
1785 headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}
1786 )
1787
1788
1789@router.get("/status")
1790async def get_controller_status(session: UserSession = Depends(get_session)):
1791 """Get current controller status."""
1792 if session.controller.controller is None:
1793 return {"initialized": False}
1794
1795 return {
1796 "initialized": True,
1797 "tick": session.controller.controller.tick,
1798 "state": convert_numpy(session.controller.controller.state.to_dict()),
1799 }
1800
#define max(a, b)
Definition Arduino.h:43
reset_controller(UserSession session=Depends(get_session))
Definition control.py:688
simulate_layer2_controller_stream(Layer2ControllerSimulateRequest request, UserSession session=Depends(get_session))
Definition control.py:947
simulate_controller(ControllerSimulateRequest request, UserSession session=Depends(get_session))
Definition control.py:425
upload_config_and_simulate(UploadFile file=File(...), float dt=0.01, UserSession session=Depends(get_session))
Definition control.py:1556
init_controller(ControllerInitRequest request, UserSession session=Depends(get_session))
Definition control.py:271
controller_step(ControllerStepRequest request, UserSession session=Depends(get_session))
Definition control.py:313
_simulate_layer2_controller(Layer2ControllerSimulateRequest request, UserSession session)
Definition control.py:706
simulate_from_layer2_config(Layer2ConfigSimulateRequest request, UserSession session=Depends(get_session))
Definition control.py:1164
Path _confined_yaml(str path, *Path roots, Path base=_PROJECT_ROOT)
Definition control.py:48
_controller_step(ControllerStepRequest request, UserSession session)
Definition control.py:321
simulate_layer2_controller(Layer2ControllerSimulateRequest request, UserSession session=Depends(get_session))
Definition control.py:698
_simulate_from_layer2_config(Layer2ConfigSimulateRequest request, UserSession session)
Definition control.py:1172
simulate_from_layer2_config_stream(Layer2ConfigSimulateRequest request, UserSession session=Depends(get_session))
Definition control.py:1272
get_controller_status(UserSession session=Depends(get_session))
Definition control.py:1790
_simulate_controller(ControllerSimulateRequest request, UserSession session)
Definition control.py:433
tuple _engine_config_roots(UserSession session)
Definition control.py:44
_init_controller(ControllerInitRequest request, UserSession session)
Definition control.py:279
RobustDDPController _fresh_controller(UserSession session, cfg, engine_config)
Definition control.py:73