3from fastapi
import APIRouter, UploadFile, File, HTTPException, Depends, Query
23 file: UploadFile = File(...),
24 session: UserSession = Depends(get_session),
25 _:
None = DesignCheckout,
27 """Upload a YAML config file and parse it."""
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)")
36 content = await file.read()
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")
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)")
53 with open(save_path,
"wb")
as f:
60 await replace_config(session, config, str(save_path))
63 "message": f
"Config loaded from {name}",
65 "session_epoch": session.epoch,
66 "load_token": session.load_token,
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}")
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}
89 """Replace the current config with a full config dict (not a merge).
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.
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.
104 except ValidationError
as e:
105 raise HTTPException(status_code=422, detail=f
"Config validation error: {e}")
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}
113@router.get("/parameters")
132 """Available injector types and propellant presets for the UI selectors (UNIFICATION P6)."""
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)
140 "injectors": available_injectors(),
141 "propellants": available_propellants(),
142 "current": {
"injector": current_injector,
"propellant": current_propellant},
146@router.get("/injector_schema")
148 injector_type: str |
None =
None,
149 session: UserSession = Depends(get_session),
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.
155 frozen_param_fields_for, INJECTOR_GEOMETRY_TEMPLATES, bindings_for,
158 if t
is None and session.app_state.has_config():
159 t = getattr(getattr(session.app_state.config,
"injector",
None),
"type",
None)
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,
171@router.post("/switch")
174 session: UserSession = Depends(get_session),
175 _:
None = DesignCheckout,
177 """Switch injector type and/or propellant, auto-reconciling the config to the right physics.
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.
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.
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".
196 if not session.app_state.has_config():
197 raise HTTPException(status_code=404, detail=
"No config loaded. Upload a config file first.")
201 reconciled = switch_config(
203 injector_type=body.get(
"injector_type"),
204 propellant_preset=body.get(
"propellant_preset"),
207 except (ValidationError, ValueError, FileNotFoundError)
as e:
208 raise HTTPException(status_code=422, detail=f
"Switch failed: {e}")
213 await run_in_threadpool(session.app_state.set_config, new_config, session.app_state.config_path)
219 "binding_warnings": validate_config_bindings(new_config),
220 "design_warning": chamber_staleness(new_config),
221 "fluid_warning": preset_fluid_drift(new_config),
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}$"),
232 """Update the current config with partial updates.
234 Accepts a nested dict of updates that will be merged with the current config.
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.
240 if not session.app_state.has_config():
241 raise HTTPException(status_code=404, detail=
"No config loaded. Upload a config file first.")
243 if isinstance(expect_sha256, str):
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.")
254 def deep_merge(base: dict, updates: dict) -> dict:
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)
263 merged = deep_merge(current, updates)
273 await run_in_threadpool(session.app_state.set_config, new_config, session.app_state.config_path)
277 "message":
"Config updated",
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}")
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}$"))