1"""Lazy, cached parquet export of a past Elodin run + a component index.
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.
9from __future__
import annotations
14from pathlib
import Path
17import pyarrow.parquet
as pq
19from .
import config, run_config
20from .naming
import classify
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
29_locks: dict[str, threading.Lock] = {}
30_locks_guard = threading.Lock()
45 return _locks.setdefault(run_id, threading.Lock())
49 return config.CACHE_DIR / run_id
53 return (
cache_dir(run_id) / _DONE).exists()
57 """Cheap [t_min, t_max] in epoch seconds from row-group stats (no full read)."""
59 md = pq.ParquetFile(parquet_path).metadata
62 for i
in range(md.schema.names.__len__())
63 if md.schema.names[i] ==
"time"
65 except (StopIteration, Exception):
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:
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:
85 """[min, max] of the (single) value column from row-group stats — no data read.
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."""
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):
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:
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:
104 return (float(lo), float(hi))
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.
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.
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.
120 from .series
import (
121 SENSOR_CLOCK_TOLERANCE_S,
126 kinds: dict[str, str] = {}
128 for f
in sorted(cdir.glob(
"*.timestamp_ns.parquet")):
129 entity = f.name[: -len(
".timestamp_ns.parquet")]
132 if vb
is None or tb
is None:
134 lo, hi = vb[0] / 1e9, vb[1] / 1e9
135 if abs(lo - tb[0]) <= SENSOR_CLOCK_TOLERANCE_S:
136 kinds[entity] = SOURCE_SENSOR
140 kinds[entity] = SOURCE_MONOTONIC
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
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
152 out = subprocess.run(
153 [
"du",
"-sk", str(db)], capture_output=
True, text=
True, timeout=60
155 if out.returncode == 0:
156 return int(out.stdout.split()[0]) * 1024
157 except (ValueError, OSError, subprocess.SubprocessError):
163 """Scan cached parquet → component index with families and time bounds."""
164 from .series
import SOURCE_DB
170 for f
in sorted(cdir.glob(
"*.parquet")):
171 comp = classify(f.stem)
175 d[
"time_source"] = entity_clocks.get(comp.entity, SOURCE_DB)
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])
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
187 "index_version": INDEX_VERSION,
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)
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),
207 (cdir / _INDEX).write_text(json.dumps(index))
212 """Export the run to parquet if not already cached. Returns the cache dir."""
213 db = config.ELODIN_DIR / run_id
215 raise FileNotFoundError(f
"run not found: {db}")
218 if refresh
and cdir.exists():
219 for p
in cdir.iterdir():
221 if (cdir / _DONE).exists():
223 cdir.mkdir(parents=
True, exist_ok=
True)
225 config.find_elodin_db(),
236 proc = subprocess.run(cmd, capture_output=
True, text=
True)
237 if proc.returncode != 0:
239 f
"elodin-db export failed ({proc.returncode}): {proc.stderr.strip()[:500]}"
242 (cdir / _DONE).write_text(
"")
247 """Return the component index, exporting/rebuilding lazily if needed.
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.
256 data = json.loads(idx.read_text())
257 if data.get(
"index_version") == INDEX_VERSION:
258 return run_config.annotate(data, run_id)
260 return run_config.annotate(
build_index(run_id), run_id)
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)