STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
config.py
Go to the documentation of this file.
1"""Config management endpoints."""
2
3from fastapi import APIRouter, UploadFile, File, HTTPException, Depends, Query
4from starlette.concurrency import run_in_threadpool
5from pathlib import Path
6from pydantic import ValidationError
7import yaml
8
9from engine.pipeline.config_schemas import PintleEngineConfig
10from backend.checkout import DesignCheckout
11from backend.session import UserSession, get_session, replace_config
12
13router = APIRouter(prefix="/api/config", tags=["config"])
14
15
16def config_to_dict(config: PintleEngineConfig) -> dict:
17 """Convert Pydantic config to JSON-serializable dict."""
18 return config.model_dump(mode="json")
19
20
21@router.post("/upload")
22async def upload_config(
23 file: UploadFile = File(...),
24 session: UserSession = Depends(get_session),
25 _: None = DesignCheckout,
26):
27 """Upload a YAML config file and parse it."""
28 # The multipart filename is the client's to choose, path and all ("../../x.yaml", an
29 # absolute "/app/configs/default.yaml"); pathlib's `/` with an absolute right side throws
30 # the left side away. Only the final component is a name; the folder is ours.
31 name = Path(file.filename or "").name
32 if not name.endswith((".yaml", ".yml")) or name.startswith("."):
33 raise HTTPException(status_code=400, detail="File must be a YAML file (.yaml or .yml)")
34
35 try:
36 content = await file.read()
37 # Parse and validate first: a file that is not a config is never written anywhere.
38 data = yaml.safe_load(content.decode("utf-8"))
39 if not isinstance(data, dict):
40 raise HTTPException(status_code=400, detail="Invalid YAML: the file is not a config mapping")
41 config = PintleEngineConfig(**data)
42
43 # Save uploads outside configs/ — personal exports must not land in the
44 # tracked tree — and under the caller's own folder, so two users
45 # uploading files with the same name do not overwrite each other.
46 from backend.main import project_root
47 uploads_dir = (project_root / "output" / "user_configs" / session.user).resolve()
48 uploads_dir.mkdir(parents=True, exist_ok=True)
49 save_path = (uploads_dir / name).resolve()
50 if save_path.parent != uploads_dir:
51 raise HTTPException(status_code=400, detail="File must be a YAML file (.yaml or .yml)")
52
53 with open(save_path, "wb") as f:
54 f.write(content)
55
56 # set_config builds/loads the CEA cache (PintleEngineRunner). A cold
57 # cache is a multi-minute synchronous build, so run it off the event
58 # loop -- inline it would block every other request (health included),
59 # which is exactly the "stuck on Connecting…" hang.
60 await replace_config(session, config, str(save_path))
61 return {
62 "status": "success",
63 "message": f"Config loaded from {name}",
64 "config": config_to_dict(config),
65 "session_epoch": session.epoch,
66 "load_token": session.load_token,
67 }
68 except HTTPException:
69 raise
70 except (yaml.YAMLError, UnicodeDecodeError) as e:
71 raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}")
72 except ValidationError as e:
73 raise HTTPException(status_code=422, detail=f"Config validation error: {e}")
74 except Exception as e:
75 raise HTTPException(status_code=500, detail=f"Failed to load config: {e}")
76
77
78@router.get("")
79async def get_config(session: UserSession = Depends(get_session)):
80 """Get the current config as JSON."""
81 if not session.app_state.has_config():
82 raise HTTPException(status_code=404, detail="No config loaded. Upload a config file first.")
83 return {"config": config_to_dict(session.app_state.config), "session_epoch": session.epoch,
84 "load_token": session.load_token}
85
86
87@router.post("/load")
88async def load_config_json(body: dict, session: UserSession = Depends(get_session)):
89 """Replace the current config with a full config dict (not a merge).
90
91 Deliberately NOT behind the checkout, unlike the other write routes here.
92 This is how the design bar loads a design into your session when you open
93 it -- including read only -- so gating it would make a design you may only
94 view impossible to open at all. It writes your own session, never the
95 stored design; saving that back is /autosave's job, and that is gated.
96
97 The per-user saved-config library (/api/configs) stores a complete config;
98 loading it must replace app_state wholesale. PUT deep-merges partial updates,
99 which would leave stale keys behind when the saved config is a different
100 injector type than the one currently loaded.
101 """
102 try:
103 config = PintleEngineConfig(**body)
104 except ValidationError as e:
105 raise HTTPException(status_code=422, detail=f"Config validation error: {e}")
106 # Off the event loop: a cold CEA cache build here would block every other
107 # request. See the note in upload_config.
108 await replace_config(session, config, session.app_state.config_path)
109 return {"status": "success", "config": config_to_dict(config), "session_epoch": session.epoch,
110 "load_token": session.load_token}
111
112
113@router.get("/parameters")
114async def get_parameters(session: UserSession = Depends(get_session)):
115 """Every parameter of the loaded config -- value, schema default, units, description -- and the
116 physics constants that live in code (engine/pipeline/parameter_registry.py). Edits go through
117 PUT /api/config like any other change."""
118 if not session.app_state.has_config():
119 raise HTTPException(status_code=404, detail="No config loaded. Upload a config file first.")
120 from engine.pipeline.parameter_registry import config_parameters
121 out = config_parameters(session.app_state.config)
122 try:
123 from engine.pipeline.code_constants import CODE_CONSTANTS
124 out["constants"] = CODE_CONSTANTS
125 except ImportError:
126 out["constants"] = []
127 return out
128
129
130@router.get("/options")
131async def get_switch_options(session: UserSession = Depends(get_session)):
132 """Available injector types and propellant presets for the UI selectors (UNIFICATION P6)."""
133 from engine.pipeline.config_switch import available_injectors, available_propellants
134 current_injector = None
135 current_propellant = None
136 if session.app_state.has_config():
137 current_injector = getattr(getattr(session.app_state.config, "injector", None), "type", None)
138 current_propellant = getattr(session.app_state.config, "propellant_preset", None)
139 return {
140 "injectors": available_injectors(),
141 "propellants": available_propellants(),
142 "current": {"injector": current_injector, "propellant": current_propellant},
143 }
144
145
146@router.get("/injector_schema")
148 injector_type: str | None = None,
149 session: UserSession = Depends(get_session),
150):
151 """Per-injector field schema for the UI to render the RIGHT controls (frozen params, geometry)
152 instead of hardcoding pintle fields (INJECTOR_PARITY_PLAN W5). Defaults to the loaded config's type.
153 """
154 from engine.core.dispatch import (
155 frozen_param_fields_for, INJECTOR_GEOMETRY_TEMPLATES, bindings_for,
156 )
157 t = injector_type
158 if t is None and session.app_state.has_config():
159 t = getattr(getattr(session.app_state.config, "injector", None), "type", None)
160 t = t or "impinging"
161 b = bindings_for(t)
162 return {
163 "injector_type": t,
164 "frozen_param_fields": frozen_param_fields_for(t),
165 "geometry_fields": INJECTOR_GEOMETRY_TEMPLATES.get(t, {}),
166 "smd_model": b.expected_smd_model or "lefebvre",
167 "use_geometry_cd": b.expected_use_geometry_cd,
168 }
169
170
171@router.post("/switch")
173 body: dict,
174 session: UserSession = Depends(get_session),
175 _: None = DesignCheckout,
176):
177 """Switch injector type and/or propellant, auto-reconciling the config to the right physics.
178
179 Body: {"injector_type": "pintle"|"impinging"|null, "propellant_preset": "methalox"|...|null}.
180 Injector change LOADS THE CANONICAL config for that type wholesale (configs/canonical/<type>.yaml);
181 the two canonical configs are intentionally independent (pintle fixed Cd vs impinging geometry-Cd —
182 they must NOT stay in sync). Propellant change overlays the preset identity onto the live geometry.
183 Then validates and stores. This is the engine behind the UI's first-class injector/propellant toggles.
184
185 IN-MEMORY ONLY: a quick injector/propellant toggle must NOT overwrite the config file on disk —
186 doing so silently clobbered configs/default.yaml when a user flipped the dropdown. The switch
187 updates app_state (so it holds for the session); persisting is an explicit Save action, not a
188 side effect of toggling.
189
190 A propellant overlay leaves the chamber seeded for the previous propellant; the response carries a
191 ``design_warning`` ("needs re-optimization") so the UI can flag the design as a seed, not a solution.
192 That is the chamber's staleness only: fluid properties off their preset (a canonical config's
193 stale LOX block, say) come back separately as ``fluid_warning`` -- the chamber is still valid,
194 and a clean injector swap must not read as "needs re-optimization".
195 """
196 if not session.app_state.has_config():
197 raise HTTPException(status_code=404, detail="No config loaded. Upload a config file first.")
198 from engine.pipeline.config_switch import switch_config, chamber_staleness, preset_fluid_drift
199 try:
200 current = config_to_dict(session.app_state.config)
201 reconciled = switch_config(
202 current,
203 injector_type=body.get("injector_type"),
204 propellant_preset=body.get("propellant_preset"),
205 )
206 new_config = PintleEngineConfig(**reconciled)
207 except (ValidationError, ValueError, FileNotFoundError) as e:
208 raise HTTPException(status_code=422, detail=f"Switch failed: {e}")
209
210 # Update in-memory state only; preserve the original config_path WITHOUT writing to it.
211 # Off the event loop: switching to a propellant whose CEA cache is not yet
212 # built triggers a multi-minute synchronous build. See upload_config.
213 await run_in_threadpool(session.app_state.set_config, new_config, session.app_state.config_path)
214
215 from engine.core.dispatch import validate_config_bindings
216 return {
217 "status": "success",
218 "config": config_to_dict(new_config),
219 "binding_warnings": validate_config_bindings(new_config),
220 "design_warning": chamber_staleness(new_config),
221 "fluid_warning": preset_fluid_drift(new_config),
222 }
223
224
225@router.put("")
227 updates: dict,
228 session: UserSession = Depends(get_session),
229 _: None = DesignCheckout,
230 expect_sha256: str | None = Query(default=None, max_length=64, pattern="^[0-9a-f]{64}$"),
231):
232 """Update the current config with partial updates.
233
234 Accepts a nested dict of updates that will be merged with the current config.
235
236 ``expect_sha256``: the design the update was computed for (Layer X's fitted feed, a reconciled
237 injector). If the live design is no longer that one the write is refused with 409: a K0 fitted
238 to one design must not land in another because a tab was a few seconds stale.
239 """
240 if not session.app_state.has_config():
241 raise HTTPException(status_code=404, detail="No config loaded. Upload a config file first.")
242 # A string only: called as a plain function (tests, other routes) the default is FastAPI's Query object.
243 if isinstance(expect_sha256, str):
244 from engine.layerx.fingerprint import config_fingerprint
245
246 if config_fingerprint(session.app_state.config) != expect_sha256:
247 raise HTTPException(status_code=409, detail="The design has changed since this was computed. Run it again first.")
248
249 try:
250 # Get current config as dict
251 current = config_to_dict(session.app_state.config)
252
253 # Deep merge updates
254 def deep_merge(base: dict, updates: dict) -> dict:
255 result = base.copy()
256 for key, value in updates.items():
257 if key in result and isinstance(result[key], dict) and isinstance(value, dict):
258 result[key] = deep_merge(result[key], value)
259 else:
260 result[key] = value
261 return result
262
263 merged = deep_merge(current, updates)
264
265 # Validate and create new config
266 new_config = PintleEngineConfig(**merged)
267
268 # In-memory only. Live edits used to be written back to config_path, but
269 # that path is the shared, git-tracked configs/default.yaml -- one user's
270 # edit would clobber it for everyone. Durable saves go through the
271 # per-user library (POST /api/configs); this working copy stays in memory.
272 # Off the event loop: see upload_config (cold CEA build would block).
273 await run_in_threadpool(session.app_state.set_config, new_config, session.app_state.config_path)
274
275 return {
276 "status": "success",
277 "message": "Config updated",
278 "config": config_to_dict(new_config),
279 }
280 except ValidationError as e:
281 raise HTTPException(status_code=422, detail=f"Config validation error: {e}")
282 except Exception as e:
283 raise HTTPException(status_code=500, detail=f"Failed to update config: {e}")
284
get_injector_schema(str|None injector_type=None, UserSession session=Depends(get_session))
Definition config.py:150
switch_config_endpoint(dict body, UserSession session=Depends(get_session), None _=DesignCheckout)
Definition config.py:176
dict config_to_dict(PintleEngineConfig config)
Definition config.py:16
load_config_json(dict body, UserSession session=Depends(get_session))
Definition config.py:88
get_config(UserSession session=Depends(get_session))
Definition config.py:79
upload_config(UploadFile file=File(...), UserSession session=Depends(get_session), None _=DesignCheckout)
Definition config.py:26
get_parameters(UserSession session=Depends(get_session))
Definition config.py:114
get_switch_options(UserSession session=Depends(get_session))
Definition config.py:131
update_config(dict updates, UserSession session=Depends(get_session), None _=DesignCheckout, str|None expect_sha256=Query(default=None, max_length=64, pattern="^[0-9a-f]{64}$"))
Definition config.py:231