STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
main.py
Go to the documentation of this file.
1"""FastAPI app: browse past Elodin runs, list sensors, plot, and export CSV.
2
3Run (dev): uvicorn backend.main:app --reload --port 8000
4Prod-ish: build the frontend to frontend/dist and this process serves it too.
5"""
6
7from __future__ import annotations
8
9from pathlib import Path
10
11from fastapi import Body, FastAPI, HTTPException, Query
12from fastapi.middleware.cors import CORSMiddleware
13from fastapi.responses import FileResponse, PlainTextResponse, StreamingResponse
14from fastapi.staticfiles import StaticFiles
15
16from . import config, descriptions, export_cache, run_config, runs, series, summary, video
17
18app = FastAPI(title="Elodin Past-Run Viewer")
19
20# Dev convenience: the Vite dev server (5173) may call us cross-origin. In the
21# single-process prod setup the frontend is same-origin so this is a no-op.
22app.add_middleware(
23 CORSMiddleware,
24 allow_origins=["*"],
25 allow_methods=["*"],
26 allow_headers=["*"],
27)
28
29
30def _parse_components(components: str | None) -> list[str]:
31 if not components:
32 return []
33 return [c for c in components.split(",") if c]
34
35
36def _time_source(value: str) -> str:
37 """Which clock the x-axis is. Defaults to the sensor clock; see series.py."""
38 if value not in series.TIME_SOURCES:
39 raise HTTPException(400, f"time_source must be one of {series.TIME_SOURCES}")
40 return value
41
42
43@app.get("/api/runs")
45 return runs.list_runs()
46
47
48@app.put("/api/runs/{run_id}/description")
49def api_set_description(run_id: str, text: str = Body(..., embed=True, max_length=4000)):
50 """Set (or clear, with empty text) this run's shared one-line description.
51
52 Unowned on purpose: there is no login on this viewer, so anyone who can see a run can
53 label it and everyone else sees that label. Last write wins.
54 """
55 if not config.RUN_RE.match(run_id):
56 raise HTTPException(400, "invalid run id")
57 if not (config.ELODIN_DIR / run_id).is_dir():
58 raise HTTPException(404, f"run not found: {run_id}")
59 try:
60 entry = descriptions.set_text(run_id, text)
61 except OSError as e:
62 # Almost always a read-only or missing mount. Say so, rather than accepting the
63 # edit and quietly dropping it — the user would have no way to tell.
64 raise HTTPException(503, f"could not save description: {e}")
65 return {"run_id": run_id, "description": entry["text"], "updated_at": entry["updated_at"]}
66
67
68@app.get("/api/runs/{run_id}/summary")
69def api_summary(run_id: str):
70 """What is knowable about a run without exporting it — size, component count and an
71 approximate duration. Sub-second; safe to call on every run selection."""
72 if not config.RUN_RE.match(run_id):
73 raise HTTPException(400, "invalid run id")
74 if not (config.ELODIN_DIR / run_id).is_dir():
75 raise HTTPException(404, f"run not found: {run_id}")
76 return summary.summarize(run_id)
77
78
79@app.get("/api/runs/{run_id}/components")
80def api_components(run_id: str):
81 """The component index. Only for an already-indexed run: selecting a run must not
82 cost a multi-second export, so building one is POST /index, an explicit action."""
83 if not config.RUN_RE.match(run_id):
84 raise HTTPException(400, "invalid run id")
85 if not export_cache.is_cached(run_id):
86 raise HTTPException(409, "run is not indexed yet")
87 try:
88 return export_cache.get_index(run_id)
89 except FileNotFoundError as e:
90 raise HTTPException(404, str(e))
91 except RuntimeError as e:
92 raise HTTPException(500, str(e))
93
94
95@app.post("/api/runs/{run_id}/index")
96def api_index(run_id: str):
97 """Export the run to parquet and build its index — the expensive step, run only when
98 asked for. Synchronous: tens of seconds on a multi-GB run, and the caller shows a
99 busy state for the duration. Concurrent callers share one export (export_cache holds
100 a per-run lock), so a double click costs nothing extra."""
101 if not config.RUN_RE.match(run_id):
102 raise HTTPException(400, "invalid run id")
103 try:
104 return export_cache.get_index(run_id)
105 except FileNotFoundError as e:
106 raise HTTPException(404, str(e))
107 except RuntimeError as e:
108 raise HTTPException(500, str(e))
109
110
111@app.get("/api/runs/{run_id}/config", response_class=PlainTextResponse)
112def api_config(run_id: str):
113 """The config snapshot taken beside this run's DB when the session started.
114
115 Served verbatim — it is the record of what actually ran, so it is never reformatted
116 or re-serialised. Runs recorded before the snapshot existed simply have no file.
117 """
118 if not config.RUN_RE.match(run_id):
119 raise HTTPException(400, "invalid run id")
120 path = run_config.snapshot_path(run_id)
121 try:
122 text = path.read_text(encoding="utf-8")
123 except OSError:
124 raise HTTPException(404, "no config snapshot for this run")
125 return PlainTextResponse(
126 text,
127 media_type="text/plain; charset=utf-8",
128 headers={"Content-Disposition": f'inline; filename="{run_id}.toml"'},
129 )
130
131
132@app.get("/api/runs/{run_id}/series")
134 run_id: str,
135 components: str = Query(...),
136 start: float | None = None,
137 end: float | None = None,
138 max_points: int = 4000,
139 time_source: str = "sensor",
140):
141 if not config.RUN_RE.match(run_id):
142 raise HTTPException(400, "invalid run id")
143 comps = _parse_components(components)
144 if not comps:
145 raise HTTPException(400, "no components requested")
146 src = _time_source(time_source)
147 export_cache.ensure_exported(run_id)
148 try:
149 return series.series_json(
150 run_id, comps, start, end, max(4, min(max_points, 50000)), src
151 )
152 except FileNotFoundError as e:
153 raise HTTPException(404, str(e))
154
155
156@app.get("/api/runs/{run_id}/download")
158 run_id: str,
159 components: str | None = None,
160 start: float | None = None,
161 end: float | None = None,
162 time_source: str = "sensor",
163):
164 """CSV export.
165
166 - With `components` (a plot selection): a **wide** CSV — one column per
167 channel aligned on a shared time grid. Bounded by the selection, ideal for
168 analysis of a few channels.
169 - Without `components` (whole run): a **long/tidy** CSV (`time,component,
170 value`) over every primary channel. Streams at constant memory; a wide
171 whole-run frame would blow up to millions of rows × hundreds of columns.
172 """
173 if not config.RUN_RE.match(run_id):
174 raise HTTPException(400, "invalid run id")
175 src = _time_source(time_source)
176 index = export_cache.get_index(run_id)
177 comps = _parse_components(components)
178 if comps:
179 rows = series.wide_csv_rows(run_id, comps, start, end, src)
180 fname = f"{run_id}_selection.csv"
181 else:
182 comps = [c["name"] for c in index["components"] if c["primary"]]
183 if not comps:
184 raise HTTPException(404, "no exportable channels")
185 rows = series.long_csv_rows(run_id, comps, start, end, src)
186 fname = f"{run_id}.csv"
187 # Rate-limit the stream so a large export can't saturate the host uplink.
188 rows = series.throttle(rows, config.MAX_DOWNLOAD_BYTES_PER_SEC)
189 return StreamingResponse(
190 rows,
191 media_type="text/csv",
192 headers={"Content-Disposition": f'attachment; filename="{fname}"'},
193 )
194
195
196@app.get("/api/runs/{run_id}/video")
197def api_run_video(run_id: str):
198 """The video clips that overlap this run, on the epoch-seconds axis the plot uses.
199
200 Matched against the run's whole extent on either clock, so switching the plot to
201 DB write time never hides a clip. Needs the index (that is where the extent lives);
202 an un-indexed run has no plot to put video on anyway. `available` is false when no
203 recordings folder is mounted: the page then shows no video panel at all.
204 """
205 if not config.RUN_RE.match(run_id):
206 raise HTTPException(400, "invalid run id")
207 if not video.available():
208 return {"available": False, "clips": []}
209 if not export_cache.is_cached(run_id):
210 raise HTTPException(409, "run is not indexed yet")
211 index = export_cache.get_index(run_id)
212 lows = [t for t in (index.get("t_min"), index.get("sensor_t_min")) if t is not None]
213 highs = [t for t in (index.get("t_max"), index.get("sensor_t_max")) if t is not None]
214 if not lows or not highs:
215 return {"available": True, "clips": []}
216 clips = video.clips_overlapping(min(lows), max(highs))
217 return {"available": True, "clips": [c.as_json() for c in clips]}
218
219
220# `private` keeps a shared cache in front of the viewer (Cloudflare, which caches .mp4 by
221# extension) from storing the clip. On a cache miss Cloudflare answers a Range request by
222# first pulling the whole object from byte 0, so a seek to the end of a fresh 190 MB Save
223# waited ~25 s per camera for its first byte (measured 2026-11-10) and the panel looked
224# dead. Uncached, the Range goes straight to this server and comes back at once. The
225# browser may still keep it: a finished clip never changes.
226VIDEO_CACHE_CONTROL = "private, max-age=86400"
227
228
229@app.get("/api/video/{recording}/{stream}.mp4")
230def api_video_file(recording: str, stream: str):
231 """One clip's file, with HTTP Range (Starlette's FileResponse answers 206) so the
232 browser can seek without downloading the whole recording."""
233 path = video.clip_file(recording, stream)
234 if path is None:
235 raise HTTPException(404, "no such clip")
236 return FileResponse(path, media_type="video/mp4", headers={"Cache-Control": VIDEO_CACHE_CONTROL})
237
238
239# Serve the built frontend if present (single-process deployment). Mounted last
240# so it never shadows /api routes.
241_dist = Path(__file__).resolve().parent.parent / "frontend" / "dist"
242if _dist.is_dir():
243 app.mount("/", StaticFiles(directory=str(_dist), html=True), name="static")
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
api_config(str run_id)
Definition main.py:112
api_runs()
Definition main.py:44
api_summary(str run_id)
Definition main.py:69
api_download(str run_id, str|None components=None, float|None start=None, float|None end=None, str time_source="sensor")
Definition main.py:163
list[str] _parse_components(str|None components)
Definition main.py:30
api_video_file(str recording, str stream)
Definition main.py:230
api_index(str run_id)
Definition main.py:96
api_set_description(str run_id, str text=Body(..., embed=True, max_length=4000))
Definition main.py:49
str _time_source(str value)
Definition main.py:36
api_components(str run_id)
Definition main.py:80
api_run_video(str run_id)
Definition main.py:197
api_series(str run_id, str components=Query(...), float|None start=None, float|None end=None, int max_points=4000, str time_source="sensor")
Definition main.py:140