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
|
@@ -0,0 +1,1494 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Inference visualization suite for Aetherscan Pipeline
|
|
3
|
+
|
|
4
|
+
Renders the end-of-run figures for a streaming CSV inference run: energy-detection statistic
|
|
5
|
+
distributions, hit spectrum (pre/post dedup), bandpass-flattening overlay, top-K stamp
|
|
6
|
+
gallery, preprocessing funnel, confidence distribution, candidate gallery + per-candidate
|
|
7
|
+
plots, latent projection through the persisted training UMAP, and a run summary card.
|
|
8
|
+
|
|
9
|
+
Data flows in three ways:
|
|
10
|
+
- transient per-cadence state (confidence histograms, subsampled latent features) collected
|
|
11
|
+
by InferenceVizCollector during the streaming loop in main._run_streaming_csv_inference;
|
|
12
|
+
- durable per-cadence artifacts (the metadata JSON written by preprocessing next to each
|
|
13
|
+
stamp .npy, and the .npy itself) — these also cover cadences the stage-aware resume
|
|
14
|
+
skipped this pass;
|
|
15
|
+
- the database (inference_results candidates, inference_cadences manifest aggregates).
|
|
16
|
+
|
|
17
|
+
Every figure is saved under {output_path}/plots/inference/{save_tag}/ and uploaded to Slack,
|
|
18
|
+
mirroring train.py's plot pattern. render_inference_visualizations wraps each figure in
|
|
19
|
+
_viz_safe: a plot bug logs an error and moves on — it can never kill a science run.
|
|
20
|
+
|
|
21
|
+
Matplotlib usage is strictly the object-oriented Figure API (never pyplot): the pipeline runs
|
|
22
|
+
background threads, and pyplot's global figure registry is not thread-safe.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import queue
|
|
31
|
+
import socket
|
|
32
|
+
import threading
|
|
33
|
+
import time
|
|
34
|
+
from collections import Counter
|
|
35
|
+
from collections.abc import Callable
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
|
|
38
|
+
import h5py
|
|
39
|
+
import numpy as np
|
|
40
|
+
from matplotlib.figure import Figure
|
|
41
|
+
|
|
42
|
+
from aetherscan.benchmark import stage_timer
|
|
43
|
+
from aetherscan.candidate_figures import (
|
|
44
|
+
OBS_ROW_LABELS,
|
|
45
|
+
candidate_frequency_range_mhz,
|
|
46
|
+
draw_cadence_strip,
|
|
47
|
+
load_display_cadence,
|
|
48
|
+
render_candidate_figure,
|
|
49
|
+
render_candidate_figures,
|
|
50
|
+
stamp_frequency_range_mhz,
|
|
51
|
+
)
|
|
52
|
+
from aetherscan.config import get_config
|
|
53
|
+
from aetherscan.data_generation import log_norm
|
|
54
|
+
from aetherscan.db import get_db
|
|
55
|
+
from aetherscan.logger import get_logger
|
|
56
|
+
from aetherscan.pfb import gen_coarse_channel_response
|
|
57
|
+
from aetherscan.seeding import STREAM_INFERENCE_VIZ, derive_rng
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
# Fixed linear bins over [0, 1] for the per-cadence P(true) histograms accumulated by the
|
|
62
|
+
# collector (identical bins everywhere, so per-cadence counts combine by addition).
|
|
63
|
+
CONFIDENCE_HIST_EDGES = np.linspace(0.0, 1.0, 101)
|
|
64
|
+
|
|
65
|
+
# Cap on latent feature rows kept for the latent-projection figure. Candidates are always
|
|
66
|
+
# kept; non-candidates fill the remaining budget first-come (earlier cadences are slightly
|
|
67
|
+
# over-represented on huge catalogs, which is fine for a qualitative projection).
|
|
68
|
+
_MAX_LATENT_POINTS = 20_000
|
|
69
|
+
|
|
70
|
+
# Frequency-axis bins for the hit spectrum figure.
|
|
71
|
+
_HIT_SPECTRUM_BINS = 200
|
|
72
|
+
|
|
73
|
+
# Fine-grid bin count used when a LEGACY sidecar's raw hit-frequency lists are reduced to
|
|
74
|
+
# a bounded per-cadence histogram at metadata-load time (#301) — new sidecars arrive
|
|
75
|
+
# pre-binned by preprocessing (_HIT_HIST_BINS, same value).
|
|
76
|
+
_LEGACY_HIT_HIST_BINS = 8192
|
|
77
|
+
|
|
78
|
+
# Cap on individually-drawn cadences in the preprocessing funnel (#301): at 270 px per
|
|
79
|
+
# cadence (1.8 in x 150 dpi) the figure exceeds Agg's hard 2^16-px canvas limit past 242
|
|
80
|
+
# cadences — the render then raises and _viz_safe swallowed it, so every catalog-scale
|
|
81
|
+
# pass silently LOST the funnel. Beyond the cap, the highest-raw-hit cadences are drawn
|
|
82
|
+
# individually and the remainder is aggregated into one summary bar.
|
|
83
|
+
_FUNNEL_MAX_CADENCES = 120
|
|
84
|
+
|
|
85
|
+
# Candidate gallery shows at most this many top-confidence candidates (per-candidate figures
|
|
86
|
+
# are governed separately by config.inference.max_candidate_plots).
|
|
87
|
+
_CANDIDATE_GALLERY_MAX = 12
|
|
88
|
+
|
|
89
|
+
# ON/OFF row labels for 6-observation ABACAD cadence strips — canonical definition lives in
|
|
90
|
+
# the TF-free candidate_figures module (#298 I9); re-exported here for the suite's figures.
|
|
91
|
+
_OBS_ROW_LABELS = OBS_ROW_LABELS
|
|
92
|
+
|
|
93
|
+
# Bounded wait for the async Slack uploader's drain at suite end: uploads are best-effort
|
|
94
|
+
# (same contract as _viz_safe), so a stuck Slack API must not hold the run open forever.
|
|
95
|
+
_UPLOAD_DRAIN_TIMEOUT_S = 180.0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _AsyncUploader:
|
|
99
|
+
"""
|
|
100
|
+
Single-worker FIFO Slack uploader (#298 I9): fig.savefig stays on the caller; the 3-4
|
|
101
|
+
HTTP round trips per figure — plus any retry-backoff sleeps on a flaky Slack — leave
|
|
102
|
+
the render critical path. ONE worker, deliberately: it preserves the suite's
|
|
103
|
+
Slack-thread figure ordering and keeps the effective API rate identical to the old
|
|
104
|
+
synchronous path. drain() must run before logger teardown;
|
|
105
|
+
render_inference_visualizations guarantees it in a finally.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self):
|
|
109
|
+
self._queue: queue.Queue = queue.Queue()
|
|
110
|
+
self._thread: threading.Thread | None = None
|
|
111
|
+
self._stop = threading.Event()
|
|
112
|
+
|
|
113
|
+
def submit(self, save_path: str, title: str) -> None:
|
|
114
|
+
if self._thread is None:
|
|
115
|
+
self._thread = threading.Thread(target=self._run, name="viz_slack_upload", daemon=True)
|
|
116
|
+
self._thread.start()
|
|
117
|
+
self._queue.put((save_path, title))
|
|
118
|
+
|
|
119
|
+
def _run(self) -> None:
|
|
120
|
+
# Capture THIS thread's stop event: drain() re-arms self._stop for any successor
|
|
121
|
+
# thread, and a timed-out predecessor must keep honoring its own signal.
|
|
122
|
+
stop = self._stop
|
|
123
|
+
while True:
|
|
124
|
+
item = self._queue.get()
|
|
125
|
+
if item is None:
|
|
126
|
+
return
|
|
127
|
+
if stop.is_set():
|
|
128
|
+
# Timed-out drain: discard the remaining backlog and spin to the sentinel
|
|
129
|
+
# so the thread exits promptly after its in-flight upload (#298 review
|
|
130
|
+
# note — no abandoned worker plodding through a dead queue)
|
|
131
|
+
continue
|
|
132
|
+
save_path, title = item
|
|
133
|
+
try:
|
|
134
|
+
logger_instance = get_logger()
|
|
135
|
+
if logger_instance:
|
|
136
|
+
logger_instance.upload_image_to_slack(save_path, title=title)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.error(f"Async Slack upload failed for {save_path}: {e}")
|
|
139
|
+
|
|
140
|
+
def drain(self) -> None:
|
|
141
|
+
"""Flush the queue and stop the worker (bounded — uploads are best-effort). On
|
|
142
|
+
timeout the worker is signalled to discard its backlog and exit after the in-flight
|
|
143
|
+
upload, rather than being abandoned mid-queue."""
|
|
144
|
+
if self._thread is None:
|
|
145
|
+
return
|
|
146
|
+
self._queue.put(None)
|
|
147
|
+
self._thread.join(timeout=_UPLOAD_DRAIN_TIMEOUT_S)
|
|
148
|
+
if self._thread.is_alive():
|
|
149
|
+
self._stop.set()
|
|
150
|
+
logger.warning(
|
|
151
|
+
f"Async Slack uploader did not drain within {_UPLOAD_DRAIN_TIMEOUT_S:.0f}s; "
|
|
152
|
+
f"remaining uploads discarded once the in-flight call returns (figures are "
|
|
153
|
+
f"on disk; the daemon thread cannot block shutdown)"
|
|
154
|
+
)
|
|
155
|
+
self._thread = None
|
|
156
|
+
self._stop = threading.Event()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
_uploader = _AsyncUploader()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class CadenceVizRecord:
|
|
164
|
+
"""Per-cadence state the viz suite keeps after the cadence's arrays are dropped."""
|
|
165
|
+
|
|
166
|
+
key: tuple
|
|
167
|
+
npy_path: str
|
|
168
|
+
metadata_path: str
|
|
169
|
+
skipped: bool # True when the stage-aware resume skipped the cadence this pass
|
|
170
|
+
n_stamps: int
|
|
171
|
+
n_candidates: int
|
|
172
|
+
provenance: dict = field(default_factory=dict)
|
|
173
|
+
confidence_hist: np.ndarray | None = None # counts over CONFIDENCE_HIST_EDGES
|
|
174
|
+
inference_duration_s: float | None = None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class InferenceVizCollector:
|
|
178
|
+
"""
|
|
179
|
+
Accumulates bounded per-cadence state during the streaming inference loop.
|
|
180
|
+
|
|
181
|
+
Memory stays O(#cadences + _MAX_LATENT_POINTS) regardless of catalog size: confidence
|
|
182
|
+
vectors are folded into fixed-bin histograms immediately, and latent features are
|
|
183
|
+
subsampled against a global budget (candidates always kept).
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(
|
|
187
|
+
self,
|
|
188
|
+
max_latent_points: int = _MAX_LATENT_POINTS,
|
|
189
|
+
gallery_pool: list | None = None,
|
|
190
|
+
):
|
|
191
|
+
self.records: list[CadenceVizRecord] = []
|
|
192
|
+
self._max_latent_points = max_latent_points
|
|
193
|
+
self._latent_chunks: list[np.ndarray] = [] # (k, num_obs * latent_dim) each
|
|
194
|
+
self._candidate_chunks: list[np.ndarray] = [] # bool masks aligned with chunks
|
|
195
|
+
self._latent_count = 0
|
|
196
|
+
config = get_config()
|
|
197
|
+
root_seed = config.reproducibility.seed if config is not None else None
|
|
198
|
+
self._rng = derive_rng(root_seed, STREAM_INFERENCE_VIZ)
|
|
199
|
+
# Bounded top-K stamp-pixel pool (#302): raw pixels of the strongest stamps seen
|
|
200
|
+
# so far, captured just before pruning deletes a cadence's .npy, so the stamp
|
|
201
|
+
# gallery stays whole (~196 KB x top_k ≈ 2.4 MB at defaults). A caller-supplied
|
|
202
|
+
# list persists it ACROSS the in-process retry attempts (#305 fix): each attempt
|
|
203
|
+
# builds a fresh collector, but a cadence pruned in an earlier attempt is
|
|
204
|
+
# resume-skipped (never re-pooled) in later ones, so a per-attempt pool would blank
|
|
205
|
+
# its gallery column on the retry render. Cross-PROCESS relaunch still can't recover
|
|
206
|
+
# those pixels (documented) — but a full-catalog run rarely relaunches.
|
|
207
|
+
self._gallery_top_k = config.inference.stamp_gallery_top_k if config is not None else 12
|
|
208
|
+
self._gallery_pixel_pool: list[tuple[float, str, int, np.ndarray]] = (
|
|
209
|
+
gallery_pool if gallery_pool is not None else []
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def record_skipped(
|
|
213
|
+
self, key: tuple, npy_path: str, metadata_path: str, manifest_row: dict
|
|
214
|
+
) -> None:
|
|
215
|
+
"""Record a cadence the stage-aware resume skipped (aggregates from its manifest row)."""
|
|
216
|
+
self.records.append(
|
|
217
|
+
CadenceVizRecord(
|
|
218
|
+
key=key,
|
|
219
|
+
npy_path=npy_path,
|
|
220
|
+
metadata_path=metadata_path,
|
|
221
|
+
skipped=True,
|
|
222
|
+
n_stamps=int(manifest_row.get("n_stamps") or 0),
|
|
223
|
+
n_candidates=int(manifest_row.get("n_candidates") or 0),
|
|
224
|
+
inference_duration_s=manifest_row.get("duration_s"),
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def record_processed(
|
|
229
|
+
self,
|
|
230
|
+
key: tuple,
|
|
231
|
+
npy_path: str,
|
|
232
|
+
metadata_path: str,
|
|
233
|
+
provenance: dict,
|
|
234
|
+
results: dict,
|
|
235
|
+
duration_s: float,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Record a cadence inferred this pass. `results` is run_inference's dict; its
|
|
238
|
+
proba_true / predictions / latents arrays are reduced here and not retained."""
|
|
239
|
+
proba_true = np.asarray(results["proba_true"])
|
|
240
|
+
predictions = np.asarray(results["predictions"])
|
|
241
|
+
confidence_hist, _ = np.histogram(np.clip(proba_true, 0.0, 1.0), bins=CONFIDENCE_HIST_EDGES)
|
|
242
|
+
|
|
243
|
+
self.records.append(
|
|
244
|
+
CadenceVizRecord(
|
|
245
|
+
key=key,
|
|
246
|
+
npy_path=npy_path,
|
|
247
|
+
metadata_path=metadata_path,
|
|
248
|
+
skipped=False,
|
|
249
|
+
n_stamps=int(results["n_cadence_snippets"]),
|
|
250
|
+
n_candidates=int(results["n_candidates"]),
|
|
251
|
+
provenance=provenance,
|
|
252
|
+
confidence_hist=confidence_hist,
|
|
253
|
+
inference_duration_s=duration_s,
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
self._budget_fill_add(results["latents"], predictions.astype(bool))
|
|
258
|
+
|
|
259
|
+
def _budget_fill_add(self, latents: np.ndarray, is_candidate: np.ndarray) -> None:
|
|
260
|
+
"""Fold one cadence's latents into the bounded projection pool by first-come greedy
|
|
261
|
+
budget-fill (not reservoir sampling): cadence-level features (obs latents
|
|
262
|
+
concatenated), candidates always kept; non-candidates fill whatever global budget
|
|
263
|
+
remains, uniformly subsampled WITHIN this cadence when they'd overflow it (later
|
|
264
|
+
cadences get nothing once the budget is spent)."""
|
|
265
|
+
keep = np.nonzero(is_candidate)[0]
|
|
266
|
+
budget = self._max_latent_points - self._latent_count - keep.size
|
|
267
|
+
non_candidates = np.nonzero(~is_candidate)[0]
|
|
268
|
+
if budget > 0 and non_candidates.size > 0:
|
|
269
|
+
if non_candidates.size > budget:
|
|
270
|
+
non_candidates = self._rng.choice(non_candidates, size=budget, replace=False)
|
|
271
|
+
keep = np.concatenate([keep, non_candidates])
|
|
272
|
+
|
|
273
|
+
if keep.size == 0:
|
|
274
|
+
# Once the global budget is spent (a cadence or two into the catalog), every
|
|
275
|
+
# non-candidate cadence lands here — so nothing may be built before this
|
|
276
|
+
# return. The full-cadence feature matrix this method used to construct first
|
|
277
|
+
# (~190 MB float64 for a 330k-stamp cadence) was thrown away each time (#301).
|
|
278
|
+
return
|
|
279
|
+
# Cadence-level rows for the kept indices only. Value-identical to
|
|
280
|
+
# prepare_latent_features(latents)[keep].astype(np.float32): the helper's
|
|
281
|
+
# float32 -> float64 -> float32 round trip is exact, and the row-major
|
|
282
|
+
# reshape is the helper's own documented layout (fancy indexing yields a
|
|
283
|
+
# fresh array, so the chunk owns its memory).
|
|
284
|
+
features = np.asarray(latents, dtype=np.float32).reshape(len(is_candidate), -1)[keep]
|
|
285
|
+
self._latent_chunks.append(features)
|
|
286
|
+
self._candidate_chunks.append(is_candidate[keep])
|
|
287
|
+
self._latent_count += keep.size
|
|
288
|
+
|
|
289
|
+
def latent_pool(self) -> tuple[np.ndarray, np.ndarray]:
|
|
290
|
+
"""Return (features, is_candidate) stacked over everything collected; empty arrays
|
|
291
|
+
when nothing was processed this pass."""
|
|
292
|
+
if not self._latent_chunks:
|
|
293
|
+
return np.empty((0, 0), dtype=np.float32), np.empty(0, dtype=bool)
|
|
294
|
+
return np.concatenate(self._latent_chunks), np.concatenate(self._candidate_chunks)
|
|
295
|
+
|
|
296
|
+
def pool_gallery_pixels(self, metadata_path: str, npy_path: str) -> None:
|
|
297
|
+
"""Capture this cadence's top-K stamp pixels into the bounded global pool (#302):
|
|
298
|
+
called by the pruning step just before it deletes the stamp .npy. The global
|
|
299
|
+
top-K is a subset of the per-cadence top-Ks, so pooling per-cadence top-K and
|
|
300
|
+
truncating globally preserves exactly the stamps plot_stamp_gallery will select.
|
|
301
|
+
Best-effort — a failure degrades the gallery, never the run."""
|
|
302
|
+
try:
|
|
303
|
+
with open(metadata_path) as f:
|
|
304
|
+
metadata = json.load(f)
|
|
305
|
+
representatives = _cadence_top_stamps(metadata, self._gallery_top_k)
|
|
306
|
+
if not representatives:
|
|
307
|
+
return
|
|
308
|
+
stamps = np.load(npy_path, mmap_mode="r")
|
|
309
|
+
for stat, idx, _freq, _freq_range in representatives:
|
|
310
|
+
self._gallery_pixel_pool.append(
|
|
311
|
+
(stat, npy_path, int(idx), np.array(stamps[int(idx)], dtype=np.float32))
|
|
312
|
+
)
|
|
313
|
+
del stamps
|
|
314
|
+
self._gallery_pixel_pool.sort(key=lambda entry: entry[0], reverse=True)
|
|
315
|
+
del self._gallery_pixel_pool[self._gallery_top_k :]
|
|
316
|
+
except Exception as e:
|
|
317
|
+
logger.warning(
|
|
318
|
+
f"Gallery pixel pooling failed for {npy_path} ({e}); the stamp gallery "
|
|
319
|
+
f"may show blank columns for this cadence"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def gallery_pixels(self) -> dict[tuple[str, int], np.ndarray]:
|
|
323
|
+
"""(npy_path, snippet_index) -> raw stamp pixels for the pooled top-K stamps."""
|
|
324
|
+
return {(path, idx): pixels for _, path, idx, pixels in self._gallery_pixel_pool}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
# Shared plumbing
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _viz_safe(name: str, fn: Callable, *args, **kwargs):
|
|
333
|
+
"""Run one figure function, log-and-swallow any exception: a plot bug must never kill a
|
|
334
|
+
science run (mirrors train.py's _safe_call, with viz-specific logging). Each figure
|
|
335
|
+
records its own pipeline_stages sub-span (#301) — the suite used to be one opaque
|
|
336
|
+
'inference.viz' span covering 73% of a cached rerun's wall."""
|
|
337
|
+
try:
|
|
338
|
+
with stage_timer(name):
|
|
339
|
+
return fn(*args, **kwargs)
|
|
340
|
+
except Exception as e:
|
|
341
|
+
logger.error(f"Inference viz '{name}' failed (run continues without it): {e}")
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _plots_dir(tag: str) -> str:
|
|
346
|
+
path = os.path.join(get_config().output_path, "plots", "inference", tag)
|
|
347
|
+
os.makedirs(path, exist_ok=True)
|
|
348
|
+
return path
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _save_and_upload(fig: Figure, filename: str, slack_title: str) -> str:
|
|
352
|
+
"""Save a figure under plots/inference/{tag}/ and queue its Slack upload (#298 I9 —
|
|
353
|
+
the upload's HTTP round trips run on the async FIFO uploader, drained before teardown
|
|
354
|
+
by render_inference_visualizations). Returns the saved path."""
|
|
355
|
+
tag = get_config().checkpoint.save_tag
|
|
356
|
+
save_path = os.path.join(_plots_dir(tag), filename)
|
|
357
|
+
fig.savefig(save_path, dpi=150, bbox_inches="tight")
|
|
358
|
+
# Eagerly release the figure's artists/render buffers. OO-API Figures aren't tracked by
|
|
359
|
+
# a global registry, so this isn't a leak fix — it just frees the backing memory now
|
|
360
|
+
# instead of at garbage collection (relevant for dense stamp/candidate galleries). The
|
|
361
|
+
# Slack upload reads the saved PNG, not the figure.
|
|
362
|
+
fig.clear()
|
|
363
|
+
logger.info(f"Inference viz saved: {save_path}")
|
|
364
|
+
|
|
365
|
+
_uploader.submit(save_path, f"{slack_title} - ({tag}, {socket.gethostname()})")
|
|
366
|
+
return save_path
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _load_metadata(record: CadenceVizRecord) -> dict | None:
|
|
370
|
+
"""Best-effort read of a cadence's metadata JSON (durable ED provenance)."""
|
|
371
|
+
try:
|
|
372
|
+
with open(record.metadata_path) as f:
|
|
373
|
+
return json.load(f)
|
|
374
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
375
|
+
logger.warning(f"Viz: could not read metadata {record.metadata_path} ({e}); skipping")
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@dataclass
|
|
380
|
+
class CadenceVizSummary:
|
|
381
|
+
"""Bounded per-cadence reduction of the metadata sidecar (#301). The suite used to
|
|
382
|
+
hold every cadence's fully parsed JSON (up to ~19 MB each on RFI-dense cadences —
|
|
383
|
+
an OOM-class hundreds of GB at 6,093-cadence catalog scale) for the whole render;
|
|
384
|
+
each figure now reads these ~KB summaries and the parsed dict is dropped as soon as
|
|
385
|
+
its cadence is reduced."""
|
|
386
|
+
|
|
387
|
+
n_raw_hits: int = 0
|
|
388
|
+
n_merged_hits: int = 0
|
|
389
|
+
n_stamp_rows: int = 0
|
|
390
|
+
npy_size_bytes: float = float("nan")
|
|
391
|
+
ed_hist_edges: np.ndarray | None = None
|
|
392
|
+
ed_hist_counts: np.ndarray | None = None # (n_on_files, n_bins) int64
|
|
393
|
+
# {raw_counts, merged_counts, freq_lo, freq_hi, raw_freq_min, raw_freq_max} — fine
|
|
394
|
+
# per-cadence histograms rebinned onto the figure's global axis at render time
|
|
395
|
+
hit_hist: dict | None = None
|
|
396
|
+
h5_path: str | None = None
|
|
397
|
+
nchans: int = 0
|
|
398
|
+
bandpass_envelopes: list | None = None
|
|
399
|
+
# Per-cadence top-K stamp representatives: (statistic, snippet_idx, freq, freq_range)
|
|
400
|
+
gallery_reps: list = field(default_factory=list)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _reduce_hit_hist(metadata: dict) -> dict | None:
|
|
404
|
+
"""Normalize a cadence's hit-frequency data to a bounded fine histogram: new sidecars
|
|
405
|
+
carry hit_spectrum_hist pre-binned by preprocessing; legacy sidecars' raw float lists
|
|
406
|
+
are binned HERE, once, on their own span — so RAM stays bounded for old and new
|
|
407
|
+
catalogs alike (#301)."""
|
|
408
|
+
hist = metadata.get("hit_spectrum_hist")
|
|
409
|
+
if hist:
|
|
410
|
+
return {
|
|
411
|
+
"raw_counts": np.asarray(hist["raw_counts"], dtype=np.int64),
|
|
412
|
+
"merged_counts": np.asarray(hist["merged_counts"], dtype=np.int64),
|
|
413
|
+
"freq_lo": float(hist["freq_lo"]),
|
|
414
|
+
"freq_hi": float(hist["freq_hi"]),
|
|
415
|
+
"raw_freq_min": float(hist["raw_freq_min"]),
|
|
416
|
+
"raw_freq_max": float(hist["raw_freq_max"]),
|
|
417
|
+
}
|
|
418
|
+
raw = metadata.get("raw_hit_frequencies_mhz") or []
|
|
419
|
+
if not raw:
|
|
420
|
+
return None
|
|
421
|
+
merged = metadata.get("merged_hit_frequencies_mhz") or []
|
|
422
|
+
lo, hi = float(np.min(raw)), float(np.max(raw))
|
|
423
|
+
span_lo, span_hi = (lo, hi) if lo < hi else (lo - 0.5, hi + 0.5)
|
|
424
|
+
edges = np.linspace(span_lo, span_hi, _LEGACY_HIT_HIST_BINS + 1)
|
|
425
|
+
return {
|
|
426
|
+
"raw_counts": np.histogram(raw, bins=edges)[0],
|
|
427
|
+
"merged_counts": np.histogram(merged, bins=edges)[0],
|
|
428
|
+
"freq_lo": span_lo,
|
|
429
|
+
"freq_hi": span_hi,
|
|
430
|
+
"raw_freq_min": lo,
|
|
431
|
+
"raw_freq_max": hi,
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _cadence_top_stamps(metadata: dict, top_k: int) -> list[tuple[float, int, float, tuple | None]]:
|
|
436
|
+
"""This cadence's top-K stamp representatives by detection statistic, overlap-offset
|
|
437
|
+
copies collapsed first (the per-cadence half of the old _select_top_stamps): stamps
|
|
438
|
+
sharing one exact statistic are one hit's offset copies — represented by the
|
|
439
|
+
median-start stamp (the offset-0 center for a full triplet). Keeping only the
|
|
440
|
+
per-cadence top-K preserves the global top-K exactly (each cadence can contribute at
|
|
441
|
+
most K entries to it) while bounding the reduction (#301)."""
|
|
442
|
+
stats_list = metadata.get("stamp_statistics") or []
|
|
443
|
+
freqs = metadata.get("stamp_frequencies_mhz") or []
|
|
444
|
+
starts = metadata.get("stamp_starts") or []
|
|
445
|
+
|
|
446
|
+
by_stat: dict[float, list[tuple[int, int]]] = {}
|
|
447
|
+
for idx, stat in enumerate(stats_list):
|
|
448
|
+
start = int(starts[idx]) if idx < len(starts) else 0
|
|
449
|
+
by_stat.setdefault(float(stat), []).append((start, idx))
|
|
450
|
+
|
|
451
|
+
representatives: list[tuple[float, int, float, tuple | None]] = []
|
|
452
|
+
for stat, members in by_stat.items():
|
|
453
|
+
members.sort() # by start; median = offset-0 center for a full triplet
|
|
454
|
+
_, idx = members[len(members) // 2]
|
|
455
|
+
freq = float(freqs[idx]) if idx < len(freqs) else float("nan")
|
|
456
|
+
representatives.append((stat, idx, freq, stamp_frequency_range_mhz(metadata, idx)))
|
|
457
|
+
|
|
458
|
+
# Distinct stats per cadence (offset copies collapsed), so this sort has no ties and
|
|
459
|
+
# cross-cadence tie order stays the stable record order the old global sort had
|
|
460
|
+
representatives.sort(key=lambda r: r[0], reverse=True)
|
|
461
|
+
return representatives[:top_k]
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _reduce_metadata(
|
|
465
|
+
record: CadenceVizRecord, metadata: dict, gallery_top_k: int
|
|
466
|
+
) -> CadenceVizSummary:
|
|
467
|
+
"""Reduce one cadence's parsed metadata to the bounded summary the figures consume."""
|
|
468
|
+
summary = CadenceVizSummary()
|
|
469
|
+
summary.n_raw_hits = int(metadata.get("n_raw_hits") or 0)
|
|
470
|
+
summary.n_merged_hits = int(metadata.get("n_merged_hits") or 0)
|
|
471
|
+
summary.n_stamp_rows = len(metadata.get("stamp_starts") or []) or record.n_stamps
|
|
472
|
+
try:
|
|
473
|
+
summary.npy_size_bytes = float(os.path.getsize(record.npy_path))
|
|
474
|
+
except OSError:
|
|
475
|
+
# The .npy was pruned after scoring (#302 default) — reconstruct the size the run
|
|
476
|
+
# transiently held from the stored geometry so the funnel/summary storage figures
|
|
477
|
+
# don't silently collapse to nan/0 on every default run. The .npy shape is
|
|
478
|
+
# (n_stamps, n_obs, time_bins, stored_width) float32 + a ~128-byte header.
|
|
479
|
+
config = get_config()
|
|
480
|
+
stored_width = metadata.get("stored_width")
|
|
481
|
+
time_bins = config.data.time_bins if config is not None else None
|
|
482
|
+
n_obs = len(metadata.get("h5_paths") or []) or 6
|
|
483
|
+
if stored_width and time_bins:
|
|
484
|
+
summary.npy_size_bytes = float(
|
|
485
|
+
summary.n_stamp_rows * n_obs * int(time_bins) * int(stored_width) * 4 + 128
|
|
486
|
+
)
|
|
487
|
+
ed_hist = metadata.get("ed_stat_hist")
|
|
488
|
+
if ed_hist:
|
|
489
|
+
summary.ed_hist_edges = np.asarray(ed_hist["bin_edges"], dtype=np.float64)
|
|
490
|
+
summary.ed_hist_counts = np.asarray(ed_hist["counts_per_on_file"], dtype=np.int64)
|
|
491
|
+
summary.hit_hist = _reduce_hit_hist(metadata)
|
|
492
|
+
h5_paths = metadata.get("h5_paths") or []
|
|
493
|
+
if h5_paths:
|
|
494
|
+
summary.h5_path = h5_paths[0]
|
|
495
|
+
summary.nchans = int((metadata.get("header") or {}).get("nchans", 0) or 0)
|
|
496
|
+
summary.bandpass_envelopes = metadata.get("bandpass_envelopes") or None
|
|
497
|
+
summary.gallery_reps = _cadence_top_stamps(metadata, gallery_top_k)
|
|
498
|
+
return summary
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _build_summaries(
|
|
502
|
+
records: list[CadenceVizRecord], gallery_top_k: int
|
|
503
|
+
) -> dict[str, CadenceVizSummary]:
|
|
504
|
+
"""One pass over the records' metadata sidecars: parse, reduce, drop — the only place
|
|
505
|
+
the suite touches the raw JSONs (#301)."""
|
|
506
|
+
summaries: dict[str, CadenceVizSummary] = {}
|
|
507
|
+
envelopes_captured = False
|
|
508
|
+
for record in records:
|
|
509
|
+
metadata = _load_metadata(record)
|
|
510
|
+
if metadata is not None:
|
|
511
|
+
summary = _reduce_metadata(record, metadata, gallery_top_k)
|
|
512
|
+
# Only plot_bandpass_flattening reads bandpass_envelopes, and it uses the FIRST
|
|
513
|
+
# cadence (in records order) that carries them, then returns — so retaining every
|
|
514
|
+
# cadence's envelopes (each ~hundreds of KB of parsed floats) for the whole render
|
|
515
|
+
# is ~GB of dead RAM at catalog scale, undoing the O(1)-per-cadence bound the
|
|
516
|
+
# summaries were introduced (#301) to guarantee. Keep them on the first summary
|
|
517
|
+
# that has them and drop the rest: _build_summaries iterates records in the same
|
|
518
|
+
# order as the consumer, so the identical cadence is selected — byte-identical figure.
|
|
519
|
+
if summary.bandpass_envelopes is not None:
|
|
520
|
+
if envelopes_captured:
|
|
521
|
+
summary.bandpass_envelopes = None
|
|
522
|
+
else:
|
|
523
|
+
envelopes_captured = True
|
|
524
|
+
summaries[record.npy_path] = summary
|
|
525
|
+
del metadata
|
|
526
|
+
return summaries
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
# _load_display_cadence and _draw_cadence_strip moved to the TF-free candidate_figures
|
|
530
|
+
# module (#298 I9) so forkserver render workers can import them without pulling TF;
|
|
531
|
+
# the suite uses them via the load_display_cadence / draw_cadence_strip imports above.
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _key_label(key: tuple, max_len: int = 28) -> str:
|
|
535
|
+
label = "/".join(str(part) for part in key)
|
|
536
|
+
return label if len(label) <= max_len else label[: max_len - 1] + "…"
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# ---------------------------------------------------------------------------
|
|
540
|
+
# Per-run figures: energy detection / preprocessing
|
|
541
|
+
# ---------------------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def plot_ed_stat_distributions(
|
|
545
|
+
records: list[CadenceVizRecord], summaries: dict[str, CadenceVizSummary]
|
|
546
|
+
) -> str | None:
|
|
547
|
+
"""Histogram of the D'Agostino-Pearson k2 statistic over all finite windows (not just
|
|
548
|
+
hits — the ED workers histogram only finite k2 values), log-log, per-ON-file overlay +
|
|
549
|
+
total, with the detection threshold marked. Sourced from the fixed-bin histograms the
|
|
550
|
+
ED workers accumulate into each cadence's metadata."""
|
|
551
|
+
config = get_config()
|
|
552
|
+
tag = config.checkpoint.save_tag
|
|
553
|
+
stat_threshold = config.inference.stat_threshold
|
|
554
|
+
|
|
555
|
+
edges: np.ndarray | None = None
|
|
556
|
+
per_on_totals: np.ndarray | None = None
|
|
557
|
+
contributing: set[str] = set() # npy_paths whose histograms actually landed in the totals
|
|
558
|
+
for record in records:
|
|
559
|
+
summary = summaries.get(record.npy_path)
|
|
560
|
+
if summary is None or summary.ed_hist_edges is None:
|
|
561
|
+
continue
|
|
562
|
+
cadence_edges = summary.ed_hist_edges
|
|
563
|
+
counts = summary.ed_hist_counts
|
|
564
|
+
if edges is None:
|
|
565
|
+
edges = cadence_edges
|
|
566
|
+
per_on_totals = np.zeros_like(counts)
|
|
567
|
+
elif cadence_edges.shape != edges.shape or not np.allclose(cadence_edges, edges):
|
|
568
|
+
logger.warning(f"Viz: {record.npy_path} has mismatched ED hist bins; skipping it")
|
|
569
|
+
continue
|
|
570
|
+
if counts.shape != per_on_totals.shape:
|
|
571
|
+
logger.warning(f"Viz: {record.npy_path} has unexpected ED hist shape; skipping it")
|
|
572
|
+
continue
|
|
573
|
+
per_on_totals += counts
|
|
574
|
+
contributing.add(record.npy_path)
|
|
575
|
+
|
|
576
|
+
if edges is None or per_on_totals is None or per_on_totals.sum() == 0:
|
|
577
|
+
logger.info("Viz: no ED statistic histograms available; skipping ed_stat_distributions")
|
|
578
|
+
return None
|
|
579
|
+
|
|
580
|
+
centers = np.sqrt(edges[:-1] * edges[1:]) # geometric bin centers (log-spaced bins)
|
|
581
|
+
|
|
582
|
+
fig = Figure(figsize=(10, 6))
|
|
583
|
+
ax = fig.subplots()
|
|
584
|
+
for on_idx in range(per_on_totals.shape[0]):
|
|
585
|
+
ax.step(
|
|
586
|
+
centers,
|
|
587
|
+
per_on_totals[on_idx],
|
|
588
|
+
where="mid",
|
|
589
|
+
lw=1.0,
|
|
590
|
+
label=f"ON file {on_idx + 1}",
|
|
591
|
+
)
|
|
592
|
+
ax.step(
|
|
593
|
+
centers, per_on_totals.sum(axis=0), where="mid", lw=1.6, color="black", label="all ON files"
|
|
594
|
+
)
|
|
595
|
+
ax.axvline(
|
|
596
|
+
stat_threshold, color="red", ls="--", lw=1.2, label=f"threshold ({stat_threshold:g})"
|
|
597
|
+
)
|
|
598
|
+
# Exact above-threshold count from the workers' hit lists (the histogram bins are fixed,
|
|
599
|
+
# so the threshold generally falls inside a bin — summing bins would be approximate).
|
|
600
|
+
# Summed over the SAME cadence subset that built the histogram, so the above/total pair
|
|
601
|
+
# stays consistent when a mismatched-bins/shape cadence was dropped above.
|
|
602
|
+
above = sum(summaries[p].n_raw_hits for p in contributing)
|
|
603
|
+
total = int(per_on_totals.sum())
|
|
604
|
+
ax.set_xscale("log")
|
|
605
|
+
ax.set_yscale("log")
|
|
606
|
+
ax.set_xlabel("D'Agostino-Pearson $k^2$ statistic")
|
|
607
|
+
ax.set_ylabel("window count")
|
|
608
|
+
ax.set_title(
|
|
609
|
+
f"Energy-detection statistic distribution ({tag})\n"
|
|
610
|
+
f"{total:,} finite windows, {above:,} above threshold "
|
|
611
|
+
f"({len(contributing)} cadence(s))"
|
|
612
|
+
)
|
|
613
|
+
ax.legend(fontsize=8)
|
|
614
|
+
ax.grid(True, which="both", alpha=0.2)
|
|
615
|
+
|
|
616
|
+
return _save_and_upload(fig, f"ed_stat_distributions_{tag}.png", "ED Statistic Distribution")
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _clamp_hit_spectrum_bins(
|
|
620
|
+
lo: float, hi: float, coarsest_fine_width: float, max_bins: int
|
|
621
|
+
) -> int:
|
|
622
|
+
"""Number of hit-spectrum figure bins over [lo, hi] that keeps each figure bin no finer
|
|
623
|
+
than the coarsest stored fine grid (>= one fine bin per figure bin), so the rebin can't
|
|
624
|
+
alias into a picket-fence (#305). Returns max_bins when the span is wide (the common
|
|
625
|
+
case) and fewer as the span narrows toward the fine-grid resolution."""
|
|
626
|
+
if coarsest_fine_width <= 0 or hi <= lo:
|
|
627
|
+
return max_bins
|
|
628
|
+
return max(1, min(max_bins, int((hi - lo) / coarsest_fine_width)))
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def plot_ed_hit_spectrum(
|
|
632
|
+
records: list[CadenceVizRecord], summaries: dict[str, CadenceVizSummary]
|
|
633
|
+
) -> str | None:
|
|
634
|
+
"""Hit density vs frequency (MHz) across the band, pre- vs post-deduplication — RFI comb
|
|
635
|
+
structure shows up immediately as spikes/picket-fences. Rendered by rebinning each
|
|
636
|
+
cadence's bounded fine histogram (pre-binned by preprocessing, or reduced from a legacy
|
|
637
|
+
sidecar's raw lists at load — #301) onto one global axis whose bin count is clamped so
|
|
638
|
+
the figure bins are never finer than the stored fine grid — so the result is visually
|
|
639
|
+
faithful to histogramming the raw floats (identical for the wide-span common case)
|
|
640
|
+
while never holding the catalog's hit lists in RAM."""
|
|
641
|
+
tag = get_config().checkpoint.save_tag
|
|
642
|
+
|
|
643
|
+
with_hits = [
|
|
644
|
+
summaries[record.npy_path]
|
|
645
|
+
for record in records
|
|
646
|
+
if summaries.get(record.npy_path) and summaries[record.npy_path].hit_hist
|
|
647
|
+
]
|
|
648
|
+
if not with_hits:
|
|
649
|
+
logger.info("Viz: no hit frequencies available; skipping ed_hit_spectrum")
|
|
650
|
+
return None
|
|
651
|
+
|
|
652
|
+
lo = min(s.hit_hist["raw_freq_min"] for s in with_hits)
|
|
653
|
+
hi = max(s.hit_hist["raw_freq_max"] for s in with_hits)
|
|
654
|
+
if lo == hi: # single-frequency degenerate range: give the histogram some width
|
|
655
|
+
lo, hi = lo - 0.5, hi + 0.5
|
|
656
|
+
|
|
657
|
+
# Clamp the figure's bin count so its bins are never FINER than the stored fine grid
|
|
658
|
+
# over this span (#305 review): new sidecars pre-bin over the whole file band, so when
|
|
659
|
+
# the catalog-wide hit span is narrow (< ~band/41) the 200-bin figure axis would be
|
|
660
|
+
# finer than the fine bins and the center-weighted rebin below would draw a spurious
|
|
661
|
+
# picket-fence (empty bins between populated ones) not present in the data.
|
|
662
|
+
coarsest_fine_width = max(
|
|
663
|
+
(s.hit_hist["freq_hi"] - s.hit_hist["freq_lo"]) / max(1, len(s.hit_hist["raw_counts"]))
|
|
664
|
+
for s in with_hits
|
|
665
|
+
)
|
|
666
|
+
n_fig_bins = _clamp_hit_spectrum_bins(lo, hi, coarsest_fine_width, _HIT_SPECTRUM_BINS)
|
|
667
|
+
bins = np.linspace(lo, hi, n_fig_bins + 1)
|
|
668
|
+
|
|
669
|
+
raw_total = np.zeros(n_fig_bins, dtype=np.float64)
|
|
670
|
+
merged_total = np.zeros(n_fig_bins, dtype=np.float64)
|
|
671
|
+
for s in with_hits:
|
|
672
|
+
h = s.hit_hist
|
|
673
|
+
fine_edges = np.linspace(h["freq_lo"], h["freq_hi"], len(h["raw_counts"]) + 1)
|
|
674
|
+
# Clip centers into the global range: a boundary hit's fine bin can center just
|
|
675
|
+
# outside [lo, hi] — its count belongs in the edge bin, exactly where the raw
|
|
676
|
+
# float would have landed
|
|
677
|
+
centers = np.clip((fine_edges[:-1] + fine_edges[1:]) / 2, lo, hi)
|
|
678
|
+
raw_total += np.histogram(centers, bins=bins, weights=h["raw_counts"])[0]
|
|
679
|
+
merged_total += np.histogram(centers, bins=bins, weights=h["merged_counts"])[0]
|
|
680
|
+
|
|
681
|
+
fig = Figure(figsize=(12, 5))
|
|
682
|
+
ax = fig.subplots()
|
|
683
|
+
ax.stairs(raw_total, bins, fill=True, alpha=0.35, label="raw hits (pre-dedup)")
|
|
684
|
+
ax.stairs(merged_total, bins, lw=1.4, color="crimson", label="merged hits")
|
|
685
|
+
ax.set_yscale("log")
|
|
686
|
+
ax.set_xlabel("frequency (MHz)")
|
|
687
|
+
ax.set_ylabel("hit count")
|
|
688
|
+
ax.set_title(
|
|
689
|
+
f"Energy-detection hit spectrum ({tag})\n"
|
|
690
|
+
f"{int(raw_total.sum()):,} raw → {int(merged_total.sum()):,} merged hits"
|
|
691
|
+
)
|
|
692
|
+
ax.legend(fontsize=9)
|
|
693
|
+
ax.grid(True, alpha=0.2)
|
|
694
|
+
|
|
695
|
+
return _save_and_upload(fig, f"ed_hit_spectrum_{tag}.png", "ED Hit Spectrum")
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _plot_bandpass_from_envelopes(
|
|
699
|
+
envelopes: list[dict], h5_path: str | None, tag: str
|
|
700
|
+
) -> str | None:
|
|
701
|
+
"""Render the bandpass-flattening figure from the decimated envelope lines persisted
|
|
702
|
+
in a cadence's metadata sidecar (#301): the same three lines per sampled channel the
|
|
703
|
+
live path draws, computed by preprocessing while the channels were resident — no h5
|
|
704
|
+
reads at viz time (they measured 114 s of cold /datag reads = 74% of a cached rerun's
|
|
705
|
+
viz span)."""
|
|
706
|
+
fig = Figure(figsize=(14, 3.2 * len(envelopes)))
|
|
707
|
+
axes = fig.subplots(len(envelopes), 2, squeeze=False)
|
|
708
|
+
overlay_label = "removed model"
|
|
709
|
+
for row, entry in enumerate(envelopes):
|
|
710
|
+
ax_raw, ax_flat = axes[row]
|
|
711
|
+
overlay_label = entry.get("overlay_label") or overlay_label
|
|
712
|
+
ax_raw.plot(
|
|
713
|
+
entry["raw"]["idx"],
|
|
714
|
+
entry["raw"]["values"],
|
|
715
|
+
lw=0.6,
|
|
716
|
+
color="tab:blue",
|
|
717
|
+
label="raw integrated spectrum",
|
|
718
|
+
)
|
|
719
|
+
ax_raw.plot(
|
|
720
|
+
entry["overlay"]["idx"],
|
|
721
|
+
entry["overlay"]["values"],
|
|
722
|
+
lw=1.2,
|
|
723
|
+
ls="--",
|
|
724
|
+
color="tab:orange",
|
|
725
|
+
label=overlay_label,
|
|
726
|
+
)
|
|
727
|
+
ax_raw.set_ylabel(f"coarse channel {entry.get('channel', '?')}\nintegrated power")
|
|
728
|
+
ax_flat.plot(
|
|
729
|
+
entry["flat"]["idx"],
|
|
730
|
+
entry["flat"]["values"],
|
|
731
|
+
lw=0.6,
|
|
732
|
+
color="tab:green",
|
|
733
|
+
label="flattened integrated spectrum",
|
|
734
|
+
)
|
|
735
|
+
if row == 0:
|
|
736
|
+
ax_raw.legend(loc="upper right", fontsize=8)
|
|
737
|
+
ax_flat.legend(loc="upper right", fontsize=8)
|
|
738
|
+
if row == len(envelopes) - 1:
|
|
739
|
+
ax_raw.set_xlabel("fine channel (within coarse channel)")
|
|
740
|
+
ax_flat.set_xlabel("fine channel (within coarse channel)")
|
|
741
|
+
|
|
742
|
+
method = "pfb" if "PFB" in overlay_label else "spline"
|
|
743
|
+
source = os.path.basename(h5_path) if h5_path else "stored envelopes"
|
|
744
|
+
fig.suptitle(f"Bandpass flattening ({method}, {tag}): {source}")
|
|
745
|
+
fig.tight_layout()
|
|
746
|
+
return _save_and_upload(fig, f"bandpass_flattening_{tag}.png", "Bandpass Flattening")
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def plot_bandpass_flattening(
|
|
750
|
+
preprocessor, records: list[CadenceVizRecord], summaries: dict[str, CadenceVizSummary]
|
|
751
|
+
) -> str | None:
|
|
752
|
+
"""Integrated spectrum raw vs flattened for a few coarse channels sampled across the
|
|
753
|
+
band of the first cadence's primary ON file, with the removed model (scaled PFB response
|
|
754
|
+
H or spline fit) overlaid — formalizes PR-07's opt-in debug artifact as a standard
|
|
755
|
+
per-run figure. Rendered from the envelopes persisted at preprocess time when a sidecar
|
|
756
|
+
carries them (#301); legacy sidecars keep the historical live-read path below."""
|
|
757
|
+
# NOTE: reaches into DataPreprocessor's private helpers on purpose — they are the
|
|
758
|
+
# single source of truth for how a channel is read/despiked/flattened, and duplicating
|
|
759
|
+
# that here would let the figure drift from what detection actually does.
|
|
760
|
+
from aetherscan.preprocessing import ( # noqa: PLC0415
|
|
761
|
+
_decimate_for_plot,
|
|
762
|
+
_fit_channel_bandpass,
|
|
763
|
+
_pfb_flatten_bandpass,
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
config = get_config()
|
|
767
|
+
tag = config.checkpoint.save_tag
|
|
768
|
+
width = config.inference.coarse_channel_width
|
|
769
|
+
|
|
770
|
+
for record in records:
|
|
771
|
+
summary = summaries.get(record.npy_path)
|
|
772
|
+
if summary is not None and summary.bandpass_envelopes:
|
|
773
|
+
return _plot_bandpass_from_envelopes(summary.bandpass_envelopes, summary.h5_path, tag)
|
|
774
|
+
|
|
775
|
+
h5_path = None
|
|
776
|
+
n_chans = 0
|
|
777
|
+
for record in records:
|
|
778
|
+
summary = summaries.get(record.npy_path)
|
|
779
|
+
if summary is not None and summary.h5_path and os.path.exists(summary.h5_path):
|
|
780
|
+
h5_path = summary.h5_path
|
|
781
|
+
n_chans = summary.nchans
|
|
782
|
+
break
|
|
783
|
+
if h5_path is None:
|
|
784
|
+
logger.info("Viz: no readable ON-source .h5 available; skipping bandpass_flattening")
|
|
785
|
+
return None
|
|
786
|
+
|
|
787
|
+
if n_chans <= 0:
|
|
788
|
+
# Header lacks nchans: fall back to the data width, mirroring preprocessing's
|
|
789
|
+
# int(header.get("nchans", data_shape[-1])) — detection processed such a cadence
|
|
790
|
+
# fine, so the figure must not silently lose it. Shape access reads h5 metadata
|
|
791
|
+
# only (no decompression), so plain h5py suffices.
|
|
792
|
+
with h5py.File(h5_path, "r") as hf:
|
|
793
|
+
n_chans = int(hf["data"].shape[-1])
|
|
794
|
+
|
|
795
|
+
n_coarse_total = n_chans // width
|
|
796
|
+
if n_coarse_total == 0:
|
|
797
|
+
logger.info(
|
|
798
|
+
f"Viz: file width ({n_chans} fine channels) is narrower than one coarse channel "
|
|
799
|
+
f"({width}); skipping bandpass_flattening"
|
|
800
|
+
)
|
|
801
|
+
return None
|
|
802
|
+
|
|
803
|
+
bandpass_flatten = preprocessor._get_bandpass_flattener(n_coarse_total)
|
|
804
|
+
pfb_active = bandpass_flatten.func is _pfb_flatten_bandpass
|
|
805
|
+
sampled = preprocessor._sample_channel_indices(n_coarse_total)
|
|
806
|
+
|
|
807
|
+
fig = Figure(figsize=(14, 3.2 * len(sampled)))
|
|
808
|
+
axes = fig.subplots(len(sampled), 2, squeeze=False)
|
|
809
|
+
for row, ch in enumerate(sampled):
|
|
810
|
+
channel = preprocessor._read_despiked_channel(h5_path, ch)
|
|
811
|
+
raw = channel.mean(axis=0)
|
|
812
|
+
flat = np.asarray(bandpass_flatten(channel)).mean(axis=0)
|
|
813
|
+
if pfb_active:
|
|
814
|
+
response = gen_coarse_channel_response(
|
|
815
|
+
width, n_coarse_total, config.inference.pfb_taps_per_channel
|
|
816
|
+
)
|
|
817
|
+
# Least-squares scale so the unit-peak response overlays the raw spectrum
|
|
818
|
+
overlay = response * (float(raw @ response) / float(response @ response))
|
|
819
|
+
overlay_label = "scaled PFB response H"
|
|
820
|
+
else:
|
|
821
|
+
overlay = _fit_channel_bandpass(raw, width, config.inference.spline_order)
|
|
822
|
+
overlay_label = "spline fit"
|
|
823
|
+
|
|
824
|
+
ax_raw, ax_flat = axes[row]
|
|
825
|
+
# Decimated to a min/max envelope: full-resolution lines are ~1M points each at
|
|
826
|
+
# GBT scale, which makes rendering slow and memory-heavy for no visual gain.
|
|
827
|
+
ax_raw.plot(
|
|
828
|
+
*_decimate_for_plot(raw), lw=0.6, color="tab:blue", label="raw integrated spectrum"
|
|
829
|
+
)
|
|
830
|
+
ax_raw.plot(
|
|
831
|
+
*_decimate_for_plot(overlay),
|
|
832
|
+
lw=1.2,
|
|
833
|
+
ls="--",
|
|
834
|
+
color="tab:orange",
|
|
835
|
+
label=overlay_label,
|
|
836
|
+
)
|
|
837
|
+
ax_raw.set_ylabel(f"coarse channel {ch}\nintegrated power")
|
|
838
|
+
ax_flat.plot(
|
|
839
|
+
*_decimate_for_plot(flat),
|
|
840
|
+
lw=0.6,
|
|
841
|
+
color="tab:green",
|
|
842
|
+
label="flattened integrated spectrum",
|
|
843
|
+
)
|
|
844
|
+
if row == 0:
|
|
845
|
+
ax_raw.legend(loc="upper right", fontsize=8)
|
|
846
|
+
ax_flat.legend(loc="upper right", fontsize=8)
|
|
847
|
+
if row == len(sampled) - 1:
|
|
848
|
+
ax_raw.set_xlabel("fine channel (within coarse channel)")
|
|
849
|
+
ax_flat.set_xlabel("fine channel (within coarse channel)")
|
|
850
|
+
|
|
851
|
+
method = "pfb" if pfb_active else "spline"
|
|
852
|
+
fig.suptitle(f"Bandpass flattening ({method}, {tag}): {os.path.basename(h5_path)}")
|
|
853
|
+
fig.tight_layout()
|
|
854
|
+
|
|
855
|
+
return _save_and_upload(fig, f"bandpass_flattening_{tag}.png", "Bandpass Flattening")
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _select_top_stamps(
|
|
859
|
+
records: list[CadenceVizRecord], summaries: dict[str, CadenceVizSummary], top_k: int
|
|
860
|
+
) -> list[tuple[CadenceVizRecord, int, float, float, tuple | None]]:
|
|
861
|
+
"""Pick the top_k stamps by detection statistic across all cadences, from the
|
|
862
|
+
per-cadence top-K representatives the reduce pass computed (_cadence_top_stamps —
|
|
863
|
+
overlap-offset copies already collapsed there). Selecting the global top-K from the
|
|
864
|
+
per-cadence top-Ks is exact: each cadence can contribute at most K entries. Returns
|
|
865
|
+
(record, snippet_index, statistic, frequency_mhz, freq_range) tuples, strongest
|
|
866
|
+
first."""
|
|
867
|
+
representatives: list[tuple[float, CadenceVizRecord, int, float, tuple | None]] = []
|
|
868
|
+
for record in records:
|
|
869
|
+
summary = summaries.get(record.npy_path)
|
|
870
|
+
if summary is None:
|
|
871
|
+
continue
|
|
872
|
+
for stat, idx, freq, freq_range in summary.gallery_reps:
|
|
873
|
+
representatives.append((stat, record, idx, freq, freq_range))
|
|
874
|
+
|
|
875
|
+
representatives.sort(key=lambda c: c[0], reverse=True)
|
|
876
|
+
return [
|
|
877
|
+
(record, idx, stat, freq, freq_range)
|
|
878
|
+
for stat, record, idx, freq, freq_range in representatives[:top_k]
|
|
879
|
+
]
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def plot_stamp_gallery(
|
|
883
|
+
records: list[CadenceVizRecord],
|
|
884
|
+
summaries: dict[str, CadenceVizSummary],
|
|
885
|
+
pixel_pool: dict[tuple[str, int], np.ndarray] | None = None,
|
|
886
|
+
) -> str | None:
|
|
887
|
+
"""Top-K stamps by detection statistic, each rendered as the 6-observation cadence
|
|
888
|
+
waterfall grid scientists actually inspect (ON/OFF rows, one stamp per column).
|
|
889
|
+
pixel_pool carries raw pixels the collector captured before pruning deleted their
|
|
890
|
+
.npy (#302) — the fallback when the direct load fails."""
|
|
891
|
+
config = get_config()
|
|
892
|
+
tag = config.checkpoint.save_tag
|
|
893
|
+
top_k = config.inference.stamp_gallery_top_k
|
|
894
|
+
|
|
895
|
+
selected = _select_top_stamps(records, summaries, top_k)
|
|
896
|
+
if not selected:
|
|
897
|
+
logger.info("Viz: no stamps available; skipping stamp_gallery")
|
|
898
|
+
return None
|
|
899
|
+
|
|
900
|
+
n_cols = len(selected)
|
|
901
|
+
n_rows = len(_OBS_ROW_LABELS)
|
|
902
|
+
fig = Figure(figsize=(1.9 * n_cols + 1.2, 1.1 * n_rows + 1.6))
|
|
903
|
+
axes = fig.subplots(n_rows, n_cols, squeeze=False)
|
|
904
|
+
|
|
905
|
+
for col, (record, idx, stat, freq, freq_range) in enumerate(selected):
|
|
906
|
+
try:
|
|
907
|
+
snippet = load_display_cadence(record.npy_path, idx)
|
|
908
|
+
except Exception as e:
|
|
909
|
+
pooled = (pixel_pool or {}).get((record.npy_path, idx))
|
|
910
|
+
if pooled is None:
|
|
911
|
+
logger.warning(f"Viz: failed to load stamp {idx} from {record.npy_path}: {e}")
|
|
912
|
+
for row in range(n_rows):
|
|
913
|
+
axes[row][col].set_axis_off()
|
|
914
|
+
continue
|
|
915
|
+
# Same display transform load_display_cadence applies to the raw stamp
|
|
916
|
+
snippet = log_norm(np.array(pooled, dtype=np.float32))
|
|
917
|
+
draw_cadence_strip(
|
|
918
|
+
[axes[row][col] for row in range(n_rows)],
|
|
919
|
+
snippet,
|
|
920
|
+
label_rows=col == 0,
|
|
921
|
+
freq_range_mhz=freq_range,
|
|
922
|
+
)
|
|
923
|
+
axes[0][col].set_title(
|
|
924
|
+
f"$k^2$={stat:.3g}\n{freq:.4f} MHz\n{_key_label(record.key, 20)}", fontsize=7
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
fig.suptitle(f"Top-{n_cols} energy-detection stamps by statistic ({tag})")
|
|
928
|
+
|
|
929
|
+
return _save_and_upload(fig, f"stamp_gallery_{tag}.png", "Stamp Gallery")
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def plot_preproc_funnel(
|
|
933
|
+
records: list[CadenceVizRecord], summaries: dict[str, CadenceVizSummary]
|
|
934
|
+
) -> str | None:
|
|
935
|
+
"""Per-cadence preprocessing funnel: raw hits → merged hits → stamps (incl. overlap
|
|
936
|
+
offsets) → snippets inferred, plus per-cadence stamp storage annotated on top. Past
|
|
937
|
+
_FUNNEL_MAX_CADENCES the strongest cadences (by raw hits) keep individual bars and
|
|
938
|
+
the rest aggregate into one summary bar (#301 — at 270 px/cadence the unbounded
|
|
939
|
+
figure exceeded Agg's 2^16-px canvas limit past 242 cadences, so every catalog-scale
|
|
940
|
+
render burned the artist work and then silently lost the figure)."""
|
|
941
|
+
tag = get_config().checkpoint.save_tag
|
|
942
|
+
|
|
943
|
+
labels: list[str] = []
|
|
944
|
+
stage_counts: list[tuple[int, int, int, int]] = []
|
|
945
|
+
storage_gb: list[float] = []
|
|
946
|
+
for record in records:
|
|
947
|
+
summary = summaries.get(record.npy_path)
|
|
948
|
+
n_raw = summary.n_raw_hits if summary else 0
|
|
949
|
+
n_merged = summary.n_merged_hits if summary else 0
|
|
950
|
+
n_stamps = (summary.n_stamp_rows if summary else 0) or record.n_stamps
|
|
951
|
+
labels.append(_key_label(record.key))
|
|
952
|
+
stage_counts.append((n_raw, n_merged, n_stamps, record.n_stamps))
|
|
953
|
+
size = summary.npy_size_bytes if summary else float("nan")
|
|
954
|
+
storage_gb.append(size / 1e9 if np.isfinite(size) else float("nan"))
|
|
955
|
+
|
|
956
|
+
if not stage_counts:
|
|
957
|
+
logger.info("Viz: no cadences recorded; skipping preproc_funnel")
|
|
958
|
+
return None
|
|
959
|
+
|
|
960
|
+
aggregated_note = ""
|
|
961
|
+
if len(stage_counts) > _FUNNEL_MAX_CADENCES:
|
|
962
|
+
order = np.argsort([c[0] for c in stage_counts])[::-1]
|
|
963
|
+
keep = set(order[:_FUNNEL_MAX_CADENCES].tolist())
|
|
964
|
+
rest = [i for i in range(len(stage_counts)) if i not in keep]
|
|
965
|
+
agg_counts = tuple(int(sum(stage_counts[i][j] for i in rest)) for j in range(4))
|
|
966
|
+
agg_storage = float(np.nansum([storage_gb[i] for i in rest]))
|
|
967
|
+
# Kept cadences stay in catalog order; the aggregate bar closes the figure
|
|
968
|
+
kept = sorted(keep)
|
|
969
|
+
labels = [labels[i] for i in kept] + [f"+{len(rest)} more (aggregated)"]
|
|
970
|
+
stage_counts = [stage_counts[i] for i in kept] + [agg_counts]
|
|
971
|
+
storage_gb = [storage_gb[i] for i in kept] + [agg_storage]
|
|
972
|
+
aggregated_note = f" — top {_FUNNEL_MAX_CADENCES} by raw hits, {len(rest)} aggregated"
|
|
973
|
+
|
|
974
|
+
stages = ("raw hits", "merged hits", "stamps (+overlap)", "snippets inferred")
|
|
975
|
+
counts = np.asarray(stage_counts, dtype=np.float64) # (n_cadences, 4)
|
|
976
|
+
n_cadences = counts.shape[0]
|
|
977
|
+
x = np.arange(n_cadences)
|
|
978
|
+
bar_width = 0.8 / len(stages)
|
|
979
|
+
|
|
980
|
+
fig = Figure(figsize=(max(8.0, 1.8 * n_cadences), 6))
|
|
981
|
+
ax = fig.subplots()
|
|
982
|
+
for stage_idx, stage in enumerate(stages):
|
|
983
|
+
ax.bar(
|
|
984
|
+
x + (stage_idx - (len(stages) - 1) / 2) * bar_width,
|
|
985
|
+
counts[:, stage_idx],
|
|
986
|
+
width=bar_width,
|
|
987
|
+
label=stage,
|
|
988
|
+
)
|
|
989
|
+
for i in range(n_cadences):
|
|
990
|
+
top = np.nanmax(counts[i]) if np.any(np.isfinite(counts[i])) else 0
|
|
991
|
+
ax.annotate(
|
|
992
|
+
f"{storage_gb[i]:.2f} GB",
|
|
993
|
+
xy=(x[i], top),
|
|
994
|
+
xytext=(0, 6),
|
|
995
|
+
textcoords="offset points",
|
|
996
|
+
ha="center",
|
|
997
|
+
fontsize=8,
|
|
998
|
+
)
|
|
999
|
+
ax.set_yscale("log")
|
|
1000
|
+
ax.set_xticks(x)
|
|
1001
|
+
ax.set_xticklabels(labels, rotation=20, ha="right", fontsize=8)
|
|
1002
|
+
ax.set_ylabel("count")
|
|
1003
|
+
ax.set_title(
|
|
1004
|
+
f"Preprocessing funnel per cadence ({tag}) — stamp storage annotated{aggregated_note}"
|
|
1005
|
+
)
|
|
1006
|
+
ax.legend(fontsize=8)
|
|
1007
|
+
ax.grid(True, axis="y", alpha=0.2)
|
|
1008
|
+
|
|
1009
|
+
return _save_and_upload(fig, f"preproc_funnel_{tag}.png", "Preprocessing Funnel")
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
# ---------------------------------------------------------------------------
|
|
1013
|
+
# Per-run figures: inference / anomaly detection
|
|
1014
|
+
# ---------------------------------------------------------------------------
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def plot_confidence_distribution(records: list[CadenceVizRecord]) -> str | None:
|
|
1018
|
+
"""P(true) histogram over all snippets inferred this pass (log-y), classification
|
|
1019
|
+
threshold marked, per-cadence overlay when the pass covered ≤ 10 cadences. Cadences
|
|
1020
|
+
skipped by the resume are excluded (their confidence vectors were transient)."""
|
|
1021
|
+
config = get_config()
|
|
1022
|
+
tag = config.checkpoint.save_tag
|
|
1023
|
+
threshold = config.inference.classification_threshold
|
|
1024
|
+
|
|
1025
|
+
with_hist = [r for r in records if r.confidence_hist is not None]
|
|
1026
|
+
if not with_hist:
|
|
1027
|
+
logger.info("Viz: no confidence histograms collected; skipping confidence_distribution")
|
|
1028
|
+
return None
|
|
1029
|
+
|
|
1030
|
+
centers = (CONFIDENCE_HIST_EDGES[:-1] + CONFIDENCE_HIST_EDGES[1:]) / 2
|
|
1031
|
+
total = np.sum([r.confidence_hist for r in with_hist], axis=0)
|
|
1032
|
+
|
|
1033
|
+
fig = Figure(figsize=(10, 6))
|
|
1034
|
+
ax = fig.subplots()
|
|
1035
|
+
ax.step(centers, total, where="mid", lw=1.8, color="black", label="all snippets")
|
|
1036
|
+
if len(with_hist) <= 10:
|
|
1037
|
+
for record in with_hist:
|
|
1038
|
+
ax.step(
|
|
1039
|
+
centers,
|
|
1040
|
+
record.confidence_hist,
|
|
1041
|
+
where="mid",
|
|
1042
|
+
lw=0.9,
|
|
1043
|
+
alpha=0.7,
|
|
1044
|
+
label=_key_label(record.key, 22),
|
|
1045
|
+
)
|
|
1046
|
+
ax.axvline(threshold, color="red", ls="--", lw=1.2, label=f"threshold ({threshold:g})")
|
|
1047
|
+
ax.set_yscale("log")
|
|
1048
|
+
ax.set_xlabel("P(true) — Random Forest")
|
|
1049
|
+
ax.set_ylabel("snippet count")
|
|
1050
|
+
n_skipped = len(records) - len(with_hist)
|
|
1051
|
+
subtitle = f"{int(total.sum()):,} snippets over {len(with_hist)} cadence(s)"
|
|
1052
|
+
if n_skipped:
|
|
1053
|
+
subtitle += f" ({n_skipped} cadence(s) resumed earlier, not shown)"
|
|
1054
|
+
ax.set_title(f"Snippet confidence distribution ({tag})\n{subtitle}")
|
|
1055
|
+
ax.legend(fontsize=8)
|
|
1056
|
+
ax.grid(True, alpha=0.2)
|
|
1057
|
+
|
|
1058
|
+
return _save_and_upload(fig, f"confidence_distribution_{tag}.png", "Confidence Distribution")
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def plot_candidate(row: dict, index: int) -> str | None:
|
|
1062
|
+
"""One candidate's full picture (implements the long-standing inference.py stub):
|
|
1063
|
+
6-panel cadence waterfall of its stamp, annotated with confidence / frequency /
|
|
1064
|
+
target / session / band, plus the 48-dim latent vector as a bar chart. Thin wrapper
|
|
1065
|
+
over the TF-free candidate_figures.render_candidate_figure (#298 I9 — the gallery path
|
|
1066
|
+
renders these across a forkserver pool; this in-process form serves direct callers)."""
|
|
1067
|
+
tag = get_config().checkpoint.save_tag
|
|
1068
|
+
save_path = render_candidate_figure(
|
|
1069
|
+
row, index, tag, _plots_dir(tag), candidate_frequency_range_mhz(row)
|
|
1070
|
+
)
|
|
1071
|
+
logger.info(f"Inference viz saved: {save_path}")
|
|
1072
|
+
_uploader.submit(save_path, f"Candidate {index} - ({tag}, {socket.gethostname()})")
|
|
1073
|
+
return save_path
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def plot_candidate_gallery() -> str | None:
|
|
1077
|
+
"""Gallery of the top candidates by confidence (6-obs waterfall strips) plus capped
|
|
1078
|
+
per-candidate figures, sourced from the inference_results table so it also covers
|
|
1079
|
+
cadences the resume skipped this pass."""
|
|
1080
|
+
config = get_config()
|
|
1081
|
+
tag = config.checkpoint.save_tag
|
|
1082
|
+
max_candidate_plots = config.inference.max_candidate_plots
|
|
1083
|
+
|
|
1084
|
+
db = get_db()
|
|
1085
|
+
if db is None:
|
|
1086
|
+
logger.info("Viz: no database instance; skipping candidate plots")
|
|
1087
|
+
return None
|
|
1088
|
+
db.flush()
|
|
1089
|
+
rows = db.query_inference_result(tag=tag, prediction=1)
|
|
1090
|
+
if not rows:
|
|
1091
|
+
logger.info("Viz: no candidates recorded; skipping candidate plots")
|
|
1092
|
+
return None
|
|
1093
|
+
rows.sort(key=lambda r: r.get("confidence") or 0.0, reverse=True)
|
|
1094
|
+
|
|
1095
|
+
# Per-candidate figures (highest confidence first, capped): rendered across the
|
|
1096
|
+
# forkserver pool (#298 I9 — independent row dict + memmap read + PNG each; per-figure
|
|
1097
|
+
# failures return None and degrade the suite exactly like _viz_safe), then uploaded in
|
|
1098
|
+
# index order through the async FIFO uploader.
|
|
1099
|
+
top_rows = rows[:max_candidate_plots]
|
|
1100
|
+
rendered = render_candidate_figures(top_rows, tag, _plots_dir(tag))
|
|
1101
|
+
hostname = socket.gethostname()
|
|
1102
|
+
for index, save_path in rendered:
|
|
1103
|
+
if save_path is None:
|
|
1104
|
+
continue
|
|
1105
|
+
logger.info(f"Inference viz saved: {save_path}")
|
|
1106
|
+
_uploader.submit(save_path, f"Candidate {index} - ({tag}, {hostname})")
|
|
1107
|
+
if len(rows) > max_candidate_plots:
|
|
1108
|
+
logger.info(
|
|
1109
|
+
f"Viz: rendered {max_candidate_plots} of {len(rows)} candidate figures "
|
|
1110
|
+
f"(--max-candidate-plots cap)"
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
gallery_rows = rows[:_CANDIDATE_GALLERY_MAX]
|
|
1114
|
+
n_cols = len(gallery_rows)
|
|
1115
|
+
n_rows = len(_OBS_ROW_LABELS)
|
|
1116
|
+
fig = Figure(figsize=(1.9 * n_cols + 1.2, 1.1 * n_rows + 1.6))
|
|
1117
|
+
axes = fig.subplots(n_rows, n_cols, squeeze=False)
|
|
1118
|
+
for col, row in enumerate(gallery_rows):
|
|
1119
|
+
try:
|
|
1120
|
+
snippet = load_display_cadence(str(row["npy_path"]), int(row["snippet_index"]))
|
|
1121
|
+
except Exception as e:
|
|
1122
|
+
logger.warning(f"Viz: failed to load candidate stamp ({e})")
|
|
1123
|
+
for r in range(n_rows):
|
|
1124
|
+
axes[r][col].set_axis_off()
|
|
1125
|
+
continue
|
|
1126
|
+
draw_cadence_strip(
|
|
1127
|
+
[axes[r][col] for r in range(n_rows)],
|
|
1128
|
+
snippet,
|
|
1129
|
+
label_rows=col == 0,
|
|
1130
|
+
freq_range_mhz=candidate_frequency_range_mhz(row),
|
|
1131
|
+
)
|
|
1132
|
+
freq = row.get("frequency_mhz")
|
|
1133
|
+
freq_label = f"{freq:.4f} MHz" if freq is not None else "freq n/a"
|
|
1134
|
+
axes[0][col].set_title(
|
|
1135
|
+
f"P={row.get('confidence', 0):.3f}\n{freq_label}\n{row.get('target') or ''}",
|
|
1136
|
+
fontsize=7,
|
|
1137
|
+
)
|
|
1138
|
+
fig.suptitle(f"Candidate gallery ({tag}): top {n_cols} of {len(rows)} by confidence")
|
|
1139
|
+
|
|
1140
|
+
return _save_and_upload(fig, f"candidate_gallery_{tag}.png", "Candidate Gallery")
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def plot_candidate_uncertainty() -> str | None:
|
|
1144
|
+
"""
|
|
1145
|
+
Candidate uncertainty view (#282): x = final RF probability (MC mean), y = MC spread,
|
|
1146
|
+
each candidate bold red over a hexbin density background of the survey's reference
|
|
1147
|
+
cloud (the seeded uniform subsample of pass-1 rejects persisted by
|
|
1148
|
+
finalize_reference_cloud). Population context is the whole point: "p=0.97,
|
|
1149
|
+
spread=0.05" is only interpretable against where the survey sits — and the dangerous
|
|
1150
|
+
quadrant (high p, high spread: a mean that looks confident while draws swing) is
|
|
1151
|
+
exactly what p alone cannot flag. The science threshold is drawn as a vertical line.
|
|
1152
|
+
"""
|
|
1153
|
+
config = get_config()
|
|
1154
|
+
tag = config.checkpoint.save_tag
|
|
1155
|
+
|
|
1156
|
+
db = get_db()
|
|
1157
|
+
if db is None:
|
|
1158
|
+
logger.info("Viz: no database instance; skipping candidate uncertainty plot")
|
|
1159
|
+
return None
|
|
1160
|
+
db.flush()
|
|
1161
|
+
rows = db.query_inference_result(
|
|
1162
|
+
tag=tag, prediction=1, columns=["mc_mean", "mc_std", "target", "frequency_mhz"]
|
|
1163
|
+
)
|
|
1164
|
+
rows = [r for r in rows if r.get("mc_mean") is not None and r.get("mc_std") is not None]
|
|
1165
|
+
|
|
1166
|
+
cloud_path = os.path.join(config.output_path, f"inference_reference_cloud_{tag}.npz")
|
|
1167
|
+
cloud = None
|
|
1168
|
+
if os.path.exists(cloud_path):
|
|
1169
|
+
# Materialize the arrays and close the archive immediately — an NpzFile keeps its
|
|
1170
|
+
# file handle open until GC, which would leak on this function's early returns
|
|
1171
|
+
with np.load(cloud_path) as cloud_npz:
|
|
1172
|
+
cloud = {key: cloud_npz[key] for key in cloud_npz.files}
|
|
1173
|
+
elif config.inference.reference_cloud_size > 0:
|
|
1174
|
+
logger.warning(
|
|
1175
|
+
f"Viz: reference cloud {cloud_path} not found — the candidate uncertainty plot "
|
|
1176
|
+
"will lack the survey background (candidates would only be compared against "
|
|
1177
|
+
"each other)"
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
if not rows and cloud is None:
|
|
1181
|
+
logger.info("Viz: no MC-scored candidates and no reference cloud; skipping plot")
|
|
1182
|
+
return None
|
|
1183
|
+
|
|
1184
|
+
fig = Figure(figsize=(9, 7))
|
|
1185
|
+
ax = fig.subplots(1, 1)
|
|
1186
|
+
|
|
1187
|
+
if cloud is not None and len(cloud["mc_mean"]) > 0:
|
|
1188
|
+
hb = ax.hexbin(
|
|
1189
|
+
cloud["mc_mean"],
|
|
1190
|
+
cloud["mc_std"],
|
|
1191
|
+
gridsize=60,
|
|
1192
|
+
bins="log",
|
|
1193
|
+
cmap="Greys",
|
|
1194
|
+
mincnt=1,
|
|
1195
|
+
)
|
|
1196
|
+
fig.colorbar(hb, ax=ax, label="survey reference density (log count)")
|
|
1197
|
+
|
|
1198
|
+
if rows:
|
|
1199
|
+
ax.scatter(
|
|
1200
|
+
[r["mc_mean"] for r in rows],
|
|
1201
|
+
[r["mc_std"] for r in rows],
|
|
1202
|
+
c="red",
|
|
1203
|
+
s=60,
|
|
1204
|
+
marker="*",
|
|
1205
|
+
zorder=5,
|
|
1206
|
+
label=f"candidates ({len(rows)})",
|
|
1207
|
+
)
|
|
1208
|
+
|
|
1209
|
+
threshold = config.inference.classification_threshold
|
|
1210
|
+
ax.axvline(threshold, color="tab:blue", linestyle="--", linewidth=1.2, alpha=0.8)
|
|
1211
|
+
ax.text(
|
|
1212
|
+
threshold,
|
|
1213
|
+
ax.get_ylim()[1],
|
|
1214
|
+
f" science threshold = {threshold}",
|
|
1215
|
+
color="tab:blue",
|
|
1216
|
+
fontsize=8,
|
|
1217
|
+
va="top",
|
|
1218
|
+
)
|
|
1219
|
+
|
|
1220
|
+
ax.set_xlabel(
|
|
1221
|
+
"RF probability (MC mean"
|
|
1222
|
+
+ (", calibrated)" if config.rf.calibration_active else ", uncalibrated)")
|
|
1223
|
+
)
|
|
1224
|
+
ax.set_ylabel("MC spread (std of draw probabilities)")
|
|
1225
|
+
cloud_note = ""
|
|
1226
|
+
if cloud is not None:
|
|
1227
|
+
cloud_note = (
|
|
1228
|
+
f" — cloud: {int(cloud['subsample_size'])} of {int(cloud['rejects_seen'])} "
|
|
1229
|
+
f"rejects, {int(cloud['mc_draws'])} draws"
|
|
1230
|
+
)
|
|
1231
|
+
ax.set_title(f"Candidate uncertainty vs survey population ({tag}){cloud_note}", fontsize=11)
|
|
1232
|
+
if rows:
|
|
1233
|
+
ax.legend(loc="upper left", fontsize=8)
|
|
1234
|
+
|
|
1235
|
+
return _save_and_upload(
|
|
1236
|
+
fig, f"candidate_uncertainty_{tag}.png", "Candidate Uncertainty vs Survey"
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def plot_inference_latent_projection(collector: InferenceVizCollector) -> str | None:
|
|
1241
|
+
"""Project this run's cadence-level latent features through the persisted cadence-level
|
|
1242
|
+
UMAP from the training run (located via the training config JSON's model_path +
|
|
1243
|
+
save_tag), over a faint background of the UMAP's training embedding. Answers "where does
|
|
1244
|
+
real data live relative to the synthetic training classes". Skips gracefully (with a
|
|
1245
|
+
log) when the training config or a persisted UMAP is unavailable."""
|
|
1246
|
+
import joblib # noqa: PLC0415 # deferred: only this figure needs it
|
|
1247
|
+
|
|
1248
|
+
config = get_config()
|
|
1249
|
+
tag = config.checkpoint.save_tag
|
|
1250
|
+
|
|
1251
|
+
features, is_candidate = collector.latent_pool()
|
|
1252
|
+
if features.size == 0:
|
|
1253
|
+
logger.info("Viz: no latent features collected this pass; skipping latent projection")
|
|
1254
|
+
return None
|
|
1255
|
+
|
|
1256
|
+
training_config_path = config.inference.config_path
|
|
1257
|
+
if not training_config_path or not os.path.exists(training_config_path):
|
|
1258
|
+
logger.info(
|
|
1259
|
+
f"Viz: training config JSON not available ({training_config_path}); "
|
|
1260
|
+
f"skipping latent projection"
|
|
1261
|
+
)
|
|
1262
|
+
return None
|
|
1263
|
+
try:
|
|
1264
|
+
with open(training_config_path) as f:
|
|
1265
|
+
training_config = json.load(f)
|
|
1266
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
1267
|
+
logger.info(f"Viz: could not read training config ({e}); skipping latent projection")
|
|
1268
|
+
return None
|
|
1269
|
+
|
|
1270
|
+
model_path = (training_config.get("paths") or {}).get("model_path")
|
|
1271
|
+
train_tag = (training_config.get("checkpoint") or {}).get("save_tag")
|
|
1272
|
+
training_section = training_config.get("training") or {}
|
|
1273
|
+
nn_values = training_section.get("latent_viz_umap_n_neighbors") or [15]
|
|
1274
|
+
md_values = training_section.get("latent_viz_umap_min_dist") or [0.1]
|
|
1275
|
+
if not model_path or not train_tag:
|
|
1276
|
+
logger.info("Viz: training config lacks model_path/save_tag; skipping latent projection")
|
|
1277
|
+
return None
|
|
1278
|
+
|
|
1279
|
+
umap_path = None
|
|
1280
|
+
nn = md = None
|
|
1281
|
+
for candidate_nn in nn_values:
|
|
1282
|
+
for candidate_md in md_values:
|
|
1283
|
+
path = os.path.join(
|
|
1284
|
+
model_path, f"umap_cadence_nn{candidate_nn}_md{candidate_md}_{train_tag}.joblib"
|
|
1285
|
+
)
|
|
1286
|
+
if os.path.exists(path):
|
|
1287
|
+
umap_path, nn, md = path, candidate_nn, candidate_md
|
|
1288
|
+
break
|
|
1289
|
+
if umap_path:
|
|
1290
|
+
break
|
|
1291
|
+
if umap_path is None:
|
|
1292
|
+
logger.info(
|
|
1293
|
+
f"Viz: no persisted cadence-level UMAP found under {model_path} for tag "
|
|
1294
|
+
f"{train_tag}; skipping latent projection"
|
|
1295
|
+
)
|
|
1296
|
+
return None
|
|
1297
|
+
|
|
1298
|
+
logger.info(f"Viz: projecting {features.shape[0]} latent features through {umap_path}")
|
|
1299
|
+
umap_model = joblib.load(umap_path)
|
|
1300
|
+
embedding = umap_model.transform(features)
|
|
1301
|
+
|
|
1302
|
+
fig = Figure(figsize=(9, 8))
|
|
1303
|
+
ax = fig.subplots()
|
|
1304
|
+
# The training fit pool's class labels are not persisted, so the training embedding is
|
|
1305
|
+
# rendered as an unlabeled backdrop — it still shows where the synthetic classes live.
|
|
1306
|
+
background = getattr(umap_model, "embedding_", None)
|
|
1307
|
+
if background is not None and len(background):
|
|
1308
|
+
ax.scatter(
|
|
1309
|
+
background[:, 0],
|
|
1310
|
+
background[:, 1],
|
|
1311
|
+
s=3,
|
|
1312
|
+
c="lightgray",
|
|
1313
|
+
alpha=0.35,
|
|
1314
|
+
linewidths=0,
|
|
1315
|
+
label=f"training embedding ({train_tag})",
|
|
1316
|
+
rasterized=True,
|
|
1317
|
+
)
|
|
1318
|
+
non_candidates = ~is_candidate
|
|
1319
|
+
if non_candidates.any():
|
|
1320
|
+
ax.scatter(
|
|
1321
|
+
embedding[non_candidates, 0],
|
|
1322
|
+
embedding[non_candidates, 1],
|
|
1323
|
+
s=6,
|
|
1324
|
+
c="tab:blue",
|
|
1325
|
+
alpha=0.5,
|
|
1326
|
+
linewidths=0,
|
|
1327
|
+
label=f"snippets ({int(non_candidates.sum()):,})",
|
|
1328
|
+
rasterized=True,
|
|
1329
|
+
)
|
|
1330
|
+
if is_candidate.any():
|
|
1331
|
+
ax.scatter(
|
|
1332
|
+
embedding[is_candidate, 0],
|
|
1333
|
+
embedding[is_candidate, 1],
|
|
1334
|
+
s=42,
|
|
1335
|
+
c="red",
|
|
1336
|
+
marker="*",
|
|
1337
|
+
edgecolors="black",
|
|
1338
|
+
linewidths=0.4,
|
|
1339
|
+
label=f"candidates ({int(is_candidate.sum())})",
|
|
1340
|
+
)
|
|
1341
|
+
ax.set_xlabel("UMAP 1")
|
|
1342
|
+
ax.set_ylabel("UMAP 2")
|
|
1343
|
+
ax.set_title(
|
|
1344
|
+
f"Inference latents through training cadence-level UMAP ({tag})\n"
|
|
1345
|
+
f"nn={nn}, md={md}, training tag {train_tag}"
|
|
1346
|
+
)
|
|
1347
|
+
ax.legend(fontsize=8, loc="best")
|
|
1348
|
+
|
|
1349
|
+
return _save_and_upload(
|
|
1350
|
+
fig, f"inference_latent_projection_{tag}.png", "Inference Latent Projection"
|
|
1351
|
+
)
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def plot_inference_summary(
|
|
1355
|
+
records: list[CadenceVizRecord], summaries: dict[str, CadenceVizSummary], totals: dict
|
|
1356
|
+
) -> str | None:
|
|
1357
|
+
"""Table-style run summary card: cadence/snippet/candidate counts, per-stage durations
|
|
1358
|
+
and throughput from the inference_cadences manifest, and per-target/band candidate
|
|
1359
|
+
counts from inference_results."""
|
|
1360
|
+
config = get_config()
|
|
1361
|
+
tag = config.checkpoint.save_tag
|
|
1362
|
+
|
|
1363
|
+
db = get_db()
|
|
1364
|
+
preproc_duration = inference_duration = 0.0
|
|
1365
|
+
if db is not None:
|
|
1366
|
+
db.flush()
|
|
1367
|
+
# NOTE: include_superseded=True is required here: _infer_cadence supersedes a
|
|
1368
|
+
# cadence's 'preprocessed' row before writing its live 'inferred' row, so on a fully
|
|
1369
|
+
# successful run every 'preprocessed' row is superseded and the default query would
|
|
1370
|
+
# hide it (preprocessing time would always read 0.0 s). Summing per status keeps each
|
|
1371
|
+
# metric on its own row — no double-counting between the 'preprocessed' and 'inferred'
|
|
1372
|
+
# rows of the same cadence.
|
|
1373
|
+
for row in db.query_inference_cadences(tag=tag, include_superseded=True):
|
|
1374
|
+
duration = row.get("duration_s") or 0.0
|
|
1375
|
+
if row.get("status") == "preprocessed":
|
|
1376
|
+
preproc_duration += duration
|
|
1377
|
+
elif row.get("status") == "inferred":
|
|
1378
|
+
inference_duration += duration
|
|
1379
|
+
|
|
1380
|
+
n_snippets = int(totals.get("n_cadence_snippets", 0))
|
|
1381
|
+
n_raw_hits = sum(s.n_raw_hits for s in summaries.values())
|
|
1382
|
+
n_merged_hits = sum(s.n_merged_hits for s in summaries.values())
|
|
1383
|
+
# File sizes were stat()ed once by the reduce pass — no second walk over the catalog
|
|
1384
|
+
storage_gb = float(
|
|
1385
|
+
np.nansum([s.npy_size_bytes for s in summaries.values()]) / 1e9 if summaries else 0.0
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
summary_rows = [
|
|
1389
|
+
("run tag", tag),
|
|
1390
|
+
("rendered", time.strftime("%Y-%m-%d %H:%M:%S")),
|
|
1391
|
+
("cadences", f"{totals.get('n_cadences', len(records))}"),
|
|
1392
|
+
(" resumed (skipped this pass)", f"{totals.get('n_skipped', 0)}"),
|
|
1393
|
+
("raw ED hits", f"{n_raw_hits:,}"),
|
|
1394
|
+
("merged hits", f"{n_merged_hits:,}"),
|
|
1395
|
+
("snippets inferred", f"{n_snippets:,}"),
|
|
1396
|
+
("candidates", f"{totals.get('n_candidates', 0):,}"),
|
|
1397
|
+
("stamp storage", f"{storage_gb:.2f} GB"),
|
|
1398
|
+
("preprocessing time", f"{preproc_duration:,.1f} s"),
|
|
1399
|
+
("inference time", f"{inference_duration:,.1f} s"),
|
|
1400
|
+
(
|
|
1401
|
+
"throughput",
|
|
1402
|
+
f"{n_snippets / inference_duration:,.1f} snippets/s"
|
|
1403
|
+
if inference_duration > 0
|
|
1404
|
+
else "n/a",
|
|
1405
|
+
),
|
|
1406
|
+
]
|
|
1407
|
+
|
|
1408
|
+
per_group: Counter = Counter()
|
|
1409
|
+
if db is not None:
|
|
1410
|
+
candidate_rows = db.query_inference_result(
|
|
1411
|
+
tag=tag, prediction=1, columns=["target", "band"]
|
|
1412
|
+
)
|
|
1413
|
+
for row in candidate_rows:
|
|
1414
|
+
per_group[(row.get("target") or "?", row.get("band") or "?")] += 1
|
|
1415
|
+
|
|
1416
|
+
fig = Figure(figsize=(8.5, 0.42 * (len(summary_rows) + min(len(per_group), 10)) + 2.5))
|
|
1417
|
+
ax = fig.subplots()
|
|
1418
|
+
ax.set_axis_off()
|
|
1419
|
+
|
|
1420
|
+
lines = [f"{name:<32} {value}" for name, value in summary_rows]
|
|
1421
|
+
if per_group:
|
|
1422
|
+
lines.append("")
|
|
1423
|
+
lines.append("candidates per target/band:")
|
|
1424
|
+
for (target, band), count in per_group.most_common(10):
|
|
1425
|
+
lines.append(f" {target} [{band}]: {count}")
|
|
1426
|
+
ax.text(
|
|
1427
|
+
0.02,
|
|
1428
|
+
0.98,
|
|
1429
|
+
"\n".join(lines),
|
|
1430
|
+
va="top",
|
|
1431
|
+
ha="left",
|
|
1432
|
+
fontsize=10,
|
|
1433
|
+
family="monospace",
|
|
1434
|
+
transform=ax.transAxes,
|
|
1435
|
+
)
|
|
1436
|
+
ax.set_title(f"Inference run summary ({tag})", fontsize=13, pad=14)
|
|
1437
|
+
|
|
1438
|
+
return _save_and_upload(fig, f"inference_summary_{tag}.png", "Inference Summary")
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
# ---------------------------------------------------------------------------
|
|
1442
|
+
# Suite entry point
|
|
1443
|
+
# ---------------------------------------------------------------------------
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
def render_inference_visualizations(
|
|
1447
|
+
collector: InferenceVizCollector, preprocessor, totals: dict
|
|
1448
|
+
) -> None:
|
|
1449
|
+
"""Render every figure of the suite, each individually exception-guarded. Called by
|
|
1450
|
+
main._run_streaming_csv_inference after a fully successful pass (and gated on
|
|
1451
|
+
config.inference.inference_viz_enabled by the caller)."""
|
|
1452
|
+
config = get_config()
|
|
1453
|
+
if config is None:
|
|
1454
|
+
raise ValueError("get_config() returned None")
|
|
1455
|
+
tag = config.checkpoint.save_tag
|
|
1456
|
+
logger.info(f"Rendering inference visualization suite under plots/inference/{tag}/")
|
|
1457
|
+
|
|
1458
|
+
records = collector.records
|
|
1459
|
+
if config.inference.inference_viz_scope == "new":
|
|
1460
|
+
# Scope 'new' (#301): resumed passes on an accumulating tag re-rendered the FULL
|
|
1461
|
+
# catalog's figures every pass. This renders only cadences inferred THIS pass;
|
|
1462
|
+
# the DB-sourced candidate figures below still cover the whole tag, and the final
|
|
1463
|
+
# pass can render everything with the 'full' default.
|
|
1464
|
+
n_before = len(records)
|
|
1465
|
+
records = [record for record in records if not record.skipped]
|
|
1466
|
+
logger.info(
|
|
1467
|
+
f"Viz scope 'new': rendering {len(records)} of {n_before} recorded cadence(s) "
|
|
1468
|
+
f"(resumed cadences excluded; candidate figures still cover the full tag)"
|
|
1469
|
+
)
|
|
1470
|
+
|
|
1471
|
+
with stage_timer("load_metadata"):
|
|
1472
|
+
summaries = _build_summaries(records, config.inference.stamp_gallery_top_k)
|
|
1473
|
+
|
|
1474
|
+
try:
|
|
1475
|
+
_viz_safe("ed_stat_distributions", plot_ed_stat_distributions, records, summaries)
|
|
1476
|
+
_viz_safe("ed_hit_spectrum", plot_ed_hit_spectrum, records, summaries)
|
|
1477
|
+
_viz_safe("bandpass_flattening", plot_bandpass_flattening, preprocessor, records, summaries)
|
|
1478
|
+
_viz_safe(
|
|
1479
|
+
"stamp_gallery", plot_stamp_gallery, records, summaries, collector.gallery_pixels()
|
|
1480
|
+
)
|
|
1481
|
+
_viz_safe("preproc_funnel", plot_preproc_funnel, records, summaries)
|
|
1482
|
+
_viz_safe("confidence_distribution", plot_confidence_distribution, records)
|
|
1483
|
+
_viz_safe("candidate_gallery", plot_candidate_gallery)
|
|
1484
|
+
_viz_safe("candidate_uncertainty", plot_candidate_uncertainty)
|
|
1485
|
+
_viz_safe("inference_latent_projection", plot_inference_latent_projection, collector)
|
|
1486
|
+
_viz_safe("inference_summary", plot_inference_summary, records, summaries, totals)
|
|
1487
|
+
finally:
|
|
1488
|
+
# The async uploader must be empty before the caller reaches logger teardown
|
|
1489
|
+
# (#298 I9) — uploads queued by any figure above are flushed here even when a
|
|
1490
|
+
# figure raised through _viz_safe's own guard
|
|
1491
|
+
with stage_timer("upload_drain"):
|
|
1492
|
+
_uploader.drain()
|
|
1493
|
+
|
|
1494
|
+
logger.info("Inference visualization suite complete")
|