STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
layerx.py
Go to the documentation of this file.
1"""Layer X endpoints: the feed system and the engine, burned together.
2
3 GET /api/layerx/status feedtwin importable? shipped data found?
4 GET /api/layerx/runs/{id}/eng a finished burn's thrust curve as an OpenRocket .eng
5 GET /api/layerx/drawings drawings this user can run
6 POST /api/layerx/drawings upload a pid-designer / feed-twin drawing (JSON)
7 GET /api/layerx/pid-designer documents pid-designer offers this user
8 POST /api/layerx/pid-designer/import pull one into this user's drawings
9 POST /api/layerx/preflight settings -> checks, derived plan, the engine link
10 POST /api/layerx/card settings -> the engine card itself (feedtwin JSON)
11 POST /api/layerx/engine-card any engine config (YAML) -> its card, for the feed-twin cockpit
12 GET /api/layerx/drawings/{id}/parameters every drawing parameter, and this user's restatements
13 PUT /api/layerx/drawings/{id}/measurements this user's restated (measured) parameters
14 POST /api/layerx/uncertainty start an uncertainty sweep (background); returns its id
15 POST /api/layerx/setpoint dome, lockup and fill for a target mean thrust (background)
16 POST /api/layerx/hardware catalogue parts for the components marked free (background)
17 GET /api/layerx/catalog the parts Hardware mode chooses from (shipped + this user's)
18 POST /api/layerx/optimize/variables legacy: the old compass search's variables, bounds and reasons
19 POST /api/layerx/optimize legacy: the old compass search (background); kept while the
20 Optimize page still calls it. Set point replaces it.
21 POST /api/layerx/reconcile resize the injector for the drawing's feed (background)
22 POST /api/layerx/export-config the design with an injector what-if written in, as YAML
23 POST /api/layerx/runs start a burn (background); returns its id
24 GET /api/layerx/runs this user's runs, newest first
25 GET /api/layerx/runs/{id} status, progress and, when done, the result
26 GET /api/layerx/runs/{id}/sidecar/{name} a finished burn's large side data (``axial``)
27 GET /api/layerx/runs/{id}/export/{fmt} a finished burn as ``csv``, ``parquet`` or ``fea`` (zip)
28 POST /api/layerx/runs/{id}/cancel
29
30Old runs of kinds that can no longer be started (the trade study) or that the rebuilt tools
31replace (the compass-search optimiser) stay listed and readable, marked ``legacy``.
32
33A run is a background thread, one at a time per user and at most
34``LAYERX_MAX_JOBS`` across the process. It works on a *copy* of the config
35taken when it starts, with its own runner, so loading a different design
36mid-run neither corrupts the run nor is corrupted by it. Finished runs are
37written to the user's data directory, so a reload finds them again.
38"""
39
40from __future__ import annotations
41
42import copy
43import json
44import os
45import re
46import threading
47import time
48import uuid
49from pathlib import Path
50from typing import Any, Callable, Dict, List, Optional, Union
51
52from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
53from pydantic import BaseModel, Field, field_validator
54
55from backend import userdata
56from backend.session import UserSession, get_session
57
58router = APIRouter(prefix="/api/layerx", tags=["layerx"])
59
60
62 """Say at startup when a place Layer X reads drawings from is missing: it lists nothing from there,
63 silently, which is how production Layer X came to see neither the twin's library nor the featured stand."""
64 import logging
65
66 from engine.layerx.sources import source_problems
67
68 for problem in source_problems():
69 logging.getLogger("layerx").warning("Layer X reads no drawings from %s", problem)
70
71
73
74MAX_JOBS = int(os.environ.get("LAYERX_MAX_JOBS", "2"))
75_JOB_SLOTS = threading.BoundedSemaphore(MAX_JOBS)
76#: Finished jobs that could not be saved, kept in memory per process (newest).
77UNSAVED_KEPT = 5
78#: Runs kept on disk per user. Older ones are pruned when a new one finishes.
79KEEP_RUNS = int(os.environ.get("LAYERX_KEEP_RUNS", "25"))
80PID_DESIGNER_URL = (os.environ.get("PID_DESIGNER_URL") or "http://127.0.0.1:8001").rstrip("/")
81PID_TIMEOUT_S = 8.0
82#: The largest drawing accepted, upload or pid-designer import. The shipped stand is ~60 kB.
83MAX_UPLOAD_BYTES = 5_000_000
84
85
87 """ASGI middleware: refuse a drawing upload larger than MAX_UPLOAD_BYTES before its body is read.
88 FastAPI parses (and Starlette spools to disk) a multipart file before the route runs, so the
89 route's own cap came after the whole body had arrived. The multipart envelope is allowed 64 kB."""
90
91 PATH = "/api/layerx/drawings"
92
93 def __init__(self, app: Any) -> None:
94 self.app = app
95
96 async def __call__(self, scope: Dict[str, Any], receive: Any, send: Any) -> None:
97 if scope.get("type") == "http" and scope.get("method") == "POST" and scope.get("path") == self.PATH:
98 headers = dict(scope.get("headers") or [])
99 length = headers.get(b"content-length")
100 status, detail = 0, ""
101 if length is None:
102 status, detail = 411, "A drawing upload needs a Content-Length."
103 elif not length.isdigit() or int(length) > MAX_UPLOAD_BYTES + 65536:
104 status, detail = 413, f"A drawing is at most {MAX_UPLOAD_BYTES // 1_000_000} MB."
105 if status:
106 body = json.dumps({"detail": detail}).encode()
107 await send({"type": "http.response.start", "status": status,
108 "headers": [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode())]})
109 await send({"type": "http.response.body", "body": body})
110 return
111 await self.app(scope, receive, send)
112
113
114# ------------------------------------------------------------------ models
115
116
117class Settings(BaseModel):
118 """Mirror of engine.layerx.LayerXSettings, validated at the edge."""
119
120 drawing_id: str
121 tank_pressure_psia: Optional[float] = Field(default=None, gt=14.0, lt=5000.0)
122 copv_pressure_psig: Optional[float] = Field(default=None, gt=0.0, lt=12000.0)
123 copv_pressure_psia: Optional[float] = Field(default=None, gt=14.0, lt=12000.0)
124 dome_psia: Optional[float] = Field(default=None, gt=14.0, lt=5000.0)
125 dome_regulator: Optional[str] = Field(default=None, max_length=200)
126 load: str = Field(default="config", pattern="^(config|fill)$")
127 fill_fraction: float = Field(default=0.95, gt=0.05, lt=0.99)
128 dry_kg: float = Field(default=0.001, ge=0.0005, le=2.0)
129 engine_model: str = Field(default="card", pattern="^(card|calibrated|native)$")
130 #: The feed twin's thermal models: None runs the twin's own Setup (its cockpit's); a value overrides it.
131 ullage_collapse: Optional[bool] = None
132 ullage_vapour: Optional[bool] = None
133 chilldown: Optional[float] = Field(default=None, ge=0.0, le=5000.0)
134 line_walls: Optional[bool] = None
135 hold_s: float = Field(default=300.0, ge=0.0, le=36000.0)
136 dt: float = Field(default=0.05, ge=0.005, le=0.2)
137 horizon_s: float = Field(default=14.0, gt=0.5, le=60.0)
138 settle: bool = True
139 replay: bool = True
140 flight: bool = False
141 liftoff_mass_kg: Optional[float] = Field(default=None, gt=1.0, lt=5000.0)
142 pressurant: Optional[str] = Field(default=None, pattern="^(nitrogen|helium)$")
143 # Opt-in choices (engine/layerx/prepare.py LayerXSettings): None is today's behaviour, exactly.
144 chug_basis: Optional[str] = Field(default=None, pattern="^(config|drawing)$")
145 chug_eroded: Optional[bool] = None
146 card_eroded_nozzle: Optional[bool] = None
147 flight_coupling: Optional[str] = Field(default=None, pattern="^(outer|inline)$")
148 #: Start diagnostic only: fuel main this long before the LOX main [s]; None: 0 (the DAQ table).
149 fuel_lead_s: Optional[float] = Field(default=None, ge=0.0, le=5.0, allow_inf_nan=False)
150 #: Start diagnostic only: main-valve opening travel [s]; None: the drawing's travel_time.
151 valve_travel_s: Optional[float] = Field(default=None, ge=0.0, le=5.0, allow_inf_nan=False)
152 #: Gas-ingestion diagnostic only: tank outlet bore [mm], one for both or [LOX, fuel]; None: the first line's.
153 outlet_d_mm: Optional[Union[float, List[Optional[float]]]] = None
154 #: Run a nitrogen-over-LOX hot fire anyway (condensation unmodelled); None: preflight refuses it.
155 ack_gn2_condensation: Optional[bool] = None
156 #: Reserved: only a hot fire is modelled.
157 test_mode: Optional[str] = Field(default=None, pattern="^hotfire$")
158 #: A what-if on the injector, applied to this run's private copy of the design only:
159 #: {"oxidizer"|"fuel": {"d_jet": m, "impingement_angle": deg, "orifice_l_over_d": -}}.
160 design_patch: Optional[Dict[str, Dict[str, float]]] = None
161
162 @field_validator("design_patch")
163 @classmethod
164 def _patch(cls, value: Optional[Dict[str, Dict[str, float]]]) -> Optional[Dict[str, Dict[str, float]]]:
165 return check_design_patch(value)
166
167 @field_validator("outlet_d_mm")
168 @classmethod
169 def _outlet(cls, value: Any) -> Any:
170 import math
171
172 if value is None:
173 return None
174 pair = [value, value] if isinstance(value, (int, float)) else list(value)
175 if len(pair) != 2:
176 raise ValueError("outlet_d_mm is one bore for both tanks or [LOX, fuel]")
177 for v in pair:
178 if v is not None and (isinstance(v, bool) or not math.isfinite(float(v)) or not 0.5 <= float(v) <= 500.0):
179 raise ValueError(f"outlet_d_mm {v!r} must be a bore in mm, 0.5-500")
180 return value
181
182
183#: What a design patch may set, per side, and its sane range.
184PATCH_FIELDS = {"d_jet": (1.0e-4, 0.02), "impingement_angle": (0.0, 80.0), "orifice_l_over_d": (0.5, 50.0)}
185
186
187def check_design_patch(value: Optional[Dict[str, Dict[str, float]]]) -> Optional[Dict[str, Dict[str, float]]]:
188 import math
189
190 if not value:
191 return None
192 for side, fields in value.items():
193 if side not in ("oxidizer", "fuel") or not isinstance(fields, dict):
194 raise ValueError(f"design_patch side {side!r} must be 'oxidizer' or 'fuel'")
195 for key, v in fields.items():
196 if key not in PATCH_FIELDS:
197 raise ValueError(f"design_patch.{side}.{key} is not patchable")
198 lo, hi = PATCH_FIELDS[key]
199 if not isinstance(v, (int, float)) or isinstance(v, bool) or not math.isfinite(v) or not lo <= v <= hi:
200 raise ValueError(f"design_patch.{side}.{key} = {v!r} is outside {lo:g}-{hi:g}")
201 return value
202
203
204from engine.layerx.patch import apply_design_patch # noqa: E402,F401 - re-exported for the routes and tests
205
206
207class PidImport(BaseModel):
208 id: str
209 owner: str = ""
210 release: str = ""
211 name: str = ""
212
213
214# ------------------------------------------------------------------ helpers
215
216
217def _user_dir(session: UserSession) -> Path:
218 return userdata.user_dir(session.user)
219
220
221def _runs_dir(session: UserSession) -> Path:
222 path = _user_dir(session) / "layerx" / "runs"
223 path.mkdir(parents=True, exist_ok=True)
224 return path
225
226
227def _store(session: UserSession):
228 from engine.layerx import DrawingStore
229
230 return DrawingStore(_user_dir(session))
231
232
233def _config_and_runner(session: UserSession, patch: Optional[Dict[str, Dict[str, float]]] = None):
234 """A private copy of the live design (with any what-if written in) and a runner of its own."""
235 if not session.app_state.has_config():
236 raise HTTPException(status_code=400, detail="No engine loaded. Load a design first.")
237 from engine.core.runner import PintleEngineRunner
238
239 config = apply_design_patch(copy.deepcopy(session.app_state.config), patch)
240 return config, PintleEngineRunner(config)
241
242
243def _settings(raw: Settings):
244 from engine.layerx import LayerXSettings
245
246 return LayerXSettings.from_dict(raw.model_dump())
247
248
249def _measurements(session: UserSession):
250 from engine.layerx.measurements import MeasurementStore
251
252 return MeasurementStore(_user_dir(session))
253
254
255def _drawing(session: UserSession, drawing_id: str):
256 drawing = _store(session).get(drawing_id)
257 if drawing is None:
258 raise HTTPException(status_code=404, detail=f"No drawing {drawing_id!r} for this user.")
259 return drawing
260
261
262def _prepare(session: UserSession, settings: Settings):
263 from engine.layerx import prepare
264
265 from engine.layerx import Check
266
267 drawing = _drawing(session, settings.drawing_id)
268 config, runner = _config_and_runner(session, settings.design_patch)
269 overrides, problems, revision = _measurements(session).load(drawing)
270 prep = prepare(config, runner, drawing, _settings(settings), overrides)
271 if overrides and revision and revision != drawing.sha256:
272 prep.checks.append(Check("overrides_revision", "Restatements from an earlier revision", "warn",
273 f"{len(overrides)} restated parameter(s) were entered against an earlier revision of "
274 f"'{drawing.name}'. They are applied where the element still exists; check them."))
275 for problem in problems:
276 prep.checks.append(Check("overrides_unreadable", "Restatements that could not be read", "warn", problem))
277 return prep, runner, config
278
279
280def _json_safe(value: Any) -> Any:
281 import math
282
283 if isinstance(value, float):
284 return value if math.isfinite(value) else None
285 if value is None or isinstance(value, (str, bool, int)):
286 return value
287 if isinstance(value, dict):
288 return {str(k): _json_safe(v) for k, v in value.items()}
289 if isinstance(value, (list, tuple, set, frozenset)):
290 return [_json_safe(v) for v in value]
291 if hasattr(value, "tolist") and callable(value.tolist): # numpy arrays and scalars
292 try:
293 return _json_safe(value.tolist())
294 except Exception: # noqa: BLE001
295 return str(value)
296 return str(value)
297
298
299# ------------------------------------------------------------------ jobs
300
301
302def _named_design(request: Request, session: UserSession) -> Optional[str]:
303 """The name of the design the caller has open, when the request names one (the design bar's
304 ``X-Design-Id`` / ``X-Design-Owner``, as the config routes read them: backend/checkout.py) and the
305 caller may see it: their own, the main design, or one shared with them. Otherwise None."""
306 from backend.checkout import DESIGN_ID_HEADER, DESIGN_OWNER_HEADER
307
308 doc_id = request.headers.get(DESIGN_ID_HEADER)
309 if not doc_id:
310 return None
311 try:
312 from backend.routers.documents import store
313
314 named = request.headers.get(DESIGN_OWNER_HEADER)
315 owner = userdata.slug_user(named) if named else session.user
316 record = store.find_record(owner, doc_id)
317 if not isinstance(record, dict):
318 return None
319 shared = record.get("sharedWith") if isinstance(record.get("sharedWith"), list) else []
320 main = store.read_featured() or {}
321 if not (owner == session.user or any(userdata.slug_user(str(e)) == session.user for e in shared)
322 or (main.get("owner"), main.get("id")) == (owner, doc_id)):
323 return None
324 name = str(record.get("name") or "").strip()
325 return name[:120] or None
326 except Exception: # noqa: BLE001 - a label; the run goes on under the session's
327 return None
328
329
330def _design_label(request: Request, session: UserSession = Depends(get_session)) -> str:
331 """What a job is filed under: the open design's name, else the file the session loaded."""
332 return _named_design(request, session) or _design_name(session)
333
334
335class Job:
336 def __init__(self, user: str, settings: Settings, design_name: str, kind: str = "run",
337 design_sha256: str = "") -> None:
338 # UTC, so ids sort by time across a daylight-saving change (pruning keeps the newest by id).
339 self.id = time.strftime("%Y%m%d-%H%M%S-", time.gmtime()) + uuid.uuid4().hex[:6]
340 self.kind = kind
341 self.user = user
342 self.settings = settings
343 self.design_name = design_name
344 #: The design's fingerprint when the job was claimed: two designs under one name are still two.
345 self.design_sha256 = design_sha256
346 self.status = "queued"
347 self.stage = "Queued"
348 self.progress = 0.0
349 self.error: Optional[str] = None
350 self.result: Optional[Dict[str, Any]] = None
352 self.finished: Optional[float] = None
353 self.cancel = threading.Event()
354 #: Large side data of the result (DATA-CONTRACT 4 sidecars), written beside the run file.
355 self.sidecars: Dict[str, Any] = {}
356 #: The first pass as it burns (run_prepared's ``live``): thrust and chamber pressure on the
357 #: burn's clock, for the run page to draw while the passes run. Shown only while it runs.
358 self.live: Optional[Dict[str, List[float]]] = None
359
360 def feed(self, t: float, thrust_n: float, pc_psia: float) -> None:
361 live = self.live if self.live is not None else {"t": [], "thrust_N": [], "pc_psia": []}
362 # One point per 50 ms of burn at most: a poll every half second carries a few kB.
363 if live["t"] and t - live["t"][-1] < 0.049:
364 return
365 live["t"].append(round(t, 3))
366 live["thrust_N"].append(round(thrust_n, 1))
367 live["pc_psia"].append(round(pc_psia, 2))
368 self.live = live
369
370 def view(self, *, with_result: bool = True) -> Dict[str, Any]:
371 out = {
372 "id": self.id,
373 "kind": self.kind,
374 "status": self.status,
375 "stage": self.stage,
376 "progress": self.progress,
377 "error": self.error,
378 "started": self.started,
379 "finished": self.finished,
380 "design": self.design_name,
381 "design_sha256": self.design_sha256,
382 "settings": self.settings.model_dump(),
383 }
384 if self.status in ("queued", "running") and self.live:
385 out["live"] = self.live
386 if with_result:
387 out["result"] = self.result
388 elif self.result:
389 out["summary"] = _listing_summary(self.result)
390 out["drawing"] = (self.result.get("provenance") or {}).get("drawing")
391 return out
392
393
394_JOBS: Dict[str, Job] = {}
395_JOBS_LOCK = threading.Lock()
396
397
398_KIND_RE = re.compile(r'"kind":"([a-z]+)"')
399RUN_ID_RE = re.compile(r"^\d{8}-\d{6}-[0-9a-f]{6}$")
400
401
402def _kind_of(path: Path) -> str:
403 """A saved job's kind: from its index entry, else from the head of its file."""
404 entry = _read_json(_index_path(path.parent, path.stem))
405 if entry and isinstance(entry.get("kind"), str):
406 return entry["kind"]
407 try:
408 with path.open("rb") as fh:
409 head = fh.read(256).decode("utf-8", "replace")
410 except OSError:
411 return "run"
412 m = _KIND_RE.search(head)
413 return m.group(1) if m else "run"
414
415
416def _index_path(session_dir: Path, run_id: str) -> Path:
417 """The run's listing entry (no result): what GET /runs reads, so a poll never parses a burn."""
418 return session_dir / "_index" / f"{run_id}.json"
419
420
421def _meta_path(session_dir: Path, run_id: str) -> Path:
422 """What the person said about a run: its name, a note, whether it is pinned."""
423 return session_dir / "_meta" / f"{run_id}.json"
424
425
426def _read_json(path: Path) -> Optional[Dict[str, Any]]:
427 try:
428 data = json.loads(path.read_text())
429 except (OSError, json.JSONDecodeError):
430 return None
431 return data if isinstance(data, dict) else None
432
433
434def _write_json(path: Path, data: Dict[str, Any]) -> None:
435 path.parent.mkdir(parents=True, exist_ok=True)
436 tmp = path.with_suffix(".json.tmp")
437 tmp.write_text(json.dumps(data, separators=(",", ":")))
438 os.replace(tmp, path)
439
440
441def _pinned(session_dir: Path, run_id: str) -> bool:
442 return bool((_read_json(_meta_path(session_dir, run_id)) or {}).get("pinned"))
443
444
445#: The sidecars a run may have, by name (DATA-CONTRACT 4).
446SIDECARS = ("axial",)
447
448
449def _sidecar_path(session_dir: Path, run_id: str, name: str) -> Path:
450 """A run's sidecar: beside the run file, in its own directory so a listing never reads it."""
451 return session_dir / "_sidecar" / f"{run_id}.{name}.json"
452
453
454def _run_files(session_dir: Path, run_id: str) -> List[Path]:
455 """Every file a saved run owns: the run, its index entry, its notes and its sidecars."""
456 return ([session_dir / f"{run_id}.json", _index_path(session_dir, run_id), _meta_path(session_dir, run_id)]
457 + [_sidecar_path(session_dir, run_id, name) for name in SIDECARS])
458
459
460def _persist(session_dir: Path, job: Job) -> None:
461 for name, data in (job.sidecars or {}).items():
462 if name in SIDECARS and isinstance(data, dict):
463 _write_json(_sidecar_path(session_dir, job.id, name), _json_safe(data))
464 _write_json(session_dir / f"{job.id}.json", job.view()) # never a half-written run file
465 _write_json(_index_path(session_dir, job.id), job.view(with_result=False))
466 if KEEP_RUNS <= 0:
467 return
468 # Pruned per kind (a run of quick burns must not delete a sweep, a search or a reconcile), and
469 # never a pinned run: the one a reconciled injector was written from stays.
470 same = sorted(p for p in session_dir.glob("*.json") if _kind_of(p) == job.kind and not _pinned(session_dir, p.stem))
471 for old in same[:-KEEP_RUNS]:
472 for path in _run_files(session_dir, old.stem):
473 try:
474 path.unlink()
475 except OSError:
476 pass
477
478
479def _execute(job: Job, work: Callable[[Callable[[str, float], None], Callable[[], bool]], Dict[str, Any]],
480 runs_dir: Path) -> None:
481 acquired = False
482 try:
483 # Wait for a process-wide slot, but let a cancel end the wait: a queued job cancelled
484 # here would otherwise hold its user's one slot until someone else's job finished.
485 while not (acquired := _JOB_SLOTS.acquire(timeout=0.25)):
486 job.stage = f"Waiting for a free slot ({MAX_JOBS} Layer X jobs at once)"
487 if job.cancel.is_set():
488 raise InterruptedError("cancelled while queued")
489 if job.cancel.is_set():
490 raise InterruptedError("cancelled")
491 job.status = "running"
492
493 def progress(stage: str, fraction: float) -> None:
494 job.stage = stage
495 job.progress = float(fraction)
496
497 progress.live = job.feed # type: ignore[attr-defined] # a burn that streams its first pass reads it
498
499 out = work(progress, job.cancel.is_set)
500 if isinstance(out, dict) and isinstance(out.get("_sidecars"), dict):
501 job.sidecars = out.pop("_sidecars")
502 job.result = _json_safe(out)
503 if job.cancel.is_set():
504 # Finished after the cancel arrived: keep what was computed, but say it was cancelled.
505 job.status, job.stage = "cancelled", "Cancelled"
506 else:
507 job.status = "done"
508 job.stage = "Done"
509 job.progress = 1.0
510 except Exception as exc: # noqa: BLE001 - a failed run is reported, never raised into the thread
511 from concurrent.futures.process import BrokenProcessPool
512
513 from engine.layerx.pool import Cancelled
514
515 # The cancel is the job saying so (Cancelled, or the queue wait's InterruptedError), or
516 # the worker pool broken by the cancel's own terminate. Anything else that happens to
517 # land after a cancel is still a failure: a real error is never filed as "cancelled".
518 if isinstance(exc, (Cancelled, InterruptedError)) or (job.cancel.is_set() and isinstance(exc, BrokenProcessPool)):
519 job.status, job.stage = "cancelled", "Cancelled"
520 else:
521 job.status, job.error = "failed", f"{type(exc).__name__}: {exc}"
522 finally:
523 if acquired:
524 _JOB_SLOTS.release()
525 job.finished = time.time()
526 if job.status in ("done", "failed", "cancelled"):
527 try:
528 _persist(runs_dir, job)
529 except Exception as exc: # noqa: BLE001 - kept in memory, said so
530 job.error = (job.error or "") + f" (not saved: {type(exc).__name__}: {exc})"
531 # Kept so the person can still read it, but not for ever: the newest few unsaved.
532 with _JOBS_LOCK:
533 unsaved = sorted((j for j in _JOBS.values() if j.finished and "(not saved:" in (j.error or "")),
534 key=lambda j: j.finished or 0)
535 for old in unsaved[:-UNSAVED_KEPT]:
536 _JOBS.pop(old.id, None)
537 else:
538 # On disk now; GET /runs/{id} reads it from there. Memory holds live jobs only.
539 with _JOBS_LOCK:
540 _JOBS.pop(job.id, None)
541
542
543# ------------------------------------------------------------------ routes
544
545
546@router.get("/status")
547async def status() -> Dict[str, Any]:
548 from engine.layerx.sources import machines_dir, shipped_drawings_dir, source_problems
549
550 try:
551 import feedtwin # noqa: F401
552 from feedtwin import __version__ as version
553
554 available, error = True, None
555 except Exception as exc: # noqa: BLE001
556 available, error, version = False, f"{type(exc).__name__}: {exc}", None
557 machines = machines_dir()
558 return {
559 "feedtwin": {"available": available, "version": version, "error": error},
560 "shipped_drawings": str(shipped_drawings_dir()) if shipped_drawings_dir().is_dir() else None,
561 "state_machines": str(machines) if machines.is_dir() else None,
562 # Each drawing source that is not there: it lists nothing, so say so (the deployed image did).
563 "source_problems": source_problems(),
564 "pid_designer_url": PID_DESIGNER_URL,
565 "max_jobs": MAX_JOBS,
566 }
567
568
569@router.get("/drawings")
570def drawings(session: UserSession = Depends(get_session)) -> List[Dict[str, Any]]:
571 return [d.listing() for d in _store(session).list()]
572
573
574@router.post("/drawings")
575def upload_drawing(file: UploadFile = File(...), session: UserSession = Depends(get_session)) -> Dict[str, Any]:
576 # A plain def, like every route here that parses or touches disk: run in the thread pool, not
577 # on the event loop every other user shares.
578 raw = file.file.read(MAX_UPLOAD_BYTES + 1)
579 if len(raw) > MAX_UPLOAD_BYTES:
580 raise HTTPException(status_code=413, detail=f"A drawing is at most {MAX_UPLOAD_BYTES // 1_000_000} MB.")
581 try:
582 payload = json.loads(raw.decode("utf-8"))
583 except (UnicodeDecodeError, json.JSONDecodeError) as exc:
584 raise HTTPException(status_code=400, detail=f"Not a JSON drawing: {exc}")
585 _check_drawing(payload)
586 name = Path(file.filename or "drawing").stem
587 try:
588 return _store(session).add(payload, name=name, source="upload").listing()
589 except ValueError as exc:
590 raise HTTPException(status_code=422, detail=str(exc))
591
592
593def _check_drawing(doc: Any) -> None:
594 """A drawing Layer X can hold: lists of node and edge objects, at least one symbol. Anything
595 else used to be stored and then break the parameter and measurement routes."""
596 if not isinstance(doc, dict) or not isinstance(doc.get("nodes"), list) or not isinstance(doc.get("edges", []), list):
597 raise HTTPException(status_code=400, detail="Expected a pid-designer drawing: an object with 'nodes' and 'edges' lists.")
598 if not all(isinstance(item, dict) for item in doc["nodes"] + list(doc.get("edges") or [])):
599 raise HTTPException(status_code=400, detail="Every node and edge must be an object.")
600 if not doc["nodes"]:
601 raise HTTPException(status_code=422, detail="The drawing has no symbols.")
602
603
604def _forward(request: Request) -> Dict[str, str]:
605 email = request.headers.get("X-Auth-Email")
606 return {"X-Auth-Email": email} if email else {}
607
608
609def _pid_get(path: str, request: Request, params: Optional[Dict[str, str]] = None) -> Any:
610 """GET one pid-designer route as JSON. Standard library only: the API image
611 installs requirements-base.txt, which carries no HTTP client."""
612 import urllib.error
613 import urllib.parse
614 import urllib.request
615
616 query = f"?{urllib.parse.urlencode(params)}" if params else ""
617 req = urllib.request.Request(f"{PID_DESIGNER_URL}{path}{query}", headers=_forward(request))
618 with urllib.request.urlopen(req, timeout=PID_TIMEOUT_S) as response: # noqa: S310 - our own sibling app
619 body = response.read(MAX_UPLOAD_BYTES + 1)
620 if len(body) > MAX_UPLOAD_BYTES:
621 raise ValueError(f"pid-designer returned more than {MAX_UPLOAD_BYTES // 1_000_000} MB")
622 return json.loads(body.decode("utf-8"))
623
624
625@router.get("/pid-designer")
626def pid_documents(request: Request) -> Dict[str, Any]:
627 """pid-designer's documents this user can open: their own and those shared.
628 Sync, so FastAPI runs the two blocking GETs in a worker thread."""
629 import urllib.error
630
631 base = "/api/pid/diagrams"
632 try:
633 mine = _pid_get(base, request)
634 except (urllib.error.URLError, OSError, ValueError) as exc:
635 return {"reachable": False, "url": PID_DESIGNER_URL, "error": str(exc), "documents": []}
636 try:
637 others = _pid_get(f"{base}/browse", request)
638 except (urllib.error.URLError, OSError, ValueError):
639 # Browsing is a convenience; it must not stop somebody importing their own work.
640 others = []
641 docs = []
642 for record in mine if isinstance(mine, list) else []:
643 docs.append({"id": record.get("id", ""), "name": record.get("name") or record.get("id", ""),
644 "owner": record.get("owner", ""), "updated_at": record.get("updatedAt") or "", "mine": True})
645 for group in others if isinstance(others, list) else []:
646 for record in group.get("designs", []):
647 docs.append({"id": record.get("id", ""), "name": record.get("name") or record.get("id", ""),
648 "owner": group.get("owner", ""), "updated_at": record.get("updatedAt") or "", "mine": False})
649 return {"reachable": True, "url": PID_DESIGNER_URL, "documents": docs}
650
651
652@router.post("/pid-designer/import")
653def pid_import(body: PidImport, request: Request, session: UserSession = Depends(get_session)) -> Dict[str, Any]:
654 import urllib.error
655
656 from urllib.parse import quote
657
658 path = f"/api/pid/diagrams/{quote(body.id, safe='')}/" + (
659 f"release/{quote(body.release, safe='')}" if body.release else "load")
660 try:
661 payload = _pid_get(path, request, {"owner": body.owner} if body.owner else None)
662 except (urllib.error.URLError, OSError, ValueError) as exc:
663 raise HTTPException(status_code=502, detail=f"pid-designer at {PID_DESIGNER_URL}: {exc}")
664 inner = payload.get("data") if isinstance(payload, dict) else None
665 body_doc = inner if isinstance(inner, dict) else payload
666 if not isinstance(body_doc, dict):
667 raise HTTPException(status_code=502, detail=f"pid-designer returned no document for {body.id!r}.")
668 _check_drawing(body_doc)
669 where = f"{body.owner}/{body.id}" if body.owner else body.id
670 stamp = f"release {body.release}" if body.release else "working copy"
671 try:
672 drawing = _store(session).add(body_doc, name=body.name or body.id,
673 source=f"pid-designer:{where}@{stamp}")
674 except ValueError as exc:
675 raise HTTPException(status_code=422, detail=str(exc))
676 return drawing.listing()
677
678
679@router.post("/preflight")
680def preflight(body: Settings, session: UserSession = Depends(get_session)) -> Dict[str, Any]:
681 """Everything a run would start from, graded. Sync: FastAPI runs it in a worker
682 thread, so its ~3 s (one EngineDesign solve, two assemblies) blocks nobody."""
683 prep, _, _ = _prepare(session, body)
684 return _json_safe(prep.preflight())
685
686
687@router.post("/card")
688def engine_card(body: Settings, session: UserSession = Depends(get_session)) -> Dict[str, Any]:
689 """The engine card a burn with these settings would use, as feedtwin reads it
690 (``feedtwin.engine.EngineCard.from_dict``): for the feed-twin cockpit, or for checking by hand."""
691 prep, _, _ = _prepare(session, body.model_copy(update={"engine_model": "card"}))
692 if prep.link is None or prep.link.card is None:
693 failing = [c.label for c in prep.checks if c.status == "fail"]
694 raise HTTPException(status_code=422, detail={"message": "No engine card.", "failing": failing})
695 return _json_safe(prep.link.card.to_dict())
696
697
698class CardRequest(BaseModel):
699 yaml: str = Field(..., description="An engine config, as EngineDesign writes it")
700 center_psia: Optional[float] = None
701
702
703@router.post("/engine-card")
704def engine_card_for_config(body: CardRequest) -> Dict[str, Any]:
705 """The engine card for any engine config, sent as YAML: no session, no drawing. What the
706 feed-twin cockpit asks for when it imports an engine, so the stand fires EngineDesign's
707 engine rather than feedtwin's simplified one (``engine.layerx.card.card_for_config_text``)."""
708 from engine.layerx.card import card_for_config_text
709
710 try:
711 return _json_safe(card_for_config_text(body.yaml, center_psia=body.center_psia))
712 except Exception as exc: # noqa: BLE001 - a config that will not build a card is the caller's to fix
713 raise HTTPException(status_code=422, detail=f"No engine card for that config: {type(exc).__name__}: {exc}")
714
715
716@router.post("/runs")
717def start_run(body: Settings, session: UserSession = Depends(get_session),
718 design: str = Depends(_design_label)) -> Dict[str, Any]:
719 def build() -> Callable[..., Dict[str, Any]]:
720 prep, runner, config = _prepare(session, body)
721 if not prep.ok:
722 failing = [c.label for c in prep.checks if c.status == "fail"]
723 raise HTTPException(status_code=422, detail={"message": "Preflight failed.", "failing": failing,
724 "preflight": _json_safe(prep.preflight())})
725 from engine.layerx import run_prepared
726
727 snapshot = _design_snapshot(config)
728
729 def work(progress: Callable[[str, float], None], cancelled: Callable[[], bool]) -> Dict[str, Any]:
730 # Every run a person starts carries its diagnostics (DATA-CONTRACT 3); they read the burn
731 # and change nothing in it. The axial heat-flux grids go beside the run file.
732 sidecars: Dict[str, Any] = {}
733 result = run_prepared(prep, runner=runner, progress=progress, cancelled=cancelled,
734 replay=prep.settings.replay, config=config, diagnostics=True, sidecars=sidecars,
735 live=getattr(progress, "live", None))
736 result.setdefault("provenance", {})["reproduce"] = snapshot
737 if sidecars:
738 result["sidecars"] = sorted(sidecars)
739 result["_sidecars"] = sidecars
740 return result
741
742 return work
743
744 return _launch(session, body, "run", build, design)
745
746
747def _describe() -> str:
748 """``git describe`` of this checkout, with a mark when it has uncommitted changes."""
749 import subprocess
750
751 root = Path(__file__).resolve().parents[2]
752 try:
753 out = subprocess.run(["git", "describe", "--always", "--dirty"], cwd=root, capture_output=True,
754 text=True, timeout=3)
755 return out.stdout.strip() or "unknown"
756 except (OSError, subprocess.SubprocessError):
757 return "unknown"
758
759
760#: The code this process loaded, read once at import. A run records what produced it: asked per run,
761#: a server started at 9ebd9fa0 and never restarted filed its second burn as '170af951-dirty' once
762#: other commits landed, though the modules it ran (lib/feedtwin, which uvicorn --reload does not
763#: watch, included) had not changed.
764_CODE_AT_START = _describe()
765
766
767def _code_version() -> str:
768 return _CODE_AT_START
769
770
771def _design_snapshot(config: Any) -> Dict[str, Any]:
772 """What it takes to burn this run again: the design as it was (YAML), and the code version.
773 The hash alone said whether the design had moved, not what it had been."""
774 import yaml
775
776 try:
777 text = yaml.safe_dump(config.model_dump(mode="json"), sort_keys=False)
778 except Exception as exc: # noqa: BLE001 - the run stands without its snapshot, and says so
779 text = f"# could not snapshot the design: {type(exc).__name__}: {exc}"
780 return {"design_yaml": text, "code": _code_version()}
781
782
783def _design_name(session: UserSession) -> str:
784 path = getattr(session.app_state, "config_path", None)
785 return (Path(path).stem if path else "") or "live design"
786
787
788def _reserve(session: UserSession, settings: Settings, kind: str, design: Optional[str] = None) -> Job:
789 """One job per user, claimed atomically: the check and the claim under one lock, before the
790 seconds of preparation, so two requests at once cannot both pass the check."""
791 with _JOBS_LOCK:
792 busy = [j for j in _JOBS.values() if j.user == session.user and j.status in ("queued", "running")]
793 if busy:
794 raise HTTPException(status_code=409, detail=f"A {busy[0].kind} ({busy[0].id}) is still going. Cancel it or wait.")
795 job = Job(session.user, settings, design or _design_name(session), kind=kind,
796 design_sha256=_fingerprint(session))
797 job.stage = "Preparing"
798 _JOBS[job.id] = job
799 return job
800
801
802def _fingerprint(session: UserSession) -> str:
803 try:
804 from engine.layerx.fingerprint import config_fingerprint
805
806 return config_fingerprint(session.app_state.config) if session.app_state.config is not None else ""
807 except Exception: # noqa: BLE001 - the name still files it
808 return ""
809
810
811def _release(job: Job) -> None:
812 with _JOBS_LOCK:
813 _JOBS.pop(job.id, None)
814
815
816def _launch(session: UserSession, settings: Settings, kind: str,
817 build: Callable[[], Callable[..., Dict[str, Any]]], design: Optional[str] = None) -> Dict[str, Any]:
818 """Claim the user's job slot, prepare (``build`` returns the work, or raises), and start."""
819 job = _reserve(session, settings, kind, design)
820 try:
821 runs_dir = _runs_dir(session)
822 work = build()
823 threading.Thread(target=_execute, args=(job, work, runs_dir), name=f"layerx-{job.id}", daemon=True).start()
824 except BaseException:
825 _release(job)
826 raise
827 return {"id": job.id, "kind": job.kind, "status": job.status}
828
829
830@router.post("/uncertainty")
831def start_uncertainty(body: Settings, session: UserSession = Depends(get_session),
832 design: str = Depends(_design_label)) -> Dict[str, Any]:
833 """One-at-a-time sweep of the unmeasured inputs (engine/layerx/uncertainty.py): the tornado
834 and the band. Parallel worker processes; ~30 s on the 6.8 kN stand."""
835 def build() -> Callable[..., Dict[str, Any]]:
836 prep, _, config = _prepare(session, body)
837 if not prep.ok:
838 failing = [c.label for c in prep.checks if c.status == "fail"]
839 raise HTTPException(status_code=422, detail={"message": "Preflight failed.", "failing": failing})
840 from engine.layerx.uncertainty import run_sweep
841
842 drawing, settings, overrides = prep.drawing, prep.settings, prep.measurements
843 return lambda progress, cancelled: run_sweep(
844 config, drawing, settings, overrides, progress=progress, cancelled=cancelled)
845
846 return _launch(session, body, "uncertainty", build, design)
847
848
849#: Hard limits on the optimiser's searched bounds, the same as Settings' (a candidate is built with
850#: dataclasses.replace, which does not validate).
851OPT_LIMITS = {"lockup_psia": (14.0, 5000.0), "copv_psig": (0.0, 12000.0), "copv_volume_L": (0.05, 500.0)}
852
853
854class OptimizeBody(BaseModel):
855 settings: Settings
856 objective: str = Field(default="impulse", pattern="^(impulse|apogee)$")
857 variables: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
858 dropout_margin_psi: float = Field(default=100.0, ge=0.0, le=3000.0)
859 stiffness: bool = True
860 of_band_rel: Optional[float] = Field(default=None, gt=0.0, lt=0.5)
861 max_evaluations: int = Field(default=40, ge=5, le=200)
862
863 @field_validator("variables")
864 @classmethod
865 def _bounds(cls, value: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
866 import math
867
868 for key, raw in value.items():
869 if key not in OPT_LIMITS:
870 raise ValueError(f"unknown variable {key!r}")
871 lo_lim, hi_lim = OPT_LIMITS[key]
872 for end in ("lo", "hi"):
873 if end in raw:
874 v = raw[end]
875 if not isinstance(v, (int, float)) or isinstance(v, bool) or not math.isfinite(v):
876 raise ValueError(f"{key}.{end} must be a finite number")
877 if not lo_lim <= v <= hi_lim:
878 raise ValueError(f"{key}.{end} = {v:g} is outside {lo_lim:g}-{hi_lim:g}")
879 return value
880
881
882@router.post("/optimize/variables")
883def optimize_variables(body: Settings, session: UserSession = Depends(get_session)) -> Dict[str, Any]:
884 """The optimiser's decision variables for these settings: bounds, start, and why."""
885 from dataclasses import asdict
886
887 from engine.layerx.optimize import default_variables
888
889 prep, _, config = _prepare(session, body)
890 if not prep.ok:
891 failing = [c.label for c in prep.checks if c.status == "fail"]
892 raise HTTPException(status_code=422, detail={"message": "Preflight failed.", "failing": failing})
893 return {"variables": [asdict(v) for v in default_variables(prep, config)],
894 "stiffness_band": prep.derived.get("stiffness_band"),
895 "design_of": getattr(getattr(config, "design_requirements", None), "optimal_of_ratio", None)}
896
897
898@router.post("/optimize")
899def start_optimize(body: OptimizeBody, session: UserSession = Depends(get_session),
900 design: str = Depends(_design_label)) -> Dict[str, Any]:
901 """Search the feed hardware (lockup, COPV fill, COPV volume) for the most impulse or apogee from
902 the fixed load, every candidate a whole burn (engine/layerx/optimize.py). Minutes."""
903 def build() -> Callable[..., Dict[str, Any]]:
904 prep, _, config = _prepare(session, body.settings)
905 if not prep.ok:
906 failing = [c.label for c in prep.checks if c.status == "fail"]
907 raise HTTPException(status_code=422, detail={"message": "Preflight failed.", "failing": failing})
908 from engine.layerx.optimize import OptimizeRequest, run_optimize
909
910 request = OptimizeRequest.from_dict(body.model_dump(exclude={"settings"}))
911 drawing, settings, overrides = prep.drawing, prep.settings, prep.measurements
912 return lambda progress, cancelled: run_optimize(
913 config, drawing, settings, overrides, request, progress=progress, cancelled=cancelled)
914
915 return _launch(session, body.settings, "optimize", build, design)
916
917
918#: Job kinds whose saved runs are listed (``legacy: true``) and readable through GET /runs/{id},
919#: read-only; nothing rewrites them. The trade study was removed on 2026-10-02 (docs/layerx/AUDIT.md
920#: D6, 9.10): set point and the uncertainty tornado cover it. The compass-search optimiser is
921#: replaced by Set point and Hardware (``/setpoint``, ``/hardware``); its endpoint stays while the
922#: old Optimise page still calls it, and what it writes is legacy too.
923LEGACY_KINDS = frozenset({"trade", "optimize"})
924
925
926def _check_meop(value: Optional[Dict[str, float]]) -> Optional[Dict[str, float]]:
927 import math
928
929 for side, v in (value or {}).items():
930 if side not in ("oxidiser", "fuel"):
931 raise ValueError(f"meop_psi side {side!r} must be 'oxidiser' or 'fuel'")
932 if not isinstance(v, (int, float)) or isinstance(v, bool) or not math.isfinite(v) or not 0.0 < v < 20000.0:
933 raise ValueError(f"meop_psi.{side} = {v!r} is not a pressure")
934 return value
935
936
937class SetpointBody(BaseModel):
938 settings: Settings
939 #: None: the design's ``design_requirements.target_thrust``.
940 target_thrust_N: Optional[float] = Field(default=None, gt=0.0, lt=1.0e6, allow_inf_nan=False)
941 thrust_tol_rel: float = Field(default=1.0e-3, ge=1.0e-5, le=0.05)
942 solve_fill: bool = True
943 margin_psi: float = Field(default=100.0, ge=0.0, le=3000.0)
944 margin_tol_psi: float = Field(default=20.0, gt=0.0, le=500.0)
945 #: Per tank side, a maximum expected operating pressure across the wall [psi]: graded as a limit.
946 meop_psi: Optional[Dict[str, float]] = None
947 design_of: Optional[float] = Field(default=None, gt=0.0, lt=10.0, allow_inf_nan=False)
948 max_burns: int = Field(default=10, ge=3, le=24)
949 replay: bool = True
950 #: With replay off: burn the answer again with the replay, and correct once for the offset.
951 verify: bool = True
952
953 @field_validator("meop_psi")
954 @classmethod
955 def _meop(cls, value: Optional[Dict[str, float]]) -> Optional[Dict[str, float]]:
956 return _check_meop(value)
957
958
959@router.post("/setpoint")
960def start_setpoint(body: SetpointBody, session: UserSession = Depends(get_session),
961 design: str = Depends(_design_label)) -> Dict[str, Any]:
962 """The dome dial, lockup and bottle fill that give a target mean thrust (engine/layerx/setpoint.py):
963 a secant on lockup, then the fill for a bottle margin, every point a whole burn. Minutes."""
964 def build() -> Callable[..., Dict[str, Any]]:
965 prep, _, config = _prepare(session, body.settings)
966 if not prep.ok:
967 failing = [c.label for c in prep.checks if c.status == "fail"]
968 raise HTTPException(status_code=422, detail={"message": "Preflight failed.", "failing": failing})
969 from engine.layerx.setpoint import SetpointRequest, run_setpoint, target_thrust
970
971 request = SetpointRequest.from_dict(body.model_dump(exclude={"settings"}))
972 try:
973 target_thrust(config, request) # refused here, before a slot is taken
974 except ValueError as exc:
975 raise HTTPException(status_code=422, detail={"message": str(exc)}) from exc
976 drawing, settings, overrides = prep.drawing, prep.settings, prep.measurements
977 return lambda progress, cancelled: run_setpoint(
978 config, drawing, settings, overrides, request, progress=progress, cancelled=cancelled)
979
980 return _launch(session, body.settings, "setpoint", build, design)
981
982
983class HardwareBody(BaseModel):
984 settings: Settings
985 #: The components that may change: [{"target": "node:SV_LOX_PRESS" | "edge:l_ox1" |
986 #: "design:oxidizer.d_jet", "kind"?: "trim_orifice", "rows"?: [catalog row ids]}].
987 components: List[Dict[str, Any]] = Field(min_length=1, max_length=8)
988 #: What the set point cannot fix: O/F is the default (one regulator presses both tanks).
989 objective: str = Field(default="of_error",
990 pattern="^(target_thrust_error|thrust_flatness|of_error|impulse|bottle_margin)$")
991 target_thrust_N: Optional[float] = Field(default=None, gt=0.0, lt=1.0e6, allow_inf_nan=False)
992 design_of: Optional[float] = Field(default=None, gt=0.0, lt=10.0, allow_inf_nan=False)
993 neighbours: int = Field(default=2, ge=1, le=4)
994 combine: bool = False
995 max_candidates: int = Field(default=8, ge=1, le=24)
996 margin_psi: float = Field(default=100.0, ge=0.0, le=3000.0)
997 meop_psi: Optional[Dict[str, float]] = None
998 verify: bool = True
999 #: A measured discharge coefficient for a trim orifice; None: Reader-Harris/Gallagher (extrapolated).
1000 trim_C: Optional[float] = Field(default=None, gt=0.1, lt=1.0, allow_inf_nan=False)
1001
1002 @field_validator("meop_psi")
1003 @classmethod
1004 def _meop(cls, value: Optional[Dict[str, float]]) -> Optional[Dict[str, float]]:
1005 return _check_meop(value)
1006
1007
1008@router.post("/hardware")
1009def start_hardware(body: HardwareBody, session: UserSession = Depends(get_session),
1010 design: str = Depends(_design_label)) -> Dict[str, Any]:
1011 """Catalogue parts for the components marked free, each candidate a whole burn on an in-memory
1012 copy of the drawing, ranked by the objective (engine/layerx/optimize.py run_hardware)."""
1013 def build() -> Callable[..., Dict[str, Any]]:
1014 prep, _, config = _prepare(session, body.settings)
1015 if not prep.ok:
1016 failing = [c.label for c in prep.checks if c.status == "fail"]
1017 raise HTTPException(status_code=422, detail={"message": "Preflight failed.", "failing": failing})
1018 from engine.layerx.catalog import load_catalogs
1019 from engine.layerx.optimize import HardwareRequest, plan_candidates, run_hardware
1020
1021 catalogs = load_catalogs(_user_dir(session))
1022 try:
1023 request = HardwareRequest.from_dict(body.model_dump(exclude={"settings"}))
1024 plan_candidates(prep, config, request, catalogs) # refused here, before a slot is taken
1025 except ValueError as exc:
1026 raise HTTPException(status_code=422, detail={"message": str(exc)}) from exc
1027 drawing, settings, overrides = prep.drawing, prep.settings, prep.measurements
1028 return lambda progress, cancelled: run_hardware(
1029 config, drawing, settings, overrides, request, catalogs=catalogs, progress=progress, cancelled=cancelled)
1030
1031 return _launch(session, body.settings, "hardware", build, design)
1032
1033
1034@router.get("/catalog")
1035def catalog(session: UserSession = Depends(get_session)) -> Dict[str, Any]:
1036 """The parts Hardware mode may choose from (engine/layerx/catalog.py): the shipped catalogues
1037 with this user's rows over them by id, the drill table, and any user file that did not read
1038 (``problems``; its shipped rows stand). Read-only: the user adds rows as files."""
1039 from engine.layerx.catalog import load_catalogs
1040
1041 return _json_safe(load_catalogs(_user_dir(session)))
1042
1043
1044class ReconcileBody(BaseModel):
1045 settings: Settings
1046 thrust_N: Optional[float] = Field(default=None, gt=0.0, lt=1.0e6, allow_inf_nan=False)
1047 of: Optional[float] = Field(default=None, gt=0.0, lt=10.0, allow_inf_nan=False)
1048 hold_spray_direction: bool = True
1049 max_passes: int = Field(default=4, ge=1, le=8)
1050
1051
1052@router.post("/reconcile")
1053def start_reconcile(body: ReconcileBody, session: UserSession = Depends(get_session),
1054 design: str = Depends(_design_label)) -> Dict[str, Any]:
1055 """Resize the injector's holes (and, if asked, its jet angles) so the design point holds under
1056 the drawing's feed, burning and refitting until they stop moving (engine/layerx/reconcile.py)."""
1057 def build() -> Callable[..., Dict[str, Any]]:
1058 prep, _, config = _prepare(session, body.settings)
1059 if not prep.ok:
1060 failing = [c.label for c in prep.checks if c.status == "fail"]
1061 raise HTTPException(status_code=422, detail={"message": "Preflight failed.", "failing": failing})
1062 from engine.layerx.reconcile import ReconcileRequest, run_reconcile
1063
1064 request = ReconcileRequest.from_dict(body.model_dump(exclude={"settings"}))
1065 drawing, settings, overrides = prep.drawing, prep.settings, prep.measurements
1066 return lambda progress, cancelled: run_reconcile(
1067 config, drawing, settings, overrides, request, progress=progress, cancelled=cancelled)
1068
1069 return _launch(session, body.settings, "reconcile", build, design)
1070
1071
1072class ExportBody(BaseModel):
1073 design_patch: Optional[Dict[str, Dict[str, float]]] = None
1074 #: Per side, the fitted feed to write too (a reconcile's design_update.feed_system).
1075 feed_system: Optional[Dict[str, Dict[str, Any]]] = None
1076 name: str = Field(default="design", max_length=80)
1077 note: str = Field(default="", max_length=400)
1078
1079 @field_validator("design_patch")
1080 @classmethod
1081 def _patch(cls, value: Optional[Dict[str, Dict[str, float]]]) -> Optional[Dict[str, Dict[str, float]]]:
1082 return check_design_patch(value)
1083
1084
1085@router.post("/export-config")
1086def export_config(body: ExportBody, session: UserSession = Depends(get_session)):
1087 """The live design with an injector what-if (and, if given, the drawing's fitted feed)
1088 written in, as a YAML file to download. The design itself is not changed."""
1089 import re as _re
1090
1091 import yaml
1092 from fastapi.responses import PlainTextResponse
1093
1094 from engine.pipeline.config_schemas import PintleEngineConfig
1095
1096 config, _ = _config_and_runner(session, body.design_patch)
1097 raw = config.model_dump(mode="json")
1098 if body.feed_system:
1099 allowed = {"K0", "supply_K", "K1", "phi_type", "roughness_m", "fittings", "derived_from"}
1100 for side, upd in body.feed_system.items():
1101 if side not in ("oxidizer", "fuel") or not isinstance(upd, dict):
1102 raise HTTPException(status_code=422, detail=f"feed_system side {side!r} must be oxidizer or fuel")
1103 if not isinstance((raw.get("feed_system") or {}).get(side), dict):
1104 raise HTTPException(status_code=422, detail=f"The design has no feed_system.{side} to write the fit into.")
1105 raw["feed_system"][side].update({k: v for k, v in upd.items() if k in allowed})
1106 try:
1107 PintleEngineConfig(**raw)
1108 except Exception as exc: # noqa: BLE001 - an export that would not load is refused
1109 raise HTTPException(status_code=422, detail=f"The exported design would not load: {exc}") from exc
1110 stamp = time.strftime("%Y-%m-%d %H:%M")
1111 lines = [f"# {body.name}: exported from Layer X, {stamp}."]
1112 for side, fields in (body.design_patch or {}).items():
1113 parts = []
1114 if "d_jet" in fields:
1115 parts.append(f"holes {fields['d_jet'] * 1e3:.4f} mm")
1116 if "impingement_angle" in fields:
1117 parts.append(f"jet angle {fields['impingement_angle']:g} deg")
1118 if "orifice_l_over_d" in fields:
1119 parts.append(f"L/d {fields['orifice_l_over_d']:.3f}")
1120 lines.append(f"# {side}: " + ", ".join(parts))
1121 for side, upd in (body.feed_system or {}).items():
1122 if "K0" in upd:
1123 lines.append(f"# {side} feed: K0 {float(upd['K0']):.4f}, fitted to the drawing's feed")
1124 if body.note:
1125 lines += [f"# {ln}" for ln in body.note.splitlines()]
1126 text = "\n".join(lines) + "\n" + yaml.safe_dump(raw, sort_keys=False, allow_unicode=True)
1127 fname = _re.sub(r"[^A-Za-z0-9._-]+", "_", body.name).strip("_") or "design"
1128 return PlainTextResponse(text, media_type="application/x-yaml",
1129 headers={"Content-Disposition": f'attachment; filename="{fname}.yaml"'})
1130
1131
1132@router.get("/drawings/{drawing_id}/parameters")
1133def drawing_parameters(drawing_id: str, session: UserSession = Depends(get_session)) -> Dict[str, Any]:
1134 from engine.layerx.measurements import override_view, parameter_table
1135
1136 drawing = _drawing(session, drawing_id)
1137 rows = parameter_table(drawing.payload)
1138 return {"rows": rows, "overrides": [override_view(o, rows) for o in _measurements(session).get(drawing)]}
1139
1140
1141@router.get("/drawings/{drawing_id}/document")
1142def drawing_document(drawing_id: str, session: UserSession = Depends(get_session)) -> Dict[str, Any]:
1143 """The drawing itself (nodes with positions, edges with routes), read-only: the schematic is
1144 drawn from its topology, never from a hand-made layout."""
1145 drawing = _drawing(session, drawing_id)
1146 return {"id": drawing.id, "name": drawing.name, "sha256": drawing.sha256, "document": drawing.payload}
1147
1148
1149# ------------------------------------------------------------------ the stand's DAQ
1150
1151
1152#: Where the DAQ's channel list lives, overridable for a deployment that keeps it elsewhere.
1153DAQ_CONFIG = Path(os.environ.get("DAQ_CONFIG") or (Path(__file__).resolve().parents[3] / "daq-server" / "config" / "config_ground_daq.toml"))
1154
1155
1156@router.get("/daq-channels")
1157def daq_channels() -> Dict[str, Any]:
1158 """The stand DAQ's channels (daq-server's ground config): name, what it reads, its range.
1159 So a drawing's instrument can be paired with the channel that will record it."""
1160 import tomllib
1161
1162 if not DAQ_CONFIG.is_file():
1163 return {"available": False, "path": str(DAQ_CONFIG), "channels": []}
1164 try:
1165 data = tomllib.loads(DAQ_CONFIG.read_text())
1166 except (OSError, tomllib.TOMLDecodeError) as exc:
1167 return {"available": False, "path": str(DAQ_CONFIG), "error": str(exc), "channels": []}
1168 channels: Dict[str, Dict[str, Any]] = {}
1169 for group, kinds in (data.get("sensors") or {}).items():
1170 if not isinstance(kinds, dict):
1171 continue
1172 for kind, table in kinds.items():
1173 if not isinstance(table, dict):
1174 continue
1175 for name, spec in table.items():
1176 if isinstance(spec, dict):
1177 channels.setdefault(name, {"name": name, "kind": kind.split("_")[-1].upper(), "group": group,
1178 "purpose": spec.get("purpose") or "", "max_psi": spec.get("max_psi")})
1179 return {"available": True, "path": str(DAQ_CONFIG), "channels": sorted(channels.values(), key=lambda c: c["name"])}
1180
1181
1182class ChannelMap(BaseModel):
1183 #: Drawing instrument id to the DAQ channel that records it.
1184 channels: Dict[str, str] = Field(default_factory=dict, max_length=200)
1185
1186 @field_validator("channels")
1187 @classmethod
1188 def _names(cls, value: Dict[str, str]) -> Dict[str, str]:
1189 for k, v in value.items():
1190 if not re.fullmatch(r"[A-Za-z0-9_.:-]{1,80}", k) or not re.fullmatch(r"[A-Za-z0-9_.:-]{0,80}", v):
1191 raise ValueError(f"channel map {k!r} -> {v!r} is not a plain name")
1192 return {k: v for k, v in value.items() if v}
1193
1194
1195def _channels_path(session: UserSession, drawing_id: str) -> Path:
1196 if not re.fullmatch(r"[0-9a-zA-Z_-]{1,80}", drawing_id):
1197 raise HTTPException(status_code=404, detail="No such drawing.")
1198 return _user_dir(session) / "layerx" / "channels" / f"{drawing_id}.json"
1199
1200
1201@router.get("/drawings/{drawing_id}/channels")
1202def get_channels(drawing_id: str, session: UserSession = Depends(get_session)) -> Dict[str, Any]:
1203 """Which DAQ channel records each of this drawing's instruments."""
1204 return {"channels": (_read_json(_channels_path(session, drawing_id)) or {}).get("channels", {})}
1205
1206
1207@router.put("/drawings/{drawing_id}/channels")
1208def put_channels(drawing_id: str, body: ChannelMap, session: UserSession = Depends(get_session)) -> Dict[str, Any]:
1209 """Pair the drawing's instruments with DAQ channels. Kept on the server with the drawing."""
1210 _write_json(_channels_path(session, drawing_id), {"channels": body.channels})
1211 return {"channels": body.channels}
1212
1213
1214class MeasurementsBody(BaseModel):
1215 overrides: List[Dict[str, Any]] = Field(max_length=500)
1216
1217
1218@router.put("/drawings/{drawing_id}/measurements")
1219def put_measurements(drawing_id: str, body: MeasurementsBody,
1220 session: UserSession = Depends(get_session)) -> Dict[str, Any]:
1221 from dataclasses import asdict
1222
1223 from engine.layerx.measurements import Override, apply_overrides
1224
1225 drawing = _drawing(session, drawing_id)
1226 try:
1227 overrides = [Override.from_dict(o) for o in body.overrides]
1228 except (KeyError, TypeError, ValueError) as exc:
1229 raise HTTPException(status_code=422, detail=str(exc))
1230 _, _, missing = apply_overrides(drawing.payload, overrides)
1231 if missing:
1232 raise HTTPException(status_code=422, detail=f"The drawing has no {', '.join(missing)}.")
1233 try:
1234 _measurements(session).put(drawing, overrides)
1235 except ValueError as exc:
1236 raise HTTPException(status_code=422, detail=str(exc))
1237 return {"overrides": [asdict(o) for o in overrides]}
1238
1239
1240def _listing_summary(result: Dict[str, Any]) -> Optional[Dict[str, Any]]:
1241 """A run's summary for the list, with the replay's delivered impulse where there is one, so
1242 the list and the result's headline say the same number."""
1243 summary = result.get("summary")
1244 delivered = ((result.get("delivered") or {}).get("summary") or {}) if isinstance(result, dict) else {}
1245 if isinstance(summary, dict) and delivered.get("total_impulse_Ns") is not None:
1246 # The delivered mean too: the list showed the replay's impulse beside the twin's mean thrust,
1247 # so impulse / burn time was not the mean it printed.
1248 summary = {**summary, "total_impulse_Ns": delivered["total_impulse_Ns"],
1249 **({"mean_thrust_N": delivered["mean_thrust_N"]} if delivered.get("mean_thrust_N") is not None else {})}
1250 flight = result.get("flight") if isinstance(result, dict) else None
1251 if isinstance(summary, dict) and isinstance(flight, dict) and flight.get("ok"):
1252 summary = {**summary, "apogee_agl_m": flight.get("apogee_agl_m")}
1253 trip = result.get("tripped") if isinstance(result, dict) else None
1254 if isinstance(summary, dict) and isinstance(trip, dict):
1255 # The list says a run stopped at a vessel trip, so its short impulse is not read as a design's.
1256 summary = {**summary, "tripped": {k: trip.get(k) for k in ("vessel", "label", "t", "p_psia", "mawp_psia")}}
1257 return summary
1258
1259
1260def _load_saved(session: UserSession, run_id: str) -> Optional[Dict[str, Any]]:
1261 if not RUN_ID_RE.match(run_id):
1262 return None
1263 path = _runs_dir(session) / f"{run_id}.json"
1264 if not path.is_file() or path.parent != _runs_dir(session):
1265 return None
1266 try:
1267 saved = json.loads(path.read_text())
1268 except (OSError, json.JSONDecodeError):
1269 return None
1270 return saved if isinstance(saved, dict) and "id" in saved else None
1271
1272
1273@router.get("/runs")
1274def list_runs(session: UserSession = Depends(get_session)) -> List[Dict[str, Any]]:
1275 runs_dir = _runs_dir(session)
1276 out: Dict[str, Dict[str, Any]] = {}
1277 for path in sorted(runs_dir.glob("*.json")):
1278 entry = _read_json(_index_path(runs_dir, path.stem))
1279 if entry is None:
1280 # Saved before the index existed: read it once, and write its entry.
1281 saved = _read_json(path)
1282 if not saved or "id" not in saved:
1283 continue
1284 result = saved.pop("result", None) or {}
1285 saved["summary"] = _listing_summary(result)
1286 saved["drawing"] = (result.get("provenance") or {}).get("drawing")
1287 entry = saved
1288 try:
1289 _write_json(_index_path(runs_dir, path.stem), entry)
1290 except OSError:
1291 pass
1292 meta = _read_json(_meta_path(runs_dir, path.stem))
1293 if meta:
1294 entry = {**entry, "meta": meta}
1295 if entry.get("kind") in LEGACY_KINDS:
1296 # Marked at read time: the files are not rewritten, and the mark goes if the kind returns.
1297 entry = {**entry, "legacy": True}
1298 out[entry["id"]] = entry
1299 with _JOBS_LOCK:
1300 for job in _JOBS.values():
1301 if job.user == session.user:
1302 out[job.id] = {**job.view(with_result=False), **({"meta": out[job.id]["meta"]} if job.id in out and "meta" in out[job.id] else {})}
1303 return sorted(out.values(), key=lambda r: r.get("started") or 0, reverse=True)
1304
1305
1306class RunMeta(BaseModel):
1307 name: Optional[str] = Field(default=None, max_length=80)
1308 note: Optional[str] = Field(default=None, max_length=2000)
1309 pinned: Optional[bool] = None
1310
1311
1312@router.patch("/runs/{run_id}")
1313def annotate_run(run_id: str, body: RunMeta, session: UserSession = Depends(get_session)) -> Dict[str, Any]:
1314 """Name, note or pin a saved run. A pinned run is never pruned."""
1315 runs_dir = _runs_dir(session)
1316 if not RUN_ID_RE.match(run_id) or not (runs_dir / f"{run_id}.json").is_file():
1317 raise HTTPException(status_code=404, detail=f"No saved run {run_id!r}.")
1318 meta = _read_json(_meta_path(runs_dir, run_id)) or {}
1319 for key, value in body.model_dump(exclude_none=True).items():
1320 meta[key] = value.strip() if isinstance(value, str) else value
1321 _write_json(_meta_path(runs_dir, run_id), meta)
1322 return meta
1323
1324
1325@router.delete("/runs/{run_id}")
1326def delete_run(run_id: str, session: UserSession = Depends(get_session)) -> Dict[str, Any]:
1327 """Delete a saved run of this user's, with its index entry and notes."""
1328 runs_dir = _runs_dir(session)
1329 with _JOBS_LOCK:
1330 live = _JOBS.get(run_id)
1331 if live is not None and live.user == session.user and live.status in ("queued", "running"):
1332 raise HTTPException(status_code=409, detail="Cancel it first.")
1333 if not RUN_ID_RE.match(run_id) or not (runs_dir / f"{run_id}.json").is_file():
1334 raise HTTPException(status_code=404, detail=f"No saved run {run_id!r}.")
1335 for path in _run_files(runs_dir, run_id):
1336 try:
1337 path.unlink()
1338 except OSError:
1339 pass
1340 return {"id": run_id, "deleted": True}
1341
1342
1343@router.get("/runs/{run_id}")
1344def get_run(run_id: str, session: UserSession = Depends(get_session)) -> Dict[str, Any]:
1345 with _JOBS_LOCK:
1346 job = _JOBS.get(run_id)
1347 if job is not None and job.user == session.user:
1348 return job.view()
1349 saved = _load_saved(session, run_id)
1350 if saved is None:
1351 raise HTTPException(status_code=404, detail=f"No run {run_id!r}.")
1352 if saved.get("kind") in LEGACY_KINDS:
1353 saved["legacy"] = True
1354 return saved
1355
1356
1357@router.get("/runs/{run_id}/eng")
1359 run_id: str,
1360 diameter_mm: Optional[float] = Query(default=None, gt=0.0, lt=5000.0, allow_inf_nan=False),
1361 length_mm: Optional[float] = Query(default=None, gt=0.0, lt=10000.0, allow_inf_nan=False),
1362 dry_kg: Optional[float] = Query(default=None, ge=0.0, lt=10000.0, allow_inf_nan=False),
1363 session: UserSession = Depends(get_session),
1364):
1365 """The burn's thrust curve as a RASP .eng file for OpenRocket (engine/layerx/eng.py)."""
1366 from fastapi.responses import PlainTextResponse
1367
1368 from engine.layerx.eng import filename, motor_header, to_eng
1369
1370 run = get_run(run_id, session)
1371 result = run.get("result")
1372 if run.get("kind", "run") != "run" or run.get("status") != "done" or not result:
1373 raise HTTPException(status_code=400, detail="Only a finished burn exports a thrust curve.")
1374 motor = result.get("motor") or {}
1375 if motor.get("diameter_mm") is None and session.app_state.has_config():
1376 # A run from before the header was recorded: size it from the design loaded now, but only
1377 # when that is the design the run burned (same fingerprint). Otherwise the caller gives them.
1378 from engine.layerx.prepare import config_fingerprint
1379
1380 if config_fingerprint(session.app_state.config) == (result.get("provenance") or {}).get("config_sha256"):
1381 result = {**result, "motor": motor_header(session.app_state.config)}
1382 try:
1383 text = to_eng(result, run_id=run_id, diameter_mm=diameter_mm, length_mm=length_mm, dry_kg=dry_kg)
1384 name = filename(result, run_id)
1385 except (TypeError, ValueError) as exc:
1386 raise HTTPException(status_code=400, detail=str(exc)) from exc
1387 return PlainTextResponse(text, headers={"Content-Disposition": f'attachment; filename="{name}"'})
1388
1389
1390def _read_sidecar(session: UserSession, run_id: str, name: str) -> Optional[Dict[str, Any]]:
1391 if name not in SIDECARS or not RUN_ID_RE.match(run_id):
1392 return None
1393 return _read_json(_sidecar_path(_runs_dir(session), run_id, name))
1394
1395
1396@router.get("/runs/{run_id}/sidecar/{name}")
1397def get_sidecar(run_id: str, name: str, session: UserSession = Depends(get_session)) -> Dict[str, Any]:
1398 """A finished run's large side data (DATA-CONTRACT 4), loaded lazily by the page that draws it.
1399 ``axial``: ``{x_mm, t, q_MW_m2: [[...]], T_wall_K: [[...]]}``, rows on ``t``. 404 when the run
1400 has none (a run saved before sidecars, a replay that did not run, or another kind of job)."""
1401 if name not in SIDECARS:
1402 raise HTTPException(status_code=404, detail=f"No sidecar {name!r}; there is {', '.join(SIDECARS)}.")
1403 data = _read_sidecar(session, run_id, name)
1404 if data is None:
1405 raise HTTPException(status_code=404, detail=f"Run {run_id!r} has no {name} sidecar.")
1406 return data
1407
1408
1409#: Export formats: media type, file suffix.
1410EXPORTS = {"csv": ("text/csv", "csv"), "parquet": ("application/vnd.apache.parquet", "parquet"),
1411 "fea": ("application/zip", "zip")}
1412
1413
1414@router.get("/runs/{run_id}/export/{fmt}")
1415def export_run(run_id: str, fmt: str, session: UserSession = Depends(get_session)):
1416 """A finished burn as a file (engine/layerx/export.py): ``csv`` every signal on the twin's
1417 steps (``name [unit]`` headers), ``parquet`` the same table typed, ``fea`` a zip of the
1418 chamber pressure, thrust and axial heat flux against time, and the peak loads."""
1419 from fastapi.responses import Response
1420
1421 from engine.layerx import export as ex
1422
1423 if fmt not in EXPORTS:
1424 raise HTTPException(status_code=404, detail=f"No export {fmt!r}; there is {', '.join(EXPORTS)}.")
1425 run = get_run(run_id, session)
1426 result = run.get("result")
1427 if run.get("kind", "run") != "run" or run.get("status") != "done" or not isinstance(result, dict) \
1428 or not isinstance(result.get("series"), dict):
1429 raise HTTPException(status_code=400, detail="Only a finished burn exports.")
1430 media, suffix = EXPORTS[fmt]
1431 try:
1432 if fmt == "csv":
1433 body: Any = ex.to_csv(result).encode("utf-8")
1434 elif fmt == "parquet":
1435 try:
1436 body = ex.to_parquet(result)
1437 except ImportError as exc:
1438 raise HTTPException(status_code=501, detail=f"Parquet export needs pyarrow on the server ({exc}).") from exc
1439 else:
1440 body = ex.fea_bundle(result, _read_sidecar(session, run_id, "axial"))
1441 except HTTPException:
1442 raise
1443 except (TypeError, ValueError, KeyError) as exc:
1444 raise HTTPException(status_code=422, detail=f"The run could not be exported: {exc}") from exc
1445 name = re.sub(r"[^A-Za-z0-9._-]+", "_", f"layerx-{run_id}" + (f"-{fmt}" if fmt == "fea" else "")) + f".{suffix}"
1446 return Response(body, media_type=media, headers={"Content-Disposition": f'attachment; filename="{name}"'})
1447
1448
1449@router.post("/runs/{run_id}/cancel")
1450async def cancel_run(run_id: str, session: UserSession = Depends(get_session)) -> Dict[str, Any]:
1451 with _JOBS_LOCK:
1452 job = _JOBS.get(run_id)
1453 if job is None or job.user != session.user:
1454 raise HTTPException(status_code=404, detail=f"No live run {run_id!r}.")
1455 job.cancel.set()
1456 return {"id": job.id, "status": "cancelling"}
static void put(text_t *t, const char *fmt,...)
Definition boot_report.c:65
Dict[str, str] _names(cls, Dict[str, str] value)
Definition layerx.py:1188
Optional[Dict[str, Dict[str, float]]] _patch(cls, Optional[Dict[str, Dict[str, float]]] value)
Definition layerx.py:1081
Optional[Dict[str, float]] _meop(cls, Optional[Dict[str, float]] value)
Definition layerx.py:1004
None __init__(self, str user, Settings settings, str design_name, str kind="run", str design_sha256="")
Definition layerx.py:337
Optional[str] error
Definition layerx.py:349
Dict[str, Any] view(self, *, bool with_result=True)
Definition layerx.py:370
None feed(self, float t, float thrust_n, float pc_psia)
Definition layerx.py:360
Optional[float] finished
Definition layerx.py:352
Optional[Dict[str, Any]] result
Definition layerx.py:350
Optional[Dict[str, List[float]]] live
Definition layerx.py:358
Dict[str, Dict[str, Any]] _bounds(cls, Dict[str, Dict[str, Any]] value)
Definition layerx.py:865
Optional[Dict[str, float]] _meop(cls, Optional[Dict[str, float]] value)
Definition layerx.py:955
Optional[Dict[str, Dict[str, float]]] _patch(cls, Optional[Dict[str, Dict[str, float]]] value)
Definition layerx.py:164
Any _outlet(cls, Any value)
Definition layerx.py:169
None __call__(self, Dict[str, Any] scope, Any receive, Any send)
Definition layerx.py:96
static void start(gnss_frame_parser_t *p, uint8_t byte)
Definition gnss_frame.c:45
static void send(void *ctx, const uint8_t *bytes, uint16_t len)
Definition gnss_task.c:27
List[Dict[str, Any]] list_runs(UserSession session=Depends(get_session))
Definition layerx.py:1274
Optional[Dict[str, Any]] _listing_summary(Dict[str, Any] result)
Definition layerx.py:1240
Dict[str, Any] engine_card_for_config(CardRequest body)
Definition layerx.py:704
Dict[str, Any] start_optimize(OptimizeBody body, UserSession session=Depends(get_session), str design=Depends(_design_label))
Definition layerx.py:900
Dict[str, Any] get_run(str run_id, UserSession session=Depends(get_session))
Definition layerx.py:1344
None _release(Job job)
Definition layerx.py:811
export_config(ExportBody body, UserSession session=Depends(get_session))
Definition layerx.py:1086
List[Dict[str, Any]] drawings(UserSession session=Depends(get_session))
Definition layerx.py:570
_measurements(UserSession session)
Definition layerx.py:249
bool _pinned(Path session_dir, str run_id)
Definition layerx.py:441
Dict[str, Any] cancel_run(str run_id, UserSession session=Depends(get_session))
Definition layerx.py:1450
None _execute(Job job, Callable[[Callable[[str, float], None], Callable[[], bool]], Dict[str, Any]] work, Path runs_dir)
Definition layerx.py:480
Path _channels_path(UserSession session, str drawing_id)
Definition layerx.py:1195
Dict[str, Any] start_hardware(HardwareBody body, UserSession session=Depends(get_session), str design=Depends(_design_label))
Definition layerx.py:1010
str _fingerprint(UserSession session)
Definition layerx.py:802
Any _pid_get(str path, Request request, Optional[Dict[str, str]] params=None)
Definition layerx.py:609
Dict[str, Any] drawing_parameters(str drawing_id, UserSession session=Depends(get_session))
Definition layerx.py:1133
Dict[str, Any] drawing_document(str drawing_id, UserSession session=Depends(get_session))
Definition layerx.py:1142
Dict[str, Any] status()
Definition layerx.py:547
Dict[str, Any] put_measurements(str drawing_id, MeasurementsBody body, UserSession session=Depends(get_session))
Definition layerx.py:1220
Job _reserve(UserSession session, Settings settings, str kind, Optional[str] design=None)
Definition layerx.py:788
Path _runs_dir(UserSession session)
Definition layerx.py:221
Dict[str, Any] start_setpoint(SetpointBody body, UserSession session=Depends(get_session), str design=Depends(_design_label))
Definition layerx.py:961
Dict[str, Any] preflight(Settings body, UserSession session=Depends(get_session))
Definition layerx.py:680
Path _user_dir(UserSession session)
Definition layerx.py:217
export_eng(str run_id, Optional[float] diameter_mm=Query(default=None, gt=0.0, lt=5000.0, allow_inf_nan=False), Optional[float] length_mm=Query(default=None, gt=0.0, lt=10000.0, allow_inf_nan=False), Optional[float] dry_kg=Query(default=None, ge=0.0, lt=10000.0, allow_inf_nan=False), UserSession session=Depends(get_session))
Definition layerx.py:1364
Optional[Dict[str, float]] _check_meop(Optional[Dict[str, float]] value)
Definition layerx.py:926
Dict[str, Any] put_channels(str drawing_id, ChannelMap body, UserSession session=Depends(get_session))
Definition layerx.py:1208
List[Path] _run_files(Path session_dir, str run_id)
Definition layerx.py:454
Dict[str, Any] _launch(UserSession session, Settings settings, str kind, Callable[[], Callable[..., Dict[str, Any]]] build, Optional[str] design=None)
Definition layerx.py:817
Path _sidecar_path(Path session_dir, str run_id, str name)
Definition layerx.py:449
Optional[Dict[str, Any]] _load_saved(UserSession session, str run_id)
Definition layerx.py:1260
Dict[str, Any] get_sidecar(str run_id, str name, UserSession session=Depends(get_session))
Definition layerx.py:1397
Dict[str, Any] _design_snapshot(Any config)
Definition layerx.py:771
Dict[str, Any] pid_documents(Request request)
Definition layerx.py:626
Dict[str, Any] upload_drawing(UploadFile file=File(...), UserSession session=Depends(get_session))
Definition layerx.py:575
Dict[str, Any] annotate_run(str run_id, RunMeta body, UserSession session=Depends(get_session))
Definition layerx.py:1313
_config_and_runner(UserSession session, Optional[Dict[str, Dict[str, float]]] patch=None)
Definition layerx.py:233
Dict[str, Any] optimize_variables(Settings body, UserSession session=Depends(get_session))
Definition layerx.py:883
Dict[str, Any] catalog(UserSession session=Depends(get_session))
Definition layerx.py:1035
Dict[str, str] _forward(Request request)
Definition layerx.py:604
_settings(Settings raw)
Definition layerx.py:243
Dict[str, Any] start_run(Settings body, UserSession session=Depends(get_session), str design=Depends(_design_label))
Definition layerx.py:718
Optional[str] _named_design(Request request, UserSession session)
Definition layerx.py:302
_store(UserSession session)
Definition layerx.py:227
None _write_json(Path path, Dict[str, Any] data)
Definition layerx.py:434
Dict[str, Any] start_uncertainty(Settings body, UserSession session=Depends(get_session), str design=Depends(_design_label))
Definition layerx.py:832
Optional[Dict[str, Any]] _read_json(Path path)
Definition layerx.py:426
str _design_label(Request request, UserSession session=Depends(get_session))
Definition layerx.py:330
str _kind_of(Path path)
Definition layerx.py:402
Optional[Dict[str, Any]] _read_sidecar(UserSession session, str run_id, str name)
Definition layerx.py:1390
Optional[Dict[str, Dict[str, float]]] check_design_patch(Optional[Dict[str, Dict[str, float]]] value)
Definition layerx.py:187
None _persist(Path session_dir, Job job)
Definition layerx.py:460
Path _meta_path(Path session_dir, str run_id)
Definition layerx.py:421
Dict[str, Any] start_reconcile(ReconcileBody body, UserSession session=Depends(get_session), str design=Depends(_design_label))
Definition layerx.py:1054
_drawing(UserSession session, str drawing_id)
Definition layerx.py:255
None _check_drawing(Any doc)
Definition layerx.py:593
Dict[str, Any] daq_channels()
Definition layerx.py:1157
_prepare(UserSession session, Settings settings)
Definition layerx.py:262
Dict[str, Any] delete_run(str run_id, UserSession session=Depends(get_session))
Definition layerx.py:1326
str _design_name(UserSession session)
Definition layerx.py:783
export_run(str run_id, str fmt, UserSession session=Depends(get_session))
Definition layerx.py:1415
Dict[str, Any] get_channels(str drawing_id, UserSession session=Depends(get_session))
Definition layerx.py:1202
Dict[str, Any] engine_card(Settings body, UserSession session=Depends(get_session))
Definition layerx.py:688
None _warn_missing_sources()
Definition layerx.py:61
Dict[str, Any] pid_import(PidImport body, Request request, UserSession session=Depends(get_session))
Definition layerx.py:653
Any _json_safe(Any value)
Definition layerx.py:280
Path _index_path(Path session_dir, str run_id)
Definition layerx.py:416
TOML_NODISCARD_CTOR time() noexcept=default
static void decode(const disk_t *d)