STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
timeseries.py
Go to the documentation of this file.
1"""Time-series evaluation endpoints."""
2
3from pathlib import Path
4from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Request
5from pydantic import BaseModel, Field, ValidationError
6from typing import List, Optional, Literal
7import math
8import numpy as np
9import pandas as pd
10import io
11import yaml
12
13from backend.checkout import require_design_checkout
14from backend.session import UserSession, get_session, replace_config, run_compute
15from engine.pipeline.time_series import generate_pressure_profile
16from engine.pipeline.config_schemas import PintleEngineConfig
17from engine.optimizer.layers.layer2_pressure import generate_pressure_curve_from_segments
18from copv.copv_solve_both import size_or_check_copv_for_polytropic_N2
19
20# Get path to N2 Z lookup table (relative to project root)
21_PROJECT_ROOT = Path(__file__).parent.parent.parent
22N2_Z_LOOKUP_CSV = str(_PROJECT_ROOT / "copv" / "n2_Z_lookup.csv")
23
24router = APIRouter(prefix="/api/timeseries", tags=["timeseries"])
25
26
27# Constants
28PSI_TO_PA = 6894.76
29PA_TO_PSI = 1.0 / PSI_TO_PA
30
31
33 """Recursively convert numpy types to Python native types."""
34 if isinstance(obj, dict):
35 return {k: convert_numpy(v) for k, v in obj.items()}
36 elif isinstance(obj, (list, tuple)):
37 return [convert_numpy(item) for item in obj]
38 elif isinstance(obj, np.ndarray):
39 return obj.tolist()
40 elif isinstance(obj, np.floating):
41 # NaN/Inf are legal model outputs (a lag model that does not define K_v, a margin
42 # that could not be evaluated) but json.dumps rejects them outright --
43 # "Out of range float values are not JSON compliant" -- which surfaced as a blanket
44 # HTTP 500 on forward evaluation. Emit JSON null instead, so a missing number reads
45 # as missing rather than taking the whole response down.
46 v = obj.item()
47 return v if math.isfinite(v) else None
48 elif isinstance(obj, np.integer):
49 return obj.item()
50 elif isinstance(obj, float):
51 return obj if math.isfinite(obj) else None
52 elif isinstance(obj, np.bool_):
53 return bool(obj)
54 else:
55 return obj
56
57
59 runner,
60 times: np.ndarray,
61 P_tank_O_psi: np.ndarray,
62 P_tank_F_psi: np.ndarray,
63 run_copv: bool = True, # NEW: flag to control COPV analysis
64 lox_mass_kg: Optional[np.ndarray] = None, # NEW: Propellant mass history for flameout masking
65 fuel_mass_kg: Optional[np.ndarray] = None, # NEW: Propellant mass history for flameout masking
66):
67 """Compute time-series results from pressure profiles.
68
69 This is a standalone version that doesn't depend on Streamlit.
70 """
71 P_tank_O_pa = np.asarray(P_tank_O_psi) * PSI_TO_PA
72 P_tank_F_pa = np.asarray(P_tank_F_psi) * PSI_TO_PA
73
74 # Check if ablative geometry tracking is enabled
75 ablative_cfg = runner.config.ablative_cooling
76 use_time_varying = (
77 ablative_cfg is not None
78 and ablative_cfg.enabled
79 and getattr(ablative_cfg, 'track_geometry_evolution', False)
80 and len(times) >= 2
81 )
82
83 if use_time_varying:
84 results = runner.evaluate_arrays_with_time(
85 times,
86 P_tank_O_pa,
87 P_tank_F_pa,
88 use_coupled_solver=True,
89 )
90 else:
91 results = runner.evaluate_arrays(P_tank_O_pa, P_tank_F_pa)
92
93 # Build results dict
94 result_data = {
95 "time": np.asarray(times).tolist(),
96 "P_tank_O_psi": np.asarray(P_tank_O_psi, dtype=float).tolist(),
97 "P_tank_F_psi": np.asarray(P_tank_F_psi, dtype=float).tolist(),
98 # Use mutable arrays for masking
99 "Pc_psi": np.asarray(results["Pc"], dtype=float) * PA_TO_PSI,
100 "thrust_kN": np.asarray(results["F"], dtype=float) / 1000.0,
101 "Isp_s": np.asarray(results["Isp"], dtype=float),
102 "MR": np.asarray(results["MR"], dtype=float),
103 "mdot_O_kg_s": np.asarray(results["mdot_O"], dtype=float),
104 "mdot_F_kg_s": np.asarray(results["mdot_F"], dtype=float),
105 "mdot_total_kg_s": np.asarray(results["mdot_total"], dtype=float),
106 "cstar_actual_m_s": np.asarray(results["cstar_actual"], dtype=float),
107 "gamma": np.asarray(results["gamma"], dtype=float),
108 }
109
110 # FLAMEOUT MASKING
111 # If mass history is provided, mask performance metrics where propellant is depleted.
112 # We define "depleted" as mass <= epsilon (e.g. 1e-4 kg).
113 # Since tanks are clamped to 0 in solver, checking <= 1e-4 is safe.
114 mask = None
115
116 # helper for robust conversion
117 def to_float_array(arr):
118 try:
119 return np.asarray(arr, dtype=float)
120 except Exception:
121 return np.zeros(len(times))
122
123 if lox_mass_kg is not None and len(lox_mass_kg) == len(times):
124 lox_m = to_float_array(lox_mass_kg)
125 mask_lox = lox_m <= 1e-4
126 if mask is None:
127 mask = mask_lox
128 else:
129 mask = mask | mask_lox
130
131 if fuel_mass_kg is not None and len(fuel_mass_kg) == len(times):
132 fuel_m = to_float_array(fuel_mass_kg)
133 mask_fuel = fuel_m <= 1e-4
134 if mask is None:
135 mask = mask_fuel
136 else:
137 mask = mask | mask_fuel
138
139 if mask is not None:
140 # LOGGING for debugging
141 n_masked = np.sum(mask)
142 if n_masked > 0:
143 import logging
144 logging.info(f"[TIMESERIES] Masking {n_masked}/{len(times)} points due to flameout.")
145
146 # Apply mask to performance metrics (set to 0.0)
147 # Note: We do NOT mask tank pressures (they show residual gas)
148 metrics_to_mask = [
149 "Pc_psi", "thrust_kN", "Isp_s", "MR",
150 "mdot_O_kg_s", "mdot_F_kg_s", "mdot_total_kg_s",
151 "cstar_actual_m_s"
152 ]
153 for key in metrics_to_mask:
154 if key in result_data:
155 # Ensure array is mutable and float type
156 arr = np.array(result_data[key], dtype=float)
157 arr[mask] = 0.0
158 result_data[key] = arr
159
160
161 # Convert arrays back to lists for JSON serialization
162 for k, v in result_data.items():
163 if isinstance(v, np.ndarray):
164 result_data[k] = v.tolist()
165
166 # Add optional fields if available
167 if "Cd_O" in results:
168 result_data["Cd_O"] = np.asarray(results["Cd_O"], dtype=float).tolist()
169 if "Cd_F" in results:
170 result_data["Cd_F"] = np.asarray(results["Cd_F"], dtype=float).tolist()
171
172 # Extract diagnostics data (matching Streamlit pattern)
173 diagnostics_list = results.get("diagnostics", [])
174 n_points = len(times)
175
176 # Initialize arrays for optional data
177 delta_P_injector_O_psi = []
178 delta_P_injector_F_psi = []
179 recession_rate_ablative_um_s = []
180 recession_rate_graphite_thermal_um_s = []
181 recession_rate_graphite_oxidation_um_s = []
182
183 # Extract data from diagnostics (matching Streamlit pattern)
184 for i, diag in enumerate(diagnostics_list):
185 if not isinstance(diag, dict):
186 delta_P_injector_O_psi.append(np.nan)
187 delta_P_injector_F_psi.append(np.nan)
188 recession_rate_ablative_um_s.append(np.nan)
189 recession_rate_graphite_thermal_um_s.append(np.nan)
190 recession_rate_graphite_oxidation_um_s.append(np.nan)
191 continue
192
193 # Injector pressure drops. The per-point diagnostics are the injector closure's, which
194 # spell it ``delta_p_injector_*`` (engine/core/injectors/impinging.py, pintle.py) -- the
195 # same keys forward mode reports. This read only the capital-P spelling, found nothing at
196 # any point, and the "Injector Pressure Drops vs Time" chart never appeared. Both
197 # spellings, and the runner's ``injector_pressure`` block if a caller passes that.
198 injector_pressure = diag.get("injector_pressure")
199 src = injector_pressure if isinstance(injector_pressure, dict) and injector_pressure else diag
200
201 def _dp(side: str):
202 v = src.get(f"delta_p_injector_{side}")
203 return v if v is not None else src.get(f"delta_P_injector_{side}")
204
205 delta_P_O = _dp("O")
206 delta_P_F = _dp("F")
207
208 if delta_P_O is not None:
209 delta_P_injector_O_psi.append(float(delta_P_O * PA_TO_PSI))
210 else:
211 delta_P_injector_O_psi.append(np.nan)
212
213 if delta_P_F is not None:
214 delta_P_injector_F_psi.append(float(delta_P_F * PA_TO_PSI))
215 else:
216 delta_P_injector_F_psi.append(np.nan)
217
218 # Cooling diagnostics - recession rates (matching Streamlit pattern)
219 cooling = diag.get("cooling", {})
220 ablative = cooling.get("ablative", {}) if cooling else {}
221 graphite = cooling.get("graphite", {}) if cooling else {}
222
223 # Ablative recession rate
224 if ablative and isinstance(ablative, dict):
225 recession_rate = ablative.get("recession_rate")
226 if recession_rate is not None:
227 recession_rate_ablative_um_s.append(float(recession_rate * 1e6)) # Convert m/s to µm/s
228 else:
229 recession_rate_ablative_um_s.append(np.nan)
230 else:
231 recession_rate_ablative_um_s.append(np.nan)
232
233 # Graphite recession rates
234 if graphite and isinstance(graphite, dict) and graphite.get("enabled", False):
235 thermal_rate = graphite.get("recession_rate_thermal")
236 oxidation_rate = graphite.get("oxidation_rate")
237 if thermal_rate is not None:
238 recession_rate_graphite_thermal_um_s.append(float(thermal_rate * 1e6)) # Convert m/s to µm/s
239 else:
240 recession_rate_graphite_thermal_um_s.append(np.nan)
241 if oxidation_rate is not None:
242 recession_rate_graphite_oxidation_um_s.append(float(oxidation_rate * 1e6)) # Convert m/s to µm/s
243 else:
244 recession_rate_graphite_oxidation_um_s.append(np.nan)
245 else:
246 recession_rate_graphite_thermal_um_s.append(np.nan)
247 recession_rate_graphite_oxidation_um_s.append(np.nan)
248
249 # Extract Heat Flux Profiles from regen or ablative cooling data
250 # Priority: regen > ablative (use whichever is available)
251 axial_positions = None
252 heat_flux_profiles = []
253 wall_temp_profiles = []
254
255 # Ablative-specific profiles (incident, conv, rad, net)
256 ablative_axial_positions = None
257 ablative_q_incident_profiles = []
258 ablative_q_conv_profiles = []
259 ablative_q_rad_profiles = []
260 ablative_q_net_profiles = []
261 ablative_throat_index = -1
262
263 # Debug: check first diagnostic for structure
264 import logging
265 logger = logging.getLogger(__name__)
266 if diagnostics_list and len(diagnostics_list) > 0 and isinstance(diagnostics_list[0], dict):
267 first_diag = diagnostics_list[0]
268 cooling_data = first_diag.get("cooling", {})
269 logger.info(f"[TIMESERIES DEBUG] First diagnostic 'cooling' keys: {list(cooling_data.keys()) if cooling_data else 'no cooling'}")
270 if cooling_data and "ablative" in cooling_data:
271 abl_data = cooling_data["ablative"]
272 logger.info(f"[TIMESERIES DEBUG] ablative keys: {list(abl_data.keys()) if isinstance(abl_data, dict) else 'not a dict'}")
273 if isinstance(abl_data, dict):
274 logger.info(f"[TIMESERIES DEBUG] ablative segment_x length: {len(abl_data.get('segment_x', []))}")
275
276 for i, diag in enumerate(diagnostics_list):
277 if not isinstance(diag, dict):
278 heat_flux_profiles.append([])
279 wall_temp_profiles.append([])
280 ablative_q_incident_profiles.append([])
281 ablative_q_conv_profiles.append([])
282 ablative_q_rad_profiles.append([])
283 ablative_q_net_profiles.append([])
284 continue
285
286 cooling = diag.get("cooling", {})
287 regen = cooling.get("regen", {}) if cooling else {}
288 ablative = cooling.get("ablative", {}) if cooling else {}
289
290 # Try regen first
291 if regen and isinstance(regen, dict):
292 segment_positions = regen.get("segment_positions", [])
293 segment_heat_flux = regen.get("segment_heat_flux", [])
294 segment_wall_temps = regen.get("segment_wall_temperatures", [])
295
296 # Set axial_positions once (should be same for all time steps)
297 if axial_positions is None and segment_positions:
298 axial_positions = [float(p) for p in segment_positions]
299
300 heat_flux_profiles.append([float(q) for q in segment_heat_flux] if segment_heat_flux else [])
301 wall_temp_profiles.append([float(t) for t in segment_wall_temps] if segment_wall_temps else [])
302 else:
303 heat_flux_profiles.append([])
304 wall_temp_profiles.append([])
305
306 # Extract ablative profile data (separate from regen)
307 if ablative and isinstance(ablative, dict):
308 segment_x = ablative.get("segment_x", [])
309 segment_q_incident = ablative.get("segment_q_incident", [])
310 segment_q_conv = ablative.get("segment_q_conv", [])
311 segment_q_rad = ablative.get("segment_q_rad", [])
312 segment_q_net = ablative.get("segment_q_net", [])
313 throat_idx = ablative.get("throat_index", -1)
314
315 # Set ablative positions once
316 if ablative_axial_positions is None and segment_x:
317 ablative_axial_positions = [float(p) for p in segment_x]
318 ablative_throat_index = throat_idx
319
320 ablative_q_incident_profiles.append([float(q) for q in segment_q_incident] if segment_q_incident else [])
321 ablative_q_conv_profiles.append([float(q) for q in segment_q_conv] if segment_q_conv else [])
322 ablative_q_rad_profiles.append([float(q) for q in segment_q_rad] if segment_q_rad else [])
323 ablative_q_net_profiles.append([float(q) for q in segment_q_net] if segment_q_net else [])
324 else:
325 ablative_q_incident_profiles.append([])
326 ablative_q_conv_profiles.append([])
327 ablative_q_rad_profiles.append([])
328 ablative_q_net_profiles.append([])
329
330 # Extract geometry evolution data (from evaluate_arrays_with_time results)
331 Lstar_mm = None
332 V_chamber_m3 = None
333 A_throat_m2 = None
334 recession_cumulative_chamber_um = None
335 recession_cumulative_throat_um = None
336
337 if "Lstar" in results:
338 Lstar_array = np.asarray(results["Lstar"], dtype=float)
339 if len(Lstar_array) == n_points:
340 Lstar_mm = (Lstar_array * 1000.0).tolist() # Convert m to mm
341
342 if "V_chamber" in results:
343 V_chamber_array = np.asarray(results["V_chamber"], dtype=float)
344 if len(V_chamber_array) == n_points:
345 V_chamber_m3 = V_chamber_array.tolist()
346
347 if "A_throat" in results:
348 A_throat_array = np.asarray(results["A_throat"], dtype=float)
349 if len(A_throat_array) == n_points:
350 A_throat_m2 = A_throat_array.tolist()
351
352 if "recession_chamber" in results:
353 recession_array = np.asarray(results["recession_chamber"], dtype=float)
354 if len(recession_array) == n_points:
355 recession_cumulative_chamber_um = (recession_array * 1e6).tolist() # Convert m to µm
356
357 if "recession_throat" in results:
358 recession_array = np.asarray(results["recession_throat"], dtype=float)
359 if len(recession_array) == n_points:
360 recession_cumulative_throat_um = (recession_array * 1e6).tolist() # Convert m to µm
361
362 # Calculate cumulative recession from rates (matching Streamlit pattern)
363 recession_cumulative_ablative_mm = None
364 recession_cumulative_graphite_thermal_mm = None
365 recession_cumulative_graphite_oxidation_mm = None
366
367 if any(np.isfinite(recession_rate_ablative_um_s)):
368 times_array = np.asarray(times)
369 dt = np.diff(times_array, prepend=times_array[0] if len(times_array) > 0 else 0.0)
370 recession_rate_m_s = np.asarray(recession_rate_ablative_um_s, dtype=float) * 1e-6 # Convert µm/s to m/s
371 recession_rate_m_s = np.nan_to_num(recession_rate_m_s, nan=0.0)
372 cumulative = np.cumsum(recession_rate_m_s * dt) * 1000.0 # Convert to mm
373 recession_cumulative_ablative_mm = cumulative.tolist()
374
375 if any(np.isfinite(recession_rate_graphite_thermal_um_s)):
376 times_array = np.asarray(times)
377 dt = np.diff(times_array, prepend=times_array[0] if len(times_array) > 0 else 0.0)
378 recession_rate_m_s = np.asarray(recession_rate_graphite_thermal_um_s, dtype=float) * 1e-6
379 recession_rate_m_s = np.nan_to_num(recession_rate_m_s, nan=0.0)
380 cumulative = np.cumsum(recession_rate_m_s * dt) * 1000.0
381 recession_cumulative_graphite_thermal_mm = cumulative.tolist()
382
383 if any(np.isfinite(recession_rate_graphite_oxidation_um_s)):
384 times_array = np.asarray(times)
385 dt = np.diff(times_array, prepend=times_array[0] if len(times_array) > 0 else 0.0)
386 recession_rate_m_s = np.asarray(recession_rate_graphite_oxidation_um_s, dtype=float) * 1e-6
387 recession_rate_m_s = np.nan_to_num(recession_rate_m_s, nan=0.0)
388 cumulative = np.cumsum(recession_rate_m_s * dt) * 1000.0
389 recession_cumulative_graphite_oxidation_mm = cumulative.tolist()
390
391 # Add optional data fields (only if data exists)
392 if delta_P_injector_O_psi and any(np.isfinite(delta_P_injector_O_psi)):
393 result_data["delta_P_injector_O_psi"] = delta_P_injector_O_psi
394 if delta_P_injector_F_psi and any(np.isfinite(delta_P_injector_F_psi)):
395 result_data["delta_P_injector_F_psi"] = delta_P_injector_F_psi
396
397 if Lstar_mm and any(np.isfinite(Lstar_mm)):
398 result_data["Lstar_mm"] = Lstar_mm
399
400 if recession_rate_ablative_um_s and any(np.isfinite(recession_rate_ablative_um_s)):
401 result_data["recession_rate_ablative_um_s"] = recession_rate_ablative_um_s
402 if recession_rate_graphite_thermal_um_s and any(np.isfinite(recession_rate_graphite_thermal_um_s)):
403 result_data["recession_rate_graphite_thermal_um_s"] = recession_rate_graphite_thermal_um_s
404 if recession_rate_graphite_oxidation_um_s and any(np.isfinite(recession_rate_graphite_oxidation_um_s)):
405 result_data["recession_rate_graphite_oxidation_um_s"] = recession_rate_graphite_oxidation_um_s
406
407 if recession_cumulative_ablative_mm and any(np.isfinite(recession_cumulative_ablative_mm)):
408 result_data["recession_cumulative_ablative_mm"] = recession_cumulative_ablative_mm
409 if recession_cumulative_graphite_thermal_mm and any(np.isfinite(recession_cumulative_graphite_thermal_mm)):
410 result_data["recession_cumulative_graphite_thermal_mm"] = recession_cumulative_graphite_thermal_mm
411 if recession_cumulative_graphite_oxidation_mm and any(np.isfinite(recession_cumulative_graphite_oxidation_mm)):
412 result_data["recession_cumulative_graphite_oxidation_mm"] = recession_cumulative_graphite_oxidation_mm
413
414 if recession_cumulative_chamber_um and any(np.isfinite(recession_cumulative_chamber_um)):
415 result_data["recession_cumulative_chamber_um"] = recession_cumulative_chamber_um
416 if recession_cumulative_throat_um and any(np.isfinite(recession_cumulative_throat_um)):
417 result_data["recession_cumulative_throat_um"] = recession_cumulative_throat_um
418
419 if V_chamber_m3 and any(np.isfinite(V_chamber_m3)) and len(V_chamber_m3) > 0:
420 result_data["V_chamber_m3"] = V_chamber_m3
421 result_data["V_chamber_initial_m3"] = float(V_chamber_m3[0])
422 if A_throat_m2 and any(np.isfinite(A_throat_m2)) and len(A_throat_m2) > 0:
423 result_data["A_throat_m2"] = A_throat_m2
424 result_data["A_throat_initial_m2"] = float(A_throat_m2[0])
425
426 # Add Heat Flux Profiles (only if data exists)
427 # Regen heat flux profiles
428 if axial_positions:
429 result_data["axial_positions_m"] = axial_positions
430 result_data["heat_flux_profiles_w_m2"] = heat_flux_profiles
431 result_data["wall_temp_profiles_k"] = wall_temp_profiles
432
433 # Ablative heat flux profiles (separate from regen)
434 # Debug logging
435 import logging
436 logger = logging.getLogger(__name__)
437 logger.info(f"[TIMESERIES] ablative_axial_positions: {ablative_axial_positions is not None}, length={len(ablative_axial_positions) if ablative_axial_positions else 0}")
438 logger.info(f"[TIMESERIES] ablative_q_incident_profiles length: {len(ablative_q_incident_profiles)}")
439 if ablative_q_incident_profiles and len(ablative_q_incident_profiles) > 0:
440 non_empty = sum(1 for p in ablative_q_incident_profiles if p and len(p) > 0)
441 logger.info(f"[TIMESERIES] ablative_q_incident_profiles non-empty profiles: {non_empty}/{len(ablative_q_incident_profiles)}")
442
443 if ablative_axial_positions:
444 result_data["ablative_axial_positions_m"] = ablative_axial_positions
445 result_data["ablative_q_incident_profiles_w_m2"] = ablative_q_incident_profiles
446 result_data["ablative_q_conv_profiles_w_m2"] = ablative_q_conv_profiles
447 result_data["ablative_q_rad_profiles_w_m2"] = ablative_q_rad_profiles
448 result_data["ablative_q_net_profiles_w_m2"] = ablative_q_net_profiles
449 result_data["ablative_throat_index"] = ablative_throat_index
450 logger.info(f"[TIMESERIES] Added ablative heat flux data to result_data")
451 else:
452 logger.info(f"[TIMESERIES] NO ablative_axial_positions - skipping ablative data")
453
454 # Calculate summary statistics
455 thrust_arr = np.asarray(results["F"], dtype=float) / 1000.0
456 Pc_arr = np.asarray(results["Pc"], dtype=float) * PA_TO_PSI
457 Isp_arr = np.asarray(results["Isp"], dtype=float)
458 mdot_arr = np.asarray(results["mdot_total"], dtype=float)
459
460 summary = {
461 "avg_thrust_kN": float(np.nanmean(thrust_arr)),
462 "peak_thrust_kN": float(np.nanmax(thrust_arr)),
463 "min_thrust_kN": float(np.nanmin(thrust_arr)),
464 "avg_Pc_psi": float(np.nanmean(Pc_arr)),
465 "peak_Pc_psi": float(np.nanmax(Pc_arr)),
466 "avg_Isp_s": float(np.nanmean(Isp_arr)),
467 "total_impulse_kNs": float(np.trapezoid(thrust_arr, times) if hasattr(np, "trapezoid") else np.trapz(thrust_arr, times)),
468 "total_propellant_kg": float(np.trapezoid(mdot_arr, times) if hasattr(np, "trapezoid") else np.trapz(mdot_arr, times)),
469 "burn_time_s": float(times[-1] - times[0]) if len(times) > 1 else 0.0,
470 }
471
472 # CEA table: points solved on thermochemistry read off the table's clamped edge (a blowdown
473 # below the table's Pc floor, an O/F past its edge). The cache clamps without a word; the
474 # summary counts them and says where, and data["cea_clamped"] marks each one.
475 from engine.pipeline.forward_report import cea_off_table
476
477 cea_clamped = [bool(isinstance(dg, dict) and dg.get("cea_clamped")) for dg in diagnostics_list]
478 if len(cea_clamped) == n_points and mask is not None:
479 cea_clamped = [c and not bool(m) for c, m in zip(cea_clamped, mask)]
480 if any(cea_clamped):
481 first = cea_clamped.index(True)
482 dg = diagnostics_list[first]
483 at = {"Pc": np.asarray(results["Pc"], dtype=float)[first], "MR": np.asarray(results["MR"], dtype=float)[first]}
484 result_data["cea_clamped"] = cea_clamped
485 summary["cea_clamped_points"] = int(sum(cea_clamped))
486 summary["cea_clamped_axes"] = sorted(
487 {a for c, d in zip(cea_clamped, diagnostics_list) if c for a in (d.get("cea_clamped") or ())}
488 )
489 summary["cea_clamped_note"] = (
490 f"{sum(cea_clamped)} of {n_points} points read CEA off the table edge; "
491 f"first at t = {float(np.asarray(times)[first]):.2f} s: {cea_off_table(dg, at)}"
492 )
493
494 # =========================================================================
495 # Propellant Mass Remaining (Tank Fill Levels)
496 # =========================================================================
497 # Calculate total propellant consumed per branch
498 mdot_O_arr = np.asarray(results["mdot_O"], dtype=float)
499 mdot_F_arr = np.asarray(results["mdot_F"], dtype=float)
500 if hasattr(np, "trapezoid"):
501 total_lox_kg = float(np.trapezoid(mdot_O_arr, times))
502 total_fuel_kg = float(np.trapezoid(mdot_F_arr, times))
503 else:
504 total_lox_kg = float(np.trapz(mdot_O_arr, times))
505 total_fuel_kg = float(np.trapz(mdot_F_arr, times))
506
507 summary["lox_propellant_kg"] = total_lox_kg
508 summary["fuel_propellant_kg"] = total_fuel_kg
509
510 # Tank fill levels. Blowdown mode hands in the mass history it integrated. Otherwise the levels
511 # start from the configured load (lox_tank.mass / fuel_tank.mass: the fire load, fixed by the
512 # competition) and fall by the integrated flow. They used to start from whatever the curve
513 # consumes, so every run ended at exactly 0 -- a burn needing 7.55 kg of LOX drew a full
514 # 7.55 kg tank where 6.61 kg is loaded, and nothing said it runs dry at 3.50 s.
515 # The curves themselves are left as run: this tab solves the pressure curve it is given; the
516 # Flight tab truncates the burn at the dry-out. The summary says where that is.
517 times_arr = np.asarray(times, dtype=float)
518 short_notes = []
519 for side, tank_attr, label, given, mdot, total in (
520 ("lox", "lox_tank", "LOX", lox_mass_kg, mdot_O_arr, total_lox_kg),
521 ("fuel", "fuel_tank", "Fuel", fuel_mass_kg, mdot_F_arr, total_fuel_kg),
522 ):
523 if given is not None and len(given) == len(times):
524 result_data[f"{side}_mass_remaining_kg"] = to_float_array(given).tolist()
525 continue
526 mdot_clean = np.nan_to_num(np.asarray(mdot, dtype=float), nan=0.0)
527 cumulative = np.zeros(len(times_arr))
528 for i in range(1, len(times_arr)):
529 dt = times_arr[i] - times_arr[i - 1]
530 cumulative[i] = cumulative[i - 1] + 0.5 * (mdot_clean[i - 1] + mdot_clean[i]) * dt
531 loaded = _loaded_mass(runner.config, tank_attr)
532 if loaded is None:
533 # No load in the config: the old view, the curve's own consumption.
534 result_data[f"{side}_mass_remaining_kg"] = (total - cumulative).tolist()
535 continue
536 result_data[f"{side}_mass_remaining_kg"] = np.maximum(loaded - cumulative, 0.0).tolist()
537 summary[f"{side}_loaded_kg"] = loaded
538 if cumulative[-1] > loaded:
539 t_dry = float(np.interp(loaded, cumulative, times_arr))
540 summary[f"{side}_short_kg"] = float(cumulative[-1] - loaded)
541 summary[f"{side}_depletion_time_s"] = t_dry
542 short_notes.append(
543 f"{label} runs dry at t = {t_dry:.2f} s: the curve needs {cumulative[-1]:.2f} kg, "
544 f"{loaded:.2f} kg is loaded"
545 )
546 if short_notes:
547 summary["propellant_short_note"] = "; ".join(short_notes)
548
549 # =========================================================================
550 # COPV Sizing Analysis - Always run if press_tank config is available
551 # AND run_copv is enabled
552 # =========================================================================
553 copv_results = None
554 if run_copv:
555 copv_results = _run_copv_analysis(
556 runner.config,
557 times,
558 result_data["mdot_O_kg_s"],
559 result_data["mdot_F_kg_s"],
560 P_tank_O_psi,
561 P_tank_F_psi,
562 )
563
564 if copv_results:
565 # Add COPV pressure trace to result data
566 result_data["copv_pressure_psi"] = copv_results["copv_pressure_psi"]
567
568 # Add COPV summary metrics
569 summary["copv_initial_pressure_psi"] = copv_results["copv_initial_pressure_psi"]
570 summary["copv_initial_mass_kg"] = copv_results["copv_initial_mass_kg"]
571 summary["copv_min_margin_psi"] = copv_results["copv_min_margin_psi"]
572 summary["copv_volume_L"] = copv_results["copv_volume_L"]
573 summary["copv_mode"] = copv_results["copv_mode"]
574 summary["copv_required_pressure_psi"] = copv_results["copv_required_pressure_psi"]
575 summary["copv_required_mass_kg"] = copv_results["copv_required_mass_kg"]
576 # What the margin is graded against: the regulator's minimum differential, when stated.
577 need = getattr(getattr(runner.config, "design_requirements", None), "regulator_min_differential_psi", None)
578 summary["copv_margin_needed_psi"] = float(need) if need is not None else None
579
580 # =========================================================================
581 # Correlation Matrix - Compute correlations between key variables
582 # =========================================================================
583 correlation_data = _compute_correlation_matrix(result_data)
584 if correlation_data:
585 result_data["correlation_matrix"] = correlation_data["matrix"]
586 result_data["correlation_labels"] = correlation_data["labels"]
587
588 return result_data, summary
589
590
591
592
593
594def _loaded_mass(config, tank_attr: str) -> Optional[float]:
595 """The configured propellant load of ``lox_tank`` / ``fuel_tank`` [kg], or None if unset."""
596 tank = getattr(config, tank_attr, None)
597 m = getattr(tank, "mass", None) if tank is not None else None
598 try:
599 m = float(m)
600 except (TypeError, ValueError):
601 return None
602 return m if math.isfinite(m) and m > 0 else None
603
604
605def _compute_correlation_matrix(result_data: dict) -> Optional[dict]:
606 """Compute correlation matrix for key time-series variables.
607
608 Returns dict with 'matrix' (2D list) and 'labels' (list of variable names).
609 """
610 # Define variables to include in correlation analysis
611 # Map from result_data keys to display labels
612 variable_map = {
613 "P_tank_O_psi": "LOX Tank P",
614 "P_tank_F_psi": "Fuel Tank P",
615 "Pc_psi": "Chamber P",
616 "thrust_kN": "Thrust",
617 "Isp_s": "Isp",
618 "MR": "O/F Ratio",
619 "mdot_O_kg_s": "LOX mdot",
620 "mdot_F_kg_s": "Fuel mdot",
621 "mdot_total_kg_s": "Total mdot",
622 "cstar_actual_m_s": "c*",
623 "gamma": "Gamma",
624 "copv_pressure_psi": "COPV P",
625 }
626
627 # Build dataframe with available variables
628 df_data = {}
629 labels = []
630
631 for key, label in variable_map.items():
632 if key in result_data and result_data[key] is not None:
633 arr = np.asarray(result_data[key], dtype=float)
634 if len(arr) > 0 and np.any(np.isfinite(arr)):
635 df_data[label] = arr
636 labels.append(label)
637
638 if len(labels) < 2:
639 return None
640
641 # Create dataframe and compute correlation
642 df = pd.DataFrame(df_data)
643 corr = df.corr().fillna(0.0)
644
645 # Convert to list format for JSON serialization
646 matrix = corr.values.tolist()
647
648 return {
649 "matrix": matrix,
650 "labels": labels,
651 }
652
653
655 config,
656 times: np.ndarray,
657 mdot_O_kg_s: list,
658 mdot_F_kg_s: list,
659 P_tank_O_psi: np.ndarray,
660 P_tank_F_psi: np.ndarray,
661) -> Optional[dict]:
662 """Run COPV blowdown analysis using the polytropic N2 solver.
663
664 Uses hardcoded defaults per requirements:
665 - n = 1.2 (polytropic exponent)
666 - T0_K = 300 (initial COPV temp)
667 - R = 296.8 (gas constant for N2)
668 - Oxidizer gas temp: 250K
669 - Fuel gas temp: 293K
670 - use_real_gas = True (use Z lookup table)
671
672 Returns dict with copv_pressure_psi trace and summary metrics, or None if unavailable.
673 """
674 # Check if press_tank config is available
675 press_tank = getattr(config, 'press_tank', None)
676 if press_tank is None:
677 return None
678
679 # Get COPV free volume from config (default 4.5L)
680 free_volume_L = getattr(press_tank, 'free_volume_L', None) or 4.5
681 copv_volume_m3 = free_volume_L / 1000.0 # Convert L to m³
682
683 # Build dataframe for COPV solver
684 df = pd.DataFrame({
685 "time": np.asarray(times),
686 "mdot_O (kg/s)": np.asarray(mdot_O_kg_s),
687 "mdot_F (kg/s)": np.asarray(mdot_F_kg_s),
688 "P_tank_O (psi)": np.asarray(P_tank_O_psi),
689 "P_tank_F (psi)": np.asarray(P_tank_F_psi),
690 })
691
692 # The charge actually loaded, when the config says (press_tank.initial_gas_mass, or the legacy
693 # press_tank.mass). With one, the panel checks THAT charge -- its P0, its trace, its margin --
694 # and shows the sized requirement beside it. Without, it can only size: the P0 that just meets
695 # the curve, i.e. a margin of zero by construction, which used to be shown as "P0" with a green
696 # 0.1 psi margin on the LE4 (3) stand whose 1.312 kg charge is ~3,800 psia.
697 m0_cfg = getattr(press_tank, "initial_gas_mass", None) or getattr(press_tank, "mass", None)
698 solver_args = dict(
699 df=df,
700 config=config,
701 n=1.2, # polytropic exponent
702 T0_K=300.0, # initial COPV temperature
703 Tp_K=293.0, # default propellant gas temp (overridden by branch temps)
704 use_real_gas=True, # use Z lookup table
705 n2_Z_csv=N2_Z_LOOKUP_CSV,
706 pressurant_R=296.8, # gas constant for N2
707 branch_temperatures_K={
708 "oxidizer": 250.0, # oxidizer gas temp
709 "fuel": 293.0, # fuel gas temp
710 },
711 copv_volume_m3=copv_volume_m3,
712 )
713
714 try:
715 # The requirement: the P0 (and charge) that just meets the curve in this volume.
716 sized = size_or_check_copv_for_polytropic_N2(**solver_args)
717 copv_result = sized
718 if m0_cfg:
719 copv_result = size_or_check_copv_for_polytropic_N2(**solver_args, copv_m0_kg=float(m0_cfg))
720
721 # Extract COPV pressure trace (Pa -> psi)
722 PH_trace_Pa = np.asarray(copv_result.get("PH_trace_Pa", []))
723 if len(PH_trace_Pa) == 0:
724 return None
725
726 copv_pressure_psi = (PH_trace_Pa * PA_TO_PSI).tolist()
727
728 return {
729 "copv_pressure_psi": copv_pressure_psi,
730 # "configured": the loaded charge, checked; "sized": no charge configured, so the
731 # numbers are the requirement and the margin is zero by construction.
732 "copv_mode": "configured" if m0_cfg else "sized",
733 "copv_initial_pressure_psi": float(copv_result.get("P0_Pa", 0) * PA_TO_PSI),
734 "copv_initial_mass_kg": float(copv_result.get("m0_kg", 0)),
735 "copv_min_margin_psi": float(copv_result.get("min_margin_Pa", 0) * PA_TO_PSI),
736 "copv_required_pressure_psi": float(sized.get("P0_Pa", 0) * PA_TO_PSI),
737 "copv_required_mass_kg": float(sized.get("m0_kg", 0)),
738 "copv_volume_L": free_volume_L,
739 }
740
741 except Exception as e:
742 # Log error but don't fail the whole request
743 import logging
744 logging.warning(f"COPV analysis failed: {e}")
745 return None
746
747
748# Request models for simple profile generation
749class ProfileParams(BaseModel):
750 """Parameters for a single propellant profile."""
751 start_pressure_psi: float = Field(..., gt=0, description="Start pressure in psi")
752 end_pressure_psi: float = Field(..., gt=0, description="End pressure in psi")
753 profile_type: Literal["linear", "exponential", "power"] = Field(default="linear")
754 decay_constant: Optional[float] = Field(default=3.0, ge=0.1, le=10.0)
755 power: Optional[float] = Field(default=2.0, ge=0.1, le=5.0)
756
757
758class GenerateProfileRequest(BaseModel):
759 """Request body for simple profile generation."""
760 duration_s: float = Field(..., gt=0, le=600, description="Burn duration in seconds")
761 n_steps: int = Field(default=101, ge=2, le=2000, description="Number of time steps")
762 lox_profile: ProfileParams
763 fuel_profile: ProfileParams
764
765
766class GenerateProfileResponse(BaseModel):
767 """Response for profile generation."""
768 status: str
769 data: dict
770 summary: dict
771 #: Set when the request replaced the session config (a YAML to /from-csv): the new token,
772 #: which the uploading tab adopts as its own load.
773 load_token: Optional[str] = None
774
775
776@router.post("/generate", response_model=GenerateProfileResponse)
777async def generate_timeseries(request: GenerateProfileRequest, session: UserSession = Depends(get_session)):
778 """Generate time-series results from simple pressure profiles.
779
780 Supports linear, exponential, and power profile types.
781
782 The run goes to the thread pool (``run_compute``): the Time-Series tab runs every design it
783 opens, ~10 s here and ~23 s on a CI runner, and on the event loop that froze /api/health and
784 every other request for as long.
785 """
786 return await run_compute(session, _generate_timeseries, request, session)
787
788
789def _generate_timeseries(request: GenerateProfileRequest, session: UserSession) -> GenerateProfileResponse:
790 # The runner is taken once: off the event loop a PUT /api/config can land mid-run.
791 _, runner = session.app_state.snapshot()
792 if runner is None:
793 raise HTTPException(
794 status_code=400,
795 detail="No config loaded. Upload a config file first."
796 )
797
798 try:
799 # Generate LOX profile
800 lox_params = {}
801 if request.lox_profile.profile_type == "exponential":
802 lox_params["decay_constant"] = request.lox_profile.decay_constant
803 elif request.lox_profile.profile_type == "power":
804 lox_params["power"] = request.lox_profile.power
805
806 times, lox_pressures = generate_pressure_profile(
807 request.lox_profile.profile_type,
808 request.lox_profile.start_pressure_psi,
809 request.lox_profile.end_pressure_psi,
810 request.duration_s,
811 request.n_steps,
812 **lox_params,
813 )
814
815 # Generate Fuel profile
816 fuel_params = {}
817 if request.fuel_profile.profile_type == "exponential":
818 fuel_params["decay_constant"] = request.fuel_profile.decay_constant
819 elif request.fuel_profile.profile_type == "power":
820 fuel_params["power"] = request.fuel_profile.power
821
822 _, fuel_pressures = generate_pressure_profile(
823 request.fuel_profile.profile_type,
824 request.fuel_profile.start_pressure_psi,
825 request.fuel_profile.end_pressure_psi,
826 request.duration_s,
827 request.n_steps,
828 **fuel_params,
829 )
830
831 # Compute time-series results
832 data, summary = compute_timeseries_results(
833 runner,
834 times,
835 lox_pressures,
836 fuel_pressures,
837 )
838
840 status="success",
841 data=convert_numpy(data),
842 summary=convert_numpy(summary),
843 )
844
845 except Exception as e:
846 raise HTTPException(
847 status_code=500,
848 detail=f"Time-series evaluation failed: {str(e)}"
849 )
850
851
852# Request models for segment-based profiles
853class PressureSegment(BaseModel):
854 """A single segment of a pressure curve."""
855 length_ratio: float = Field(..., ge=0.01, le=1.0, description="Fraction of total time")
856 type: Literal["blowdown", "linear"] = Field(default="blowdown")
857 start_pressure_psi: float = Field(..., gt=0, description="Start pressure in psi")
858 end_pressure_psi: float = Field(..., gt=0, description="End pressure in psi")
859 k: float = Field(default=0.5, ge=0.1, le=3.0, description="Blowdown decay constant")
860
861
862class SegmentsRequest(BaseModel):
863 """Request body for segment-based profile generation."""
864 duration_s: float = Field(..., gt=0, le=600, description="Burn duration in seconds")
865 n_points: int = Field(default=200, ge=10, le=2000, description="Number of time points")
866 lox_segments: List[PressureSegment] = Field(default=[], max_length=20)
867 fuel_segments: List[PressureSegment] = Field(default=[], max_length=20)
868
869 # Blowdown mode parameters
870 blowdown_mode: bool = Field(default=False, description="Enable pure blowdown (no COPV regulation)")
871 lox_initial_pressure_psi: Optional[float] = Field(default=None, gt=0, description="Initial LOX tank pressure (psi), required for blowdown mode")
872 fuel_initial_pressure_psi: Optional[float] = Field(default=None, gt=0, description="Initial fuel tank pressure (psi), required for blowdown mode")
873 total_propellant_kg: Optional[float] = Field(default=None, gt=0, description="Total propellant mass [kg]; split by of_ratio")
874 of_ratio: Optional[float] = Field(default=None, gt=0, description="O/F for propellant split (defaults to design_requirements.optimal_of_ratio)")
875 lox_tank_volume_m3: Optional[float] = Field(default=None, gt=0, description="LOX tank total volume [m³]")
876 lox_ullage_volume_m3: Optional[float] = Field(default=None, gt=0, description="LOX ullage volume at start [m³]")
877 fuel_tank_volume_m3: Optional[float] = Field(default=None, gt=0, description="Fuel tank total volume [m³]")
878 fuel_ullage_volume_m3: Optional[float] = Field(default=None, gt=0, description="Fuel ullage volume at start [m³]")
879
880
881class SegmentsResponse(BaseModel):
882 """Response for segment-based profile generation."""
883 status: str
884 data: dict
885 summary: dict
886 lox_curve_preview: List[float]
887 fuel_curve_preview: List[float]
888
889
890def segments_to_dict_list(segments: List[PressureSegment]) -> List[dict]:
891 """Convert Pydantic segments to dict list for curve generation."""
892 result = []
893 prev_end = None
894
895 for i, seg in enumerate(segments):
896 # First segment uses its start pressure; subsequent segments chain from previous end
897 if i == 0:
898 start_p = seg.start_pressure_psi * PSI_TO_PA
899 else:
900 start_p = prev_end if prev_end else seg.start_pressure_psi * PSI_TO_PA
901
902 end_p = seg.end_pressure_psi * PSI_TO_PA
903
904 # A segment may rise or fall as typed: blowdown, a regulated stand's slow climb, a
905 # pressurant schedule -- the curve is the user's, not a feed-system model's.
906 result.append({
907 "length_ratio": seg.length_ratio,
908 "type": seg.type,
909 "start_pressure": start_p,
910 "end_pressure": end_p,
911 "k": seg.k,
912 })
913 prev_end = end_p
914
915 return result
916
917
918@router.post("/from-segments", response_model=SegmentsResponse)
919async def generate_from_segments(request: SegmentsRequest, session: UserSession = Depends(get_session)):
920 """Generate time-series results from segment-based pressure curves.
921
922 Two modes:
923 1. Regulated mode (default): Uses segments to define pressure curves, COPV analysis enabled
924 2. Blowdown mode: Simulates pure tank blowdown from initial conditions, ignores segments
925
926 Each segment specifies:
927 - length_ratio: fraction of total time (0-1)
928 - type: 'blowdown' or 'linear'
929 - start_pressure_psi: start pressure (auto-chained from previous segment)
930 - end_pressure_psi: end pressure
931 - k: blowdown decay constant (0.1-3.0)
932
933 The blowdown formula: P(t) = P_end + (P_start - P_end) * exp(-k * t_norm)
934
935 Off the event loop, like /generate: a design whose Time-Series tab is set to segments runs
936 this on open.
937 """
938 return await run_compute(session, _generate_from_segments, request, session)
939
940
941def _generate_from_segments(request: SegmentsRequest, session: UserSession) -> SegmentsResponse:
942 # One runner for the whole run. Off the event loop a PUT /api/config can land mid-run, and
943 # the blowdown loop below calls the engine at every step: read live, half a burn would be
944 # one design and half another.
945 _, runner = session.app_state.snapshot()
946 if runner is None:
947 raise HTTPException(
948 status_code=400,
949 detail="No config loaded. Upload a config file first."
950 )
951
952 # Validate blowdown mode parameters
953 if request.blowdown_mode:
954 if request.lox_initial_pressure_psi is None:
955 raise HTTPException(
956 status_code=400,
957 detail="lox_initial_pressure_psi is required when blowdown_mode=true"
958 )
959 if request.fuel_initial_pressure_psi is None:
960 raise HTTPException(
961 status_code=400,
962 detail="fuel_initial_pressure_psi is required when blowdown_mode=true"
963 )
964 else:
965 # Validate regulated mode parameters
966 if not request.lox_segments or not request.fuel_segments:
967 raise HTTPException(
968 status_code=400,
969 detail="Segments are required when blowdown_mode=false"
970 )
971
972 try:
973 # Generate time array
974 times = np.linspace(0, request.duration_s, request.n_points)
975
976 if request.blowdown_mode:
977 # ===== BLOWDOWN MODE =====
978 from copv.blowdown_solver import simulate_coupled_blowdown, resolve_blowdown_tank_state
979
980 try:
981 V_lox, m_lox_init, _, V_fuel, m_fuel_init, _ = resolve_blowdown_tank_state(
982 runner.config,
983 total_propellant_kg=request.total_propellant_kg,
984 of_ratio=request.of_ratio,
985 lox_tank_volume_m3=request.lox_tank_volume_m3,
986 lox_ullage_volume_m3=request.lox_ullage_volume_m3,
987 fuel_tank_volume_m3=request.fuel_tank_volume_m3,
988 fuel_ullage_volume_m3=request.fuel_ullage_volume_m3,
989 )
990 except ValueError as exc:
991 raise HTTPException(status_code=400, detail=str(exc)) from exc
992
993 # Import coupled blowdown solver
994
995 # Define engine callback for coupled solver
996 def engine_evaluator(P_lox_Pa: float, P_fuel_Pa: float):
997 # Run single-point evaluation
998 # Note: evaluate returns dict with mdot_O and mdot_F (kg/s)
999 try:
1000 res = runner.evaluate(
1001 P_tank_O=P_lox_Pa,
1002 P_tank_F=P_fuel_Pa,
1003 silent=True
1004 )
1005 return res["mdot_O"], res["mdot_F"]
1006 except Exception:
1007 # If evaluation fails (e.g. pressure too low for CEA), return 0 flow
1008 return 0.0, 0.0
1009
1010 # Run coupled simulation
1011 blowdown_results = simulate_coupled_blowdown(
1012 times=times,
1013 evaluate_engine_fn=engine_evaluator,
1014 P_lox_initial_Pa=request.lox_initial_pressure_psi * PSI_TO_PA,
1015 P_fuel_initial_Pa=request.fuel_initial_pressure_psi * PSI_TO_PA,
1016 config=runner.config,
1017 R_pressurant=296.803, # N2
1018 T_lox_gas_K=250.0,
1019 T_fuel_gas_K=293.0,
1020 n_polytropic=1.2,
1021 use_real_gas=True,
1022 n2_Z_csv=N2_Z_LOOKUP_CSV,
1023 total_propellant_kg=request.total_propellant_kg,
1024 of_ratio=request.of_ratio,
1025 lox_tank_volume_m3=request.lox_tank_volume_m3,
1026 lox_ullage_volume_m3=request.lox_ullage_volume_m3,
1027 fuel_tank_volume_m3=request.fuel_tank_volume_m3,
1028 fuel_ullage_volume_m3=request.fuel_ullage_volume_m3,
1029 )
1030
1031 # Extract Actual Blowdown Pressures
1032 lox_curve_pa = blowdown_results["lox"]["P_Pa"]
1033 fuel_curve_pa = blowdown_results["fuel"]["P_Pa"]
1034 lox_curve_psi = lox_curve_pa * PA_TO_PSI
1035 fuel_curve_psi = fuel_curve_pa * PA_TO_PSI
1036
1037 # Extract Propellant Mass History (for flameout masking)
1038 lox_mass_kg = blowdown_results["lox"]["m_prop_kg"]
1039 fuel_mass_kg = blowdown_results["fuel"]["m_prop_kg"]
1040
1041 # Run final evaluation with actual blowdown pressures
1042 data, summary = compute_timeseries_results(
1043 runner,
1044 times,
1045 lox_curve_psi,
1046 fuel_curve_psi,
1047 run_copv=False, # Skip COPV analysis for efficiency
1048 lox_mass_kg=lox_mass_kg,
1049 fuel_mass_kg=fuel_mass_kg,
1050 )
1051
1052 summary["blowdown_initial_lox_mass_kg"] = float(m_lox_init)
1053 summary["blowdown_initial_fuel_mass_kg"] = float(m_fuel_init)
1054 summary["blowdown_lox_tank_volume_m3"] = float(V_lox)
1055 summary["blowdown_fuel_tank_volume_m3"] = float(V_fuel)
1056 summary["blowdown_initial_lox_ullage_m3"] = float(V_lox - m_lox_init / float(runner.config.fluids["oxidizer"].density))
1057 summary["blowdown_initial_fuel_ullage_m3"] = float(V_fuel - m_fuel_init / float(runner.config.fluids["fuel"].density))
1058
1059 # (COPV analysis skipped via flag, so no cleanup needed)
1060
1061 else:
1062 # ===== REGULATED MODE (original behavior) =====
1063 # Convert segments to dict format
1064 lox_seg_dicts = segments_to_dict_list(request.lox_segments)
1065 fuel_seg_dicts = segments_to_dict_list(request.fuel_segments)
1066
1067 # Generate pressure curves from segments (Pa)
1068 lox_curve_pa = generate_pressure_curve_from_segments(
1069 lox_seg_dicts,
1070 n_points=request.n_points, allow_rise=True, exact_ends=True,
1071 )
1072 fuel_curve_pa = generate_pressure_curve_from_segments(
1073 fuel_seg_dicts,
1074 n_points=request.n_points, allow_rise=True, exact_ends=True,
1075 )
1076
1077 # Convert to psi for the API
1078 lox_curve_psi = lox_curve_pa * PA_TO_PSI
1079 fuel_curve_psi = fuel_curve_pa * PA_TO_PSI
1080
1081 # Compute time-series results (includes COPV analysis)
1082 data, summary = compute_timeseries_results(
1083 runner,
1084 times,
1085 lox_curve_psi,
1086 fuel_curve_psi,
1087 )
1088
1089 return SegmentsResponse(
1090 status="success",
1091 data=convert_numpy(data),
1092 summary=convert_numpy(summary),
1093 lox_curve_preview=lox_curve_psi.tolist(),
1094 fuel_curve_preview=fuel_curve_psi.tolist(),
1095 )
1096
1097 except Exception as e:
1098 raise HTTPException(
1099 status_code=500,
1100 detail=f"Segment-based evaluation failed: {str(e)}"
1101 )
1102
1103
1104# Preview endpoint for real-time curve visualization
1105class PreviewSegmentsRequest(BaseModel):
1106 """Request for curve preview without full evaluation."""
1107 n_points: int = Field(default=100, ge=10, le=500)
1108 segments: List[PressureSegment] = Field(..., min_length=1, max_length=20)
1109
1110
1111class PreviewResponse(BaseModel):
1112 """Response with curve preview data."""
1113 curve_psi: List[float]
1114 normalized_time: List[float]
1115
1116
1117@router.post("/preview-curve", response_model=PreviewResponse)
1118async def preview_curve(request: PreviewSegmentsRequest):
1119 """Preview a pressure curve from segments without running full evaluation.
1120
1121 Useful for real-time curve visualization in the UI.
1122 """
1123 try:
1124 seg_dicts = segments_to_dict_list(request.segments)
1125 curve_pa = generate_pressure_curve_from_segments(
1126 seg_dicts,
1127 n_points=request.n_points, allow_rise=True, exact_ends=True,
1128 )
1129 curve_psi = (curve_pa * PA_TO_PSI).tolist()
1130 normalized_time = np.linspace(0, 1, request.n_points).tolist()
1131
1132 return PreviewResponse(
1133 curve_psi=curve_psi,
1134 normalized_time=normalized_time,
1135 )
1136 except Exception as e:
1137 raise HTTPException(
1138 status_code=500,
1139 detail=f"Curve preview failed: {str(e)}"
1140 )
1141
1142
1143@router.post("/from-csv", response_model=GenerateProfileResponse)
1145 request: Request,
1146 file: UploadFile = File(...),
1147 session: UserSession = Depends(get_session),
1148):
1149 """Generate time-series results from uploaded CSV file or YAML config file.
1150
1151 CSV files must contain columns:
1152 - T (or time): time in seconds
1153 - P_O (or P_tank_O): LOX tank pressure in psi
1154 - P_F (or P_tank_F): Fuel tank pressure in psi
1155
1156 YAML config files:
1157 - Must be a valid PintleEngineConfig with a 'pressure_curves' section; one without is
1158 refused (400) and changes nothing
1159 - Replace the session config wholesale, as /api/config/upload does: behind the design
1160 checkout, off the event loop, with a new load_token (returned)
1161 - If time column is missing in CSV, it will be generated with uniform spacing.
1162
1163 The run itself goes through ``run_compute`` like /generate: on the event loop it froze
1164 every other request for its length, and outside the compute lock it shared the session's
1165 runner with whatever else was solving.
1166 """
1167 contents = await file.read()
1168 filename = file.filename or ""
1169
1170 if not filename.endswith(('.yaml', '.yml')):
1171 return await run_compute(session, _generate_from_csv_bytes, contents, session)
1172
1173 # A YAML config. Everything that can refuse it runs before anything changes: a 4xx here
1174 # used to come back after the config had already been swapped in, and the design bar then
1175 # autosaved the "failed" upload over the open design.
1176 try:
1177 config_dict = yaml.safe_load(contents.decode("utf-8"))
1178 if not isinstance(config_dict, dict):
1179 raise HTTPException(status_code=400, detail="Invalid YAML: the file is not a config mapping")
1180 config = PintleEngineConfig(**config_dict)
1181 except HTTPException:
1182 raise
1183 except (yaml.YAMLError, UnicodeDecodeError) as e:
1184 raise HTTPException(status_code=400, detail=f"Invalid YAML: {str(e)}")
1185 except ValidationError as e:
1186 raise HTTPException(status_code=422, detail=f"Config validation error: {str(e)}")
1187
1188 if getattr(config, "pressure_curves", None) is None:
1189 raise HTTPException(
1190 status_code=400,
1191 detail="Config file does not contain a 'pressure_curves' section. Please upload a config with pressure_curves or use a CSV file."
1192 )
1193
1194 try:
1195 times, lox_curve_psi, fuel_curve_psi = _curves_from_config(config)
1196 except Exception as e:
1197 raise HTTPException(status_code=500, detail=f"Failed to process config file: {str(e)}")
1198
1199 # The swap is a write to the live design: the same checkout /api/config/upload asks for.
1200 require_design_checkout(request)
1201 load_token = await replace_config(session, config)
1202
1203 def run() -> GenerateProfileResponse:
1204 _, runner = session.app_state.snapshot()
1205 try:
1206 data, summary = compute_timeseries_results(runner, times, lox_curve_psi, fuel_curve_psi)
1207 except Exception as e:
1208 raise HTTPException(status_code=500, detail=f"Failed to process config file: {str(e)}")
1210 status="success",
1211 data=convert_numpy(data),
1212 summary=convert_numpy(summary),
1213 load_token=load_token,
1214 )
1215
1216 return await run_compute(session, run)
1217
1218
1219def _curves_from_config(config: PintleEngineConfig):
1220 """(times, LOX psi, fuel psi) from a config's Layer 2 ``pressure_curves`` section."""
1221 pressure_curves = config.pressure_curves
1222 n_points = pressure_curves.n_points
1223
1224 def segments(segs):
1225 return [
1226 {
1227 "length_ratio": seg.length_ratio,
1228 "type": seg.type,
1229 "start_pressure": seg.start_pressure_pa,
1230 "end_pressure": seg.end_pressure_pa,
1231 "k": seg.k or 0.5,
1232 }
1233 for seg in segs
1234 ]
1235
1236 lox_curve_pa = generate_pressure_curve_from_segments(segments(pressure_curves.lox_segments), n_points=n_points)
1237 fuel_curve_pa = generate_pressure_curve_from_segments(segments(pressure_curves.fuel_segments), n_points=n_points)
1238 times = np.linspace(0, pressure_curves.target_burn_time_s, n_points)
1239 return times, lox_curve_pa * PA_TO_PSI, fuel_curve_pa * PA_TO_PSI
1240
1241
1242def _generate_from_csv_bytes(contents: bytes, session: UserSession) -> GenerateProfileResponse:
1243 """A CSV of tank pressures run on the session's design (in the thread pool, under the lock)."""
1244 # The runner is taken once: off the event loop a PUT /api/config can land mid-run.
1245 _, runner = session.app_state.snapshot()
1246 if runner is None:
1247 raise HTTPException(
1248 status_code=400,
1249 detail="No config loaded. Upload a config file first."
1250 )
1251
1252 try:
1253 df = pd.read_csv(io.BytesIO(contents))
1254
1255 # Normalize column names (case-insensitive, handle variations)
1256 df.columns = df.columns.str.strip()
1257 column_map = {}
1258 for col in df.columns:
1259 col_lower = col.lower()
1260 if col_lower in ['t', 'time', 'time_s', 'time (s)']:
1261 column_map[col] = 'time'
1262 elif col_lower in ['p_o', 'p_tank_o', 'p_tank_o_psi', 'lox_pressure', 'lox_pressure_psi']:
1263 column_map[col] = 'P_O'
1264 elif col_lower in ['p_f', 'p_tank_f', 'p_tank_f_psi', 'fuel_pressure', 'fuel_pressure_psi']:
1265 column_map[col] = 'P_F'
1266
1267 # Rename columns
1268 df = df.rename(columns=column_map)
1269
1270 # Check required columns
1271 if 'P_O' not in df.columns or 'P_F' not in df.columns:
1272 raise HTTPException(
1273 status_code=400,
1274 detail="CSV must contain columns P_O (or P_tank_O) and P_F (or P_tank_F)"
1275 )
1276
1277 # Generate time column if missing
1278 if 'time' not in df.columns:
1279 # Use uniform spacing, default 0.1s
1280 n_points = len(df)
1281 df['time'] = np.linspace(0, n_points * 0.1, n_points)
1282
1283 # Sort by time
1284 df = df.sort_values('time').reset_index(drop=True)
1285
1286 # Extract arrays
1287 times = np.asarray(df['time'], dtype=float)
1288 P_tank_O_psi = np.asarray(df['P_O'], dtype=float)
1289 P_tank_F_psi = np.asarray(df['P_F'], dtype=float)
1290
1291 # Validate data
1292 if len(times) < 2:
1293 raise HTTPException(
1294 status_code=400,
1295 detail="CSV must contain at least 2 data points"
1296 )
1297
1298 if np.any(np.isnan(P_tank_O_psi)) or np.any(np.isnan(P_tank_F_psi)):
1299 raise HTTPException(
1300 status_code=400,
1301 detail="Pressure values cannot contain NaN"
1302 )
1303
1304 if np.any(P_tank_O_psi <= 0) or np.any(P_tank_F_psi <= 0):
1305 raise HTTPException(
1306 status_code=400,
1307 detail="Pressure values must be positive"
1308 )
1309
1310 # Compute time-series results
1311 data, summary = compute_timeseries_results(
1312 runner,
1313 times,
1314 P_tank_O_psi,
1315 P_tank_F_psi,
1316 )
1317
1319 status="success",
1320 data=convert_numpy(data),
1321 summary=convert_numpy(summary),
1322 )
1323
1324 except HTTPException:
1325 raise
1326 except pd.errors.EmptyDataError:
1327 raise HTTPException(
1328 status_code=400,
1329 detail="CSV file is empty"
1330 )
1331 except pd.errors.ParserError as e:
1332 raise HTTPException(
1333 status_code=400,
1334 detail=f"Failed to parse CSV: {str(e)}"
1335 )
1336 except Exception as e:
1337 raise HTTPException(
1338 status_code=500,
1339 detail=f"Time-series evaluation from CSV failed: {str(e)}"
1340 )
compute_timeseries_results(runner, np.ndarray times, np.ndarray P_tank_O_psi, np.ndarray P_tank_F_psi, bool run_copv=True, Optional[np.ndarray] lox_mass_kg=None, Optional[np.ndarray] fuel_mass_kg=None)
Definition timeseries.py:66
generate_from_segments(SegmentsRequest request, UserSession session=Depends(get_session))
List[dict] segments_to_dict_list(List[PressureSegment] segments)
GenerateProfileResponse _generate_timeseries(GenerateProfileRequest request, UserSession session)
SegmentsResponse _generate_from_segments(SegmentsRequest request, UserSession session)
generate_timeseries(GenerateProfileRequest request, UserSession session=Depends(get_session))
_curves_from_config(PintleEngineConfig config)
Optional[dict] _run_copv_analysis(config, np.ndarray times, list mdot_O_kg_s, list mdot_F_kg_s, np.ndarray P_tank_O_psi, np.ndarray P_tank_F_psi)
Optional[dict] _compute_correlation_matrix(dict result_data)
Optional[float] _loaded_mass(config, str tank_attr)
generate_from_csv(Request request, UploadFile file=File(...), UserSession session=Depends(get_session))
GenerateProfileResponse _generate_from_csv_bytes(bytes contents, UserSession session)
preview_curve(PreviewSegmentsRequest request)
Definition run.py:1