STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
optimizer.py
Go to the documentation of this file.
1"""Optimizer endpoints for design optimization and Layer 1."""
2
3from fastapi import APIRouter, HTTPException, Depends
4from fastapi.responses import StreamingResponse
5from pydantic import BaseModel, Field
6from typing import Optional, Dict, Any
7import asyncio
8import json
9import traceback
10import numpy as np
11import threading
12import math
13import yaml
14
15from backend.checkout import DesignCheckout
16from backend.session import UserSession, get_session, JOB_SEMAPHORE, replace_config
17from backend.routers.config import config_to_dict
18
19
20def layer1_design_is_valid(results: Dict[str, Any]) -> tuple[bool, list]:
21 """Did Layer 1 produce a design that passed its own validation gates?
22
23 Returns ``(is_valid, reasons)``. A design is valid only when it reports NO failure
24 reasons and no ``*_check_passed`` / ``*_gate_passed`` flag is False.
25
26 Why this exists: the optimized geometry used to be written back into the live config
27 unconditionally, INCLUDING when validation failed. A failed run then became the seed for
28 the next run, which started from a wrecked geometry, failed worse, and saved something
29 worse again -- a death spiral that bricked a working design (observed live: expansion_ratio
30 4.91 -> 9.74, oxidizer d_jet 2.33 -> 4.35 mm, and every subsequent run infeasible with the
31 UI giving no indication the seed was the problem). Combined with the known run-to-run
32 spread, one unlucky run in three could permanently destroy a design the user had converged.
33 """
34 perf = (results or {}).get("performance") or {}
35 if not perf:
36 # No performance payload at all -> we cannot show it passed, so treat it as failed.
37 # Fail-safe matters here: the consequence of a wrong "valid" is overwriting a working
38 # design, while the consequence of a wrong "invalid" is merely not saving one.
39 return False, ["no performance data returned"]
40 reasons = list(perf.get("failure_reasons") or [])
41 for key, passed in perf.items():
42 if (key.endswith("_check_passed") or key.endswith("_gate_passed")) and passed is False:
43 reasons.append(f"{key} = False")
44 return (not reasons), reasons
45
46
48 old: Optional[Dict[str, Any]], incoming: Dict[str, Any]
49) -> Dict[str, Any]:
50 """Lay a (possibly partial) requirements payload over the requirements already loaded.
51
52 A key ABSENT from ``incoming`` keeps its current value. A key sent as an explicit
53 ``None`` is cleared. Those are different intents -- a form that only knows a dozen
54 fields must not, by not mentioning them, reset the ~100 ``layer1_*`` knobs, the injector
55 face limits and the pinned seed to schema defaults. It did: the save route rebuilt
56 ``design_requirements`` from the payload alone, and the next run optimised a different
57 problem than the one the file described. ``frozen_parameters`` merges key by key with the
58 same null-clears rule, as it already did.
59 """
60 merged: Dict[str, Any] = dict(old or {})
61 prev_fp = merged.get("frozen_parameters")
62 old_fp: Dict[str, Any] = (
63 {k: v for k, v in prev_fp.items() if v is not None} if isinstance(prev_fp, dict) else {}
64 )
65 for key, value in incoming.items():
66 if key != "frozen_parameters":
67 merged[key] = value
68 if "frozen_parameters" in incoming:
69 new_fp = incoming.get("frozen_parameters")
70 fp = dict(old_fp)
71 for k, v in (new_fp.items() if isinstance(new_fp, dict) else ()):
72 if v is None:
73 fp.pop(k, None)
74 else:
75 fp[k] = v
76 merged["frozen_parameters"] = fp if fp else None
77 elif old_fp:
78 merged["frozen_parameters"] = old_fp
79 elif "frozen_parameters" in merged:
80 merged["frozen_parameters"] = None
81 return merged
82from engine.pipeline.config_schemas import DesignRequirementsConfig
83from engine.optimizer.layers.layer1_static_optimization import run_layer1_optimization
84from engine.optimizer.layers.layer2_pressure import run_layer2_pressure
85from engine.optimizer.layers.layer3_thermal_protection import run_layer3_thermal_protection
86
87
89 """Recursively convert numpy types to Python native types for JSON serialization."""
90 if isinstance(obj, dict):
91 return {k: convert_numpy(v) for k, v in obj.items()}
92 elif isinstance(obj, (list, tuple)):
93 return [convert_numpy(item) for item in obj]
94 elif isinstance(obj, np.ndarray):
95 return obj.tolist()
96 elif isinstance(obj, (np.integer, np.floating)):
97 # Convert numpy scalar to Python scalar, then sanitize non-finite floats
98 val = obj.item()
99 if isinstance(val, float) and not math.isfinite(val):
100 return None
101 return val
102 elif isinstance(obj, np.bool_):
103 return bool(obj)
104 elif isinstance(obj, bool):
105 return bool(obj)
106 elif isinstance(obj, float):
107 # JSON forbids NaN/Infinity; scrub them here so JSON.parse never fails
108 return obj if math.isfinite(obj) else None
109 elif isinstance(obj, (int, str, type(None))):
110 return obj
111 else:
112 # Try to convert to string for unknown types
113 try:
114 return str(obj)
115 except:
116 return None
117
118
119def safe_json_dumps(payload: Any) -> str:
120 """Strict JSON serialization for SSE: converts numpy + strips NaN/Inf."""
121 sanitized = convert_numpy(payload)
122 # allow_nan=False ensures we never emit invalid JSON tokens like NaN
123 return json.dumps(sanitized, allow_nan=False)
124
125router = APIRouter(prefix="/api/optimizer", tags=["optimizer"])
126
127
128# Request/Response models
130 """Request body for saving design requirements."""
131 requirements: Dict[str, Any] = Field(..., description="Design requirements dictionary")
132
133
135 """Response for design requirements."""
136 requirements: Optional[Dict[str, Any]] = Field(None, description="Design requirements dictionary")
137
138
139class Layer1Request(BaseModel):
140 """Request body for Layer 1 optimization."""
141 thrust_tolerance: float = Field(default=0.1, ge=0.01, le=0.2, description="Thrust tolerance (0.1 = 10%)")
142 target_burn_time: Optional[float] = Field(default=None, gt=0, description="Target burn time [s] (from design requirements if None)")
143
144
145class Layer2Request(BaseModel):
146 """Request body for Layer 2 optimization."""
147 max_iterations: int = Field(default=20, ge=1, le=100, description="Maximum optimization iterations")
148 save_plots: bool = Field(default=False, description="Save evaluation plots")
149
150
151# Optimization job status/stop state is per-user now, held on session.optimizer
152# (backend/session.py). It used to be module globals here, which meant two users
153# shared one status slot and one stop event -- B was refused while A ran, A could
154# read B's results, and A's stop cancelled B's run. Each endpoint below reaches it
155# through `session.optimizer.*`.
156
157
158@router.post("/design-requirements")
160 request: DesignRequirementsRequest,
161 session: UserSession = Depends(get_session),
162 _: None = DesignCheckout,
163):
164 """Save design requirements to config."""
165 if not session.app_state.has_config():
166 raise HTTPException(
167 status_code=400,
168 detail="No config loaded. Upload a config file first."
169 )
170
171 try:
172 # Overlay the payload on what is loaded; a partial payload must not reset the rest.
173 old_dr = session.app_state.config.design_requirements
175 old_dr.model_dump() if old_dr is not None else None,
176 dict(request.requirements),
177 )
178
179 # Validate requirements using Pydantic
180 requirements = DesignRequirementsConfig(**req_in)
181
182 # Update config with validated requirements
183 session.app_state.config.design_requirements = requirements
184 from engine.pipeline.burn_time_sync import sync_burn_time_fields
185
186 sync_burn_time_fields(session.app_state.config)
187
188 return {
189 "status": "success",
190 "message": "Design requirements saved successfully",
191 "requirements": requirements.model_dump()
192 }
193 except Exception as e:
194 raise HTTPException(
195 status_code=400,
196 detail=f"Failed to save design requirements: {str(e)}"
197 )
198
199
200@router.get("/design-requirements", response_model=DesignRequirementsResponse)
201async def get_design_requirements(session: UserSession = Depends(get_session)):
202 """Get current design requirements from config."""
203 if not session.app_state.has_config():
204 return DesignRequirementsResponse(requirements=None)
205
206 if session.app_state.config.design_requirements is None:
207 return DesignRequirementsResponse(requirements=None)
208
210 requirements=session.app_state.config.design_requirements.model_dump()
211 )
212
213
214@router.get("/layer1/status")
215async def get_layer1_status(session: UserSession = Depends(get_session)):
216 """Get Layer 1 optimization status."""
217 return {
218 "running": session.optimizer.optimization_status["running"],
219 "progress": session.optimizer.optimization_status["progress"],
220 "stage": session.optimizer.optimization_status["stage"],
221 "message": session.optimizer.optimization_status["message"],
222 "has_results": session.optimizer.optimization_status["results"] is not None,
223 "error": session.optimizer.optimization_status["error"],
224 }
225
226
227@router.get("/layer1/results")
228async def get_layer1_results(session: UserSession = Depends(get_session)):
229 """Get Layer 1 optimization results."""
230 if session.optimizer.optimization_status["results"] is None:
231 raise HTTPException(
232 status_code=404,
233 detail="No optimization results available. Run Layer 1 optimization first."
234 )
235
236 return {
237 "status": "success",
238 "results": session.optimizer.optimization_status["results"],
239 }
240
241
242#: Download names, as the UI has always saved them.
243_CSV_NAMES = {2: "pressure_curves.csv", 3: "layer3_results.csv"}
244
245
246@router.get("/layer{layer}/results.csv")
247async def get_layer_results_csv(layer: int, session: UserSession = Depends(get_session)):
248 """Layer 2 or 3's pressure curves as CSV (backend/results_csv.py): time, LOX and fuel tank
249 pressure. The file the UI's "CSV" button saves, written here so anyone can fetch it."""
250 from fastapi.responses import PlainTextResponse
251
252 from backend.results_csv import pressure_curves_csv
253
254 status = {2: session.optimizer.layer2_status, 3: session.optimizer.layer3_status}.get(layer)
255 if status is None:
256 raise HTTPException(status_code=404, detail="Only Layers 2 and 3 have pressure curves.")
257 results = status["results"]
258 if not results or not results.get("time_array"):
259 raise HTTPException(status_code=404, detail=f"No Layer {layer} results with pressure curves. Run Layer {layer} first.")
260 return PlainTextResponse(
261 pressure_curves_csv(results),
262 media_type="text/csv",
263 headers={"Content-Disposition": f'attachment; filename="{_CSV_NAMES[layer]}"'},
264 )
265
266
267@router.get("/layer2/status")
268async def get_layer2_status(session: UserSession = Depends(get_session)):
269 """Get Layer 2 optimization status."""
270 return {
271 "running": session.optimizer.layer2_status["running"],
272 "progress": session.optimizer.layer2_status["progress"],
273 "stage": session.optimizer.layer2_status["stage"],
274 "message": session.optimizer.layer2_status["message"],
275 "has_results": session.optimizer.layer2_status["results"] is not None,
276 "error": session.optimizer.layer2_status["error"],
277 }
278
279
280@router.get("/layer2/results")
281async def get_layer2_results(session: UserSession = Depends(get_session)):
282 """Get Layer 2 optimization results."""
283 if session.optimizer.layer2_status["results"] is None:
284 raise HTTPException(
285 status_code=404,
286 detail="No Layer 2 optimization results available. Run Layer 2 optimization first."
287 )
288
289 return {
290 "status": "success",
291 "results": session.optimizer.layer2_status["results"],
292 }
293
294
295@router.post("/layer2/stop")
296async def stop_layer2(session: UserSession = Depends(get_session)):
297 """Stop the currently running Layer 2 optimization."""
298
299 if not session.optimizer.layer2_status["running"]:
300 raise HTTPException(
301 status_code=400,
302 detail="No Layer 2 optimization is currently running."
303 )
304
305 with session.optimizer.stop_event_lock:
306 if session.optimizer.stop_event is not None:
307 session.optimizer.stop_event.set()
308 session.optimizer.layer2_status["message"] = "Stopping optimization..."
309 return {
310 "status": "success",
311 "message": "Stop signal sent to optimizer."
312 }
313 else:
314 return {
315 "status": "error",
316 "message": "Stop event not initialized."
317 }
318
319
320@router.post("/layer1/stop")
321async def stop_layer1(session: UserSession = Depends(get_session)):
322 """Stop the currently running Layer 1 optimization."""
323
324 if not session.optimizer.optimization_status["running"]:
325 raise HTTPException(
326 status_code=400,
327 detail="No optimization is currently running."
328 )
329
330 with session.optimizer.stop_event_lock:
331 if session.optimizer.stop_event is not None:
332 session.optimizer.stop_event.set()
333 session.optimizer.optimization_status["message"] = "Stopping optimization..."
334 return {
335 "status": "success",
336 "message": "Stop signal sent to optimizer."
337 }
338 else:
339 return {
340 "status": "error",
341 "message": "Stop event not initialized."
342 }
343
344
345@router.get("/layer1")
346async def run_layer1(
347 thrust_tolerance: float = 0.1,
348 target_burn_time: float | None = None,
349 report_every_n: int = 1,
350 session: UserSession = Depends(get_session),
351 _: None = DesignCheckout,
352):
353 """Run Layer 1 optimization with Server-Sent Events for progress updates.
354
355 Note: max_iterations is hardcoded in the optimizer for consistent robust convergence.
356
357 Returns a stream of progress updates in SSE format.
358 """
359 if not session.app_state.has_config():
360 raise HTTPException(
361 status_code=400,
362 detail="No config loaded. Upload a config file first."
363 )
364
365 if not session.app_state.runner:
366 raise HTTPException(
367 status_code=400,
368 detail="Runner not initialized. Please check config."
369 )
370
371 # Check for design requirements
372 if session.app_state.config.design_requirements is None:
373 raise HTTPException(
374 status_code=400,
375 detail="No design requirements set. Save design requirements first."
376 )
377
378 # Check if already running
379 if session.optimizer.optimization_status["running"]:
380 raise HTTPException(
381 status_code=409,
382 detail="Optimization already running. Please wait for it to complete."
383 )
384
385 async def event_generator():
386 """Generate SSE events for optimization progress."""
387
388 # Create new stop event for this optimization run
389 with session.optimizer.stop_event_lock:
390 session.optimizer.stop_event = threading.Event()
391
392 session.optimizer.optimization_status["running"] = True
393 session.optimizer.optimization_status["progress"] = 0.0
394 session.optimizer.optimization_status["stage"] = "Initializing"
395 session.optimizer.optimization_status["message"] = "Starting Layer 1 optimization..."
396 session.optimizer.optimization_status["results"] = None
397 session.optimizer.optimization_status["error"] = None
398
399 # Send initial status
400 yield f"data: {safe_json_dumps({'type': 'status', 'progress': 0.0, 'stage': 'Initializing', 'message': 'Starting optimization...'})}\n\n"
401
402 try:
403 # Get design requirements
404 requirements = session.app_state.config.design_requirements.model_dump()
405 # Add report_every_n to requirements if not already set
406 if "report_every_n" not in requirements:
407 requirements["report_every_n"] = report_every_n
408 burn_time = target_burn_time or requirements.get("target_burn_time", 10.0)
409
410 # Prepare pressure config
411 pressure_config = {
412 "mode": "optimizer_controlled",
413 "max_lox_pressure_psi": requirements.get("max_lox_tank_pressure_psi", 700.0),
414 "max_fuel_pressure_psi": requirements.get("max_fuel_tank_pressure_psi", 850.0),
415 "target_burn_time": burn_time,
416 "n_segments": 3,
417 }
418
419 # Prepare tolerances
420 tolerances = {
421 "thrust": thrust_tolerance,
422 "apogee": 0.15,
423 }
424
425 # Objective history - use thread-safe list
426 objective_history = []
427 objective_history_lock = threading.Lock()
428 last_sent_objective_count = 0
429
430 # Progress callback
431 def update_progress(stage: str, progress: float, message: str):
432 session.optimizer.optimization_status["progress"] = progress
433 session.optimizer.optimization_status["stage"] = stage
434 session.optimizer.optimization_status["message"] = message
435
436 # Objective callback - thread-safe
437 def objective_callback(iteration: int, objective: float, best_objective: float):
438 with objective_history_lock:
439 objective_history.append({
440 "iteration": int(iteration),
441 "objective": float(objective),
442 "best_objective": float(best_objective),
443 })
444
445 # Run optimization (blocking)
446 # Note: This will block the event loop, but for now we'll keep it simple
447 # In production, you'd want to run this in a thread pool
448 import concurrent.futures
449
450 def run_optimization():
451 return run_layer1_optimization(
452 config_obj=session.app_state.config,
453 runner=session.app_state.runner,
454 requirements=requirements,
455 target_burn_time=burn_time,
456 tolerances=tolerances,
457 pressure_config=pressure_config,
458 update_progress=update_progress,
459 log_status=lambda stage, msg: None, # Not used for SSE
460 objective_callback=objective_callback,
461 stop_event=session.optimizer.stop_event, # Pass stop event to optimizer
462 )
463
464 # Run in thread pool to avoid blocking
465 loop = asyncio.get_event_loop()
466 async with JOB_SEMAPHORE: # cap concurrent heavy jobs across all users
467 with concurrent.futures.ThreadPoolExecutor() as pool:
468 # Send progress updates while optimization runs
469 future = loop.run_in_executor(pool, run_optimization)
470
471 while not future.done():
472 # Send progress update (convert numpy types)
473 progress_data = convert_numpy({
474 'type': 'progress',
475 'progress': session.optimizer.optimization_status['progress'],
476 'stage': session.optimizer.optimization_status['stage'],
477 'message': session.optimizer.optimization_status['message']
478 })
479 yield f"data: {safe_json_dumps(progress_data)}\n\n"
480
481 # Check for new objective history updates and send them
482 with objective_history_lock:
483 if len(objective_history) > last_sent_objective_count:
484 # Get new entries
485 new_entries = objective_history[last_sent_objective_count:]
486 last_sent_objective_count = len(objective_history)
487
488 # Send objective update event
489 objective_data = convert_numpy({
490 'type': 'objective',
491 'objective_history': new_entries,
492 'total_count': last_sent_objective_count,
493 })
494 yield f"data: {safe_json_dumps(objective_data)}\n\n"
495
496 await asyncio.sleep(0.5)
497
498 # Get results - check if stopped
499 try:
500 optimized_config, results = future.result()
501 # Check if stop was requested after completion
502 with session.optimizer.stop_event_lock:
503 if session.optimizer.stop_event and session.optimizer.stop_event.is_set():
504 session.optimizer.optimization_status["running"] = False
505 session.optimizer.optimization_status["progress"] = 0.0
506 session.optimizer.optimization_status["stage"] = "Stopped"
507 session.optimizer.optimization_status["message"] = "Optimization stopped by user"
508 yield f"data: {safe_json_dumps({'type': 'error', 'error': 'Optimization stopped by user'})}\n\n"
509 return
510 except Exception as e:
511 # Check if error was due to stop request
512 with session.optimizer.stop_event_lock:
513 if session.optimizer.stop_event and session.optimizer.stop_event.is_set():
514 session.optimizer.optimization_status["running"] = False
515 session.optimizer.optimization_status["progress"] = 0.0
516 session.optimizer.optimization_status["stage"] = "Stopped"
517 session.optimizer.optimization_status["message"] = "Optimization stopped by user"
518 yield f"data: {safe_json_dumps({'type': 'error', 'error': 'Optimization stopped by user'})}\n\n"
519 return
520 raise
521
522 # Update config and recreate runner with new config -- ONLY if the design actually
523 # validated. An INVALID result must never become the seed for the next run: it is
524 # what turns one unlucky run into a permanently bricked design (see
525 # layer1_design_is_valid). The bad result is still reported below so the user can
526 # see what failed; it just does not get to overwrite a working geometry.
527 design_valid, invalid_reasons = layer1_design_is_valid(results)
528 if design_valid:
529 session.app_state.set_config(optimized_config)
530 persisted_config = optimized_config
531 else:
532 persisted_config = session.app_state.config
533 print(
534 "⚠ Layer 1 result did NOT validate — keeping the previous geometry as the "
535 f"seed rather than saving it. Reasons: {invalid_reasons}"
536 )
537
538 # Store results (convert numpy types for JSON serialization)
539 performance = results.get("performance", {})
540 # Add target exit pressure to performance for easy access
541 exit_pressure_targeting = results.get("exit_pressure_targeting", {})
542 if exit_pressure_targeting.get("target_P_exit") is not None:
543 performance["target_P_exit"] = exit_pressure_targeting["target_P_exit"]
544
545 # ``config`` is what the frontend autosaves into the working copy, so it must be the
546 # config we actually kept -- not the rejected one. Otherwise the guard above is
547 # pointless: the invalid geometry would still reach current.json via autosave.
548 results_dict = convert_numpy({
549 "performance": performance,
550 # Layer 1 does not emit a "validation" key, so this was always {} and the UI's
551 # validation panel got nothing. Report the real gate outcome instead.
552 "validation": results.get("validation") or {
553 "valid": design_valid,
554 "failure_reasons": invalid_reasons,
555 },
556 "design_saved": design_valid,
557 "geometry": results.get("optimized_parameters", {}),
558 "objective_history": objective_history,
559 "iteration_history": results.get("iteration_history", []),
560 "convergence_info": results.get("convergence_info", {}),
561 "config": config_to_dict(persisted_config),
562 "config_yaml": yaml.dump(config_to_dict(persisted_config), default_flow_style=False),
563 })
564 session.optimizer.optimization_status["results"] = results_dict
565 session.optimizer.optimization_status["progress"] = 1.0
566 session.optimizer.optimization_status["stage"] = "Complete"
567 session.optimizer.optimization_status["message"] = "Optimization completed successfully"
568
569 # Send completion event
570 yield f"data: {safe_json_dumps({'type': 'complete', 'results': results_dict})}\n\n"
571
572 except Exception as e:
573 # Check if this is a stop request
574 error_str = str(e).lower()
575 if "stopped by user" in error_str or "optimization stopped" in error_str:
576 error_msg = "Optimization stopped by user"
577 session.optimizer.optimization_status["error"] = None
578 session.optimizer.optimization_status["message"] = error_msg
579 session.optimizer.optimization_status["stage"] = "Stopped"
580 # Send stop event
581 yield f"data: {safe_json_dumps({'type': 'error', 'error': error_msg})}\n\n"
582 else:
583 error_msg = f"Optimization failed: {str(e)}"
584 error_trace = traceback.format_exc()
585 session.optimizer.optimization_status["error"] = error_msg
586 session.optimizer.optimization_status["message"] = error_msg
587 # Send error event
588 yield f"data: {safe_json_dumps({'type': 'error', 'error': error_msg, 'traceback': error_trace})}\n\n"
589
590 finally:
591 session.optimizer.optimization_status["running"] = False
592 # Clear stop event
593 with session.optimizer.stop_event_lock:
594 session.optimizer.stop_event = None
595
596 return StreamingResponse(
597 event_generator(),
598 media_type="text/event-stream",
599 headers={
600 "Cache-Control": "no-cache",
601 "Connection": "keep-alive",
602 "X-Accel-Buffering": "no", # Disable nginx buffering
603 }
604 )
605
606
607@router.get("/layer2")
608async def run_layer2(
609 max_iterations: int = 20,
610 save_plots: bool = False,
611 de_maxiter: int = 5,
612 de_popsize: int = 2,
613 de_n_time_points: int = 25,
614 session: UserSession = Depends(get_session),
615 _: None = DesignCheckout,
616):
617 """Run Layer 2 optimization with Server-Sent Events for progress updates."""
618 if not session.app_state.has_config():
619 raise HTTPException(
620 status_code=400,
621 detail="No config loaded. Run Layer 1 or upload a config first."
622 )
623
624 if not session.app_state.runner:
625 raise HTTPException(
626 status_code=400,
627 detail="Runner not initialized."
628 )
629
630 # Check for design requirements
631 if session.app_state.config.design_requirements is None:
632 raise HTTPException(
633 status_code=400,
634 detail="No design requirements set."
635 )
636
637 # Check if already running
638 if session.optimizer.layer2_status["running"]:
639 raise HTTPException(
640 status_code=409,
641 detail="Layer 2 optimization already running."
642 )
643
644 async def event_generator():
645
646 with session.optimizer.stop_event_lock:
647 session.optimizer.stop_event = threading.Event()
648
649 session.optimizer.layer2_status.update({
650 "running": True,
651 "progress": 0.0,
652 "stage": "Initializing",
653 "message": "Starting Layer 2 optimization...",
654 "results": None,
655 "error": None,
656 })
657
658 yield f"data: {safe_json_dumps({'type': 'status', 'progress': 0.0, 'stage': 'Initializing', 'message': 'Starting optimization...'})}\n\n"
659
660 try:
661 reqs = session.app_state.config.design_requirements
662 burn_time = reqs.target_burn_time
663
664 # Extract initial pressures from config if available, otherwise use defaults
665 # Layer 1 should have set these in the config
666 initial_lox_p = 500.0 * 6894.76
667 if session.app_state.config.lox_tank and session.app_state.config.lox_tank.initial_pressure_psi:
668 initial_lox_p = session.app_state.config.lox_tank.initial_pressure_psi * 6894.76
669
670 initial_fuel_p = 500.0 * 6894.76
671 if session.app_state.config.fuel_tank and session.app_state.config.fuel_tank.initial_pressure_psi:
672 initial_fuel_p = session.app_state.config.fuel_tank.initial_pressure_psi * 6894.76
673
674 # Extract parameters from requirements or config
675 target_thrust = reqs.target_thrust
676 target_apogee = reqs.target_apogee or 3048.0
677
678 # Rocket dry mass calculation
679 rocket_dry_mass_kg = 50.0 # Default
680 if session.app_state.config.rocket:
681 r = session.app_state.config.rocket
682 if r.airframe_mass is not None and r.engine_mass is not None:
683 # Sum up components if broken down
684 rocket_dry_mass_kg = (
685 (r.airframe_mass or 0) +
686 (r.engine_mass or 0) +
687 (r.lox_tank_structure_mass or 0) +
688 (r.fuel_tank_structure_mass or 0) +
689 (r.copv_dry_mass or 0)
690 )
691 elif r.propulsion_dry_mass is not None:
692 rocket_dry_mass_kg = (r.airframe_mass or 0) + r.propulsion_dry_mass
693
694 # Tank capacities
695 lox_capacity = reqs.lox_tank_capacity_kg or 25.0
696 fuel_capacity = reqs.fuel_tank_capacity_kg or 15.0
697
698 objective_history = []
699 objective_history_lock = threading.Lock()
700 last_sent_count = 0
701
702 # Track best pressure curves for streaming to UI
703 best_pressure_curves = {"time": None, "lox": None, "fuel": None, "copv_pressure": None, "copv_time": None}
704 pressure_curves_lock = threading.Lock()
705 pressure_curves_updated = threading.Event()
706
707 def update_progress(stage: str, progress: float, message: str):
708 session.optimizer.layer2_status.update({"progress": progress, "stage": stage, "message": message})
709
710 def objective_callback(iteration: int, objective: float, best_objective: float):
711 with objective_history_lock:
712 objective_history.append({
713 "iteration": int(iteration),
714 "objective": float(objective),
715 "best_objective": float(best_objective),
716 })
717
718 def pressure_curve_callback(time_arr, P_lox, P_fuel, copv_pressure=None, copv_time=None):
719 """Called when a new best solution is found - stream pressure curves to UI."""
720 with pressure_curves_lock:
721 best_pressure_curves["time"] = time_arr
722 best_pressure_curves["lox"] = P_lox
723 best_pressure_curves["fuel"] = P_fuel
724 best_pressure_curves["copv_pressure"] = copv_pressure
725 best_pressure_curves["copv_time"] = copv_time
726 pressure_curves_updated.set() # Signal that new curves are available
727
728
729
730 import concurrent.futures
731
732 # Capture the current stop event explicitly to avoid any global lookup ambiguity
733 current_stop_event = session.optimizer.stop_event
734
735 def run_opt():
736 return run_layer2_pressure(
737 optimized_config=session.app_state.config,
738 initial_lox_pressure_pa=initial_lox_p,
739 initial_fuel_pressure_pa=initial_fuel_p,
740 peak_thrust=target_thrust,
741 target_apogee_m=target_apogee,
742 rocket_dry_mass_kg=rocket_dry_mass_kg,
743 max_lox_tank_capacity_kg=lox_capacity,
744 max_fuel_tank_capacity_kg=fuel_capacity,
745 target_burn_time=burn_time,
746 n_time_points=200,
747 update_progress=update_progress,
748 objective_callback=objective_callback,
749 pressure_curve_callback=pressure_curve_callback,
750 max_iterations=max_iterations,
751 save_evaluation_plots=save_plots,
752 stop_event=current_stop_event, # Use captured event
753 de_maxiter=de_maxiter,
754 de_popsize=de_popsize,
755 de_n_time_points=de_n_time_points,
756 )
757
758 # Wait, I need to check the design requirements for tank capacities
759 # It seems DesignRequirementsConfig doesn't have capacity_kg?
760 # Let me check engine/pipeline/config_schemas.py
761
762 loop = asyncio.get_event_loop()
763 async with JOB_SEMAPHORE: # cap concurrent heavy jobs across all users
764 with concurrent.futures.ThreadPoolExecutor() as pool:
765 future = loop.run_in_executor(pool, run_opt)
766
767 while not future.done():
768 yield f"data: {safe_json_dumps({'type': 'progress', 'progress': session.optimizer.layer2_status['progress'], 'stage': session.optimizer.layer2_status['stage'], 'message': session.optimizer.layer2_status['message']})}\n\n"
769
770 with objective_history_lock:
771 if len(objective_history) > last_sent_count:
772 new_entries = objective_history[last_sent_count:]
773 last_sent_count = len(objective_history)
774 yield f"data: {safe_json_dumps({'type': 'objective', 'objective_history': new_entries, 'total_count': last_sent_count})}\n\n"
775
776 # Check for new pressure curves and send them
777 if pressure_curves_updated.is_set():
778 with pressure_curves_lock:
779 if best_pressure_curves["time"] is not None:
780 # Send pressure curves to frontend (including COPV data)
781 curves_data = convert_numpy({
782 'type': 'pressure_curves',
783 'time_array': best_pressure_curves["time"],
784 'lox_pressure': best_pressure_curves["lox"],
785 'fuel_pressure': best_pressure_curves["fuel"],
786 'copv_pressure': best_pressure_curves["copv_pressure"],
787 'copv_time': best_pressure_curves["copv_time"],
788 })
789 yield f"data: {safe_json_dumps(curves_data)}\n\n"
790 pressure_curves_updated.clear() # Reset flag
791
792
793 await asyncio.sleep(0.5)
794
795 optimized_config, time_array, P_lox, P_fuel, summary, success = future.result()
796
797 # Check if stopped - if so, still return results (with best solution found)
798 stopped_by_user = False
799 with session.optimizer.stop_event_lock:
800 if session.optimizer.stop_event and session.optimizer.stop_event.is_set():
801 stopped_by_user = True
802
803 # Update app state (even if stopped - we want to save the best solution)
804 session.app_state.set_config(optimized_config)
805
806 results_dict = convert_numpy({
807 "performance": summary, # Layer 2 summary contains performance info
808 "summary": summary,
809 "objective_history": objective_history,
810 "time_array": time_array,
811 "lox_pressure": P_lox,
812 "fuel_pressure": P_fuel,
813 "config": config_to_dict(optimized_config),
814 "config_yaml": yaml.dump(config_to_dict(optimized_config), default_flow_style=False),
815 })
816
817 session.optimizer.layer2_status.update({
818 "results": results_dict,
819 "progress": 1.0,
820 "stage": "Complete" if not stopped_by_user else "Stopped",
821 "message": "Layer 2 optimization complete" if not stopped_by_user else "Stopped by user - using best solution found",
822 })
823
824 yield f"data: {safe_json_dumps({'type': 'complete', 'results': results_dict, 'stopped_by_user': stopped_by_user})}\n\n"
825
826 except Exception as e:
827 session.optimizer.layer2_status["error"] = str(e)
828 yield f"data: {safe_json_dumps({'type': 'error', 'error': str(e), 'traceback': traceback.format_exc()})}\n\n"
829 finally:
830 session.optimizer.layer2_status["running"] = False
831
832 # Safety measure: if connection drops or something fails, ensure we signal stop
833 # to any running optimizer thread so it doesn't become a zombie.
834 with session.optimizer.stop_event_lock:
835 if session.optimizer.stop_event is not None:
836 session.optimizer.stop_event.set()
837 session.optimizer.stop_event = None
838
839 return StreamingResponse(
840 event_generator(),
841 media_type="text/event-stream",
842 headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}
843 )
844
845
846from fastapi import UploadFile, File as FastAPIFile
847
848@router.post("/layer2/upload-config")
850 file: UploadFile = FastAPIFile(...),
851 session: UserSession = Depends(get_session),
852 _: None = DesignCheckout,
853):
854 """Upload a config to use for Layer 2.
855
856 A wholesale replacement like /api/config/upload: behind the checkout, off the event loop,
857 with a new load_token (``replace_config``). Without the token a second tab of this user
858 kept autosaving -- the uploaded engine into its own design.
859 """
860 try:
861 content = await file.read()
862 config_dict = yaml.safe_load(content)
863
864 # Validate and update app state
865 from engine.pipeline.config_schemas import PintleEngineConfig
866 config = PintleEngineConfig(**config_dict)
867 load_token = await replace_config(session, config)
868
869 return {
870 "status": "success",
871 "message": "Config uploaded successfully for Layer 2",
872 "config": config_to_dict(config),
873 "session_epoch": session.epoch,
874 "load_token": load_token,
875 }
876 except Exception as e:
877 raise HTTPException(
878 status_code=400,
879 detail=f"Failed to upload config: {str(e)}"
880 )
881
882
883# ============================================================================
884# Layer 3: Thermal Protection Optimization Endpoints
885# ============================================================================
886
887@router.get("/layer3/status")
888async def get_layer3_status(session: UserSession = Depends(get_session)):
889 """Get Layer 3 optimization status."""
890 return {
891 "running": session.optimizer.layer3_status["running"],
892 "progress": session.optimizer.layer3_status["progress"],
893 "stage": session.optimizer.layer3_status["stage"],
894 "message": session.optimizer.layer3_status["message"],
895 "has_results": session.optimizer.layer3_status["results"] is not None,
896 "error": session.optimizer.layer3_status["error"],
897 }
898
899
900@router.get("/layer3/results")
901async def get_layer3_results(session: UserSession = Depends(get_session)):
902 """Get Layer 3 optimization results."""
903 if session.optimizer.layer3_status["results"] is None:
904 raise HTTPException(
905 status_code=404,
906 detail="No Layer 3 optimization results available. Run Layer 3 optimization first."
907 )
908
909 return {
910 "status": "success",
911 "results": session.optimizer.layer3_status["results"],
912 }
913
914
915@router.post("/layer3/stop")
916async def stop_layer3(session: UserSession = Depends(get_session)):
917 """Stop the currently running Layer 3 optimization."""
918
919 if not session.optimizer.layer3_status["running"]:
920 raise HTTPException(
921 status_code=400,
922 detail="No Layer 3 optimization is currently running."
923 )
924
925 with session.optimizer.stop_event_lock:
926 if session.optimizer.stop_event is not None:
927 session.optimizer.stop_event.set()
928 session.optimizer.layer3_status["message"] = "Stopping optimization..."
929 return {
930 "status": "success",
931 "message": "Stop signal sent to optimizer."
932 }
933 else:
934 return {
935 "status": "error",
936 "message": "Stop event not initialized."
937 }
938
939
940@router.post("/layer3/upload-config")
942 file: UploadFile = FastAPIFile(...),
943 session: UserSession = Depends(get_session),
944 _: None = DesignCheckout,
945):
946 """Upload a config to use for Layer 3 (should have pressure_curves from Layer 2).
947
948 A wholesale replacement: see upload_layer2_config.
949 """
950 try:
951 content = await file.read()
952 config_dict = yaml.safe_load(content)
953
954 # Validate and update app state
955 from engine.pipeline.config_schemas import PintleEngineConfig
956 config = PintleEngineConfig(**config_dict)
957
958 # Verify config has pressure_curves section (required for Layer 3)
959 if not hasattr(config, 'pressure_curves') or config.pressure_curves is None:
960 raise HTTPException(
961 status_code=400,
962 detail="Config must have pressure_curves section from Layer 2 optimization."
963 )
964
965 load_token = await replace_config(session, config)
966
967 return {
968 "status": "success",
969 "message": "Config uploaded successfully for Layer 3",
970 "config": config_to_dict(config),
971 "session_epoch": session.epoch,
972 "load_token": load_token,
973 }
974 except HTTPException:
975 raise
976 except Exception as e:
977 raise HTTPException(
978 status_code=400,
979 detail=f"Failed to upload config: {str(e)}"
980 )
981
982
983@router.get("/layer3")
984async def run_layer3(
985 max_iterations: int = 20,
986 save_plots: bool = False,
987 optimization_method: str = "gradient",
988 session: UserSession = Depends(get_session),
989 _: None = DesignCheckout,
990):
991 """Run Layer 3 thermal protection optimization with Server-Sent Events for progress updates.
992
993 Args:
994 optimization_method: "gradient" (fast, ~5-15 evals), "cma" (thorough, ~60-80 evals), or "de" (fallback)
995 """
996 if not session.app_state.has_config():
997 raise HTTPException(
998 status_code=400,
999 detail="No config loaded. Run Layer 2 or upload a config first."
1000 )
1001
1002 if not session.app_state.runner:
1003 raise HTTPException(
1004 status_code=400,
1005 detail="Runner not initialized."
1006 )
1007
1008 # Verify config has pressure_curves from Layer 2
1009 if not hasattr(session.app_state.config, 'pressure_curves') or session.app_state.config.pressure_curves is None:
1010 raise HTTPException(
1011 status_code=400,
1012 detail="Config must have pressure_curves from Layer 2 optimization."
1013 )
1014
1015 # Check if already running
1016 if session.optimizer.layer3_status["running"]:
1017 raise HTTPException(
1018 status_code=409,
1019 detail="Layer 3 optimization already running."
1020 )
1021
1022 async def event_generator():
1023
1024 with session.optimizer.stop_event_lock:
1025 session.optimizer.stop_event = threading.Event()
1026
1027 session.optimizer.layer3_status.update({
1028 "running": True,
1029 "progress": 0.0,
1030 "stage": "Initializing",
1031 "message": "Starting Layer 3 thermal protection optimization...",
1032 "results": None,
1033 "error": None,
1034 })
1035
1036 yield f"data: {safe_json_dumps({'type': 'status', 'progress': 0.0, 'stage': 'Initializing', 'message': 'Starting optimization...'})}\n\n"
1037
1038 try:
1039 # Extract pressure curves from config
1040 pressure_curves = session.app_state.config.pressure_curves
1041 burn_time = pressure_curves.target_burn_time_s
1042 n_points = pressure_curves.n_points or 200
1043
1044 # Generate time array and pressure arrays from segments
1045 from engine.optimizer.layers.layer2_pressure import generate_pressure_curve_from_segments
1046
1047 time_array = np.linspace(0, burn_time, n_points)
1048
1049 # Convert segments to dict format for generate_pressure_curve_from_segments
1050 lox_segments_dicts = [
1051 {
1052 "length_ratio": seg.length_ratio,
1053 "type": seg.type,
1054 "start_pressure": seg.start_pressure_pa,
1055 "end_pressure": seg.end_pressure_pa,
1056 "k": seg.k if seg.k is not None else 0.3,
1057 }
1058 for seg in pressure_curves.lox_segments
1059 ]
1060
1061 fuel_segments_dicts = [
1062 {
1063 "length_ratio": seg.length_ratio,
1064 "type": seg.type,
1065 "start_pressure": seg.start_pressure_pa,
1066 "end_pressure": seg.end_pressure_pa,
1067 "k": seg.k if seg.k is not None else 0.3,
1068 }
1069 for seg in pressure_curves.fuel_segments
1070 ]
1071
1072 # Generate LOX pressure curve
1073 P_tank_O_array = generate_pressure_curve_from_segments(
1074 lox_segments_dicts,
1075 n_points
1076 )
1077
1078 # Generate Fuel pressure curve
1079 P_tank_F_array = generate_pressure_curve_from_segments(
1080 fuel_segments_dicts,
1081 n_points
1082 )
1083
1084 # Get baseline time series results from Layer 2 (if available)
1085 # For now, we'll run a quick evaluation to get baseline
1086 baseline_results = session.app_state.runner.evaluate_arrays_with_time(
1087 time_array,
1088 P_tank_O_array,
1089 P_tank_F_array,
1090 track_ablative_geometry=True,
1091 use_coupled_solver=True,
1092 )
1093
1094 objective_history = []
1095 objective_history_lock = threading.Lock()
1096 last_sent_count = 0
1097
1098 # Track best pressure curves for streaming to UI
1099 best_pressure_curves = {"time": None, "lox": None, "fuel": None, "copv_pressure": None, "copv_time": None}
1100 pressure_curves_lock = threading.Lock()
1101 pressure_curves_updated = threading.Event()
1102
1103 def update_progress(stage: str, progress: float, message: str):
1104 session.optimizer.layer3_status.update({"progress": progress, "stage": stage, "message": message})
1105
1106 def log_status(stage: str, message: str):
1107 # Log status messages (not used for SSE, but required by layer3)
1108 pass
1109
1110 def objective_callback(iteration: int, objective: float, best_objective: float):
1111 with objective_history_lock:
1112 objective_history.append({
1113 "iteration": int(iteration),
1114 "objective": float(objective),
1115 "best_objective": float(best_objective),
1116 })
1117
1118 import concurrent.futures
1119
1120 current_stop_event = session.optimizer.stop_event
1121
1122 def run_opt():
1123 return run_layer3_thermal_protection(
1124 optimized_config=session.app_state.config,
1125 time_array=time_array,
1126 P_tank_O_array=P_tank_O_array,
1127 P_tank_F_array=P_tank_F_array,
1128 full_time_results=baseline_results,
1129 n_time_points=n_points,
1130 update_progress=update_progress,
1131 log_status=log_status,
1132 objective_callback=objective_callback,
1133 optimization_method=optimization_method,
1134 )
1135
1136 loop = asyncio.get_event_loop()
1137 async with JOB_SEMAPHORE: # cap concurrent heavy jobs across all users
1138 with concurrent.futures.ThreadPoolExecutor() as pool:
1139 future = loop.run_in_executor(pool, run_opt)
1140
1141 while not future.done():
1142 yield f"data: {safe_json_dumps({'type': 'progress', 'progress': session.optimizer.layer3_status['progress'], 'stage': session.optimizer.layer3_status['stage'], 'message': session.optimizer.layer3_status['message']})}\n\n"
1143
1144 with objective_history_lock:
1145 if len(objective_history) > last_sent_count:
1146 new_entries = objective_history[last_sent_count:]
1147 last_sent_count = len(objective_history)
1148 yield f"data: {safe_json_dumps({'type': 'objective', 'objective_history': new_entries, 'total_count': last_sent_count})}\n\n"
1149
1150 # Send pressure curves (same as Layer 2 baseline)
1151 if not pressure_curves_updated.is_set():
1152 with pressure_curves_lock:
1153 best_pressure_curves["time"] = time_array
1154 best_pressure_curves["lox"] = P_tank_O_array
1155 best_pressure_curves["fuel"] = P_tank_F_array
1156 pressure_curves_updated.set()
1157
1158 if pressure_curves_updated.is_set():
1159 with pressure_curves_lock:
1160 if best_pressure_curves["time"] is not None:
1161 curves_data = convert_numpy({
1162 'type': 'pressure_curves',
1163 'time_array': best_pressure_curves["time"],
1164 'lox_pressure': best_pressure_curves["lox"],
1165 'fuel_pressure': best_pressure_curves["fuel"],
1166 'copv_pressure': best_pressure_curves["copv_pressure"],
1167 'copv_time': best_pressure_curves["copv_time"],
1168 })
1169 yield f"data: {safe_json_dumps(curves_data)}\n\n"
1170 pressure_curves_updated.clear()
1171
1172 await asyncio.sleep(0.5)
1173
1174 optimized_config, updated_time_results, thermal_results = future.result()
1175
1176 # Check if stopped
1177 stopped_by_user = False
1178 with session.optimizer.stop_event_lock:
1179 if session.optimizer.stop_event and session.optimizer.stop_event.is_set():
1180 stopped_by_user = True
1181
1182 # Update app state and recreate runner with new config
1183 session.app_state.set_config(optimized_config)
1184
1185 # Build comprehensive summary from thermal_results and updated_time_results
1186 try:
1187 # Extract performance arrays
1188 thrust_array = np.atleast_1d(updated_time_results.get("F", []))
1189 isp_array = np.atleast_1d(updated_time_results.get("Isp", []))
1190 mr_array = np.atleast_1d(updated_time_results.get("MR", []))
1191 pc_array = np.atleast_1d(updated_time_results.get("Pc", []))
1192
1193 # Ensure dimensions match for integration
1194 safe_time_array = time_array
1195 if len(thrust_array) != len(time_array):
1196 if len(thrust_array) > 0:
1197 # If sizes mismatch, create a safe time array for integration
1198 safe_time_array = np.linspace(0, burn_time, len(thrust_array))
1199 else:
1200 safe_time_array = np.array([])
1201
1202 # Calculate performance metrics (with safe handling of empty arrays)
1203 total_impulse = None
1204 if len(thrust_array) > 0 and len(safe_time_array) == len(thrust_array):
1205 if hasattr(np, 'trapezoid'):
1206 total_impulse = float(np.trapezoid(thrust_array, safe_time_array))
1207 else:
1208 total_impulse = float(np.trapz(thrust_array, safe_time_array))
1209
1210 avg_thrust = float(np.mean(thrust_array)) if len(thrust_array) > 0 else None
1211 peak_thrust = float(np.max(thrust_array)) if len(thrust_array) > 0 else None
1212 avg_isp = float(np.mean(isp_array)) if len(isp_array) > 0 else None
1213 avg_of = float(np.mean(mr_array)) if len(mr_array) > 0 else None
1214 min_of = float(np.min(mr_array)) if len(mr_array) > 0 else None
1215 max_of = float(np.max(mr_array)) if len(mr_array) > 0 else None
1216 avg_pc = float(np.mean(pc_array)) if len(pc_array) > 0 else None
1217
1218 summary = {
1219 # Thermal protection metrics
1220 "optimized_ablative_thickness": thermal_results.get("optimized_ablative_thickness"),
1221 "optimized_graphite_thickness": thermal_results.get("optimized_graphite_thickness"),
1222 "max_recession_chamber": thermal_results.get("max_recession_chamber"),
1223 "max_recession_throat": thermal_results.get("max_recession_throat"),
1224 "thermal_protection_valid": thermal_results.get("thermal_protection_valid"),
1225 "ablative_adequate": thermal_results.get("ablative_adequate"),
1226 "graphite_adequate": thermal_results.get("graphite_adequate"),
1227
1228 # Performance metrics
1229 "total_impulse_Ns": total_impulse,
1230 "avg_thrust_N": avg_thrust,
1231 "peak_thrust_N": peak_thrust,
1232 "avg_isp_s": avg_isp,
1233 "avg_chamber_pressure_Pa": avg_pc,
1234 "burn_time_s": burn_time,
1235
1236 # O/F ratio statistics
1237 "avg_of_ratio": avg_of,
1238 "min_of_ratio": min_of,
1239 "max_of_ratio": max_of,
1240
1241 # Stability
1242 "min_stability_margin": float(np.min(updated_time_results.get("chugging_stability_margin", [0]))) if "chugging_stability_margin" in updated_time_results else None,
1243 }
1244 except Exception as e:
1245 # Fallback summary if calculation fails
1246 print(f"Error calculating Layer 3 summary: {e}")
1247 traceback.print_exc()
1248
1249 summary = {
1250 "optimized_ablative_thickness": thermal_results.get("optimized_ablative_thickness"),
1251 "optimized_graphite_thickness": thermal_results.get("optimized_graphite_thickness"),
1252 "max_recession_chamber": thermal_results.get("max_recession_chamber"),
1253 "max_recession_throat": thermal_results.get("max_recession_throat"),
1254 "thermal_protection_valid": thermal_results.get("thermal_protection_valid"),
1255 "ablative_adequate": thermal_results.get("ablative_adequate"),
1256 "graphite_adequate": thermal_results.get("graphite_adequate"),
1257 "error": f"Summary calculation failed: {str(e)}"
1258 }
1259
1260 results_dict = convert_numpy({
1261 "performance": updated_time_results,
1262 "summary": summary,
1263 "objective_history": objective_history,
1264 "time_array": time_array,
1265 "lox_pressure": P_tank_O_array,
1266 "fuel_pressure": P_tank_F_array,
1267 "config": config_to_dict(optimized_config),
1268 "config_yaml": yaml.dump(config_to_dict(optimized_config), default_flow_style=False),
1269 })
1270
1271 session.optimizer.layer3_status.update({
1272 "results": results_dict,
1273 "progress": 1.0,
1274 "stage": "Complete" if not stopped_by_user else "Stopped",
1275 "message": "Layer 3 thermal protection optimization complete" if not stopped_by_user else "Stopped by user - using best solution found",
1276 })
1277
1278 yield f"data: {safe_json_dumps({'type': 'complete', 'results': results_dict, 'stopped_by_user': stopped_by_user})}\n\n"
1279
1280 except Exception as e:
1281 session.optimizer.layer3_status["error"] = str(e)
1282 yield f"data: {safe_json_dumps({'type': 'error', 'error': str(e), 'traceback': traceback.format_exc()})}\n\n"
1283 finally:
1284 session.optimizer.layer3_status["running"] = False
1285 with session.optimizer.stop_event_lock:
1286 if session.optimizer.stop_event is not None:
1287 session.optimizer.stop_event.set()
1288 session.optimizer.stop_event = None
1289
1290 return StreamingResponse(
1291 event_generator(),
1292 media_type="text/event-stream",
1293 headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}
1294 )
1295
upload_layer2_config(UploadFile file=FastAPIFile(...), UserSession session=Depends(get_session), None _=DesignCheckout)
Definition optimizer.py:853
get_layer1_status(UserSession session=Depends(get_session))
Definition optimizer.py:215
run_layer1(float thrust_tolerance=0.1, float|None target_burn_time=None, int report_every_n=1, UserSession session=Depends(get_session), None _=DesignCheckout)
Definition optimizer.py:352
run_layer2(int max_iterations=20, bool save_plots=False, int de_maxiter=5, int de_popsize=2, int de_n_time_points=25, UserSession session=Depends(get_session), None _=DesignCheckout)
Definition optimizer.py:616
get_layer3_results(UserSession session=Depends(get_session))
Definition optimizer.py:901
tuple[bool, list] layer1_design_is_valid(Dict[str, Any] results)
Definition optimizer.py:20
get_layer1_results(UserSession session=Depends(get_session))
Definition optimizer.py:228
run_layer3(int max_iterations=20, bool save_plots=False, str optimization_method="gradient", UserSession session=Depends(get_session), None _=DesignCheckout)
Definition optimizer.py:990
get_design_requirements(UserSession session=Depends(get_session))
Definition optimizer.py:201
get_layer2_results(UserSession session=Depends(get_session))
Definition optimizer.py:281
str safe_json_dumps(Any payload)
Definition optimizer.py:119
stop_layer3(UserSession session=Depends(get_session))
Definition optimizer.py:916
get_layer2_status(UserSession session=Depends(get_session))
Definition optimizer.py:268
stop_layer1(UserSession session=Depends(get_session))
Definition optimizer.py:321
stop_layer2(UserSession session=Depends(get_session))
Definition optimizer.py:296
get_layer_results_csv(int layer, UserSession session=Depends(get_session))
Definition optimizer.py:247
upload_layer3_config(UploadFile file=FastAPIFile(...), UserSession session=Depends(get_session), None _=DesignCheckout)
Definition optimizer.py:945
save_design_requirements(DesignRequirementsRequest request, UserSession session=Depends(get_session), None _=DesignCheckout)
Definition optimizer.py:163
Dict[str, Any] merge_design_requirements(Optional[Dict[str, Any]] old, Dict[str, Any] incoming)
Definition optimizer.py:49
get_layer3_status(UserSession session=Depends(get_session))
Definition optimizer.py:888