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.
aetherscan/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """
2
+ Aetherscan: Breakthrough Listen's first end-to-end production-grade DL pipeline for SETI @ scale.
3
+
4
+ Provides tools for technosignature detection using signal processing and deep learning techniques
5
+ applied to radio astronomy data. Import submodules directly (aetherscan.config, aetherscan.models,
6
+ aetherscan.data_generation, aetherscan.preprocessing, etc.) — the top-level package intentionally
7
+ exposes only the version string.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from importlib.metadata import PackageNotFoundError, version
13
+
14
+ # Version is single-sourced from pyproject.toml's [project].version via the installed
15
+ # distribution's metadata. Source-tree runs (PYTHONPATH=src, the NGC container) have no
16
+ # installed distribution — they fall back to a dev sentinel, which also keeps the
17
+ # version-coupled HF weight resolution (hf_hub.version_default_revision) inactive there.
18
+ try:
19
+ __version__ = version("aetherscan")
20
+ except PackageNotFoundError:
21
+ __version__ = "0.0.0.dev0"
22
+ __author__ = "Zach Yek"
23
+
24
+ # TODO: determine which components are necessary to expose for public API
25
+ # # Core configuration
26
+ # from aetherscan.config import Config
27
+ #
28
+ # # Models
29
+ # from aetherscan.models import RandomForestModel, Sampling, create_beta_vae_model
30
+ #
31
+ # # Data processing
32
+ # from aetherscan.data_generation import DataGenerator
33
+ # from aetherscan.preprocessing import DataPreprocessor
34
+ #
35
+ # __all__ = [
36
+ # "Config",
37
+ # "RandomForestModel",
38
+ # "Sampling",
39
+ # "create_beta_vae_model",
40
+ # "DataGenerator",
41
+ # "DataPreprocessor",
42
+ # ]
43
+
44
+ # Minimal public API - import submodules explicitly as needed
45
+ __all__ = []
@@ -0,0 +1,179 @@
1
+ """
2
+ Always-on stage timing for the Aetherscan pipeline.
3
+
4
+ stage_timer() wraps a pipeline stage (context manager or decorator) and records one
5
+ (stage, start_time, end_time, duration_s, tag, metadata) row into the pipeline_stages DB
6
+ table through the database's writer queue — two time.time() calls plus one queue put, so
7
+ the overhead is negligible and the timers stay on in production runs.
8
+
9
+ Stage names are hierarchical dot-names ("train.round_02.data_generation"). Nesting is
10
+ automatic within a thread: a stage_timer entered while another is active on the same
11
+ thread records its name relative to the active one, so instrumented library code (e.g.
12
+ the encode/rf sub-stages inside InferencePipeline.run_inference) inherits whatever
13
+ umbrella span the caller opened without name plumbing. Timers on different threads don't
14
+ interact (the active-stage stack is thread-local), which matters because training,
15
+ prefetch preprocessing, and the producer drainer all time stages concurrently.
16
+
17
+ record_stage() writes a span measured elsewhere (explicit start/end timestamps) — used by
18
+ the round-data producer, whose generation happens in another process: the producer can't
19
+ touch the DB writer queue (a thread queue.Queue), so it reports (start, end) over its
20
+ result-message channel and the main-process drainer records it here.
21
+
22
+ Rows are read back by utils/benchmark_report.py (stage tree + timeline plot) and by
23
+ monitor._save_plot()'s stage-band overlay.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import logging
30
+ import threading
31
+ import time
32
+ from contextlib import ContextDecorator
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # Thread-local stack of active stage names (full dotted names), driving relative naming
37
+ _ACTIVE_STAGES = threading.local()
38
+
39
+
40
+ def _stage_stack() -> list[str]:
41
+ """The current thread's stack of active (full) stage names."""
42
+ stack = getattr(_ACTIVE_STAGES, "stack", None)
43
+ if stack is None:
44
+ stack = []
45
+ _ACTIVE_STAGES.stack = stack
46
+ return stack
47
+
48
+
49
+ def current_stage() -> str | None:
50
+ """Full dotted name of the innermost stage_timer active on this thread, or None."""
51
+ stack = _stage_stack()
52
+ return stack[-1] if stack else None
53
+
54
+
55
+ def round_stage_name(round_number: int) -> str:
56
+ """Canonical umbrella stage name for a 1-based training round ("train.round_02").
57
+
58
+ Shared by the trainer (which opens the umbrella span) and the round-data producer
59
+ (which, running in another process, records the data_generation child by absolute name
60
+ and so can't rely on thread-local nesting). Routing both sites through this helper keeps
61
+ the two name constructions from drifting — a mismatch would orphan the producer's
62
+ data_generation span outside the round subtree in the report tree.
63
+ """
64
+ return f"train.round_{round_number:02d}"
65
+
66
+
67
+ def record_stage(
68
+ stage: str,
69
+ start_time: float,
70
+ end_time: float,
71
+ tag: str | None = None,
72
+ metadata: dict | None = None,
73
+ ) -> None:
74
+ """
75
+ Record one pipeline stage span with explicit timestamps (no nesting resolution — the
76
+ name is used as-is). tag defaults to the run's save tag. Never raises: benchmarking
77
+ must not be able to fail the pipeline, so a missing DB (unit tests, dev scripts) or a
78
+ serialization hiccup downgrades to a debug/warning log.
79
+ """
80
+ try:
81
+ # Late imports keep this module import-light and avoid any import-cycle risk
82
+ # (db imports config + manager; nothing imports benchmark back).
83
+ from aetherscan.config import get_config # noqa: PLC0415
84
+ from aetherscan.db.db import Database # noqa: PLC0415
85
+
86
+ # Read the singleton directly (not via get_db()): a missing DB is an expected,
87
+ # already-handled case here, and get_db() logs a WARNING on every miss — noise
88
+ # that could reach Slack. This is a read, not an instantiation.
89
+ db = Database._instance
90
+ if db is None:
91
+ logger.debug(f"No database instance — dropping stage timing for {stage!r}")
92
+ return
93
+
94
+ if tag is None:
95
+ config = get_config()
96
+ # NOTE: tag stays None if a timer fires before checkpoint.save_tag is wired
97
+ # (early init, pre-CLI-parse). Such rows won't match a tag="..." query filter.
98
+ # In practice timers don't fire that early, so this is a documented edge, not a bug.
99
+ tag = config.checkpoint.save_tag if config is not None else None
100
+
101
+ db.write_pipeline_stage(
102
+ stage=stage,
103
+ start_time=start_time,
104
+ end_time=end_time,
105
+ tag=tag,
106
+ metadata=json.dumps(metadata) if metadata else None,
107
+ )
108
+ except Exception as e:
109
+ logger.warning(f"Failed to record stage timing for {stage!r}: {e}")
110
+
111
+
112
+ class _StageTimer(ContextDecorator):
113
+ """Context manager / decorator that times a block and records it via record_stage().
114
+
115
+ One instance holds per-entry state (full_name/start_time), so a decorated function is
116
+ fine to call repeatedly from one thread but not concurrently from several — for
117
+ concurrent callers, create the timer inside the function (`with stage_timer(...)`)."""
118
+
119
+ def __init__(self, stage: str, tag: str | None = None, metadata: dict | None = None):
120
+ self.stage = stage
121
+ self.tag = tag
122
+ self.metadata = metadata
123
+ self.full_name: str | None = None
124
+ self.start_time: float | None = None
125
+
126
+ def __enter__(self):
127
+ parent = current_stage()
128
+ self.full_name = f"{parent}.{self.stage}" if parent else self.stage
129
+ _stage_stack().append(self.full_name)
130
+ self.start_time = time.time()
131
+ return self
132
+
133
+ def __exit__(self, exc_type, exc_value, exc_tb):
134
+ end_time = time.time()
135
+ stack = _stage_stack()
136
+ # Pop our own frame (guarded: a corrupted stack must not mask the block's result)
137
+ if stack and stack[-1] == self.full_name:
138
+ stack.pop()
139
+ else:
140
+ logger.warning(
141
+ f"stage_timer stack mismatch on exit of {self.full_name!r} "
142
+ f"(top: {(stack[-1] if stack else None)!r})"
143
+ )
144
+
145
+ metadata = self.metadata
146
+ if exc_type is not None:
147
+ # Record the failure but never suppress it (return None -> exception propagates)
148
+ metadata = dict(metadata or {})
149
+ metadata["status"] = "failed"
150
+ metadata["error"] = f"{exc_type.__name__}: {exc_value}"
151
+
152
+ # Implicit None return -> exceptions propagate (never suppressed)
153
+ record_stage(
154
+ self.full_name,
155
+ self.start_time,
156
+ end_time,
157
+ tag=self.tag,
158
+ metadata=metadata,
159
+ )
160
+
161
+
162
+ def stage_timer(stage: str, tag: str | None = None, metadata: dict | None = None) -> _StageTimer:
163
+ """
164
+ Time a pipeline stage and queue a pipeline_stages DB row on exit.
165
+
166
+ with stage_timer("train.round_02.data_generation"):
167
+ ...
168
+
169
+ @stage_timer("inference.viz")
170
+ def render(): ...
171
+
172
+ `stage` is a dotted hierarchical name. If another stage_timer is active on the same
173
+ thread, `stage` is recorded relative to it (entering stage_timer("encode") while
174
+ "inference.infer_cadence_001" is active records "inference.infer_cadence_001.encode");
175
+ with no active parent it is recorded as-is. On exception the span is still recorded
176
+ (metadata carries status=failed plus the error) and the exception propagates.
177
+ `metadata`, when given, is a small JSON-serializable dict stored on the row.
178
+ """
179
+ return _StageTimer(stage, tag=tag, metadata=metadata)
@@ -0,0 +1,328 @@
1
+ """
2
+ Process-parallel per-candidate figure rendering for the inference viz suite (#298 I9).
3
+
4
+ The ≤ max_candidate_plots per-candidate figures are independent (one DB row dict + one
5
+ memmap stamp read + one PNG each) but were rendered strictly serially on the main thread at
6
+ finalize time. This module farms them across a small process pool, mirroring
7
+ shap_parallel.py / latent_gif.py's isolation pattern: **forkserver context with an empty
8
+ preload**, so workers never re-import the parent's ``__main__`` (``aetherscan.main`` → TF →
9
+ the whole stack) and never touch the CUDA-initialized parent state — ``inference_viz``
10
+ itself imports TF transitively via ``aetherscan.models``, which is exactly why the
11
+ figure-building code lives HERE, deliberately TF-free, and ``inference_viz`` imports it
12
+ (never the reverse).
13
+
14
+ Figures are built on the object-oriented matplotlib API (``Figure`` + implicit Agg canvas —
15
+ pyplot is never imported, so no GUI backend can be selected in a worker). Per-figure
16
+ containment mirrors ``_viz_safe``: a failed render returns None for that candidate and the
17
+ suite continues. Slack uploads stay with the PARENT (inference_viz's async uploader), fed in
18
+ index order from the returned paths.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import functools
24
+ import json
25
+ import logging
26
+ import multiprocessing as mp
27
+ import os
28
+ import uuid
29
+ from concurrent.futures import ProcessPoolExecutor
30
+
31
+ import numpy as np
32
+ from matplotlib.figure import Figure
33
+
34
+ from aetherscan.data_generation import log_norm
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # ABACAD cadence row labels (ON at positions 0/2/4) — single source for every figure that
39
+ # draws observation strips (the suite imports it from here).
40
+ OBS_ROW_LABELS = ("ON", "OFF", "ON", "OFF", "ON", "OFF")
41
+
42
+ # Pool sizing: candidate figures are ~1 s of matplotlib each with a hard cap of
43
+ # max_candidate_plots (50), so a handful of workers saturates the win; below the floor the
44
+ # pool spin-up would cost more than it saves and rendering stays in-process.
45
+ _MAX_RENDER_WORKERS = 8
46
+ _MIN_ROWS_FOR_POOL = 4
47
+
48
+
49
+ def candidate_sidecar_path(npy_path: str) -> str:
50
+ """Sibling .candidates.npz path for a cadence's pruned candidate snippets (#302)."""
51
+ return os.path.splitext(npy_path)[0] + ".candidates.npz"
52
+
53
+
54
+ def write_candidate_snippet_sidecar(npy_path: str, snippet_indices) -> str:
55
+ """Snapshot the candidate snippets (~196 KB each) of a cadence into an atomic
56
+ .candidates.npz sidecar next to its metadata (#302): stamp-cache pruning deletes the
57
+ multi-GB stamp .npy after scoring, and the candidate figures re-read their snippets
58
+ from this sidecar instead. Raw (pre-log-norm) values, exactly as stored in the .npy,
59
+ so load_display_cadence's fallback applies the identical display transform."""
60
+ stamps = np.load(npy_path, mmap_mode="r")
61
+ indices = np.asarray(sorted({int(i) for i in snippet_indices}), dtype=np.int64)
62
+ rows = np.array(stamps[indices], dtype=np.float32)
63
+ del stamps
64
+ path = candidate_sidecar_path(npy_path)
65
+ tmp_path = f"{path}.{os.getpid()}.{uuid.uuid4().hex}.tmp"
66
+ with open(tmp_path, "wb") as f:
67
+ np.savez(f, snippet_indices=indices, stamps=rows)
68
+ os.replace(tmp_path, path)
69
+ return path
70
+
71
+
72
+ def _load_snippet_from_sidecar(npy_path: str, snippet_index: int) -> np.ndarray:
73
+ """Read one candidate snippet from the pruned cadence's sidecar; raises KeyError when
74
+ the snippet was not a candidate (non-candidate pixels are gone by design, #302)."""
75
+ with np.load(candidate_sidecar_path(npy_path)) as sidecar:
76
+ match = np.nonzero(sidecar["snippet_indices"] == int(snippet_index))[0]
77
+ if not len(match):
78
+ raise KeyError(
79
+ f"snippet {snippet_index} is not in the candidate sidecar for {npy_path} "
80
+ f"(its stamp .npy was pruned and only candidate snippets were kept)"
81
+ )
82
+ return np.array(sidecar["stamps"][int(match[0])], dtype=np.float32)
83
+
84
+
85
+ def load_display_cadence(npy_path: str, snippet_index: int) -> np.ndarray:
86
+ """Load one snippet's (num_obs, time_bins, width) stamp from its .npy — falling back
87
+ to the candidate snippet sidecar when the .npy was pruned (#302) — and log-normalize
88
+ it for display (the same transform the model input path applies)."""
89
+ try:
90
+ stamps = np.load(npy_path, mmap_mode="r")
91
+ except OSError:
92
+ snippet = _load_snippet_from_sidecar(npy_path, snippet_index)
93
+ else:
94
+ snippet = np.array(stamps[snippet_index], dtype=np.float32)
95
+ del stamps
96
+ return log_norm(snippet)
97
+
98
+
99
+ def cadence_metadata_path(npy_path: str) -> str:
100
+ """Sibling .json path for a cadence's metadata — the same naming rule as
101
+ preprocessing.DataPreprocessor.cadence_metadata_path, duplicated here (one line) so
102
+ render workers never import the preprocessing module (manager/db singletons, setigen)."""
103
+ return os.path.splitext(npy_path)[0] + ".json"
104
+
105
+
106
+ def stamp_frequency_range_mhz(metadata: dict, snippet_index: int) -> tuple[float, float] | None:
107
+ """
108
+ Frequency range (MHz) spanned by one stamp's frequency axis — bin 0 through the last
109
+ RAW bin — from the cadence metadata sidecar: header fch1/foff plus the stamp's start
110
+ index and stamp_width (#298 follow-up: cadence-snippet plots label their x-axis with
111
+ the frequency span). Returned in bin order, so a negative foff yields a descending
112
+ (high → low) pair — callers print it as-is. None when any field is missing or malformed
113
+ (legacy sidecars): callers keep the unlabeled axis.
114
+ """
115
+ try:
116
+ header = metadata.get("header") or {}
117
+ fch1 = float(header["fch1"])
118
+ foff = float(header["foff"])
119
+ start = int((metadata.get("stamp_starts") or [])[snippet_index])
120
+ width = int(metadata["stamp_width"])
121
+ except (KeyError, IndexError, TypeError, ValueError):
122
+ return None
123
+ return fch1 + foff * start, fch1 + foff * (start + width - 1)
124
+
125
+
126
+ def draw_cadence_strip(
127
+ axes_column,
128
+ snippet: np.ndarray,
129
+ label_rows: bool,
130
+ freq_range_mhz: tuple[float, float] | None = None,
131
+ ) -> None:
132
+ """Draw one snippet's 6 observation waterfalls down a column of axes. With
133
+ freq_range_mhz, the bottom axis is labeled with the stamp's frequency span (bin order,
134
+ so a descending pair reflects a negative foff)."""
135
+ for obs_idx, ax in enumerate(axes_column):
136
+ ax.imshow(
137
+ snippet[obs_idx],
138
+ aspect="auto",
139
+ origin="lower",
140
+ cmap="viridis",
141
+ vmin=0.0,
142
+ vmax=1.0,
143
+ interpolation="nearest",
144
+ )
145
+ ax.set_xticks([])
146
+ ax.set_yticks([])
147
+ if label_rows:
148
+ ax.set_ylabel(OBS_ROW_LABELS[obs_idx], fontsize=8, rotation=0, ha="right", va="center")
149
+ if freq_range_mhz is not None:
150
+ low, high = freq_range_mhz
151
+ axes_column[-1].set_xlabel(f"{low:.4f} → {high:.4f} MHz", fontsize=6)
152
+
153
+
154
+ def candidate_annotation(row: dict) -> str:
155
+ lines = [f"confidence: {row.get('confidence', float('nan')):.4f}"]
156
+ if row.get("frequency_mhz") is not None:
157
+ lines.append(f"frequency: {row['frequency_mhz']:.6f} MHz")
158
+ for label in ("target", "session", "band", "cadence_id"):
159
+ if row.get(label) is not None:
160
+ lines.append(f"{label}: {row[label]}")
161
+ if row.get("timestamp_observed") is not None:
162
+ lines.append(f"tstart (MJD): {row['timestamp_observed']:.5f}")
163
+ if row.get("h5_path"):
164
+ lines.append(f"h5: {os.path.basename(str(row['h5_path']))}")
165
+ lines.append(f"npy: {os.path.basename(str(row.get('npy_path', '')))}")
166
+ lines.append(f"snippet: {row.get('snippet_index')}")
167
+ return "\n".join(lines)
168
+
169
+
170
+ @functools.lru_cache(maxsize=4)
171
+ def _load_sidecar_cached(metadata_path: str) -> dict | None:
172
+ """Small parent-side LRU over parsed metadata sidecars (#301): candidates cluster on
173
+ few cadences, and the gallery/task-build path parsed the SAME multi-MB JSON once per
174
+ candidate row (~15-20 s serial before the render pool even started). Sidecars are
175
+ immutable once published (atomic tmp -> os.replace), so a cached parse can never go
176
+ stale; maxsize bounds the held dicts.
177
+
178
+ CONTRACT: the returned dict is the CACHED object, aliased across every caller —
179
+ treat it as immutable (a mutation would silently poison all later readers of the
180
+ same sidecar; a copy here would defeat the memory bound)."""
181
+ try:
182
+ with open(metadata_path) as f:
183
+ return json.load(f)
184
+ except (OSError, json.JSONDecodeError):
185
+ return None
186
+
187
+
188
+ def candidate_frequency_range_mhz(row: dict) -> tuple[float, float] | None:
189
+ """Best-effort frequency span for one candidate row: read its cadence's metadata
190
+ sidecar (derived from npy_path, cached across rows) and look up the stamp's range.
191
+ None on any failure — the figure keeps its generic axis label."""
192
+ try:
193
+ metadata = _load_sidecar_cached(cadence_metadata_path(str(row["npy_path"])))
194
+ except KeyError:
195
+ return None
196
+ if metadata is None:
197
+ return None
198
+ return stamp_frequency_range_mhz(metadata, int(row["snippet_index"]))
199
+
200
+
201
+ def render_candidate_figure(
202
+ row: dict,
203
+ index: int,
204
+ tag: str,
205
+ plots_dir: str,
206
+ freq_range_mhz: tuple[float, float] | None = None,
207
+ ) -> str:
208
+ """
209
+ Build and save one candidate's figure (the long-standing inference.py stub): 6-panel
210
+ cadence waterfall of its stamp, annotated with confidence / frequency / target /
211
+ session / band, plus the latent vector as a bar chart. Returns the saved PNG path.
212
+ Pure function of its arguments — no config/db/logger singletons — so it runs
213
+ identically in-process and in a forkserver worker. freq_range_mhz labels the waterfall
214
+ x-axis with the stamp's frequency span (callers precompute it from the metadata
215
+ sidecar; the axis stays generic when None).
216
+ """
217
+ snippet = load_display_cadence(str(row["npy_path"]), int(row["snippet_index"]))
218
+ n_obs = snippet.shape[0]
219
+
220
+ fig = Figure(figsize=(11, 7))
221
+ grid = fig.add_gridspec(n_obs, 2, width_ratios=(2.2, 1.6), hspace=0.15, wspace=0.25, right=0.97)
222
+ waterfall_axes = [fig.add_subplot(grid[i, 0]) for i in range(n_obs)]
223
+ draw_cadence_strip(waterfall_axes, snippet, label_rows=True)
224
+ if freq_range_mhz is not None:
225
+ low, high = freq_range_mhz
226
+ waterfall_axes[-1].set_xlabel(f"frequency: {low:.6f} → {high:.6f} MHz")
227
+ else:
228
+ waterfall_axes[-1].set_xlabel("frequency bin")
229
+
230
+ # Right column: latent bar chart on top, provenance text below. A nested gridspec gives
231
+ # the two panels a dedicated vertical gap so the bar chart's x-axis label can never
232
+ # collide with the first metadata line; the text panel takes the taller share so all of
233
+ # the provenance lines stay clear of the axis label regardless of how many there are.
234
+ right_grid = grid[:, 1].subgridspec(2, 1, height_ratios=(1.0, 1.3), hspace=0.55)
235
+ latent_ax = fig.add_subplot(right_grid[0])
236
+ latent_json = row.get("latent_vector")
237
+ if latent_json:
238
+ latent = np.asarray(json.loads(latent_json), dtype=np.float64).ravel()
239
+ latent_dim = latent.size // n_obs if latent.size % n_obs == 0 else latent.size
240
+ colors = [f"C{(i // latent_dim) % 10}" for i in range(latent.size)]
241
+ latent_ax.bar(np.arange(latent.size), latent, color=colors)
242
+ latent_ax.set_xlabel("latent dimension (colored per observation)", fontsize=8)
243
+ latent_ax.set_ylabel("z", fontsize=8)
244
+ latent_ax.tick_params(labelsize=7)
245
+ latent_ax.grid(True, axis="y", alpha=0.2)
246
+ else:
247
+ latent_ax.set_axis_off()
248
+ latent_ax.text(0.5, 0.5, "no latent vector stored", ha="center", va="center")
249
+
250
+ text_ax = fig.add_subplot(right_grid[1])
251
+ text_ax.set_axis_off()
252
+ text_ax.text(
253
+ 0.0, 1.0, candidate_annotation(row), va="top", ha="left", fontsize=9, family="monospace"
254
+ )
255
+
256
+ fig.suptitle(f"Candidate {index} ({tag}) — P(true) = {row.get('confidence', 0):.4f}")
257
+
258
+ save_path = os.path.join(plots_dir, f"candidate_{index}_{tag}.png")
259
+ fig.savefig(save_path, dpi=150, bbox_inches="tight")
260
+ fig.clear()
261
+ return save_path
262
+
263
+
264
+ def _pin_worker_threads() -> None:
265
+ # Must run before numpy imports its native thread pools; matplotlib rendering is
266
+ # single-threaded, so this only tames incidental numpy/BLAS threads (mirrors
267
+ # shap_parallel._pin_worker_threads). setdefault so an explicitly-set env is respected.
268
+ for var in (
269
+ "OMP_NUM_THREADS",
270
+ "OPENBLAS_NUM_THREADS",
271
+ "MKL_NUM_THREADS",
272
+ "NUMEXPR_NUM_THREADS",
273
+ "VECLIB_MAXIMUM_THREADS",
274
+ "BLIS_NUM_THREADS",
275
+ ):
276
+ os.environ.setdefault(var, "1")
277
+
278
+
279
+ def _render_task(args: tuple) -> tuple[int, str | None]:
280
+ """Worker body: render one candidate with _viz_safe-style containment (a failed figure
281
+ must degrade the suite, never abort the pool pass)."""
282
+ row, index, tag, plots_dir, freq_range_mhz = args
283
+ try:
284
+ return index, render_candidate_figure(row, index, tag, plots_dir, freq_range_mhz)
285
+ except Exception as e:
286
+ logger.error(f"Candidate figure {index} failed (suite continues without it): {e}")
287
+ return index, None
288
+
289
+
290
+ def render_candidate_figures(
291
+ rows: list[dict], tag: str, plots_dir: str, n_workers: int | None = None
292
+ ) -> list[tuple[int, str | None]]:
293
+ """
294
+ Render one figure per (row, index) pair — in index order in the returned list — across
295
+ a forkserver process pool, falling back to in-process rendering for small candidate
296
+ counts or any pool-level failure. Rows must be plain dicts of primitives (the
297
+ inference_results row shape); paths are None for candidates whose render failed.
298
+ Frequency spans are resolved in the PARENT (one sidecar read per row, best-effort) so
299
+ workers stay pure.
300
+ """
301
+ tasks = [
302
+ (row, index, tag, plots_dir, candidate_frequency_range_mhz(row))
303
+ for index, row in enumerate(rows)
304
+ ]
305
+ if not tasks:
306
+ return []
307
+
308
+ n_workers = min(_MAX_RENDER_WORKERS, len(tasks), n_workers or (os.cpu_count() or 1))
309
+ if n_workers <= 1 or len(tasks) < _MIN_ROWS_FOR_POOL:
310
+ return [_render_task(task) for task in tasks]
311
+
312
+ try:
313
+ # forkserver with an EMPTY preload: the fork server is spawned clean and does NOT
314
+ # re-import the parent's __main__ (aetherscan.main -> TF -> CUDA) — a bare fork of
315
+ # the CUDA-initialized 5-GPU parent at finalize time is exactly what this avoids.
316
+ ctx = mp.get_context("forkserver")
317
+ ctx.set_forkserver_preload([])
318
+ with ProcessPoolExecutor(
319
+ max_workers=n_workers,
320
+ mp_context=ctx,
321
+ initializer=_pin_worker_threads,
322
+ ) as pool:
323
+ return list(pool.map(_render_task, tasks))
324
+ except Exception as e:
325
+ logger.error(
326
+ f"Candidate-figure pool failed ({e}); rendering the remaining figures in-process"
327
+ )
328
+ return [_render_task(task) for task in tasks]