matthieulel commited on
Commit
764face
·
1 Parent(s): c2f9938

Add dataset index, async filters, fix slider bounds, add spinner

Browse files
CLAUDE.md CHANGED
@@ -36,10 +36,14 @@ python app.py
36
 
37
  ## Key Patterns
38
 
 
 
 
 
39
  - **Annotation state** is thread-safe (`threading.Lock`) and saved to `state/annotations.json` on every annotation, synced to HF Hub periodically
40
  - **Spectrum selection** picks random un-annotated spectra; prefers those with cached plots for instant display
41
  - **Annotation categories** are configurable in `dataset_config.yaml` (Galaxy, Star, QSO, Interesting, Bad Data)
42
- - **Spectrum filters** (redshift range, S/N, ZWARN) are configurable in `dataset_config.yaml`
43
  - **Spectrum plots** are generated with matplotlib and cached as PNGs on disk (`cache/plots/`)
44
  - **Background pre-fetch** thread downloads and renders remaining spectra after the app starts (first 4 are synchronous)
45
  - **Callbacks** use Dash `ctx.triggered_id` to determine which annotation button was clicked
 
36
 
37
  ## Key Patterns
38
 
39
+ - **Full dataset filtering**: All ~100K rows' metadata is streamed at startup (metadata-only, no spectrum arrays). Once loaded, filters operate on the entire dataset. Metadata is published incrementally every 10K rows.
40
+ - **On-demand spectrum fetching**: When filters select spectra outside the initial pool, their spectrum data is fetched in a background thread and plots generated progressively.
41
+ - **Sequential dataset-order indexing**: All indices (initial pool, full metadata, annotations) use consistent dataset-order indices (0..N-1). No shuffle is used to avoid index space collisions.
42
+ - **Serialized background tasks**: Full metadata loads first (undisturbed ~8 min), then remaining initial pool spectra (~7 min). This avoids bandwidth competition between concurrent HF streams.
43
  - **Annotation state** is thread-safe (`threading.Lock`) and saved to `state/annotations.json` on every annotation, synced to HF Hub periodically
44
  - **Spectrum selection** picks random un-annotated spectra; prefers those with cached plots for instant display
45
  - **Annotation categories** are configurable in `dataset_config.yaml` (Galaxy, Star, QSO, Interesting, Bad Data)
46
+ - **Spectrum filters** (redshift range, EBV, ZWARN) are configurable in `dataset_config.yaml`
47
  - **Spectrum plots** are generated with matplotlib and cached as PNGs on disk (`cache/plots/`)
48
  - **Background pre-fetch** thread downloads and renders remaining spectra after the app starts (first 4 are synchronous)
49
  - **Callbacks** use Dash `ctx.triggered_id` to determine which annotation button was clicked
src/annotations.py CHANGED
@@ -218,7 +218,7 @@ def select_next_spectrum(filters: dict | None = None) -> int:
218
  Prefers un-annotated spectra; favours those whose plots are already cached.
219
  If filters is provided, restricts to matching spectra.
220
  """
221
- from src.spectrum_data import get_filtered_pool
222
 
223
  with _lock:
224
  if _state is None:
@@ -238,7 +238,11 @@ def select_next_spectrum(filters: dict | None = None) -> int:
238
  if not unannotated:
239
  unannotated = pool # all done — cycle again
240
 
241
- return random.choice(unannotated)
 
 
 
 
242
 
243
 
244
  def get_stats() -> dict:
 
218
  Prefers un-annotated spectra; favours those whose plots are already cached.
219
  If filters is provided, restricts to matching spectra.
220
  """
221
+ from src.spectrum_data import get_filtered_pool, is_plot_cached
222
 
223
  with _lock:
224
  if _state is None:
 
238
  if not unannotated:
239
  unannotated = pool # all done — cycle again
240
 
241
+ # Prefer spectra whose plots are already generated (avoids broken images
242
+ # while the background thread is still downloading data)
243
+ cached = [idx for idx in unannotated if is_plot_cached(idx)]
244
+ source = cached if cached else unannotated
245
+ return random.choice(source)
246
 
247
 
248
  def get_stats() -> dict:
src/callbacks.py CHANGED
@@ -1,5 +1,7 @@
1
  """Dash callbacks for DESI Spectra Zoo."""
2
 
 
 
3
  import uuid
4
  import logging
5
 
@@ -9,7 +11,11 @@ from dash.exceptions import PreventUpdate
9
 
10
  from src import annotations
11
  from src.hf_logging import log_annotation_event
12
- from src.spectrum_data import ensure_plot, get_filtered_pool, find_by_object_id
 
 
 
 
13
  from src.components import (
14
  create_spectrum_display,
15
  create_annotation_buttons,
@@ -26,13 +32,34 @@ _CAT_BY_BTN_ID = {f"annotation-btn-{cat['id']}": cat["id"] for cat in ANNOTATION
26
 
27
 
28
  def _parse_filters(z_range, ebv_max, exclude_zwarn):
29
- """Build a filters dict from UI control values."""
 
 
 
 
 
 
 
 
30
  filters = {}
 
 
31
  if z_range and len(z_range) == 2:
32
- filters["z_min"] = z_range[0]
33
- filters["z_max"] = z_range[1]
 
 
 
 
 
 
 
34
  if ebv_max is not None:
35
- filters["ebv_max"] = ebv_max
 
 
 
 
36
  if exclude_zwarn:
37
  filters["exclude_zwarn"] = True
38
  return filters
@@ -62,6 +89,11 @@ def register_callbacks(app):
62
  stats = annotations.get_stats()
63
  recent = annotations.get_recent()
64
  pool_count = len(get_filtered_pool({}))
 
 
 
 
 
65
  return (
66
  create_spectrum_display(idx),
67
  create_annotation_buttons(),
@@ -70,7 +102,7 @@ def register_callbacks(app):
70
  session_id,
71
  stats,
72
  create_recent_rows(recent),
73
- f"{pool_count} spectra",
74
  )
75
 
76
  # ---------- Annotation click ----------
@@ -135,6 +167,30 @@ def register_callbacks(app):
135
  stats = annotations.get_stats()
136
  recent = annotations.get_recent()
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  return (
139
  create_spectrum_display(next_idx),
140
  next_idx,
@@ -162,6 +218,24 @@ def register_callbacks(app):
162
  raise PreventUpdate
163
  next_idx = annotations.select_next_spectrum(filters=active_filters or {})
164
  ensure_plot(next_idx)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  return create_spectrum_display(next_idx), next_idx
166
 
167
  # ---------- Apply filters ----------
@@ -188,10 +262,17 @@ def register_callbacks(app):
188
  matched = get_filtered_pool(filters)
189
  count = len(matched)
190
 
 
 
 
 
 
 
 
191
  if count == 0:
192
  return (
193
  filters,
194
- "0 spectra — try relaxing filters",
195
  html.Div(
196
  "No spectra match these filters. Try adjusting the ranges.",
197
  style={
@@ -204,11 +285,43 @@ def register_callbacks(app):
204
  dash.no_update,
205
  )
206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  next_idx = annotations.select_next_spectrum(filters=filters)
208
  ensure_plot(next_idx)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  return (
210
  filters,
211
- f"{count} spectra",
212
  create_spectrum_display(next_idx),
213
  next_idx,
214
  )
@@ -229,6 +342,25 @@ def register_callbacks(app):
229
  idx = find_by_object_id(object_id_value)
230
  if idx is None:
231
  raise PreventUpdate
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  ensure_plot(idx)
233
  return create_spectrum_display(idx), idx
234
 
 
1
  """Dash callbacks for DESI Spectra Zoo."""
2
 
3
+ import random
4
+ import threading
5
  import uuid
6
  import logging
7
 
 
11
 
12
  from src import annotations
13
  from src.hf_logging import log_annotation_event
14
+ from src.spectrum_data import (
15
+ ensure_plot, get_filtered_pool, find_by_object_id,
16
+ is_plot_cached, fetch_spectra_for_indices, cancel_fetch,
17
+ is_full_metadata_ready, get_full_metadata_count,
18
+ )
19
  from src.components import (
20
  create_spectrum_display,
21
  create_annotation_buttons,
 
32
 
33
 
34
  def _parse_filters(z_range, ebv_max, exclude_zwarn):
35
+ """Build a filters dict from UI control values.
36
+
37
+ Filters are only applied when the user has actually moved a slider
38
+ away from its default (extreme) position. This avoids excluding
39
+ spectra due to rounding of slider bounds.
40
+ """
41
+ from src.spectrum_data import get_metadata_ranges
42
+ import math
43
+
44
  filters = {}
45
+ ranges = get_metadata_ranges()
46
+
47
  if z_range and len(z_range) == 2:
48
+ real_z = ranges.get("Z", (0, 5))
49
+ slider_z_min = math.floor(real_z[0] * 100) / 100
50
+ slider_z_max = math.ceil(real_z[1] * 100) / 100
51
+ # Only apply if user moved the slider away from its default bound
52
+ if z_range[0] > slider_z_min:
53
+ filters["z_min"] = z_range[0]
54
+ if z_range[1] < slider_z_max:
55
+ filters["z_max"] = z_range[1]
56
+
57
  if ebv_max is not None:
58
+ real_ebv = ranges.get("EBV", (0, 1))
59
+ slider_ebv_max = math.ceil(real_ebv[1] * 1000) / 1000
60
+ if ebv_max < slider_ebv_max:
61
+ filters["ebv_max"] = ebv_max
62
+
63
  if exclude_zwarn:
64
  filters["exclude_zwarn"] = True
65
  return filters
 
89
  stats = annotations.get_stats()
90
  recent = annotations.get_recent()
91
  pool_count = len(get_filtered_pool({}))
92
+ total = get_full_metadata_count()
93
+ if is_full_metadata_ready() and total:
94
+ count_label = f"{pool_count} / {total} spectra"
95
+ else:
96
+ count_label = f"{pool_count} spectra"
97
  return (
98
  create_spectrum_display(idx),
99
  create_annotation_buttons(),
 
102
  session_id,
103
  stats,
104
  create_recent_rows(recent),
105
+ count_label,
106
  )
107
 
108
  # ---------- Annotation click ----------
 
167
  stats = annotations.get_stats()
168
  recent = annotations.get_recent()
169
 
170
+ if not is_plot_cached(next_idx):
171
+ # Spectrum data not yet downloaded — trigger background fetch
172
+ threading.Thread(
173
+ target=fetch_spectra_for_indices,
174
+ args=([next_idx],),
175
+ daemon=True,
176
+ ).start()
177
+ return (
178
+ html.Div(
179
+ "Fetching next spectrum... Click Skip to retry.",
180
+ style={
181
+ "textAlign": "center",
182
+ "padding": "40px",
183
+ "color": "rgba(255,213,79,0.6)",
184
+ "fontFamily": "'Outfit', sans-serif",
185
+ },
186
+ ),
187
+ next_idx,
188
+ ann_count + 1,
189
+ create_stats_panel(stats),
190
+ stats,
191
+ create_recent_rows(recent),
192
+ )
193
+
194
  return (
195
  create_spectrum_display(next_idx),
196
  next_idx,
 
218
  raise PreventUpdate
219
  next_idx = annotations.select_next_spectrum(filters=active_filters or {})
220
  ensure_plot(next_idx)
221
+ if not is_plot_cached(next_idx):
222
+ threading.Thread(
223
+ target=fetch_spectra_for_indices,
224
+ args=([next_idx],),
225
+ daemon=True,
226
+ ).start()
227
+ return (
228
+ html.Div(
229
+ "Fetching next spectrum... Click Skip to retry.",
230
+ style={
231
+ "textAlign": "center",
232
+ "padding": "40px",
233
+ "color": "rgba(255,213,79,0.6)",
234
+ "fontFamily": "'Outfit', sans-serif",
235
+ },
236
+ ),
237
+ next_idx,
238
+ )
239
  return create_spectrum_display(next_idx), next_idx
240
 
241
  # ---------- Apply filters ----------
 
262
  matched = get_filtered_pool(filters)
263
  count = len(matched)
264
 
265
+ full_ready = is_full_metadata_ready()
266
+ total = get_full_metadata_count()
267
+ if full_ready and total:
268
+ source_label = f" / {total}"
269
+ else:
270
+ source_label = ""
271
+
272
  if count == 0:
273
  return (
274
  filters,
275
+ f"0{source_label} spectra — try relaxing filters",
276
  html.Div(
277
  "No spectra match these filters. Try adjusting the ranges.",
278
  style={
 
285
  dash.no_update,
286
  )
287
 
288
+ # Pick a random sample of up to 500 to fetch spectra for
289
+ MAX_FETCH = 500
290
+ to_fetch = random.sample(matched, min(MAX_FETCH, len(matched)))
291
+
292
+ # Start background download for spectra not yet cached
293
+ cancel_fetch() # cancel any in-progress fetch
294
+ threading.Thread(
295
+ target=fetch_spectra_for_indices,
296
+ args=(to_fetch,),
297
+ daemon=True,
298
+ ).start()
299
+
300
+ # Show a spectrum that's already cached, or the first one to fetch
301
  next_idx = annotations.select_next_spectrum(filters=filters)
302
  ensure_plot(next_idx)
303
+
304
+ count_label = f"{count}{source_label} spectra"
305
+ if not is_plot_cached(next_idx):
306
+ return (
307
+ filters,
308
+ count_label + " (fetching...)",
309
+ html.Div(
310
+ f"Fetching spectrum data... ({count} match). "
311
+ "Try again in a few seconds.",
312
+ style={
313
+ "textAlign": "center",
314
+ "padding": "40px",
315
+ "color": "rgba(255,213,79,0.6)",
316
+ "fontFamily": "'Outfit', sans-serif",
317
+ },
318
+ ),
319
+ dash.no_update,
320
+ )
321
+
322
  return (
323
  filters,
324
+ count_label,
325
  create_spectrum_display(next_idx),
326
  next_idx,
327
  )
 
342
  idx = find_by_object_id(object_id_value)
343
  if idx is None:
344
  raise PreventUpdate
345
+ # If not cached, fetch on-demand in background then show loading msg
346
+ if not is_plot_cached(idx):
347
+ threading.Thread(
348
+ target=fetch_spectra_for_indices,
349
+ args=([idx],),
350
+ daemon=True,
351
+ ).start()
352
+ return (
353
+ html.Div(
354
+ f"Fetching spectrum {object_id_value}... Click Skip or retry in a few seconds.",
355
+ style={
356
+ "textAlign": "center",
357
+ "padding": "40px",
358
+ "color": "rgba(255,213,79,0.6)",
359
+ "fontFamily": "'Outfit', sans-serif",
360
+ },
361
+ ),
362
+ idx,
363
+ )
364
  ensure_plot(idx)
365
  return create_spectrum_display(idx), idx
366
 
src/components.py CHANGED
@@ -567,10 +567,11 @@ def create_filter_panel():
567
  z_range = ranges.get("Z", (0, 5))
568
  ebv_range = ranges.get("EBV", (0, 1))
569
 
570
- # Round for nicer slider bounds
571
- z_min_val = max(0, round(z_range[0], 2))
572
- z_max_val = round(z_range[1] + 0.01, 2)
573
- ebv_max_val = round(ebv_range[1] + 0.01, 3)
 
574
 
575
  return html.Div([
576
  # Collapsible header
@@ -747,8 +748,34 @@ def create_layout():
747
  ),
748
 
749
  # Main arena: spectrum display + annotation buttons
750
- html.Div(id="spectrum-container",
751
- style={"position": "relative", "zIndex": "10"}),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
752
 
753
  html.Div(id="annotation-buttons-container",
754
  style={
 
567
  z_range = ranges.get("Z", (0, 5))
568
  ebv_range = ranges.get("EBV", (0, 1))
569
 
570
+ import math
571
+ # Use floor/ceil so slider bounds always cover ALL data values
572
+ z_min_val = math.floor(z_range[0] * 100) / 100 # e.g. -0.003 → -0.01
573
+ z_max_val = math.ceil(z_range[1] * 100) / 100 # e.g. 6.437 → 6.44
574
+ ebv_max_val = math.ceil(ebv_range[1] * 1000) / 1000 # e.g. 0.876 → 0.876
575
 
576
  return html.Div([
577
  # Collapsible header
 
748
  ),
749
 
750
  # Main arena: spectrum display + annotation buttons
751
+ html.Div(
752
+ html.Div(
753
+ [
754
+ dbc.Spinner(
755
+ color="warning",
756
+ spinner_style={"width": "3rem", "height": "3rem"},
757
+ ),
758
+ html.Div(
759
+ "Loading first spectrum...",
760
+ style={
761
+ "marginTop": "16px",
762
+ "fontFamily": "'Outfit', sans-serif",
763
+ "fontSize": "0.85rem",
764
+ "color": "rgba(255,255,255,0.4)",
765
+ },
766
+ ),
767
+ ],
768
+ style={
769
+ "display": "flex",
770
+ "flexDirection": "column",
771
+ "alignItems": "center",
772
+ "justifyContent": "center",
773
+ "padding": "60px 0",
774
+ },
775
+ ),
776
+ id="spectrum-container",
777
+ style={"position": "relative", "zIndex": "10"},
778
+ ),
779
 
780
  html.Div(id="annotation-buttons-container",
781
  style={
src/spectrum_data.py CHANGED
@@ -1,8 +1,11 @@
1
  """DESI spectrum data: streaming loader, plot generation, and caching.
2
 
3
- Two-pass streaming:
4
- - Pass 1: metadata-only streaming (fast, all spectra)
5
- - Pass 2: full spectrum streaming for plot generation (background)
 
 
 
6
  """
7
 
8
  from __future__ import annotations
@@ -45,12 +48,26 @@ LINES = {
45
  _BG_COLOR = "#0a0a1e"
46
  _SHUFFLE_BUFFER = 200
47
 
 
 
 
 
 
 
48
  # ---------------------------------------------------------------------------
49
  # State
50
  # ---------------------------------------------------------------------------
51
  _lock = threading.Lock()
52
- _metadata_cache: dict[int, dict] = {}
53
- _row_cache: dict[int, dict] = {} # full row data for on-demand plot generation
 
 
 
 
 
 
 
 
54
 
55
  _plot_dir = Path(PLOT_CACHE_DIR)
56
 
@@ -59,11 +76,10 @@ _plot_dir = Path(PLOT_CACHE_DIR)
59
  # Streaming dataset factory
60
  # ---------------------------------------------------------------------------
61
 
62
- def _make_dataset(seed: int, pool_size: int, with_spectrum: bool = True):
63
- """Return a shuffled, length-limited streaming dataset iterator.
64
 
65
- with_spectrum=False skips the spectrum column entirely at the Parquet level.
66
- Both modes use the same seed+buffer so row i is always the same spectrum.
67
  """
68
  from datasets import load_dataset
69
 
@@ -78,17 +94,28 @@ def _make_dataset(seed: int, pool_size: int, with_spectrum: bool = True):
78
  features = getattr(ds, "features", None) or {}
79
 
80
  if not with_spectrum:
81
- meta_cols = [c for c in [
82
- ID_COLUMN, "Z", "ZERR", "ZWARN", "EBV",
83
- "FLUX_G", "FLUX_R", "FLUX_Z",
84
- "FIBERFLUX_G", "FIBERFLUX_R", "FIBERFLUX_Z",
85
- ] if c in features]
86
  if meta_cols:
87
  ds = ds.select_columns(meta_cols)
88
 
89
- ds = ds.shuffle(seed=seed, buffer_size=_SHUFFLE_BUFFER)
90
- ds = ds.take(pool_size)
91
- return iter(ds)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
 
94
  # ---------------------------------------------------------------------------
@@ -101,10 +128,18 @@ def register_metadata(metadata_map: dict[int, dict]):
101
  _metadata_cache.update(metadata_map)
102
 
103
 
 
 
 
 
 
 
 
 
 
104
  def get_display_name(row_index: int) -> str:
105
  """Return a human-readable name for a spectrum."""
106
- with _lock:
107
- meta = _metadata_cache.get(row_index)
108
  if meta is not None:
109
  id_val = meta.get(ID_COLUMN)
110
  if id_val is not None:
@@ -114,8 +149,7 @@ def get_display_name(row_index: int) -> str:
114
 
115
  def get_display_info(row_index: int) -> dict:
116
  """Return full metadata for display (object_id, Z, ZERR, ZWARN, EBV)."""
117
- with _lock:
118
- meta = _metadata_cache.get(row_index)
119
  if meta:
120
  return {
121
  "object_id": str(meta.get(ID_COLUMN, f"Spectrum #{row_index}")),
@@ -129,26 +163,19 @@ def get_display_info(row_index: int) -> dict:
129
 
130
 
131
  def find_by_object_id(search_id: str) -> int | None:
132
- """Find the pool index for a given object ID string. Returns None if not found."""
133
  search_id = search_id.strip()
134
  if not search_id:
135
  return None
 
136
  with _lock:
137
- for idx, meta in _metadata_cache.items():
 
138
  if str(meta.get(ID_COLUMN, "")) == search_id:
139
  return idx
140
  return None
141
 
142
 
143
- def get_row_index_by_id(display_name: str) -> int | None:
144
- """Reverse-lookup: return the row index for a given display name, or None."""
145
- with _lock:
146
- for row_index, meta in _metadata_cache.items():
147
- if meta is not None and str(meta.get(ID_COLUMN)) == display_name:
148
- return row_index
149
- return None
150
-
151
-
152
  # ---------------------------------------------------------------------------
153
  # Plot cache (PNG files on disk)
154
  # ---------------------------------------------------------------------------
@@ -341,51 +368,52 @@ def sample_pool_streaming(
341
  seed: int | None = None,
342
  prefetch_plots: int = 4,
343
  ) -> tuple[list[int], dict[int, dict], int]:
344
- """Build the spectrum pool, caching a small batch of plots before returning.
345
 
346
- Two-pass streaming approach:
347
- - Pass 1: metadata only (fast, all spectra) no spectrum arrays downloaded
348
- - Pass 2: full rows for plot generation — first N sync, rest background
 
 
 
 
349
 
350
  Returns:
351
- ids: sequential ints 0..N-1
352
  metadata_map: {id -> metadata dict}
353
- seed: seed used
354
  """
355
  if seed is None:
356
  seed = random.randint(0, 2**32 - 1)
357
 
358
  _plot_dir.mkdir(parents=True, exist_ok=True)
359
 
360
- # Pass 1: metadata only fast, no spectrum data downloaded
361
- logger.info("Streaming metadata for %d spectra (seed=%d)...", pool_size, seed)
 
 
 
 
362
  ids: list[int] = []
363
  metadata_map: dict[int, dict] = {}
364
-
365
- for i, row in enumerate(_make_dataset(seed, pool_size, with_spectrum=False)):
366
- metadata_map[i] = {
367
- ID_COLUMN: row.get(ID_COLUMN),
368
- "Z": row.get("Z", 0),
369
- "ZERR": row.get("ZERR", 0),
370
- "ZWARN": row.get("ZWARN", False),
371
- "EBV": row.get("EBV", 0),
372
- "FLUX_G": row.get("FLUX_G", 0),
373
- "FLUX_R": row.get("FLUX_R", 0),
374
- "FLUX_Z": row.get("FLUX_Z", 0),
375
- "FIBERFLUX_G": row.get("FIBERFLUX_G", 0),
376
- "FIBERFLUX_R": row.get("FIBERFLUX_R", 0),
377
- "FIBERFLUX_Z": row.get("FIBERFLUX_Z", 0),
378
- }
379
  ids.append(i)
380
 
381
- logger.info("All %d spectrum IDs ready", len(ids))
382
 
383
- # Pass 2: full rows for plot generation same seed => same row order
384
- spec_it = _make_dataset(seed, pool_size, with_spectrum=True)
 
385
  sync_count = min(prefetch_plots, pool_size)
386
 
387
  for i in range(sync_count):
388
- row = next(spec_it)
 
 
 
389
  with _lock:
390
  _row_cache[i] = row
391
  if not is_plot_cached(i):
@@ -396,43 +424,164 @@ def sample_pool_streaming(
396
  logger.warning("Plot generation failed for spectrum %d: %s", i, e)
397
  logger.info("Pre-fetched plot %d/%d", i + 1, sync_count)
398
 
399
- logger.info("%d plots cached — app ready, %d remaining in background",
400
  sync_count, pool_size - sync_count)
401
 
402
- if sync_count < pool_size:
403
- def _bg():
404
- for i in range(sync_count, pool_size):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
  try:
406
- row = next(spec_it)
407
- with _lock:
408
- _row_cache[i] = row
409
- if not is_plot_cached(i):
410
- png_bytes = _generate_plot(i, row)
411
- (_plot_dir / f"{i}.png").write_bytes(png_bytes)
412
- except StopIteration:
413
- break
414
  except Exception as e:
415
  logger.warning("Background plot error at spectrum %d: %s", i, e)
416
- logger.info("Background plot generation complete")
417
 
418
- threading.Thread(target=_bg, daemon=True).start()
419
 
420
  return ids, metadata_map, seed
421
 
422
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
  def get_pool() -> list[int]:
424
- """Return the current pool indices."""
425
  with _lock:
 
 
426
  return sorted(_metadata_cache.keys())
427
 
428
 
429
  def get_metadata_ranges() -> dict:
430
- """Compute min/max for numeric metadata across the pool.
431
 
432
- Used by the UI to set slider ranges.
433
  """
434
  with _lock:
435
- metas = list(_metadata_cache.values())
436
  if not metas:
437
  return {}
438
 
@@ -452,16 +601,12 @@ def get_metadata_ranges() -> dict:
452
 
453
 
454
  def get_filtered_pool(filters: dict) -> list[int]:
455
- """Return pool indices matching the given filters.
456
 
457
- filters is a dict with optional keys:
458
- z_min, z_max: redshift range
459
- exclude_zwarn: bool
460
- ebv_max: max EBV
461
- snr_min: minimum S/N (approximated from FLUX_R if available)
462
  """
463
  with _lock:
464
- metas = dict(_metadata_cache)
465
 
466
  z_min = filters.get("z_min")
467
  z_max = filters.get("z_max")
 
1
  """DESI spectrum data: streaming loader, plot generation, and caching.
2
 
3
+ Architecture:
4
+ - Full metadata index: ALL rows' metadata loaded at startup (no spectrum arrays).
5
+ Enables filtering across the entire dataset.
6
+ - On-demand spectrum fetch: when filters select new spectra, their spectrum data
7
+ is downloaded in background and plots generated progressively.
8
+ - Initial quick pool: a small batch of spectra with full data for immediate display.
9
  """
10
 
11
  from __future__ import annotations
 
48
  _BG_COLOR = "#0a0a1e"
49
  _SHUFFLE_BUFFER = 200
50
 
51
+ _META_COLUMNS = [
52
+ ID_COLUMN, "Z", "ZERR", "ZWARN", "EBV",
53
+ "FLUX_G", "FLUX_R", "FLUX_Z",
54
+ "FIBERFLUX_G", "FIBERFLUX_R", "FIBERFLUX_Z",
55
+ ]
56
+
57
  # ---------------------------------------------------------------------------
58
  # State
59
  # ---------------------------------------------------------------------------
60
  _lock = threading.Lock()
61
+ _metadata_cache: dict[int, dict] = {} # active pool metadata (initial + fetched)
62
+ _row_cache: dict[int, dict] = {} # full row data for plot generation
63
+
64
+ # Full metadata index: ALL dataset rows (dataset-order index → metadata)
65
+ _full_metadata: dict[int, dict] = {}
66
+ _full_metadata_ready = threading.Event()
67
+ _full_metadata_total: int = 0 # total rows in dataset
68
+
69
+ # Background fetch control
70
+ _fetch_cancel = threading.Event()
71
 
72
  _plot_dir = Path(PLOT_CACHE_DIR)
73
 
 
76
  # Streaming dataset factory
77
  # ---------------------------------------------------------------------------
78
 
79
+ def _make_streaming_dataset(with_spectrum: bool = True):
80
+ """Return a sequential streaming dataset iterator over ALL rows.
81
 
82
+ with_spectrum=False skips the spectrum column at the Parquet level.
 
83
  """
84
  from datasets import load_dataset
85
 
 
94
  features = getattr(ds, "features", None) or {}
95
 
96
  if not with_spectrum:
97
+ meta_cols = [c for c in _META_COLUMNS if c in features]
 
 
 
 
98
  if meta_cols:
99
  ds = ds.select_columns(meta_cols)
100
 
101
+ return ds
102
+
103
+
104
+ def _row_to_meta(row: dict) -> dict:
105
+ """Extract metadata fields from a dataset row."""
106
+ return {
107
+ ID_COLUMN: row.get(ID_COLUMN),
108
+ "Z": row.get("Z", 0),
109
+ "ZERR": row.get("ZERR", 0),
110
+ "ZWARN": row.get("ZWARN", False),
111
+ "EBV": row.get("EBV", 0),
112
+ "FLUX_G": row.get("FLUX_G", 0),
113
+ "FLUX_R": row.get("FLUX_R", 0),
114
+ "FLUX_Z": row.get("FLUX_Z", 0),
115
+ "FIBERFLUX_G": row.get("FIBERFLUX_G", 0),
116
+ "FIBERFLUX_R": row.get("FIBERFLUX_R", 0),
117
+ "FIBERFLUX_Z": row.get("FIBERFLUX_Z", 0),
118
+ }
119
 
120
 
121
  # ---------------------------------------------------------------------------
 
128
  _metadata_cache.update(metadata_map)
129
 
130
 
131
+ def _get_meta(row_index: int) -> dict | None:
132
+ """Get metadata for a row, preferring full index, falling back to active cache."""
133
+ with _lock:
134
+ meta = _full_metadata.get(row_index)
135
+ if meta is None:
136
+ meta = _metadata_cache.get(row_index)
137
+ return meta
138
+
139
+
140
  def get_display_name(row_index: int) -> str:
141
  """Return a human-readable name for a spectrum."""
142
+ meta = _get_meta(row_index)
 
143
  if meta is not None:
144
  id_val = meta.get(ID_COLUMN)
145
  if id_val is not None:
 
149
 
150
  def get_display_info(row_index: int) -> dict:
151
  """Return full metadata for display (object_id, Z, ZERR, ZWARN, EBV)."""
152
+ meta = _get_meta(row_index)
 
153
  if meta:
154
  return {
155
  "object_id": str(meta.get(ID_COLUMN, f"Spectrum #{row_index}")),
 
163
 
164
 
165
  def find_by_object_id(search_id: str) -> int | None:
166
+ """Find the dataset index for a given object ID string."""
167
  search_id = search_id.strip()
168
  if not search_id:
169
  return None
170
+ # Search full index first, then active cache
171
  with _lock:
172
+ source = _full_metadata if _full_metadata else _metadata_cache
173
+ for idx, meta in source.items():
174
  if str(meta.get(ID_COLUMN, "")) == search_id:
175
  return idx
176
  return None
177
 
178
 
 
 
 
 
 
 
 
 
 
179
  # ---------------------------------------------------------------------------
180
  # Plot cache (PNG files on disk)
181
  # ---------------------------------------------------------------------------
 
368
  seed: int | None = None,
369
  prefetch_plots: int = 4,
370
  ) -> tuple[list[int], dict[int, dict], int]:
371
+ """Build the initial quick pool and start full metadata loading in background.
372
 
373
+ Uses **sequential dataset-order** indexing (no shuffle) so that the initial
374
+ pool indices are consistent with the full metadata index loaded later.
375
+
376
+ 1. Streams metadata for first `pool_size` rows (sequential, fast).
377
+ 2. Streams spectrum data for the first `prefetch_plots` synchronously.
378
+ 3. Continues spectrum data for the rest in background.
379
+ 4. Starts a background thread to load ALL metadata (full dataset index).
380
 
381
  Returns:
382
+ ids: dataset-order ints 0..pool_size-1
383
  metadata_map: {id -> metadata dict}
384
+ seed: kept for API compat
385
  """
386
  if seed is None:
387
  seed = random.randint(0, 2**32 - 1)
388
 
389
  _plot_dir.mkdir(parents=True, exist_ok=True)
390
 
391
+ # --- Phase 1: metadata for first pool_size rows (sequential, fast) ---
392
+ logger.info("Streaming metadata for initial pool of %d spectra (sequential)...",
393
+ pool_size)
394
+
395
+ ds_meta = _make_streaming_dataset(with_spectrum=False)
396
+
397
  ids: list[int] = []
398
  metadata_map: dict[int, dict] = {}
399
+ for i, row in enumerate(ds_meta):
400
+ if i >= pool_size:
401
+ break
402
+ metadata_map[i] = _row_to_meta(row)
 
 
 
 
 
 
 
 
 
 
 
403
  ids.append(i)
404
 
405
+ logger.info("All %d initial spectrum IDs ready", len(ids))
406
 
407
+ # --- Phase 2: spectrum data for first N plots (synchronous) ---
408
+ ds_full = _make_streaming_dataset(with_spectrum=True)
409
+ spec_it = iter(ds_full)
410
  sync_count = min(prefetch_plots, pool_size)
411
 
412
  for i in range(sync_count):
413
+ try:
414
+ row = next(spec_it)
415
+ except StopIteration:
416
+ break
417
  with _lock:
418
  _row_cache[i] = row
419
  if not is_plot_cached(i):
 
424
  logger.warning("Plot generation failed for spectrum %d: %s", i, e)
425
  logger.info("Pre-fetched plot %d/%d", i + 1, sync_count)
426
 
427
+ logger.info("%d plots cached — app ready, %d remaining after metadata load",
428
  sync_count, pool_size - sync_count)
429
 
430
+ # --- Background: full metadata FIRST, then remaining initial pool spectra ---
431
+ # Serializing avoids bandwidth competition between two concurrent streams.
432
+ _pool_size_for_bg = pool_size
433
+ _sync_count_for_bg = sync_count
434
+
435
+ def _bg_metadata_then_spectra():
436
+ # 1) Load full metadata index (metadata-only, fast)
437
+ _load_full_metadata_index()
438
+
439
+ # 2) Load remaining initial pool spectra (with spectrum data)
440
+ logger.info("Now loading remaining initial pool spectra %d-%d...",
441
+ _sync_count_for_bg, _pool_size_for_bg - 1)
442
+ ds = _make_streaming_dataset(with_spectrum=True)
443
+ for i, row in enumerate(ds):
444
+ if _fetch_cancel.is_set():
445
+ logger.info("Initial pool loading cancelled by fetch_cancel")
446
+ break
447
+ if i < _sync_count_for_bg:
448
+ continue
449
+ if i >= _pool_size_for_bg:
450
+ break
451
+ with _lock:
452
+ _row_cache[i] = row
453
+ if not is_plot_cached(i):
454
  try:
455
+ png_bytes = _generate_plot(i, row)
456
+ (_plot_dir / f"{i}.png").write_bytes(png_bytes)
 
 
 
 
 
 
457
  except Exception as e:
458
  logger.warning("Background plot error at spectrum %d: %s", i, e)
459
+ logger.info("Initial pool plot generation complete")
460
 
461
+ threading.Thread(target=_bg_metadata_then_spectra, daemon=True).start()
462
 
463
  return ids, metadata_map, seed
464
 
465
 
466
+ # ---------------------------------------------------------------------------
467
+ # Full metadata index (all dataset rows)
468
+ # ---------------------------------------------------------------------------
469
+
470
+ def _load_full_metadata_index():
471
+ """Stream ALL dataset rows (metadata-only) to build the full index.
472
+
473
+ Runs in background at startup. Updates _full_metadata incrementally
474
+ every 10K rows so filters can work on partial data during loading.
475
+ """
476
+ global _full_metadata, _full_metadata_total
477
+
478
+ logger.info("Loading full metadata index (all rows, metadata-only)...")
479
+
480
+ try:
481
+ ds = _make_streaming_dataset(with_spectrum=False)
482
+ temp: dict[int, dict] = {}
483
+ for i, row in enumerate(ds):
484
+ temp[i] = _row_to_meta(row)
485
+ if (i + 1) % 10000 == 0:
486
+ logger.info("Metadata indexed: %d rows...", i + 1)
487
+ # Publish partial index so filters work on available data
488
+ with _lock:
489
+ _full_metadata = dict(temp)
490
+ _full_metadata_total = len(temp)
491
+
492
+ with _lock:
493
+ _full_metadata = temp
494
+ _full_metadata_total = len(temp)
495
+ _full_metadata_ready.set()
496
+ logger.info("Full metadata index ready: %d rows", len(temp))
497
+
498
+ except Exception as e:
499
+ logger.error("Failed to load full metadata index: %s", e)
500
+ _full_metadata_ready.set() # unblock waiters even on failure
501
+
502
+
503
+ def is_full_metadata_ready() -> bool:
504
+ """Check if the full metadata index has finished loading."""
505
+ return _full_metadata_ready.is_set()
506
+
507
+
508
+ def get_full_metadata_count() -> int:
509
+ """Return total number of rows in the full metadata index (0 if not ready)."""
510
+ with _lock:
511
+ return _full_metadata_total
512
+
513
+
514
+ # ---------------------------------------------------------------------------
515
+ # On-demand spectrum fetching
516
+ # ---------------------------------------------------------------------------
517
+
518
+ def fetch_spectra_for_indices(indices: list[int]) -> None:
519
+ """Download spectrum data + generate plots for given dataset-order indices.
520
+
521
+ Streams through the dataset sequentially, collecting only the needed rows.
522
+ Called in a background thread when filters select new spectra.
523
+ """
524
+ # Only fetch indices that aren't already cached
525
+ needed = [i for i in indices if not is_plot_cached(i) and i not in _row_cache]
526
+ if not needed:
527
+ return
528
+
529
+ _fetch_cancel.clear()
530
+ target_set = set(needed)
531
+ max_idx = max(needed)
532
+
533
+ logger.info("Fetching spectrum data for %d spectra (up to idx %d)...",
534
+ len(target_set), max_idx)
535
+
536
+ try:
537
+ ds = _make_streaming_dataset(with_spectrum=True)
538
+ for i, row in enumerate(ds):
539
+ if _fetch_cancel.is_set():
540
+ logger.info("Spectrum fetch cancelled")
541
+ return
542
+ if i in target_set:
543
+ with _lock:
544
+ _row_cache[i] = row
545
+ _metadata_cache[i] = _row_to_meta(row)
546
+ if not is_plot_cached(i):
547
+ try:
548
+ png_bytes = _generate_plot(i, row)
549
+ _plot_dir.mkdir(parents=True, exist_ok=True)
550
+ (_plot_dir / f"{i}.png").write_bytes(png_bytes)
551
+ except Exception as e:
552
+ logger.warning("Plot error for spectrum %d: %s", i, e)
553
+ target_set.discard(i)
554
+ if not target_set:
555
+ break
556
+ if i > max_idx:
557
+ break
558
+
559
+ logger.info("Spectrum fetch complete (%d remaining)", len(target_set))
560
+
561
+ except Exception as e:
562
+ logger.error("Spectrum fetch failed: %s", e)
563
+
564
+
565
+ def cancel_fetch():
566
+ """Signal the background fetch thread to stop."""
567
+ _fetch_cancel.set()
568
+
569
+
570
  def get_pool() -> list[int]:
571
+ """Return the current pool indices (all indices with metadata)."""
572
  with _lock:
573
+ if _full_metadata:
574
+ return sorted(_full_metadata.keys())
575
  return sorted(_metadata_cache.keys())
576
 
577
 
578
  def get_metadata_ranges() -> dict:
579
+ """Compute min/max for numeric metadata.
580
 
581
+ Uses full index if available, else falls back to active pool.
582
  """
583
  with _lock:
584
+ metas = list(_full_metadata.values()) if _full_metadata else list(_metadata_cache.values())
585
  if not metas:
586
  return {}
587
 
 
601
 
602
 
603
  def get_filtered_pool(filters: dict) -> list[int]:
604
+ """Return dataset indices matching the given filters.
605
 
606
+ Uses full metadata index if available, otherwise the active pool.
 
 
 
 
607
  """
608
  with _lock:
609
+ metas = dict(_full_metadata) if _full_metadata else dict(_metadata_cache)
610
 
611
  z_min = filters.get("z_min")
612
  z_max = filters.get("z_max")
state/annotations.json DELETED
@@ -1,516 +0,0 @@
1
- {
2
- "dataset_id": "MultimodalUniverse/desi",
3
- "pool": [
4
- 0,
5
- 1,
6
- 2,
7
- 3,
8
- 4,
9
- 5,
10
- 6,
11
- 7,
12
- 8,
13
- 9,
14
- 10,
15
- 11,
16
- 12,
17
- 13,
18
- 14,
19
- 15,
20
- 16,
21
- 17,
22
- 18,
23
- 19,
24
- 20,
25
- 21,
26
- 22,
27
- 23,
28
- 24,
29
- 25,
30
- 26,
31
- 27,
32
- 28,
33
- 29,
34
- 30,
35
- 31,
36
- 32,
37
- 33,
38
- 34,
39
- 35,
40
- 36,
41
- 37,
42
- 38,
43
- 39,
44
- 40,
45
- 41,
46
- 42,
47
- 43,
48
- 44,
49
- 45,
50
- 46,
51
- 47,
52
- 48,
53
- 49,
54
- 50,
55
- 51,
56
- 52,
57
- 53,
58
- 54,
59
- 55,
60
- 56,
61
- 57,
62
- 58,
63
- 59,
64
- 60,
65
- 61,
66
- 62,
67
- 63,
68
- 64,
69
- 65,
70
- 66,
71
- 67,
72
- 68,
73
- 69,
74
- 70,
75
- 71,
76
- 72,
77
- 73,
78
- 74,
79
- 75,
80
- 76,
81
- 77,
82
- 78,
83
- 79,
84
- 80,
85
- 81,
86
- 82,
87
- 83,
88
- 84,
89
- 85,
90
- 86,
91
- 87,
92
- 88,
93
- 89,
94
- 90,
95
- 91,
96
- 92,
97
- 93,
98
- 94,
99
- 95,
100
- 96,
101
- 97,
102
- 98,
103
- 99,
104
- 100,
105
- 101,
106
- 102,
107
- 103,
108
- 104,
109
- 105,
110
- 106,
111
- 107,
112
- 108,
113
- 109,
114
- 110,
115
- 111,
116
- 112,
117
- 113,
118
- 114,
119
- 115,
120
- 116,
121
- 117,
122
- 118,
123
- 119,
124
- 120,
125
- 121,
126
- 122,
127
- 123,
128
- 124,
129
- 125,
130
- 126,
131
- 127,
132
- 128,
133
- 129,
134
- 130,
135
- 131,
136
- 132,
137
- 133,
138
- 134,
139
- 135,
140
- 136,
141
- 137,
142
- 138,
143
- 139,
144
- 140,
145
- 141,
146
- 142,
147
- 143,
148
- 144,
149
- 145,
150
- 146,
151
- 147,
152
- 148,
153
- 149,
154
- 150,
155
- 151,
156
- 152,
157
- 153,
158
- 154,
159
- 155,
160
- 156,
161
- 157,
162
- 158,
163
- 159,
164
- 160,
165
- 161,
166
- 162,
167
- 163,
168
- 164,
169
- 165,
170
- 166,
171
- 167,
172
- 168,
173
- 169,
174
- 170,
175
- 171,
176
- 172,
177
- 173,
178
- 174,
179
- 175,
180
- 176,
181
- 177,
182
- 178,
183
- 179,
184
- 180,
185
- 181,
186
- 182,
187
- 183,
188
- 184,
189
- 185,
190
- 186,
191
- 187,
192
- 188,
193
- 189,
194
- 190,
195
- 191,
196
- 192,
197
- 193,
198
- 194,
199
- 195,
200
- 196,
201
- 197,
202
- 198,
203
- 199,
204
- 200,
205
- 201,
206
- 202,
207
- 203,
208
- 204,
209
- 205,
210
- 206,
211
- 207,
212
- 208,
213
- 209,
214
- 210,
215
- 211,
216
- 212,
217
- 213,
218
- 214,
219
- 215,
220
- 216,
221
- 217,
222
- 218,
223
- 219,
224
- 220,
225
- 221,
226
- 222,
227
- 223,
228
- 224,
229
- 225,
230
- 226,
231
- 227,
232
- 228,
233
- 229,
234
- 230,
235
- 231,
236
- 232,
237
- 233,
238
- 234,
239
- 235,
240
- 236,
241
- 237,
242
- 238,
243
- 239,
244
- 240,
245
- 241,
246
- 242,
247
- 243,
248
- 244,
249
- 245,
250
- 246,
251
- 247,
252
- 248,
253
- 249,
254
- 250,
255
- 251,
256
- 252,
257
- 253,
258
- 254,
259
- 255,
260
- 256,
261
- 257,
262
- 258,
263
- 259,
264
- 260,
265
- 261,
266
- 262,
267
- 263,
268
- 264,
269
- 265,
270
- 266,
271
- 267,
272
- 268,
273
- 269,
274
- 270,
275
- 271,
276
- 272,
277
- 273,
278
- 274,
279
- 275,
280
- 276,
281
- 277,
282
- 278,
283
- 279,
284
- 280,
285
- 281,
286
- 282,
287
- 283,
288
- 284,
289
- 285,
290
- 286,
291
- 287,
292
- 288,
293
- 289,
294
- 290,
295
- 291,
296
- 292,
297
- 293,
298
- 294,
299
- 295,
300
- 296,
301
- 297,
302
- 298,
303
- 299,
304
- 300,
305
- 301,
306
- 302,
307
- 303,
308
- 304,
309
- 305,
310
- 306,
311
- 307,
312
- 308,
313
- 309,
314
- 310,
315
- 311,
316
- 312,
317
- 313,
318
- 314,
319
- 315,
320
- 316,
321
- 317,
322
- 318,
323
- 319,
324
- 320,
325
- 321,
326
- 322,
327
- 323,
328
- 324,
329
- 325,
330
- 326,
331
- 327,
332
- 328,
333
- 329,
334
- 330,
335
- 331,
336
- 332,
337
- 333,
338
- 334,
339
- 335,
340
- 336,
341
- 337,
342
- 338,
343
- 339,
344
- 340,
345
- 341,
346
- 342,
347
- 343,
348
- 344,
349
- 345,
350
- 346,
351
- 347,
352
- 348,
353
- 349,
354
- 350,
355
- 351,
356
- 352,
357
- 353,
358
- 354,
359
- 355,
360
- 356,
361
- 357,
362
- 358,
363
- 359,
364
- 360,
365
- 361,
366
- 362,
367
- 363,
368
- 364,
369
- 365,
370
- 366,
371
- 367,
372
- 368,
373
- 369,
374
- 370,
375
- 371,
376
- 372,
377
- 373,
378
- 374,
379
- 375,
380
- 376,
381
- 377,
382
- 378,
383
- 379,
384
- 380,
385
- 381,
386
- 382,
387
- 383,
388
- 384,
389
- 385,
390
- 386,
391
- 387,
392
- 388,
393
- 389,
394
- 390,
395
- 391,
396
- 392,
397
- 393,
398
- 394,
399
- 395,
400
- 396,
401
- 397,
402
- 398,
403
- 399,
404
- 400,
405
- 401,
406
- 402,
407
- 403,
408
- 404,
409
- 405,
410
- 406,
411
- 407,
412
- 408,
413
- 409,
414
- 410,
415
- 411,
416
- 412,
417
- 413,
418
- 414,
419
- 415,
420
- 416,
421
- 417,
422
- 418,
423
- 419,
424
- 420,
425
- 421,
426
- 422,
427
- 423,
428
- 424,
429
- 425,
430
- 426,
431
- 427,
432
- 428,
433
- 429,
434
- 430,
435
- 431,
436
- 432,
437
- 433,
438
- 434,
439
- 435,
440
- 436,
441
- 437,
442
- 438,
443
- 439,
444
- 440,
445
- 441,
446
- 442,
447
- 443,
448
- 444,
449
- 445,
450
- 446,
451
- 447,
452
- 448,
453
- 449,
454
- 450,
455
- 451,
456
- 452,
457
- 453,
458
- 454,
459
- 455,
460
- 456,
461
- 457,
462
- 458,
463
- 459,
464
- 460,
465
- 461,
466
- 462,
467
- 463,
468
- 464,
469
- 465,
470
- 466,
471
- 467,
472
- 468,
473
- 469,
474
- 470,
475
- 471,
476
- 472,
477
- 473,
478
- 474,
479
- 475,
480
- 476,
481
- 477,
482
- 478,
483
- 479,
484
- 480,
485
- 481,
486
- 482,
487
- 483,
488
- 484,
489
- 485,
490
- 486,
491
- 487,
492
- 488,
493
- 489,
494
- 490,
495
- 491,
496
- 492,
497
- 493,
498
- 494,
499
- 495,
500
- 496,
501
- 497,
502
- 498,
503
- 499
504
- ],
505
- "annotations": [
506
- {
507
- "spectrum_index": 435,
508
- "spectrum_name": "39627752067827082",
509
- "category": "galaxy",
510
- "session_id": "77d21f0bf33941ce87f901e82471c840",
511
- "comment": "",
512
- "timestamp": "2026-03-31T10:01:54.189866"
513
- }
514
- ],
515
- "total": 1
516
- }