aetherscan 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,512 @@
1
+ """
2
+ Process-parallel latent-space GIF frame rendering (#278).
3
+
4
+ The latent-GIF stage's cost is almost entirely matplotlib: the old implementation rebuilt a
5
+ figure, re-scattered every category, and saved a PNG per frame on one thread — ~6.6 s/frame,
6
+ ~55 min per GIF, ~24-29 h across the 24-GIF sweep. Frames are fully independent (each needs
7
+ only its 2D coords, labels, the shared axis limits and palette), so this module renders them
8
+ across a process pool, mirroring shap_parallel.py's isolation pattern: forkserver context with
9
+ an EMPTY preload so workers never re-import the parent __main__ (aetherscan.main -> TF -> the
10
+ training stack), and this module itself stays off the TF import graph (train.py imports it,
11
+ never the reverse). Unlike SHAP, per-worker memory is tiny (one chunk of frames), so the pool
12
+ is core-bound — size it by cores, not RAM.
13
+
14
+ Within each worker the figure and the per-category scatter artists are created ONCE and updated
15
+ per frame via set_offsets / set_title (the standard animation idiom); the legend is rebuilt per
16
+ frame from the categories actually present, matching the old output exactly. Rendering is
17
+ deterministic: the same frames produce byte-identical PNGs regardless of worker count.
18
+
19
+ GIF assembly (imageio) and Slack upload stay in the caller — only PNG rendering parallelizes.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import multiprocessing as mp
25
+ import os
26
+ from concurrent.futures import ProcessPoolExecutor
27
+ from dataclasses import dataclass
28
+
29
+ import numpy as np
30
+
31
+ # Worker-global render state, set up once per worker by _worker_init (module globals survive
32
+ # across _worker calls within one worker process, so the figure is built once, not per chunk)
33
+ _STYLE: dict | None = None
34
+ _FIG = None
35
+ _AX = None
36
+ _ARTISTS: list | None = None
37
+
38
+
39
+ @dataclass
40
+ class FrameCategory:
41
+ """One scatter category: its mask keys (signal_type, optional ON/OFF) and its style."""
42
+
43
+ signal_type: str
44
+ onoff: str | None # None for cadence-level (labels-only masking)
45
+ color: str
46
+ marker: str
47
+ display_name: str
48
+
49
+
50
+ def batched_umap_transform(model, coords_list: list[np.ndarray]) -> list[np.ndarray]:
51
+ """
52
+ Project every snapshot's coords through `model` in ONE stacked .transform() call instead
53
+ of len(coords_list) serial calls. Returns the per-snapshot list split back out, order
54
+ preserved.
55
+
56
+ NOT used by the pipeline: the #278 benchmark measured it ~9% faster but NOT
57
+ output-identical to per-snapshot transforms (UMAP consumes its random_state stream
58
+ differently for the joint batch), which violates #278's outputs-unchanged constraint.
59
+ Kept as benchmark-support so bench_latent_gif.py can keep re-measuring the trade.
60
+ """
61
+ if not coords_list:
62
+ return []
63
+ stacked = np.vstack(coords_list)
64
+ transformed = model.transform(stacked)
65
+ split_points = np.cumsum([len(coords) for coords in coords_list])[:-1]
66
+ return [np.ascontiguousarray(part) for part in np.split(transformed, split_points)]
67
+
68
+
69
+ def _worker_init(style: dict) -> None:
70
+ """Pin the Agg backend and stash the shared style; the figure is built lazily on the
71
+ first frame so matplotlib initializes inside the worker, never in the forkserver."""
72
+ import matplotlib # noqa: PLC0415
73
+
74
+ matplotlib.use("Agg")
75
+ global _STYLE
76
+ _STYLE = style
77
+
78
+
79
+ def _ensure_figure():
80
+ """Build the reusable figure + one (initially empty) scatter artist per category."""
81
+ global _FIG, _AX, _ARTISTS
82
+ if _FIG is not None:
83
+ return
84
+ import matplotlib.pyplot as plt # noqa: PLC0415
85
+
86
+ style = _STYLE
87
+ _FIG, _AX = plt.subplots(1, 1, figsize=tuple(style["figsize"]))
88
+ _ARTISTS = []
89
+ for category in style["categories"]:
90
+ artist = _AX.scatter(
91
+ [],
92
+ [],
93
+ c=category.color,
94
+ marker=category.marker,
95
+ s=10,
96
+ alpha=0.75,
97
+ label=category.display_name,
98
+ rasterized=True,
99
+ )
100
+ _ARTISTS.append(artist)
101
+ _AX.set_xlim(tuple(style["xlim"]))
102
+ _AX.set_ylim(tuple(style["ylim"]))
103
+ # Fixed limits + a single-line title => the geometry is frame-independent, so one
104
+ # tight_layout call up front matches the old per-frame call's result
105
+ _AX.set_title(" ", fontsize=11)
106
+ _FIG.tight_layout()
107
+
108
+
109
+ def _render_chunk(task: tuple) -> list[str]:
110
+ """Render one chunk of frames to PNGs; returns the frame paths in order."""
111
+ frames, frame_indices = task
112
+ _ensure_figure()
113
+ style = _STYLE
114
+ categories = style["categories"]
115
+ paths: list[str] = []
116
+
117
+ for frame, frame_idx in zip(frames, frame_indices, strict=True):
118
+ coords_2d = frame["coords"]
119
+ labels_arr = np.asarray(frame["labels"])
120
+ onoff_arr = np.asarray(frame["onoff"]) if frame.get("onoff") is not None else None
121
+
122
+ visible = []
123
+ for category, artist in zip(categories, _ARTISTS, strict=True):
124
+ mask = labels_arr == category.signal_type
125
+ if category.onoff is not None and onoff_arr is not None:
126
+ mask &= onoff_arr == category.onoff
127
+ if mask.any():
128
+ artist.set_offsets(coords_2d[mask])
129
+ visible.append(artist)
130
+ else:
131
+ artist.set_offsets(np.empty((0, 2)))
132
+ # Legend rebuilt from the categories present in THIS frame — same entries the old
133
+ # per-frame implementation produced (it only scattered non-empty categories)
134
+ _AX.legend(handles=visible, **style["legend_kwargs"])
135
+ _AX.set_title(frame["title"], fontsize=11)
136
+
137
+ frame_path = os.path.join(
138
+ style["out_dir"], f"{style['method_name']}_frame_{frame_idx:05d}.png"
139
+ )
140
+ _FIG.savefig(frame_path, dpi=style["dpi"])
141
+ paths.append(frame_path)
142
+
143
+ return paths
144
+
145
+
146
+ def render_latent_gif_frames(
147
+ frames: list[dict],
148
+ categories: list[FrameCategory],
149
+ xlim: tuple[float, float],
150
+ ylim: tuple[float, float],
151
+ legend_kwargs: dict,
152
+ out_dir: str,
153
+ method_name: str,
154
+ n_workers: int,
155
+ figsize: tuple[float, float] = (10, 8),
156
+ dpi: int = 100,
157
+ ) -> list[str]:
158
+ """
159
+ Render one PNG per frame across a process pool; returns the frame paths in frame order.
160
+
161
+ Each frame dict carries: "coords" (N, 2 float array), "labels" (N str array), optional
162
+ "onoff" (N str array, obs-level only), and "title" (str). n_workers <= 1 renders fully
163
+ in-process (no pool) — byte-identical output either way.
164
+ """
165
+ if not frames:
166
+ return []
167
+
168
+ os.makedirs(out_dir, exist_ok=True)
169
+ style = {
170
+ "categories": categories,
171
+ "xlim": tuple(xlim),
172
+ "ylim": tuple(ylim),
173
+ "legend_kwargs": legend_kwargs,
174
+ "out_dir": out_dir,
175
+ "method_name": method_name,
176
+ "figsize": figsize,
177
+ "dpi": dpi,
178
+ }
179
+
180
+ n_workers = max(1, min(n_workers, len(frames)))
181
+ if n_workers == 1:
182
+ # In-process render — reset worker globals afterwards so repeated calls (the multi-GIF
183
+ # sweep runs in one process) rebuild against the new style
184
+ global _FIG, _AX, _ARTISTS, _STYLE
185
+ try:
186
+ _worker_init(style)
187
+ all_indices = list(range(len(frames)))
188
+ return _render_chunk((frames, all_indices))
189
+ finally:
190
+ if _FIG is not None:
191
+ import matplotlib.pyplot as plt # noqa: PLC0415
192
+
193
+ plt.close(_FIG)
194
+ _FIG = _AX = _ARTISTS = _STYLE = None
195
+
196
+ # Forkserver with an empty preload list, exactly like shap_parallel.py: workers get a
197
+ # clean interpreter that imports only this light module, never the parent's TF stack
198
+ ctx = mp.get_context("forkserver")
199
+ ctx.set_forkserver_preload([])
200
+
201
+ chunk_bounds = np.array_split(np.arange(len(frames)), n_workers)
202
+ tasks = [
203
+ ([frames[i] for i in chunk], [int(i) for i in chunk])
204
+ for chunk in chunk_bounds
205
+ if len(chunk)
206
+ ]
207
+
208
+ with ProcessPoolExecutor(
209
+ max_workers=len(tasks), mp_context=ctx, initializer=_worker_init, initargs=(style,)
210
+ ) as pool:
211
+ chunk_results = list(pool.map(_render_chunk, tasks))
212
+
213
+ return [path for chunk_paths in chunk_results for path in chunk_paths]
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Whole-combo parallelism (the #278 follow-up): one (level, nn, md) UMAP combo —
218
+ # fit + persist + per-snapshot transforms + frame render + GIF assembly — per worker.
219
+ #
220
+ # The 24-combo sweep was strictly serial in train.plot_latent_space_gif at ~95% single-core
221
+ # (~1.7-1.9 h per run) even after frame rendering parallelized: every combo is an independent
222
+ # UMAP fit with its own derived random_state, reads the same shared inputs read-only, and
223
+ # writes distinct files. Farming WHOLE combos to forkserver workers therefore cannot change
224
+ # any output byte — unlike the rejected within-fit ideas (batched_umap_transform above,
225
+ # precomputed-knn reuse), nothing changes how any single fit consumes its RNG stream.
226
+
227
+ # Palettes moved verbatim from train.plot_latent_space_gif so combo workers build their
228
+ # categories without importing train (obs/cadence palettes deliberately distinct — the
229
+ # cadence colors are shared with downstream RF plots).
230
+ OBS_COLORS = {
231
+ ("false_no_signal", "ON"): "#1565C0",
232
+ ("false_no_signal", "OFF"): "#64B5F6",
233
+ ("false_with_rfi", "ON"): "#F9A825",
234
+ ("false_with_rfi", "OFF"): "#FFF176",
235
+ ("true_only_eti", "ON"): "#2E7D32",
236
+ ("true_only_eti", "OFF"): "#81C784",
237
+ ("true_eti_rfi", "ON"): "#C62828",
238
+ ("true_eti_rfi", "OFF"): "#EF5350",
239
+ }
240
+ OBS_MARKERS = {"ON": "^", "OFF": "x"}
241
+ OBS_DISPLAY_NAMES = {
242
+ ("false_no_signal", "ON"): "No Signal (ON)",
243
+ ("false_no_signal", "OFF"): "No Signal (OFF)",
244
+ ("false_with_rfi", "ON"): "RFI Only (ON)",
245
+ ("false_with_rfi", "OFF"): "RFI Only (OFF)",
246
+ ("true_only_eti", "ON"): "ETI Only (ON)",
247
+ ("true_only_eti", "OFF"): "ETI Only (OFF)",
248
+ ("true_eti_rfi", "ON"): "ETI+RFI (ON)",
249
+ ("true_eti_rfi", "OFF"): "ETI+RFI (OFF)",
250
+ }
251
+ CADENCE_COLORS = {
252
+ "false_no_signal": "tab:blue",
253
+ "false_with_rfi": "tab:green",
254
+ "true_only_eti": "tab:red",
255
+ "true_eti_rfi": "tab:orange",
256
+ }
257
+ CADENCE_DISPLAY_NAMES = {
258
+ "false_no_signal": "No Signal",
259
+ "false_with_rfi": "RFI Only",
260
+ "true_only_eti": "ETI Only",
261
+ "true_eti_rfi": "ETI+RFI",
262
+ }
263
+
264
+ # Worker-global cache of the shared input bundle: one ProcessPoolExecutor worker handles
265
+ # several combos, and they all read the same bundle file
266
+ _SWEEP_BUNDLE: dict | None = None
267
+ _SWEEP_BUNDLE_PATH: str | None = None
268
+
269
+
270
+ def categories_for_mode(mode: str) -> tuple[list[FrameCategory], dict]:
271
+ """The scatter categories + legend kwargs for one GIF level (moved verbatim from
272
+ train.plot_latent_space_gif's closure)."""
273
+ if mode == "obs":
274
+ categories = [
275
+ FrameCategory(
276
+ signal_type=stype,
277
+ onoff=status,
278
+ color=color,
279
+ marker=OBS_MARKERS[status],
280
+ display_name=OBS_DISPLAY_NAMES[(stype, status)],
281
+ )
282
+ for (stype, status), color in OBS_COLORS.items()
283
+ ]
284
+ legend_kwargs = {
285
+ "loc": "upper right",
286
+ "fontsize": 8,
287
+ "markerscale": 2,
288
+ "ncol": 2,
289
+ "framealpha": 0.8,
290
+ }
291
+ elif mode == "cadence":
292
+ categories = [
293
+ FrameCategory(
294
+ signal_type=stype,
295
+ onoff=None,
296
+ color=color,
297
+ marker="o",
298
+ display_name=CADENCE_DISPLAY_NAMES[stype],
299
+ )
300
+ for stype, color in CADENCE_COLORS.items()
301
+ ]
302
+ legend_kwargs = {
303
+ "loc": "upper right",
304
+ "fontsize": 8,
305
+ "markerscale": 2,
306
+ "framealpha": 0.8,
307
+ }
308
+ else:
309
+ raise ValueError(f"mode must be 'obs' or 'cadence', got {mode!r}")
310
+ return categories, legend_kwargs
311
+
312
+
313
+ def compute_frame_limits(
314
+ transformed_list: list[np.ndarray],
315
+ ) -> tuple[tuple[float, float], tuple[float, float]]:
316
+ """Shared axis limits over every frame's 2D projection, padded 5% (moved verbatim)."""
317
+ x_min = min(t[:, 0].min() for t in transformed_list)
318
+ x_max = max(t[:, 0].max() for t in transformed_list)
319
+ y_min = min(t[:, 1].min() for t in transformed_list)
320
+ y_max = max(t[:, 1].max() for t in transformed_list)
321
+ x_pad = (x_max - x_min) * 0.05
322
+ y_pad = (y_max - y_min) * 0.05
323
+ return (x_min - x_pad, x_max + x_pad), (y_min - y_pad, y_max + y_pad)
324
+
325
+
326
+ def build_snapshot_frames(
327
+ transformed_list: list[np.ndarray],
328
+ snapshot_metadata: list[dict],
329
+ labels_list: list,
330
+ onoff_list: list | None,
331
+ display_method: str,
332
+ ) -> list[dict]:
333
+ """Assemble render_latent_gif_frames' frame dicts (coords/labels/onoff/title) for one
334
+ combo — the title logic moved verbatim from train.plot_latent_space_gif's closure."""
335
+ frames = []
336
+ for frame_idx, meta in enumerate(snapshot_metadata):
337
+ meta_snr_base = meta["snr_base"]
338
+ meta_snr_range = meta["snr_range"]
339
+ meta_snr_floor = meta_snr_base if meta_snr_base is not None else "?"
340
+ meta_snr_ceil = (
341
+ meta_snr_base + meta_snr_range
342
+ if meta_snr_base is not None and meta_snr_range is not None
343
+ else "?"
344
+ )
345
+ frames.append(
346
+ {
347
+ "coords": transformed_list[frame_idx],
348
+ "labels": labels_list[frame_idx],
349
+ "onoff": onoff_list[frame_idx] if onoff_list is not None else None,
350
+ "title": (
351
+ f"Beta-VAE Latent Space: {display_method} — "
352
+ f"Round {meta['round_number']}, "
353
+ f"Epoch {meta['epoch_number']}, "
354
+ f"Step {meta['step_number']} "
355
+ f"(SNR: {meta_snr_floor}–{meta_snr_ceil})"
356
+ ),
357
+ }
358
+ )
359
+ return frames
360
+
361
+
362
+ def _sweep_worker_init() -> None:
363
+ """Pin BLAS-family thread pools before numpy/umap initialize in the fresh forkserver
364
+ interpreter (the shap_parallel pattern) — but deliberately NOT numba's.
365
+
366
+ UMAP calls numba.set_num_threads(cpu_count) on its large-N paths, and numba hard-errors
367
+ when asked to grow past the pool it launched ("Cannot set NUMBA_NUM_THREADS to a
368
+ different value once the threads have been launched") — so capping NUMBA_NUM_THREADS at
369
+ 1 breaks production-scale fits outright (found by the first full-scale run; the small-N
370
+ unit fits take the brute-force path and never touch the thread API). OMP_NUM_THREADS is
371
+ left unpinned for the same reason: numba's threading layer may be OpenMP-backed, in
372
+ which case the OMP cap is the same trap under a different name. Shrinking is always
373
+ legal, so an unpinned launch matches the pre-parallel serial behavior exactly (the old
374
+ in-process sweep also ran with an unpinned numba pool); the seeded fits force their
375
+ single-threaded deterministic paths regardless."""
376
+ for var in (
377
+ "OPENBLAS_NUM_THREADS",
378
+ "MKL_NUM_THREADS",
379
+ "NUMEXPR_NUM_THREADS",
380
+ "VECLIB_MAXIMUM_THREADS",
381
+ "BLIS_NUM_THREADS",
382
+ ):
383
+ os.environ.setdefault(var, "1")
384
+
385
+
386
+ def _load_sweep_bundle(path: str) -> dict:
387
+ global _SWEEP_BUNDLE, _SWEEP_BUNDLE_PATH
388
+ if path != _SWEEP_BUNDLE_PATH:
389
+ import joblib # noqa: PLC0415 (deferred so _sweep_worker_init pins threads first)
390
+
391
+ _SWEEP_BUNDLE = joblib.load(path)
392
+ _SWEEP_BUNDLE_PATH = path
393
+ return _SWEEP_BUNDLE
394
+
395
+
396
+ def run_umap_gif_combo(task: dict) -> dict:
397
+ """
398
+ One (mode, nn, md) combo end-to-end: fit the UMAP on the mode's fit pool, persist it
399
+ (warn-not-fail, matching the serial path), transform every snapshot serially (the
400
+ output-identical form — see batched_umap_transform's rejection note), render the frame
401
+ PNGs in-process, and assemble the GIF. Runs in a forkserver worker (or inline when the
402
+ sweep is serial); consumes only the combo's own derived random_state.
403
+
404
+ Returns {method_name, display_method, gif_path, umap_path, n_frames, warnings} —
405
+ `warnings` carries messages for the parent to log (forkserver workers have no handler),
406
+ and gif assembly/Slack stay split: assembly here, upload in the caller.
407
+ """
408
+ import imageio.v3 as iio # noqa: PLC0415 (deferred so _sweep_worker_init pins threads first)
409
+ import joblib # noqa: PLC0415
410
+ import umap # noqa: PLC0415
411
+
412
+ bundle = _load_sweep_bundle(task["bundle_path"])
413
+ mode = task["mode"]
414
+ warnings: list[str] = []
415
+
416
+ if mode == "obs":
417
+ fit_pool = bundle["fit_pool_obs"]
418
+ coords_list = bundle["coords_obs"]
419
+ labels_list = bundle["labels_obs"]
420
+ onoff_list = bundle["onoff_obs"]
421
+ else:
422
+ fit_pool = bundle["fit_pool_cadence"]
423
+ coords_list = bundle["coords_cadence"]
424
+ labels_list = bundle["labels_cadence"]
425
+ onoff_list = None
426
+
427
+ model = umap.UMAP(
428
+ n_components=2,
429
+ random_state=task["seed"],
430
+ n_neighbors=task["nn"],
431
+ min_dist=task["md"],
432
+ ).fit(fit_pool)
433
+
434
+ try:
435
+ os.makedirs(os.path.dirname(task["umap_path"]), exist_ok=True)
436
+ joblib.dump(model, task["umap_path"])
437
+ except Exception as exc:
438
+ warnings.append(f"Failed to persist {mode}-level UMAP model ({task['umap_path']}): {exc}")
439
+
440
+ transformed = [model.transform(coords) for coords in coords_list]
441
+ del model
442
+
443
+ xlim, ylim = compute_frame_limits(transformed)
444
+ categories, legend_kwargs = categories_for_mode(mode)
445
+ frames = build_snapshot_frames(
446
+ transformed, bundle["snapshot_metadata"], labels_list, onoff_list, task["display_method"]
447
+ )
448
+
449
+ frame_paths = render_latent_gif_frames(
450
+ frames,
451
+ categories=categories,
452
+ xlim=xlim,
453
+ ylim=ylim,
454
+ legend_kwargs=legend_kwargs,
455
+ out_dir=task["frames_dir"],
456
+ method_name=task["method_name"],
457
+ n_workers=1, # combos are the parallel unit; frames render inline per combo
458
+ )
459
+
460
+ n_frames = len(frame_paths)
461
+ if n_frames > 0:
462
+ # Assemble the GIF by streaming one frame at a time (moved verbatim from train)
463
+ with iio.imopen(task["gif_path"], "w", plugin="pillow") as gif_writer:
464
+ for frame_path in frame_paths:
465
+ frame = iio.imread(frame_path)
466
+ gif_writer.write(
467
+ frame,
468
+ duration=task["duration_ms"],
469
+ loop=0,
470
+ is_batch=False,
471
+ )
472
+ del frame
473
+
474
+ return {
475
+ "method_name": task["method_name"],
476
+ "display_method": task["display_method"],
477
+ "gif_path": task["gif_path"],
478
+ "umap_path": task["umap_path"],
479
+ "n_frames": n_frames,
480
+ "warnings": warnings,
481
+ }
482
+
483
+
484
+ def run_umap_gif_sweep(tasks: list[dict], n_workers: int) -> list[dict]:
485
+ """
486
+ Run every (mode, nn, md) combo across a forkserver pool (empty preload, threads pinned —
487
+ the shap_parallel isolation pattern), or serially in-process when n_workers <= 1. Results
488
+ come back in task order. Outputs are byte-identical either way: each combo's fit consumes
489
+ only its own derived random_state, and process isolation cannot change that stream.
490
+ """
491
+ if not tasks:
492
+ return []
493
+
494
+ n_workers = max(1, min(n_workers, len(tasks)))
495
+ if n_workers == 1:
496
+ # In-process combos cache the shared bundle in THIS process's module globals — clear
497
+ # them afterwards so the caller's pre-sweep memory cleanup (train.py dels its copies
498
+ # of the bundled inputs) isn't quietly undone by a lingering multi-hundred-MB cache.
499
+ # The pooled arm needs no equivalent: its cache lives in short-lived workers.
500
+ global _SWEEP_BUNDLE, _SWEEP_BUNDLE_PATH
501
+ try:
502
+ return [run_umap_gif_combo(task) for task in tasks]
503
+ finally:
504
+ _SWEEP_BUNDLE = None
505
+ _SWEEP_BUNDLE_PATH = None
506
+
507
+ ctx = mp.get_context("forkserver")
508
+ ctx.set_forkserver_preload([])
509
+ with ProcessPoolExecutor(
510
+ max_workers=n_workers, mp_context=ctx, initializer=_sweep_worker_init
511
+ ) as pool:
512
+ return list(pool.map(run_umap_gif_combo, tasks))