1"""Layer X endpoints: the feed system and the engine, burned together.
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
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``.
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.
40from __future__
import annotations
49from pathlib
import Path
50from typing
import Any, Callable, Dict, List, Optional, Union
52from fastapi
import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
53from pydantic
import BaseModel, Field, field_validator
55from backend
import userdata
58router = APIRouter(prefix=
"/api/layerx", tags=[
"layerx"])
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."""
68 for problem
in source_problems():
69 logging.getLogger(
"layerx").warning(
"Layer X reads no drawings from %s", problem)
74MAX_JOBS = int(os.environ.get(
"LAYERX_MAX_JOBS",
"2"))
75_JOB_SLOTS = threading.BoundedSemaphore(MAX_JOBS)
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(
"/")
83MAX_UPLOAD_BYTES = 5_000_000
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."""
91 PATH =
"/api/layerx/drawings"
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,
""
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."
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})
111 await self.
app(scope, receive, send)
118 """Mirror of engine.layerx.LayerXSettings, validated at the edge."""
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)$")
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)
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)$")
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)$")
149 fuel_lead_s: Optional[float] = Field(default=
None, ge=0.0, le=5.0, allow_inf_nan=
False)
151 valve_travel_s: Optional[float] = Field(default=
None, ge=0.0, le=5.0, allow_inf_nan=
False)
153 outlet_d_mm: Optional[Union[float, List[Optional[float]]]] =
None
155 ack_gn2_condensation: Optional[bool] =
None
157 test_mode: Optional[str] = Field(default=
None, pattern=
"^hotfire$")
160 design_patch: Optional[Dict[str, Dict[str, float]]] =
None
162 @field_validator("design_patch")
164 def _patch(cls, value: Optional[Dict[str, Dict[str, float]]]) -> Optional[Dict[str, Dict[str, float]]]:
167 @field_validator("outlet_d_mm")
174 pair = [value, value]
if isinstance(value, (int, float))
else list(value)
176 raise ValueError(
"outlet_d_mm is one bore for both tanks or [LOX, fuel]")
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")
184PATCH_FIELDS = {
"d_jet": (1.0e-4, 0.02),
"impingement_angle": (0.0, 80.0),
"orifice_l_over_d": (0.5, 50.0)}
187def check_design_patch(value: Optional[Dict[str, Dict[str, float]]]) -> Optional[Dict[str, Dict[str, float]]]:
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}")
218 return userdata.user_dir(session.user)
222 path =
_user_dir(session) /
"layerx" /
"runs"
223 path.mkdir(parents=
True, exist_ok=
True)
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.")
239 config = apply_design_patch(copy.deepcopy(session.app_state.config), patch)
240 return config, PintleEngineRunner(config)
246 return LayerXSettings.from_dict(raw.model_dump())
252 return MeasurementStore(
_user_dir(session))
255def _drawing(session: UserSession, drawing_id: str):
256 drawing =
_store(session).get(drawing_id)
258 raise HTTPException(status_code=404, detail=f
"No drawing {drawing_id!r} for this user.")
262def _prepare(session: UserSession, settings: Settings):
267 drawing =
_drawing(session, settings.drawing_id)
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
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)):
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)):
291 if hasattr(value,
"tolist")
and callable(value.tolist):
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."""
308 doc_id = request.headers.get(DESIGN_ID_HEADER)
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):
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)):
324 name = str(record.get(
"name")
or "").strip()
325 return name[:120]
or None
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."""
336 def __init__(self, user: str, settings: Settings, design_name: str, kind: str =
"run",
337 design_sha256: str =
"") ->
None:
339 self.
id = time.strftime(
"%Y%m%d-%H%M%S-", time.gmtime()) + uuid.uuid4().hex[:6]
350 self.
result: Optional[Dict[str, Any]] =
None
358 self.
live: Optional[Dict[str, List[float]]] =
None
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": []}
363 if live[
"t"]
and t - live[
"t"][-1] < 0.049:
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))
370 def view(self, *, with_result: bool =
True) -> Dict[str, Any]:
382 "settings": self.
settings.model_dump(),
384 if self.
status in (
"queued",
"running")
and self.
live:
385 out[
"live"] = self.
live
387 out[
"result"] = self.
result
390 out[
"drawing"] = (self.
result.get(
"provenance")
or {}).get(
"drawing")
394_JOBS: Dict[str, Job] = {}
395_JOBS_LOCK = threading.Lock()
398_KIND_RE = re.compile(
r'"kind":"([a-z]+)"')
399RUN_ID_RE = re.compile(
r"^\d{8}-\d{6}-[0-9a-f]{6}$")
403 """A saved job's kind: from its index entry, else from the head of its file."""
405 if entry
and isinstance(entry.get(
"kind"), str):
408 with path.open(
"rb")
as fh:
409 head = fh.read(256).
decode(
"utf-8",
"replace")
412 m = _KIND_RE.search(head)
413 return m.group(1)
if m
else "run"
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"
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"
428 data = json.loads(path.read_text())
429 except (OSError, json.JSONDecodeError):
431 return data
if isinstance(data, dict)
else 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)
441def _pinned(session_dir: Path, run_id: str) -> bool:
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"
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])
461 for name, data
in (job.sidecars
or {}).items():
462 if name
in SIDECARS
and isinstance(data, dict):
464 _write_json(session_dir / f
"{job.id}.json", job.view())
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):
479def _execute(job: Job, work: Callable[[Callable[[str, float],
None], Callable[[], bool]], Dict[str, Any]],
480 runs_dir: Path) ->
None:
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"
493 def progress(stage: str, fraction: float) ->
None:
495 job.progress = float(fraction)
497 progress.live = job.feed
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")
503 if job.cancel.is_set():
505 job.status, job.stage =
"cancelled",
"Cancelled"
510 except Exception
as exc:
511 from concurrent.futures.process
import BrokenProcessPool
518 if isinstance(exc, (Cancelled, InterruptedError))
or (job.cancel.is_set()
and isinstance(exc, BrokenProcessPool)):
519 job.status, job.stage =
"cancelled",
"Cancelled"
521 job.status, job.error =
"failed", f
"{type(exc).__name__}: {exc}"
526 if job.status
in (
"done",
"failed",
"cancelled"):
529 except Exception
as exc:
530 job.error = (job.error
or "") + f
" (not saved: {type(exc).__name__}: {exc})"
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)
540 _JOBS.pop(job.id,
None)
546@router.get("/status")
552 from feedtwin
import __version__
as version
554 available, error =
True,
None
555 except Exception
as exc:
556 available, error, version =
False, f
"{type(exc).__name__}: {exc}",
None
557 machines = machines_dir()
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,
563 "source_problems": source_problems(),
564 "pid_designer_url": PID_DESIGNER_URL,
565 "max_jobs": MAX_JOBS,
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()]
574@router.post("/drawings")
575def upload_drawing(file: UploadFile = File(...), session: UserSession = Depends(get_session)) -> Dict[str, Any]:
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.")
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}")
586 name = Path(file.filename
or "drawing").stem
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))
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.")
601 raise HTTPException(status_code=422, detail=
"The drawing has no symbols.")
605 email = request.headers.get(
"X-Auth-Email")
606 return {
"X-Auth-Email": email}
if email
else {}
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."""
614 import urllib.request
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:
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"))
625@router.get("/pid-designer")
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."""
631 base =
"/api/pid/diagrams"
634 except (urllib.error.URLError, OSError, ValueError)
as exc:
635 return {
"reachable":
False,
"url": PID_DESIGNER_URL,
"error": str(exc),
"documents": []}
637 others =
_pid_get(f
"{base}/browse", request)
638 except (urllib.error.URLError, OSError, ValueError):
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}
652@router.post("/pid-designer/import")
653def pid_import(body: PidImport, request: Request, session: UserSession = Depends(get_session)) -> Dict[str, Any]:
656 from urllib.parse
import quote
658 path = f
"/api/pid/diagrams/{quote(body.id, safe='')}/" + (
659 f
"release/{quote(body.release, safe='')}" if body.release
else "load")
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}.")
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"
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()
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)
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})
699 yaml: str = Field(..., description=
"An engine config, as EngineDesign writes it")
700 center_psia: Optional[float] =
None
703@router.post("/engine-card")
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``)."""
711 return _json_safe(card_for_config_text(body.yaml, center_psia=body.center_psia))
712 except Exception
as exc:
713 raise HTTPException(status_code=422, detail=f
"No engine card for that config: {type(exc).__name__}: {exc}")
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)
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,
729 def work(progress: Callable[[str, float],
None], cancelled: Callable[[], bool]) -> Dict[str, Any]:
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
738 result[
"sidecars"] = sorted(sidecars)
739 result[
"_sidecars"] = sidecars
744 return _launch(session, body,
"run", build, design)
748 """``git describe`` of this checkout, with a mark when it has uncommitted changes."""
751 root = Path(__file__).resolve().parents[2]
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):
768 return _CODE_AT_START
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."""
777 text = yaml.safe_dump(config.model_dump(mode=
"json"), sort_keys=
False)
778 except Exception
as exc:
779 text = f
"# could not snapshot the design: {type(exc).__name__}: {exc}"
784 path = getattr(session.app_state,
"config_path",
None)
785 return (Path(path).stem
if path
else "")
or "live design"
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."""
792 busy = [j
for j
in _JOBS.values()
if j.user == session.user
and j.status
in (
"queued",
"running")]
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,
797 job.stage =
"Preparing"
806 return config_fingerprint(session.app_state.config)
if session.app_state.config
is not None else ""
813 _JOBS.pop(job.id,
None)
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)
823 threading.Thread(target=_execute, args=(job, work, runs_dir), name=f
"layerx-{job.id}", daemon=
True).
start()
824 except BaseException:
827 return {
"id": job.id,
"kind": job.kind,
"status": job.status}
830@router.post("/uncertainty")
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)
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})
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)
846 return _launch(session, body,
"uncertainty", build, design)
851OPT_LIMITS = {
"lockup_psia": (14.0, 5000.0),
"copv_psig": (0.0, 12000.0),
"copv_volume_L": (0.05, 500.0)}
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)
863 @field_validator("variables")
865 def _bounds(cls, value: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
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"):
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}")
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
889 prep, _, config =
_prepare(session, body)
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)}
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)
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})
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)
915 return _launch(session, body.settings,
"optimize", build, design)
923LEGACY_KINDS = frozenset({
"trade",
"optimize"})
926def _check_meop(value: Optional[Dict[str, float]]) -> Optional[Dict[str, float]]:
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")
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)
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)
953 @field_validator("meop_psi")
955 def _meop(cls, value: Optional[Dict[str, float]]) -> Optional[Dict[str, float]]:
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)
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})
971 request = SetpointRequest.from_dict(body.model_dump(exclude={
"settings"}))
973 target_thrust(config, request)
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)
980 return _launch(session, body.settings,
"setpoint", build, design)
987 components: List[Dict[str, Any]] = Field(min_length=1, max_length=8)
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
1000 trim_C: Optional[float] = Field(default=
None, gt=0.1, lt=1.0, allow_inf_nan=
False)
1002 @field_validator("meop_psi")
1004 def _meop(cls, value: Optional[Dict[str, float]]) -> Optional[Dict[str, float]]:
1008@router.post("/hardware")
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)
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})
1021 catalogs = load_catalogs(
_user_dir(session))
1023 request = HardwareRequest.from_dict(body.model_dump(exclude={
"settings"}))
1024 plan_candidates(prep, config, request, catalogs)
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)
1031 return _launch(session, body.settings,
"hardware", build, design)
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."""
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)
1052@router.post("/reconcile")
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)
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})
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)
1069 return _launch(session, body.settings,
"reconcile", build, design)
1073 design_patch: Optional[Dict[str, Dict[str, float]]] =
None
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)
1079 @field_validator("design_patch")
1081 def _patch(cls, value: Optional[Dict[str, Dict[str, float]]]) -> Optional[Dict[str, Dict[str, float]]]:
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."""
1092 from fastapi.responses
import PlainTextResponse
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})
1107 PintleEngineConfig(**raw)
1108 except Exception
as exc:
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():
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():
1123 lines.append(f
"# {side} feed: K0 {float(upd['K0']):.4f}, fitted to the drawing's feed")
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"'})
1132@router.get("/drawings/{drawing_id}/parameters")
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)]}
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}
1153DAQ_CONFIG = Path(os.environ.get(
"DAQ_CONFIG")
or (Path(__file__).resolve().parents[3] /
"daq-server" /
"config" /
"config_ground_daq.toml"))
1156@router.get("/daq-channels")
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."""
1162 if not DAQ_CONFIG.is_file():
1163 return {
"available":
False,
"path": str(DAQ_CONFIG),
"channels": []}
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):
1172 for kind, table
in kinds.items():
1173 if not isinstance(table, dict):
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"])}
1184 channels: Dict[str, str] = Field(default_factory=dict, max_length=200)
1186 @field_validator("channels")
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}
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"
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."""
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."""
1211 return {
"channels": body.channels}
1215 overrides: List[Dict[str, Any]] = Field(max_length=500)
1218@router.put("/drawings/{drawing_id}/measurements")
1220 session: UserSession = Depends(get_session)) -> Dict[str, Any]:
1221 from dataclasses
import asdict
1225 drawing =
_drawing(session, drawing_id)
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)
1232 raise HTTPException(status_code=422, detail=f
"The drawing has no {', '.join(missing)}.")
1235 except ValueError
as exc:
1236 raise HTTPException(status_code=422, detail=str(exc))
1237 return {
"overrides": [asdict(o)
for o
in overrides]}
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:
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):
1256 summary = {**summary,
"tripped": {k: trip.get(k)
for k
in (
"vessel",
"label",
"t",
"p_psia",
"mawp_psia")}}
1260def _load_saved(session: UserSession, run_id: str) -> Optional[Dict[str, Any]]:
1261 if not RUN_ID_RE.match(run_id):
1263 path =
_runs_dir(session) / f
"{run_id}.json"
1264 if not path.is_file()
or path.parent !=
_runs_dir(session):
1267 saved = json.loads(path.read_text())
1268 except (OSError, json.JSONDecodeError):
1270 return saved
if isinstance(saved, dict)
and "id" in saved
else None
1274def list_runs(session: UserSession = Depends(get_session)) -> List[Dict[str, Any]]:
1276 out: Dict[str, Dict[str, Any]] = {}
1277 for path
in sorted(runs_dir.glob(
"*.json")):
1282 if not saved
or "id" not in saved:
1284 result = saved.pop(
"result",
None)
or {}
1286 saved[
"drawing"] = (result.get(
"provenance")
or {}).get(
"drawing")
1294 entry = {**entry,
"meta": meta}
1295 if entry.get(
"kind")
in LEGACY_KINDS:
1297 entry = {**entry,
"legacy":
True}
1298 out[entry[
"id"]] = entry
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)
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
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."""
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}.")
1319 for key, value
in body.model_dump(exclude_none=
True).items():
1320 meta[key] = value.strip()
if isinstance(value, str)
else value
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."""
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}.")
1340 return {
"id": run_id,
"deleted":
True}
1343@router.get("/runs/{run_id}")
1344def get_run(run_id: str, session: UserSession = Depends(get_session)) -> Dict[str, Any]:
1346 job = _JOBS.get(run_id)
1347 if job
is not None and job.user == session.user:
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
1357@router.get("/runs/{run_id}/eng")
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),
1365 """The burn's thrust curve as a RASP .eng file for OpenRocket (engine/layerx/eng.py)."""
1366 from fastapi.responses
import PlainTextResponse
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():
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)}
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}"'})
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):
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)}.")
1405 raise HTTPException(status_code=404, detail=f
"Run {run_id!r} has no {name} sidecar.")
1410EXPORTS = {
"csv": (
"text/csv",
"csv"),
"parquet": (
"application/vnd.apache.parquet",
"parquet"),
1411 "fea": (
"application/zip",
"zip")}
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
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]
1433 body: Any = ex.to_csv(result).encode(
"utf-8")
1434 elif fmt ==
"parquet":
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
1440 body = ex.fea_bundle(result,
_read_sidecar(session, run_id,
"axial"))
1441 except HTTPException:
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}"'})
1449@router.post("/runs/{run_id}/cancel")
1450async def cancel_run(run_id: str, session: UserSession = Depends(get_session)) -> Dict[str, Any]:
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}.")
1456 return {
"id": job.id,
"status":
"cancelling"}
static void put(text_t *t, const char *fmt,...)
Dict[str, str] _names(cls, Dict[str, str] value)
Optional[Dict[str, Dict[str, float]]] _patch(cls, Optional[Dict[str, Dict[str, float]]] value)
Optional[Dict[str, float]] _meop(cls, Optional[Dict[str, float]] value)
None __init__(self, str user, Settings settings, str design_name, str kind="run", str design_sha256="")
Dict[str, Any] view(self, *, bool with_result=True)
None feed(self, float t, float thrust_n, float pc_psia)
Optional[Dict[str, Any]] result
Optional[Dict[str, List[float]]] live
Dict[str, Dict[str, Any]] _bounds(cls, Dict[str, Dict[str, Any]] value)
Optional[Dict[str, float]] _meop(cls, Optional[Dict[str, float]] value)
Optional[Dict[str, Dict[str, float]]] _patch(cls, Optional[Dict[str, Dict[str, float]]] value)
Any _outlet(cls, Any value)
None __call__(self, Dict[str, Any] scope, Any receive, Any send)
None __init__(self, Any app)
static void start(gnss_frame_parser_t *p, uint8_t byte)
static void send(void *ctx, const uint8_t *bytes, uint16_t len)
List[Dict[str, Any]] list_runs(UserSession session=Depends(get_session))
Optional[Dict[str, Any]] _listing_summary(Dict[str, Any] result)
Dict[str, Any] engine_card_for_config(CardRequest body)
Dict[str, Any] start_optimize(OptimizeBody body, UserSession session=Depends(get_session), str design=Depends(_design_label))
Dict[str, Any] get_run(str run_id, UserSession session=Depends(get_session))
export_config(ExportBody body, UserSession session=Depends(get_session))
List[Dict[str, Any]] drawings(UserSession session=Depends(get_session))
_measurements(UserSession session)
bool _pinned(Path session_dir, str run_id)
Dict[str, Any] cancel_run(str run_id, UserSession session=Depends(get_session))
None _execute(Job job, Callable[[Callable[[str, float], None], Callable[[], bool]], Dict[str, Any]] work, Path runs_dir)
Path _channels_path(UserSession session, str drawing_id)
Dict[str, Any] start_hardware(HardwareBody body, UserSession session=Depends(get_session), str design=Depends(_design_label))
str _fingerprint(UserSession session)
Any _pid_get(str path, Request request, Optional[Dict[str, str]] params=None)
Dict[str, Any] drawing_parameters(str drawing_id, UserSession session=Depends(get_session))
Dict[str, Any] drawing_document(str drawing_id, UserSession session=Depends(get_session))
Dict[str, Any] put_measurements(str drawing_id, MeasurementsBody body, UserSession session=Depends(get_session))
Job _reserve(UserSession session, Settings settings, str kind, Optional[str] design=None)
Path _runs_dir(UserSession session)
Dict[str, Any] start_setpoint(SetpointBody body, UserSession session=Depends(get_session), str design=Depends(_design_label))
Dict[str, Any] preflight(Settings body, UserSession session=Depends(get_session))
Path _user_dir(UserSession session)
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))
Optional[Dict[str, float]] _check_meop(Optional[Dict[str, float]] value)
Dict[str, Any] put_channels(str drawing_id, ChannelMap body, UserSession session=Depends(get_session))
List[Path] _run_files(Path session_dir, str run_id)
Dict[str, Any] _launch(UserSession session, Settings settings, str kind, Callable[[], Callable[..., Dict[str, Any]]] build, Optional[str] design=None)
Path _sidecar_path(Path session_dir, str run_id, str name)
Optional[Dict[str, Any]] _load_saved(UserSession session, str run_id)
Dict[str, Any] get_sidecar(str run_id, str name, UserSession session=Depends(get_session))
Dict[str, Any] _design_snapshot(Any config)
Dict[str, Any] pid_documents(Request request)
Dict[str, Any] upload_drawing(UploadFile file=File(...), UserSession session=Depends(get_session))
Dict[str, Any] annotate_run(str run_id, RunMeta body, UserSession session=Depends(get_session))
_config_and_runner(UserSession session, Optional[Dict[str, Dict[str, float]]] patch=None)
Dict[str, Any] optimize_variables(Settings body, UserSession session=Depends(get_session))
Dict[str, Any] catalog(UserSession session=Depends(get_session))
Dict[str, str] _forward(Request request)
Dict[str, Any] start_run(Settings body, UserSession session=Depends(get_session), str design=Depends(_design_label))
Optional[str] _named_design(Request request, UserSession session)
_store(UserSession session)
None _write_json(Path path, Dict[str, Any] data)
Dict[str, Any] start_uncertainty(Settings body, UserSession session=Depends(get_session), str design=Depends(_design_label))
Optional[Dict[str, Any]] _read_json(Path path)
str _design_label(Request request, UserSession session=Depends(get_session))
Optional[Dict[str, Any]] _read_sidecar(UserSession session, str run_id, str name)
Optional[Dict[str, Dict[str, float]]] check_design_patch(Optional[Dict[str, Dict[str, float]]] value)
None _persist(Path session_dir, Job job)
Path _meta_path(Path session_dir, str run_id)
Dict[str, Any] start_reconcile(ReconcileBody body, UserSession session=Depends(get_session), str design=Depends(_design_label))
_drawing(UserSession session, str drawing_id)
None _check_drawing(Any doc)
Dict[str, Any] daq_channels()
_prepare(UserSession session, Settings settings)
Dict[str, Any] delete_run(str run_id, UserSession session=Depends(get_session))
str _design_name(UserSession session)
export_run(str run_id, str fmt, UserSession session=Depends(get_session))
Dict[str, Any] get_channels(str drawing_id, UserSession session=Depends(get_session))
Dict[str, Any] engine_card(Settings body, UserSession session=Depends(get_session))
None _warn_missing_sources()
Dict[str, Any] pid_import(PidImport body, Request request, UserSession session=Depends(get_session))
Any _json_safe(Any value)
Path _index_path(Path session_dir, str run_id)
TOML_NODISCARD_CTOR time() noexcept=default
static void decode(const disk_t *d)