aetherscan 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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")