STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
export_cache.py
Go to the documentation of this file.
1"""Lazy, cached parquet export of a past Elodin run + a component index.
2
3Key mechanic (verified against real runs): the parquet exporter only resolves
4human component names when given a `--pattern`; `--pattern '*'` names every
5named component and keeps the ~few genuinely-unnamed ones as hashes. Past-run
6dirs are immutable, so we export once and reuse the cache forever.
7"""
8
9from __future__ import annotations
10
11import json
12import subprocess
13import threading
14from pathlib import Path
15
16import numpy as np
17import pyarrow.parquet as pq
18
19from . import config, run_config
20from .naming import classify
21
22
23def _dt_to_epoch_s(dt) -> float:
24 """Naive datetime/np.datetime64 → epoch seconds, treating the wall value as
25 UTC (matches series.load_series so index bounds and series times align)."""
26 return np.datetime64(dt).astype("datetime64[ns]").astype("int64") / 1e9
27
28# One lock per run id so concurrent requests can't launch duplicate exports.
29_locks: dict[str, threading.Lock] = {}
30_locks_guard = threading.Lock()
31
32_DONE = "_done"
33_INDEX = "_index.json"
34
35# Bump when the component index schema or classification (naming.py units /
36# families / discrete) changes, so cached indexes for already-exported runs are
37# rebuilt instead of serving stale metadata. The parquet export itself is
38# unaffected (it stays valid), only the derived index is recomputed.
39# 3: per-component time_source + the sensor-clock time bounds.
40INDEX_VERSION = 3
41
42
43def _lock_for(run_id: str) -> threading.Lock:
44 with _locks_guard:
45 return _locks.setdefault(run_id, threading.Lock())
46
47
48def cache_dir(run_id: str) -> Path:
49 return config.CACHE_DIR / run_id
50
51
52def is_cached(run_id: str) -> bool:
53 return (cache_dir(run_id) / _DONE).exists()
54
55
56def _time_bounds(parquet_path: Path) -> tuple[float, float] | None:
57 """Cheap [t_min, t_max] in epoch seconds from row-group stats (no full read)."""
58 try:
59 md = pq.ParquetFile(parquet_path).metadata
60 ti = next(
61 i
62 for i in range(md.schema.names.__len__())
63 if md.schema.names[i] == "time"
64 )
65 except (StopIteration, Exception):
66 return None
67 lo = hi = None
68 for rg in range(md.num_row_groups):
69 st = md.row_group(rg).column(ti).statistics
70 if st is None or not st.has_min_max:
71 return None
72 # pyarrow returns naive python datetimes for timestamp stats. Convert via
73 # numpy datetime64 (treats the wall value as UTC) so these bounds match
74 # series.load_series exactly — using .timestamp() would apply local tz.
75 smn = _dt_to_epoch_s(st.min)
76 smx = _dt_to_epoch_s(st.max)
77 lo = smn if lo is None else min(lo, smn)
78 hi = smx if hi is None else max(hi, smx)
79 if lo is None or hi is None:
80 return None
81 return (lo, hi)
82
83
84def _value_bounds(parquet_path: Path) -> tuple[float, float] | None:
85 """[min, max] of the (single) value column from row-group stats — no data read.
86
87 Used on a `*.timestamp_ns` parquet to get the sensor clock's extent, which is what
88 the x-axis actually spans when that clock is in use."""
89 try:
90 md = pq.ParquetFile(parquet_path).metadata
91 names = md.schema.names
92 vi = next(i for i in range(len(names)) if names[i] != "time")
93 except (StopIteration, Exception):
94 return None
95 lo = hi = None
96 for rg in range(md.num_row_groups):
97 st = md.row_group(rg).column(vi).statistics
98 if st is None or not st.has_min_max:
99 return None
100 lo = st.min if lo is None else min(lo, st.min)
101 hi = st.max if hi is None else max(hi, st.max)
102 if lo is None or hi is None:
103 return None
104 return (float(lo), float(hi))
105
106
107def _entity_clocks(cdir: Path) -> tuple[dict[str, str], float | None, float | None]:
108 """Per-entity clock kind, and the run's extent on the clocks actually drawn.
109
110 The extent has to be a union across every entity, each measured on its OWN clock,
111 because they do not cover the same interval: the heartbeat and command channels start
112 at boot, ~3.7 s before the first sensor packet on a real run. Taking only the
113 sensor-clocked entities' extent clipped the start of every one of those traces.
114
115 Classification mirrors series.sensor_clock and shares its tolerance so the two always
116 agree, but works off row-group statistics — no data read. min-vs-min stands in for
117 that function's median: the two differ by milliseconds, which decides nothing here
118 and shifts a window bound by nothing that matters.
119 """
120 from .series import (
121 SENSOR_CLOCK_TOLERANCE_S,
122 SOURCE_MONOTONIC,
123 SOURCE_SENSOR,
124 )
125
126 kinds: dict[str, str] = {}
127 t_min = t_max = None
128 for f in sorted(cdir.glob("*.timestamp_ns.parquet")):
129 entity = f.name[: -len(".timestamp_ns.parquet")]
130 vb = _value_bounds(f)
131 tb = _time_bounds(f)
132 if vb is None or tb is None:
133 continue
134 lo, hi = vb[0] / 1e9, vb[1] / 1e9
135 if abs(lo - tb[0]) <= SENSOR_CLOCK_TOLERANCE_S:
136 kinds[entity] = SOURCE_SENSOR
137 else:
138 # Re-anchored by (db − stamp), so its extent lands back on its DB extent to
139 # within the write latency. Use that rather than re-deriving the shift.
140 kinds[entity] = SOURCE_MONOTONIC
141 lo, hi = tb
142 t_min = lo if t_min is None else min(t_min, lo)
143 t_max = hi if t_max is None else max(t_max, hi)
144 return kinds, t_min, t_max
145
146
147def _db_size_bytes(run_id: str) -> int | None:
148 """Real on-disk size of the source DB dir (its data/index files are sparse,
149 so `du -sk` block counts are used). One-time cost at export."""
150 db = config.ELODIN_DIR / run_id
151 try:
152 out = subprocess.run(
153 ["du", "-sk", str(db)], capture_output=True, text=True, timeout=60
154 )
155 if out.returncode == 0:
156 return int(out.stdout.split()[0]) * 1024
157 except (ValueError, OSError, subprocess.SubprocessError):
158 pass
159 return None
160
161
162def build_index(run_id: str) -> dict:
163 """Scan cached parquet → component index with families and time bounds."""
164 from .series import SOURCE_DB
165
166 cdir = cache_dir(run_id)
167 entity_clocks, s_min, s_max = _entity_clocks(cdir)
168 comps = []
169 t_min = t_max = None
170 for f in sorted(cdir.glob("*.parquet")):
171 comp = classify(f.stem)
172 d = comp.as_dict()
173 # Which clock this channel is actually drawn on, so a per-channel difference is
174 # never silent: its own stamp, its own stamp re-anchored, or the DB's write time.
175 d["time_source"] = entity_clocks.get(comp.entity, SOURCE_DB)
176 bounds = _time_bounds(f)
177 if bounds:
178 d["t_min"], d["t_max"] = bounds
179 t_min = bounds[0] if t_min is None else min(t_min, bounds[0])
180 t_max = bounds[1] if t_max is None else max(t_max, bounds[1])
181 comps.append(d)
182 # The window spans whichever clock is in use, so both extents are indexed. A run
183 # where nothing carries a wall clock falls back to the DB's.
184 sensor_min = s_min if s_min is not None else t_min
185 sensor_max = s_max if s_max is not None else t_max
186 index = {
187 "index_version": INDEX_VERSION,
188 "run_id": run_id,
189 "t_min": t_min,
190 "t_max": t_max,
191 "duration_s": (t_max - t_min) if (t_min is not None and t_max is not None) else None,
192 "sensor_t_min": sensor_min,
193 "sensor_t_max": sensor_max,
194 "sensor_duration_s": (
195 (sensor_max - sensor_min)
196 if (sensor_min is not None and sensor_max is not None)
197 else None
198 ),
199 # Their relative timing is exact; their absolute position carries the median
200 # write latency as a few-ms bias, which is worth being able to say out loud.
201 "n_reanchored": sum(1 for c in comps if c["time_source"] == "monotonic"),
202 "n_db_only": sum(1 for c in comps if c["time_source"] == SOURCE_DB),
203 "n_components": len(comps),
204 "size_bytes": _db_size_bytes(run_id),
205 "components": comps,
206 }
207 (cdir / _INDEX).write_text(json.dumps(index))
208 return index
209
210
211def ensure_exported(run_id: str, refresh: bool = False) -> Path:
212 """Export the run to parquet if not already cached. Returns the cache dir."""
213 db = config.ELODIN_DIR / run_id
214 if not db.is_dir():
215 raise FileNotFoundError(f"run not found: {db}")
216 cdir = cache_dir(run_id)
217 with _lock_for(run_id):
218 if refresh and cdir.exists():
219 for p in cdir.iterdir():
220 p.unlink()
221 if (cdir / _DONE).exists():
222 return cdir
223 cdir.mkdir(parents=True, exist_ok=True)
224 cmd = [
225 config.find_elodin_db(),
226 "export",
227 str(db),
228 "-o",
229 str(cdir),
230 "--format",
231 "parquet",
232 "--flatten",
233 "--pattern",
234 "*", # REQUIRED: makes the exporter resolve human component names.
235 ]
236 proc = subprocess.run(cmd, capture_output=True, text=True)
237 if proc.returncode != 0:
238 raise RuntimeError(
239 f"elodin-db export failed ({proc.returncode}): {proc.stderr.strip()[:500]}"
240 )
241 build_index(run_id)
242 (cdir / _DONE).write_text("")
243 return cdir
244
245
246def get_index(run_id: str) -> dict:
247 """Return the component index, exporting/rebuilding lazily if needed.
248
249 Config-derived names (run_config.annotate) are layered on at read time rather than
250 cached with the index — the snapshot is a separate file from the parquet, so one
251 added or corrected later takes effect without discarding the export.
252 """
253 ensure_exported(run_id)
254 idx = cache_dir(run_id) / _INDEX
255 if idx.exists():
256 data = json.loads(idx.read_text())
257 if data.get("index_version") == INDEX_VERSION:
258 return run_config.annotate(data, run_id)
259 # Stale index from an older classification — rebuild from the parquet.
260 return run_config.annotate(build_index(run_id), run_id)
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
bool is_cached(str run_id)
dict build_index(str run_id)
Path cache_dir(str run_id)
tuple[float, float]|None _value_bounds(Path parquet_path)
Path ensure_exported(str run_id, bool refresh=False)
tuple[dict[str, str], float|None, float|None] _entity_clocks(Path cdir)
tuple[float, float]|None _time_bounds(Path parquet_path)
int|None _db_size_bytes(str run_id)
threading.Lock _lock_for(str run_id)
dict get_index(str run_id)