STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
geometry.py
Go to the documentation of this file.
1"""Chamber geometry visualization endpoint."""
2
3from fastapi import APIRouter, HTTPException, Depends
4from pydantic import BaseModel
5from typing import Optional
6import numpy as np
7
8from backend.session import UserSession, get_session
9from engine.pipeline.chamber_geometry_fixed import calculate_chamber_geometry_fixed
10from engine.pipeline.config_schemas import AblativeCoolingConfig, GraphiteInsertConfig
11from engine.core.nozzle_solver import rao
12from engine.core.chamber_geometry_solver import solve_chamber_geometry_with_cea
13from engine.pipeline.cea_cache import CEACache
14
16 """Convergent half-angle [rad] from design_requirements, else the 45 deg default.
17
18 The drawn contour used to be pinned at 45 deg while Layer 1 optimised against
19 ``layer1_contraction_half_angle_deg``, so the geometry tab and the DXF showed a
20 chamber that was not the one being scored.
21 """
22 import numpy as _np
23 from engine.core.chamber_geometry import theta_default as _td
24
25 req = getattr(config, "design_requirements", None)
26 raw = None
27 if req is not None:
28 raw = req.get("layer1_contraction_half_angle_deg") if isinstance(req, dict) \
29 else getattr(req, "layer1_contraction_half_angle_deg", None)
30 try:
31 if raw is not None and _np.isfinite(float(raw)) and 0.0 < float(raw) < 90.0:
32 return float(_np.deg2rad(float(raw)))
33 except (TypeError, ValueError):
34 pass
35 return float(_td)
36
37
38router = APIRouter(prefix="/api/geometry", tags=["geometry"])
39
40
41class GeometryResponse(BaseModel):
42 """Chamber geometry data for visualization."""
43 positions: list[float]
44 R_gas: list[float]
45 R_ablative_outer: list[float]
46 R_graphite_outer: list[float]
47 R_stainless: list[float]
48 throat_position: float
49 graphite_start: float
50 graphite_end: float
51 D_chamber: float
52 D_throat: float
53 D_exit: float
54 # Metadata
55 L_chamber: float
56 L_nozzle: float
57 expansion_ratio: float
58 ablative_enabled: bool
59 graphite_enabled: bool
60 # Rao nozzle contour (x, y points)
61 nozzle_x: list[float]
62 nozzle_y: list[float]
63 nozzle_method: str
64 # Chamber contour from CEA solver (x, y points)
65 chamber_contour_x: list[float]
66 chamber_contour_y: list[float]
67 # Solver info
68 Cf: Optional[float] = None
69 Cf_ideal: Optional[float] = None
70 A_throat_solved: Optional[float] = None
71 chamber_contour_method: Optional[str] = None # "solved" or "cea_iterative"
72 # Optimized thicknesses from Layer 3 (if available)
73 t_abl_opt_mm: Optional[float] = None
74 t_gra_opt_mm: Optional[float] = None
75 # Baseline (config) thicknesses from ablative_cooling / graphite_insert
76 t_abl_config_mm: Optional[float] = None
77 t_gra_config_mm: Optional[float] = None
78
79
80@router.get("", response_model=GeometryResponse)
81async def get_chamber_geometry(session: UserSession = Depends(get_session)):
82 """Get chamber geometry for visualization.
83
84 Returns the geometry arrays needed to render the chamber cross-section:
85 - Gas boundary (inner surface)
86 - Ablative liner (chamber region)
87 - Graphite insert (throat region)
88 - Stainless steel case (outer)
89 """
90 if not session.app_state.has_config():
91 raise HTTPException(
92 status_code=400,
93 detail="No config loaded. Upload a config file first."
94 )
95
96 config = session.app_state.config
97
98 try:
99 # =====================================================================
100 # Extract parameters - prefer chamber_geometry, fallback to legacy
101 # =====================================================================
102 chamber_geom = getattr(config, 'chamber_geometry', None)
103 chamber = getattr(config, 'chamber', None)
104 nozzle = getattr(config, 'nozzle', None)
105
106 # Require either chamber_geometry OR (chamber AND nozzle)
107 if chamber_geom is None and (chamber is None or nozzle is None):
108 raise HTTPException(
109 status_code=400,
110 detail="Must provide either 'chamber_geometry' section or both 'chamber' and 'nozzle' sections"
111 )
112
113 # Use chamber_geometry if available, otherwise use legacy sections
114 if chamber_geom is not None:
115 # New unified chamber_geometry section
116 D_chamber = getattr(chamber_geom, 'chamber_diameter', 0.08)
117 D_exit = getattr(chamber_geom, 'exit_diameter', 0.1)
118 expansion_ratio = getattr(chamber_geom, 'expansion_ratio', 8.0)
119 A_throat = getattr(chamber_geom, 'A_throat', None)
120 L_chamber = getattr(chamber_geom, 'length', None)
121 else:
122 # Legacy chamber/nozzle sections
123 D_chamber = getattr(chamber, 'chamber_inner_diameter', 0.08) if chamber else 0.08
124 D_exit = (getattr(chamber, 'exit_diameter', None) if chamber else None) or (getattr(nozzle, 'exit_diameter', 0.1) if nozzle else 0.1)
125 expansion_ratio = getattr(nozzle, 'expansion_ratio', 8.0) if nozzle else 8.0
126 A_throat = (getattr(chamber, 'A_throat', None) if chamber else None) or (getattr(nozzle, 'A_throat', 1e-4) if nozzle else 1e-4)
127 L_chamber = getattr(chamber, 'length', 0.15) if chamber else 0.15
128
129 # Compute derived values if not provided
130 if A_throat is None:
131 # Compute from exit area and expansion ratio
132 A_exit = np.pi * (D_exit / 2.0) ** 2
133 A_throat = A_exit / expansion_ratio
134
135 D_throat = np.sqrt(4 * A_throat / np.pi)
136 A_exit = A_throat * expansion_ratio
137
138 if L_chamber is None:
139 L_chamber = 0.15 # Default
140
141 # Estimate nozzle length from exit diameter
142 L_nozzle = getattr(nozzle, 'length', None) if nozzle else None
143 if L_nozzle is None:
144 # Estimate: ~15 degree half-angle conical nozzle
145 L_nozzle = (D_exit - D_throat) / (2 * np.tan(np.radians(15)))
146
147 # Ablative config
148 ablative_cfg: AblativeCoolingConfig | None = None
149 ablative_enabled = False
150 if hasattr(config, 'ablative_cooling') and config.ablative_cooling:
151 ablative_cfg = config.ablative_cooling
152 ablative_enabled = getattr(ablative_cfg, 'enabled', False)
153
154 # Graphite config
155 graphite_cfg: GraphiteInsertConfig | None = None
156 graphite_enabled = False
157 if hasattr(config, 'graphite_insert') and config.graphite_insert:
158 graphite_cfg = config.graphite_insert
159 graphite_enabled = getattr(graphite_cfg, 'enabled', False)
160
161 # Calculate chamber geometry (chamber + simple nozzle for layers)
162 geometry = calculate_chamber_geometry_fixed(
163 L_chamber=L_chamber,
164 D_chamber=D_chamber,
165 D_throat=D_throat,
166 L_nozzle=L_nozzle,
167 expansion_ratio=expansion_ratio,
168 ablative_config=ablative_cfg if ablative_enabled else None,
169 graphite_config=graphite_cfg if graphite_enabled else None,
170 recession_chamber=0.0, # Fresh geometry, no recession
171 recession_graphite=0.0,
172 n_points=200,
173 )
174
175 # Generate chamber contour using solved_chamber_plot or solve_chamber_geometry_with_cea
176 # Prefer solved_chamber_plot if all geometry parameters are available (fast, no iteration)
177 # Otherwise fallback to CEA solver (iterative, finds throat area)
178 chamber_contour_x = []
179 chamber_contour_y = []
180 Cf_solved = None
181 Cf_ideal_solved = None
182 A_throat_solved = None
183 chamber_contour_method = None
184
185 try:
186 # Check if we have all parameters for solved_chamber_plot (from optimizer output)
187 use_solved_plot = False
188 if chamber_geom is not None:
189 required_attrs = ['A_throat', 'A_exit', 'volume', 'Lstar', 'chamber_diameter', 'length']
190 has_all = all(
191 hasattr(chamber_geom, attr) and getattr(chamber_geom, attr) is not None
192 for attr in required_attrs
193 )
194 if has_all:
195 use_solved_plot = True
196
197 if use_solved_plot:
198 # Fast path: Use solved_chamber_plot (no CEA iteration needed)
199 from engine.core.chamber_geometry_solver import solved_chamber_plot
200
201 chamber_pts, table_data, lengths = solved_chamber_plot(
202 area_throat=chamber_geom.A_throat,
203 area_exit=chamber_geom.A_exit,
204 volume_chamber=chamber_geom.volume,
205 lstar=chamber_geom.Lstar,
206 chamber_diameter=chamber_geom.chamber_diameter,
207 length=chamber_geom.length,
208 do_plot=False,
209 steps=200,
211 )
212
213 # Extract contour points (x, y)
214 chamber_contour_x = chamber_pts[:, 0].tolist()
215 chamber_contour_y = chamber_pts[:, 1].tolist()
216
217 # Get Cf from config if available (optimizer sets this)
218 Cf_solved = getattr(chamber_geom, 'Cf', None)
219 Cf_ideal_solved = getattr(chamber_geom, 'Cf_ideal', None)
220 A_throat_solved = chamber_geom.A_throat
221 chamber_contour_method = "solved"
222
223 else:
224 # Fallback path: Use CEA solver (iterative)
225 # =====================================================================
226 # Get ALL design parameters from config.chamber_geometry (preferred)
227 # or fallback to legacy config.chamber + config.nozzle
228 # These are the inputs to solve_chamber_geometry_with_cea
229 # =====================================================================
230 if chamber_geom is not None:
231 # New unified chamber_geometry section
232 Pc_design = getattr(chamber_geom, 'design_pressure', 2.0e6)
233 F_design = getattr(chamber_geom, 'design_thrust', 5000.0)
234 MR = getattr(chamber_geom, 'design_MR', 2.55)
235 Lstar = getattr(chamber_geom, 'Lstar', 1.0)
236 D_chamber_design = getattr(chamber_geom, 'chamber_diameter', D_chamber)
237 D_exit_design = getattr(chamber_geom, 'exit_diameter', D_exit)
238 nozzle_eff = getattr(chamber_geom, 'nozzle_efficiency', 0.95)
239 else:
240 # Legacy sections (chamber and nozzle should exist if we get here due to check above)
241 Pc_design = getattr(chamber, 'design_pressure', 2.0e6) if chamber else 2.0e6
242 F_design = getattr(chamber, 'design_thrust', 5000.0) if chamber else 5000.0
243 MR = getattr(chamber, 'design_MR', 2.55) if chamber else 2.55
244 Lstar = getattr(chamber, 'Lstar', 1.0) if chamber else 1.0
245 D_chamber_design = getattr(chamber, 'chamber_inner_diameter', D_chamber) if chamber else D_chamber
246 D_exit_design = getattr(chamber, 'exit_diameter', D_exit) if chamber else (getattr(nozzle, 'exit_diameter', D_exit) if nozzle else D_exit)
247 nozzle_eff = getattr(nozzle, 'efficiency', 0.95) if nozzle else 0.95
248
249 # Get ambient pressure from environment.elevation
250 Pa = 101325.0 # Default sea level
251 if hasattr(config, 'environment') and config.environment:
252 elevation = getattr(config.environment, 'elevation', 0.0)
253 # Barometric formula: P = P0 * (1 - 2.25577e-5 * h)^5.25588
254 if elevation > 0:
255 Pa = 101325.0 * (1.0 - 2.25577e-5 * elevation) ** 5.25588
256
257 # Get CEA config and create cache
258 cea_config = config.combustion.cea if hasattr(config, 'combustion') and hasattr(config.combustion, 'cea') else None
259
260 if cea_config is not None:
261 cea_cache = CEACache(cea_config)
262
263 # Solve chamber geometry with CEA - ALL inputs from config
264 chamber_pts, table_data, total_length, solver_info = solve_chamber_geometry_with_cea(
265 pc_design=Pc_design,
266 thrust_design=F_design,
267 cea_cache=cea_cache,
268 MR=MR,
269 diameter_inner=D_chamber_design,
270 diameter_exit=D_exit_design,
271 l_star=Lstar,
272 Pa=Pa,
273 nozzle_efficiency=nozzle_eff,
274 do_plot=False,
275 verbose=False,
277 )
278
279 # Extract contour points (x, y)
280 chamber_contour_x = chamber_pts[:, 0].tolist()
281 chamber_contour_y = chamber_pts[:, 1].tolist()
282
283 # Extract solver results
284 Cf_solved = solver_info.get('final_Cf')
285 Cf_ideal_solved = solver_info.get('final_Cf_ideal')
286 A_throat_solved = solver_info.get('final_A_throat')
287 chamber_contour_method = "cea_iterative"
288
289 except Exception as e:
290 # If both solvers fail, chamber_contour remains empty
291 # This is not critical - we still have layer geometry
292 import warnings
293 warnings.warn(f"Chamber contour solver failed: {e}")
294 chamber_contour_method = "failed"
295
296 # Generate Rao bell nozzle contour
297 # The rao() function returns contour starting from upstream of throat
298 try:
299 nozzle_pts, x_first, y_first = rao(
300 area_throat=A_throat,
301 area_exit=A_exit,
302 bell_percent=0.8, # 80% bell is standard
303 steps=100,
304 do_plot=False,
305 method="garcia",
306 )
307 # Shift nozzle contour so throat (x=0 in nozzle coords) aligns with L_chamber
308 # The nozzle contour x=0 is at the throat
309 nozzle_x = (nozzle_pts[:, 0] + L_chamber).tolist()
310 nozzle_y = nozzle_pts[:, 1].tolist()
311 nozzle_method = "rao_garcia"
312 except Exception as e:
313 # Fallback to simple conical if Rao fails
314 nozzle_x = []
315 nozzle_y = []
316 nozzle_method = f"fallback_conical (rao failed: {str(e)[:50]})"
317
318 # Get Layer 3 optimized thicknesses if available
319 t_abl_opt_mm: float | None = None
320 t_gra_opt_mm: float | None = None
321
322 # Overlay this user's own Layer 3 optimizer results, if they have run it.
323 # Per-user now (session.optimizer), so one person's Layer 3 run never bleeds
324 # optimized thicknesses into another person's geometry view.
325 layer3_status = session.optimizer.layer3_status
326 if layer3_status.get("results") is not None:
327 l3_results = layer3_status["results"]
328 summary = l3_results.get("summary", {})
329 t_abl_opt_mm = summary.get("optimized_ablative_thickness")
330 if t_abl_opt_mm is not None:
331 t_abl_opt_mm *= 1000.0 # Convert to mm
332 t_gra_opt_mm = summary.get("optimized_graphite_thickness")
333 if t_gra_opt_mm is not None:
334 t_gra_opt_mm *= 1000.0 # Convert to mm
335
336 # Baseline (config) thicknesses in mm, if available
337 t_abl_config_mm: float | None = None
338 t_gra_config_mm: float | None = None
339 if ablative_cfg is not None:
340 try:
341 t_abl_config_mm = float(getattr(ablative_cfg, "initial_thickness", 0.0)) * 1000.0
342 except Exception:
343 t_abl_config_mm = None
344 if graphite_cfg is not None:
345 try:
346 t_gra_config_mm = float(getattr(graphite_cfg, "initial_thickness", 0.0)) * 1000.0
347 except Exception:
348 t_gra_config_mm = None
349
350 return GeometryResponse(
351 positions=geometry["positions"].tolist(),
352 R_gas=geometry["R_gas"].tolist(),
353 R_ablative_outer=geometry["R_ablative_outer"].tolist(),
354 R_graphite_outer=geometry["R_graphite_outer"].tolist(),
355 R_stainless=geometry["R_stainless"].tolist(),
356 throat_position=float(geometry["throat_position"]),
357 graphite_start=float(geometry["graphite_start"]),
358 graphite_end=float(geometry["graphite_end"]),
359 D_chamber=float(geometry["D_chamber"]),
360 D_throat=float(geometry["D_throat"]),
361 D_exit=float(geometry["D_exit"]),
362 L_chamber=L_chamber,
363 L_nozzle=L_nozzle,
364 expansion_ratio=expansion_ratio,
365 ablative_enabled=ablative_enabled,
366 graphite_enabled=graphite_enabled,
367 nozzle_x=nozzle_x,
368 nozzle_y=nozzle_y,
369 nozzle_method=nozzle_method,
370 chamber_contour_x=chamber_contour_x,
371 chamber_contour_y=chamber_contour_y,
372 Cf=Cf_solved,
373 Cf_ideal=Cf_ideal_solved,
374 A_throat_solved=A_throat_solved,
375 chamber_contour_method=chamber_contour_method,
376 t_abl_opt_mm=t_abl_opt_mm,
377 t_gra_opt_mm=t_gra_opt_mm,
378 t_abl_config_mm=t_abl_config_mm,
379 t_gra_config_mm=t_gra_config_mm,
380 )
381
382 except Exception as e:
383 raise HTTPException(
384 status_code=500,
385 detail=f"Failed to calculate geometry: {str(e)}"
386 )
387
388
389
390# ---------------------------------------------------------------------------------------------
391# Injector layout. The drawing, Layer 1 and scripts/design_audit.py all read this one
392# derivation (engine/core/injectors/layout.py); the frontend used to carry its own copy.
393# ---------------------------------------------------------------------------------------------
394
395from engine.core.injectors.layout import layout_from_config as _layout_from_config
396
397
398def _injector_layout_or_404(cfg) -> dict:
399 # TODO(flows): no solve is at hand here (the session keeps no evaluation result, and POST
400 # takes a partial config), so the layout runs without ``flows``: the manifold velocity-head
401 # and orifice cavitation checks do not run, and the plate is checked at
402 # target_chamber_pressure_psi. Pass ``flows=flows_from_result(result, cfg)`` once a cached
403 # evaluation is available (scripts/design_audit.py and scripts/injector_layout.py do).
404 out = _layout_from_config(cfg)
405 if out is None:
406 raise HTTPException(status_code=404, detail="No impinging injector in this config.")
407 return out
408
409
410@router.get("/injector")
411async def get_injector_layout(session: UserSession = Depends(get_session)):
412 """Face, passage and back-face geometry of the session's impinging injector."""
413 if not session.app_state.has_config():
414 raise HTTPException(status_code=404, detail="No config loaded.")
415 return _injector_layout_or_404(session.app_state.config)
416
417
418@router.post("/injector")
419async def post_injector_layout(body: dict):
420 """The same, for a config the caller holds (e.g. a Forward-mode result's config).
421
422 Takes ``{"config": {...}}`` as a plain dict and needs only the injector, chamber
423 diameter, discharge and design-requirements sections -- no full validation, so a
424 partial config still draws.
425 """
426 cfg = body.get("config") if isinstance(body, dict) else None
427 if not isinstance(cfg, dict):
428 raise HTTPException(status_code=422, detail="Expected {\"config\": {...}}.")
429 return _injector_layout_or_404(cfg)
430
431
432@router.get("/injector/spray")
433def get_injector_spray(sensitivity: bool = True, session: UserSession = Depends(get_session)):
434 """Spray and mixing report of the session's impinging injector at its own tank pressures
435 (engine/core/injectors/spray_report.py). Solves the engine, and with ``sensitivity`` once more
436 per open input (~20 s): a plain ``def`` so FastAPI runs it off the event loop."""
437 if not session.app_state.has_config():
438 raise HTTPException(status_code=404, detail="No config loaded.")
439 cfg = session.app_state.config
440 if str(getattr(cfg.injector, "type", "")).lower() != "impinging":
441 raise HTTPException(status_code=404, detail="No impinging injector in this config.")
442 from engine.core.injectors.spray_report import spray_mixing_report
443 return _jsonable(spray_mixing_report(cfg, with_sensitivity=sensitivity))
444
445
447 """NaN and inf are not JSON: they go out as null."""
448 import math as _m
449 if isinstance(v, dict):
450 return {k: _jsonable(x) for k, x in v.items()}
451 if isinstance(v, (list, tuple)):
452 return [_jsonable(x) for x in v]
453 if isinstance(v, float) and not _m.isfinite(v):
454 return None
455 if isinstance(v, (np.floating, np.integer)):
456 return _jsonable(v.item())
457 return v
get_injector_layout(UserSession session=Depends(get_session))
Definition geometry.py:411
dict _injector_layout_or_404(cfg)
Definition geometry.py:398
post_injector_layout(dict body)
Definition geometry.py:419
get_injector_spray(bool sensitivity=True, UserSession session=Depends(get_session))
Definition geometry.py:433
get_chamber_geometry(UserSession session=Depends(get_session))
Definition geometry.py:81
float _configured_convergent_half_angle_rad(config)
Definition geometry.py:15