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 +45 -0
- aetherscan/benchmark.py +179 -0
- aetherscan/candidate_figures.py +328 -0
- aetherscan/cli.py +3032 -0
- aetherscan/config.py +1002 -0
- aetherscan/dashboard.py +709 -0
- aetherscan/dashboard_cli.py +51 -0
- aetherscan/dashboard_launcher.py +194 -0
- aetherscan/data_generation.py +1448 -0
- aetherscan/db/__init__.py +21 -0
- aetherscan/db/db.py +2789 -0
- aetherscan/hf_hub.py +547 -0
- aetherscan/inference.py +912 -0
- aetherscan/inference_viz.py +1494 -0
- aetherscan/latent_gif.py +512 -0
- aetherscan/latent_variants.py +352 -0
- aetherscan/logger/__init__.py +21 -0
- aetherscan/logger/logger.py +467 -0
- aetherscan/logger/slack_handler.py +760 -0
- aetherscan/main.py +1269 -0
- aetherscan/manager/__init__.py +21 -0
- aetherscan/manager/manager.py +781 -0
- aetherscan/models/__init__.py +21 -0
- aetherscan/models/random_forest.py +171 -0
- aetherscan/models/vae.py +849 -0
- aetherscan/monitor/__init__.py +19 -0
- aetherscan/monitor/monitor.py +935 -0
- aetherscan/pfb.py +161 -0
- aetherscan/preprocessing.py +2718 -0
- aetherscan/rf_metrics.py +100 -0
- aetherscan/round_data.py +932 -0
- aetherscan/run_state.py +277 -0
- aetherscan/seeding.py +160 -0
- aetherscan/shap_parallel.py +238 -0
- aetherscan/tag_guards.py +206 -0
- aetherscan/train.py +7681 -0
- aetherscan-1.0.0.dist-info/METADATA +1187 -0
- aetherscan-1.0.0.dist-info/RECORD +41 -0
- aetherscan-1.0.0.dist-info/WHEEL +4 -0
- aetherscan-1.0.0.dist-info/entry_points.txt +2 -0
- aetherscan-1.0.0.dist-info/licenses/LICENSE +13 -0
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__ = []
|
aetherscan/benchmark.py
ADDED
|
@@ -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]
|