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,1448 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Synthetic data generation for Aetherscan Pipeline
|
|
3
|
+
Handles signal injection & log-normalization for training
|
|
4
|
+
Uses multiprocessing and shared memory to process data in parallel
|
|
5
|
+
|
|
6
|
+
Generated rounds are written straight into disk-backed .npy memmaps (see
|
|
7
|
+
aetherscan.round_data): each pool task generates a batch of cadences and writes them
|
|
8
|
+
into its slice of the round's memmap in-place, returning only the small per-sample
|
|
9
|
+
stats dicts — no more per-sample IPC pickling or ~294 GB in-RAM round arrays.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import gc
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import random
|
|
19
|
+
import shutil
|
|
20
|
+
import signal
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from multiprocessing import Pool
|
|
24
|
+
from multiprocessing.shared_memory import SharedMemory
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
import setigen as stg
|
|
28
|
+
from astropy import units as u
|
|
29
|
+
from scipy import stats as scipy_stats
|
|
30
|
+
|
|
31
|
+
from aetherscan.config import get_config
|
|
32
|
+
from aetherscan.db import get_db
|
|
33
|
+
from aetherscan.logger import init_worker_logging
|
|
34
|
+
from aetherscan.manager import get_manager
|
|
35
|
+
from aetherscan.round_data import RoundDataPaths, build_manifest, write_done_manifest
|
|
36
|
+
from aetherscan.seeding import STREAM_DATA_GEN, derive_rng
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
# NOTE: find a way to avoid using global refs (store under manager.py maybe?)
|
|
41
|
+
# Global variables to store background data for multiprocessing workers
|
|
42
|
+
# This avoids serialization overhead when passing data between workers
|
|
43
|
+
_GLOBAL_SHM = None
|
|
44
|
+
_GLOBAL_BACKGROUNDS = None
|
|
45
|
+
_GLOBAL_SHAPE = None
|
|
46
|
+
_GLOBAL_DTYPE = None
|
|
47
|
+
|
|
48
|
+
# Per-process memo of Stage-A (raw-background) intensity stats, keyed by background_index.
|
|
49
|
+
# Only consulted on the worker path, where the plate handed to create_* IS this immutable
|
|
50
|
+
# shared-memory block — see _stage_a_stats. Cleared whenever a worker (re)attaches its
|
|
51
|
+
# backgrounds so a recycled process can never serve a previous block's stats.
|
|
52
|
+
_STAGE_A_STATS_CACHE: dict[int, dict[str, float]] = {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _init_worker(shm_name, shape, dtype, log_queue=None):
|
|
56
|
+
"""
|
|
57
|
+
Worker pool initializer: attach to the named shared-memory block holding the background
|
|
58
|
+
plates, seed numpy/random from the worker PID so each process gets a distinct RNG state, and
|
|
59
|
+
set up logging.
|
|
60
|
+
|
|
61
|
+
Passing shm_name/shape/dtype through the pool initializer (rather than per-task args) avoids
|
|
62
|
+
re-serializing the array on every map() call. `log_queue` must be passed explicitly for
|
|
63
|
+
pools whose parent process was spawn-started (the RoundDataProducer's — no inherited Logger
|
|
64
|
+
singleton there); fork-started pools omit it and inherit the singleton's queue. The worker
|
|
65
|
+
installs a SIGTERM handler that closes its shared-memory file descriptor before letting the
|
|
66
|
+
signal kill the process; the main process is responsible for unlinking shared memory
|
|
67
|
+
afterwards (handled by ResourceManager).
|
|
68
|
+
"""
|
|
69
|
+
global _GLOBAL_SHM, _GLOBAL_BACKGROUNDS, _GLOBAL_SHAPE, _GLOBAL_DTYPE
|
|
70
|
+
|
|
71
|
+
# Initialize worker logging
|
|
72
|
+
init_worker_logging(log_queue)
|
|
73
|
+
|
|
74
|
+
# Seed processes with process IDs so each worker gets a different random state
|
|
75
|
+
random.seed(os.getpid())
|
|
76
|
+
np.random.seed(os.getpid())
|
|
77
|
+
|
|
78
|
+
# Attach to existing shared memory block
|
|
79
|
+
_GLOBAL_SHM = SharedMemory(name=shm_name)
|
|
80
|
+
|
|
81
|
+
# Create numpy array view of shared memory (no copy!)
|
|
82
|
+
_GLOBAL_BACKGROUNDS = np.ndarray(shape, dtype=dtype, buffer=_GLOBAL_SHM.buf)
|
|
83
|
+
_GLOBAL_SHAPE = shape
|
|
84
|
+
_GLOBAL_DTYPE = dtype
|
|
85
|
+
|
|
86
|
+
# Fresh backgrounds -> drop any Stage-A stats memoized against a prior attachment.
|
|
87
|
+
_STAGE_A_STATS_CACHE.clear()
|
|
88
|
+
|
|
89
|
+
# Ignore SIGINT (Ctrl+C) in workers - let manager from parent handle cleanup coordination
|
|
90
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
91
|
+
|
|
92
|
+
# Setup custom SIGTERM handler for additional cleanup before termination
|
|
93
|
+
# Note, manager will escalate SIGTERM to SIGKILL after pool_terminate_timeout seconds (see config.py)
|
|
94
|
+
# This may interrupt the worker's cleanup process
|
|
95
|
+
# Consider increasing pool_terminate_timeout if you're experiencing such issues
|
|
96
|
+
def cleanup_on_sigterm(signum, frame):
|
|
97
|
+
"""SIGTERM handler that closes the worker's shared-memory fd before re-raising the signal."""
|
|
98
|
+
# Note, a race condition may occur if a worker receives more than 1 SIGTERM delivery
|
|
99
|
+
# at a time, triggering re-entry of the same cleanup handler
|
|
100
|
+
# It suffices to guard against this by simply suppressing exceptions, since subsequent
|
|
101
|
+
# close() calls will just raise an error; no state corruption or kernel-level hazards exist
|
|
102
|
+
# Also, there are no cross-worker race conditions, since each worker's close() operates on
|
|
103
|
+
# per-process resources, even though they all refer to the same underlying POSIX shm object
|
|
104
|
+
with contextlib.suppress(Exception):
|
|
105
|
+
if _GLOBAL_SHM is not None:
|
|
106
|
+
# WARN: DO NOT LOG ANYTHING ON CLEANUP!
|
|
107
|
+
# Any calls to logger will attempt to put a message onto QueueHandler, whose feeder
|
|
108
|
+
# thread needs the GIL to transfer data to the underlying pipe. However, the main
|
|
109
|
+
# process may be holding the GIL (e.g. TF's prefetch threads during training)
|
|
110
|
+
# This causes deadlocks, preventing workers from completing their SIGTERM handlers
|
|
111
|
+
# logger.info(f"Closing shared memory file descriptor in worker PID {os.getpid()}")
|
|
112
|
+
_GLOBAL_SHM.close()
|
|
113
|
+
|
|
114
|
+
# Restore default handler and re-raise SIGTERM to resume termination
|
|
115
|
+
signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
|
116
|
+
os.kill(os.getpid(), signal.SIGTERM)
|
|
117
|
+
|
|
118
|
+
# Register SIGTERM handler for graceful cleanup on pool.terminate()
|
|
119
|
+
signal.signal(signal.SIGTERM, cleanup_on_sigterm)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def log_norm(
|
|
123
|
+
data: np.ndarray, return_params: bool = False
|
|
124
|
+
) -> np.ndarray | tuple[np.ndarray, tuple[float, float]]:
|
|
125
|
+
"""Log-transform `data` (with a 1e-10 epsilon), then shift and rescale into [0, 1].
|
|
126
|
+
|
|
127
|
+
With return_params=True, also returns the (min_log, range_log) normalization parameters,
|
|
128
|
+
which allow an approximate inversion back to linear intensity:
|
|
129
|
+
linear ≈ exp(normalized * range_log + min_log). Used by the latent-traversal plot to
|
|
130
|
+
display decoded reconstructions on a near-physical scale. The inversion is DISPLAY-ONLY
|
|
131
|
+
and never exact: (1) the upstream frequency downsampling is lossy and cannot be truly
|
|
132
|
+
undone, and (2) these params are per-observation, so the plot collapses them to a
|
|
133
|
+
per-class mean over the ON observations while a latent-traversal decode blends
|
|
134
|
+
observations. Do not treat inverted values as calibrated intensities.
|
|
135
|
+
"""
|
|
136
|
+
# Add small epsilon to avoid log(0)
|
|
137
|
+
data = data + 1e-10
|
|
138
|
+
|
|
139
|
+
# Transform data into log-space
|
|
140
|
+
data = np.log(data)
|
|
141
|
+
# Shift data to be >= 0
|
|
142
|
+
min_log = float(data.min())
|
|
143
|
+
data = data - min_log
|
|
144
|
+
# Normalize data to [0, 1]
|
|
145
|
+
range_log = float(data.max())
|
|
146
|
+
if range_log > 0:
|
|
147
|
+
data = data / range_log
|
|
148
|
+
|
|
149
|
+
if return_params:
|
|
150
|
+
return data, (min_log, range_log)
|
|
151
|
+
return data
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# NOTE: not 100% sure how this function works. ported from Peter's code. comments added by Claude. assuming it works as intended?
|
|
155
|
+
def _draw_signal_params(
|
|
156
|
+
total_time: int, width_bin: int, freq_resolution: float, time_resolution: float
|
|
157
|
+
) -> tuple[dict[str, float], bool]:
|
|
158
|
+
"""
|
|
159
|
+
Consume the RNG draws for one signal injection and return the realized geometry
|
|
160
|
+
{drift_rate, signal_width, starting_bin, slope_pixel, y_intercept} plus
|
|
161
|
+
slope_was_clamped — WITHOUT materializing anything.
|
|
162
|
+
|
|
163
|
+
The draw order — random.random (starting_bin), np.random.choice (drift direction),
|
|
164
|
+
random.random (slope noise), random.random (signal width) — is the byte-compatibility
|
|
165
|
+
contract with the pre-split new_cadence: create_true_double replays this exact sequence
|
|
166
|
+
per retry attempt and materializes only the accepted pair, so reordering, adding, or
|
|
167
|
+
removing a draw changes every seeded round (pinned by tests).
|
|
168
|
+
"""
|
|
169
|
+
# NOTE: should noise = 3 parametrized?
|
|
170
|
+
# Set noise parameter (for simulating randomness in drift rate calculation)
|
|
171
|
+
noise = 3
|
|
172
|
+
|
|
173
|
+
# Randomly select a starting frequency bin (channel) to start the signal injection
|
|
174
|
+
# Avoids edges (bin 0)
|
|
175
|
+
starting_bin = int(random.random() * (width_bin - 1)) + 1
|
|
176
|
+
|
|
177
|
+
# Randomly select a positive or negative drift direction
|
|
178
|
+
if np.random.choice([-1, 1]) > 0:
|
|
179
|
+
# Positive drift
|
|
180
|
+
slope_pixel = total_time / starting_bin # Signal drifts upward in frequency
|
|
181
|
+
# Convert from pixel space to physical units by multiplying by time_resolution / freq_resolution ratio
|
|
182
|
+
# Then add random noise to make drift rates more realistic
|
|
183
|
+
slope_physical = (slope_pixel) * (
|
|
184
|
+
time_resolution / freq_resolution
|
|
185
|
+
) + random.random() * noise
|
|
186
|
+
else:
|
|
187
|
+
# Negative drift
|
|
188
|
+
slope_pixel = total_time / (starting_bin - width_bin) # Signal drifts downward in frequency
|
|
189
|
+
# Convert from pixel space to physical units by multiplying by time_resolution / freq_resolution ratio
|
|
190
|
+
# Then add random noise to make drift rates more realistic
|
|
191
|
+
slope_physical = (slope_pixel) * (
|
|
192
|
+
time_resolution / freq_resolution
|
|
193
|
+
) - random.random() * noise
|
|
194
|
+
|
|
195
|
+
# Clamp slopes that are too small to prevent divide-by-zero errors
|
|
196
|
+
# While this may alter the physics slightly, a near-zero slope is an edge-case representing a
|
|
197
|
+
# nearly horizontal signal trajectory
|
|
198
|
+
# Note that we still preserve the drift direction; merely the magnitude is clamped
|
|
199
|
+
# NOTE: should MIN_SLOPE_PHYSICAL = 1e-6 be parametrized in config.py instead?
|
|
200
|
+
# NOTE: does slope_pixel need to be changed before db write if slope_physical is clamped?
|
|
201
|
+
MIN_SLOPE_PHYSICAL = 1e-6 # noqa: N806
|
|
202
|
+
slope_was_clamped = False
|
|
203
|
+
if abs(slope_physical) < MIN_SLOPE_PHYSICAL:
|
|
204
|
+
slope_was_clamped = True
|
|
205
|
+
logger.warning(f"new_cadence: slope_physical ({slope_physical}) near zero, clamping")
|
|
206
|
+
slope_physical = (
|
|
207
|
+
np.sign(slope_physical) * MIN_SLOPE_PHYSICAL
|
|
208
|
+
if slope_physical != 0
|
|
209
|
+
else MIN_SLOPE_PHYSICAL
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Convert slope to drift rate
|
|
213
|
+
drift_rate = -1 * (1 / slope_physical)
|
|
214
|
+
|
|
215
|
+
# NOTE: should 0-50 randomness be parametrized?
|
|
216
|
+
# NOTE: where does 18.0 come from? hard-coded time resolution?
|
|
217
|
+
# Calculate signal width (in Hz)
|
|
218
|
+
# Base random component: 0-50 Hz
|
|
219
|
+
# Add component proportional to drift rate magnitude to keep signal coherent
|
|
220
|
+
signal_width = random.random() * 50 + abs(drift_rate) * 18.0 / 1
|
|
221
|
+
|
|
222
|
+
# Calculate y-intercept for linear signal trajectory
|
|
223
|
+
y_intercept = total_time - slope_pixel * (starting_bin)
|
|
224
|
+
|
|
225
|
+
params = {
|
|
226
|
+
"drift_rate": drift_rate,
|
|
227
|
+
"signal_width": signal_width,
|
|
228
|
+
"starting_bin": starting_bin,
|
|
229
|
+
"slope_pixel": slope_pixel,
|
|
230
|
+
"y_intercept": y_intercept,
|
|
231
|
+
}
|
|
232
|
+
return params, slope_was_clamped
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _signal_info_from_params(snr: float, params: dict[str, float]) -> dict[str, float]:
|
|
236
|
+
"""Build the signal_info dict from drawn geometry. Key order is the DB row-order contract
|
|
237
|
+
(write_segment_stats iterates signal_info.items()) — keep it stable."""
|
|
238
|
+
return {
|
|
239
|
+
"snr": float(snr),
|
|
240
|
+
"drift_rate": float(params["drift_rate"]),
|
|
241
|
+
"signal_width": float(params["signal_width"]),
|
|
242
|
+
"starting_bin": float(params["starting_bin"]),
|
|
243
|
+
"slope_pixel": float(params["slope_pixel"]),
|
|
244
|
+
"y_intercept": float(params["y_intercept"]),
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _inject_drawn_signal(
|
|
249
|
+
data: np.ndarray,
|
|
250
|
+
snr: float,
|
|
251
|
+
params: dict[str, float],
|
|
252
|
+
freq_resolution: float,
|
|
253
|
+
time_resolution: float,
|
|
254
|
+
) -> np.ndarray:
|
|
255
|
+
"""
|
|
256
|
+
Materialize one drawn signal into `data` and return the modified array.
|
|
257
|
+
|
|
258
|
+
Contract: consumes NO RNG (pinned by test) — create_true_double relies on this to defer
|
|
259
|
+
materialization until after the intersection-acceptance test without shifting the seeded
|
|
260
|
+
per-task stream.
|
|
261
|
+
"""
|
|
262
|
+
# Create setigen Frame
|
|
263
|
+
frame = stg.Frame.from_data(
|
|
264
|
+
df=freq_resolution * u.Hz,
|
|
265
|
+
dt=time_resolution * u.s,
|
|
266
|
+
fch1=0 * u.MHz, # Set reference frequency (center frequency offset)
|
|
267
|
+
data=data,
|
|
268
|
+
ascending=True, # Frequency increases with channel index
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# Inject signal
|
|
272
|
+
signal = frame.add_signal(
|
|
273
|
+
# Use linear drift trajectory starting at starting_bin & with the calculated drift rate
|
|
274
|
+
stg.constant_path(
|
|
275
|
+
f_start=frame.get_frequency(index=int(params["starting_bin"])),
|
|
276
|
+
drift_rate=params["drift_rate"] * u.Hz / u.s,
|
|
277
|
+
),
|
|
278
|
+
# Constant intensity over time, calibrated to achieve target snr
|
|
279
|
+
stg.constant_t_profile(level=frame.get_intensity(snr=snr)),
|
|
280
|
+
# Gaussian shape in frequency domain with calculated signal width
|
|
281
|
+
stg.gaussian_f_profile(width=params["signal_width"] * u.Hz),
|
|
282
|
+
# Constant bandpass profile (no frequency-dependent scaling)
|
|
283
|
+
stg.constant_bp_profile(level=1),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# Extract the modified data (with signal injection) from the setigen Frame
|
|
287
|
+
modified_data = frame.data.copy()
|
|
288
|
+
|
|
289
|
+
# Cleanup intermediate data. Refcounting frees the Frame's arrays immediately — do NOT
|
|
290
|
+
# add a gc.collect() here: this function runs once per injection (~2.5M times per
|
|
291
|
+
# production round), and a full collection per call cost ~23 ms against ~4.5 ms for
|
|
292
|
+
# everything else in this function (~5x the total generation wall). The per-chunk
|
|
293
|
+
# collect in generate_round_to_memmap covers cycle cleanup.
|
|
294
|
+
del frame, signal
|
|
295
|
+
|
|
296
|
+
return modified_data
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def new_cadence(
|
|
300
|
+
data: np.ndarray, snr: float, width_bin: int, freq_resolution: float, time_resolution: float
|
|
301
|
+
) -> tuple[np.ndarray, dict[str, float], bool]:
|
|
302
|
+
"""
|
|
303
|
+
Inject a single drifting narrowband signal into a stacked cadence array.
|
|
304
|
+
|
|
305
|
+
Returns (modified_data, signal_info, slope_was_clamped). signal_info carries the realized
|
|
306
|
+
parameters {snr, drift_rate, signal_width, starting_bin, slope_pixel, y_intercept};
|
|
307
|
+
slope_was_clamped is True if the drift slope was forced away from ~0 to avoid a degenerate
|
|
308
|
+
near-vertical injection (downstream queries can filter on this). Composition of
|
|
309
|
+
_draw_signal_params + _inject_drawn_signal; byte-identical to the pre-split function.
|
|
310
|
+
"""
|
|
311
|
+
params, slope_was_clamped = _draw_signal_params(
|
|
312
|
+
data.shape[0], width_bin, freq_resolution, time_resolution
|
|
313
|
+
)
|
|
314
|
+
modified_data = _inject_drawn_signal(data, snr, params, freq_resolution, time_resolution)
|
|
315
|
+
return modified_data, _signal_info_from_params(snr, params), slope_was_clamped
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# Float-rounding pad for the ON-band boundary comparison in check_valid_intersection (#118).
|
|
319
|
+
# Same-sign trajectory pairs intersect exactly ON a band edge (y* = 0 in exact arithmetic:
|
|
320
|
+
# positive-drift lines all pass through (0, 0) and negative-drift lines through (width_bin, 0),
|
|
321
|
+
# see new_cadence's y_intercept construction), but the float intersection lands at y* ~ -1e-14,
|
|
322
|
+
# so a bare inclusive comparison used to accept ~4.8% of them by rounding luck. 1e-9 is several
|
|
323
|
+
# orders of magnitude above the observed rounding noise yet negligible against the pixel-scale
|
|
324
|
+
# band geometry (an extra ~2e-9-wide rejection sliver per boundary).
|
|
325
|
+
_ON_BAND_BOUNDARY_EPS = 1e-9
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def check_valid_intersection(slope_1, slope_2, intercept_1, intercept_2):
|
|
329
|
+
"""
|
|
330
|
+
Check if 2 drifting signals intersect in the ON regions.
|
|
331
|
+
|
|
332
|
+
ON-band boundaries are deliberately INCLUSIVE: an intersection lying exactly on a band edge
|
|
333
|
+
counts as inside the ON region and invalidates the pair (returns False) — a boundary
|
|
334
|
+
crossing still overlaps ON-region pixels once the signals' finite width is considered. The
|
|
335
|
+
comparison is padded by _ON_BAND_BOUNDARY_EPS so float rounding cannot flip an
|
|
336
|
+
exact-boundary case to "valid".
|
|
337
|
+
"""
|
|
338
|
+
if slope_1 == slope_2:
|
|
339
|
+
return True # Parallel lines never intersect. Avoids division by 0
|
|
340
|
+
|
|
341
|
+
x_intersect = (intercept_2 - intercept_1) / (slope_1 - slope_2)
|
|
342
|
+
y_intersect = slope_1 * x_intersect + intercept_1
|
|
343
|
+
|
|
344
|
+
on_y_coords = [(0, 16), (32, 48), (64, 80)]
|
|
345
|
+
return all(
|
|
346
|
+
not (y_lower - _ON_BAND_BOUNDARY_EPS <= y_intersect <= y_upper + _ON_BAND_BOUNDARY_EPS)
|
|
347
|
+
for y_lower, y_upper in on_y_coords
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# TODO: add more sophisticated statistics for data leakage analysis
|
|
352
|
+
def _compute_intensity_stats(data: np.ndarray) -> dict[str, float]:
|
|
353
|
+
"""
|
|
354
|
+
Compute global intensity statistics on a spectrogram array, flattening to 1-D first and
|
|
355
|
+
promoting to float64 to avoid overflow in higher-order moments (especially pre-normalization).
|
|
356
|
+
|
|
357
|
+
Returns {global_mean, global_median, global_std, global_mad, global_skew, global_kurtosis}.
|
|
358
|
+
Empty input returns NaN for every key — write_injection_stat() will record those with
|
|
359
|
+
is_finite=0 so they can be filtered out at query time.
|
|
360
|
+
"""
|
|
361
|
+
# Return NaN for empty arrays
|
|
362
|
+
# write_injection_stat() will set is_finite=0
|
|
363
|
+
if data.size == 0:
|
|
364
|
+
logger.warning("_compute_intensity_stats received empty array")
|
|
365
|
+
return dict.fromkeys(
|
|
366
|
+
[
|
|
367
|
+
"global_mean",
|
|
368
|
+
"global_median",
|
|
369
|
+
"global_std",
|
|
370
|
+
"global_mad",
|
|
371
|
+
"global_skew",
|
|
372
|
+
"global_kurtosis",
|
|
373
|
+
],
|
|
374
|
+
float("nan"),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# Temporarily promote to float64 to prevent overflow in higher-order moments (especially for
|
|
378
|
+
# pre-normalization stats). copy=False avoids a redundant copy when `data` is already float64
|
|
379
|
+
# (Stage B/C inputs) — `flat` is only ever read here (median/mean/std/abs/skew/kurtosis all
|
|
380
|
+
# produce new arrays), so aliasing `data` is safe.
|
|
381
|
+
flat = data.ravel().astype(np.float64, copy=False)
|
|
382
|
+
median_val = np.median(flat)
|
|
383
|
+
|
|
384
|
+
stats = {
|
|
385
|
+
"global_mean": float(np.mean(flat)),
|
|
386
|
+
"global_median": float(median_val),
|
|
387
|
+
"global_std": float(np.std(flat)),
|
|
388
|
+
"global_mad": float(np.median(np.abs(flat - median_val))),
|
|
389
|
+
"global_skew": float(scipy_stats.skew(flat)),
|
|
390
|
+
"global_kurtosis": float(scipy_stats.kurtosis(flat)),
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return stats
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _stage_a_stats(plate: np.ndarray, background_index: int, base: np.ndarray) -> dict[str, float]:
|
|
397
|
+
"""Stage-A (raw-background) intensity stats, memoized per background_index on the worker path.
|
|
398
|
+
|
|
399
|
+
Stage A is a pure function of the raw background, but each background is drawn ~n_samples /
|
|
400
|
+
n_backgrounds times per round (~33x at production defaults), so recomputing its two median
|
|
401
|
+
sorts + skew/kurtosis every draw is pure redundancy. In a pool worker the `plate` handed to
|
|
402
|
+
create_* IS `_GLOBAL_BACKGROUNDS` — a single block loaded once and never mutated for the
|
|
403
|
+
process's life — so caching by index there is byte-identical and safe. Every other caller
|
|
404
|
+
(the in-process RF-dataset path, tests) passes a different array and is NOT cached, so no
|
|
405
|
+
stale/foreign plate's stats can ever leak. A fresh dict is returned each call (callers store
|
|
406
|
+
or .copy() it) so the cached entry is never mutated in place.
|
|
407
|
+
"""
|
|
408
|
+
if _GLOBAL_BACKGROUNDS is not None and plate is _GLOBAL_BACKGROUNDS:
|
|
409
|
+
cached = _STAGE_A_STATS_CACHE.get(background_index)
|
|
410
|
+
if cached is None:
|
|
411
|
+
cached = _compute_intensity_stats(base)
|
|
412
|
+
_STAGE_A_STATS_CACHE[background_index] = cached
|
|
413
|
+
return dict(cached)
|
|
414
|
+
return _compute_intensity_stats(base)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def create_false(
|
|
418
|
+
plate: np.ndarray,
|
|
419
|
+
snr_base: float,
|
|
420
|
+
snr_range: float,
|
|
421
|
+
width_bin: int,
|
|
422
|
+
freq_resolution: float,
|
|
423
|
+
time_resolution: float,
|
|
424
|
+
inject: bool = True,
|
|
425
|
+
dynamic_range: float | None = None,
|
|
426
|
+
) -> tuple[np.ndarray, dict]:
|
|
427
|
+
"""
|
|
428
|
+
Create a false-class cadence and return (final, sample_info). final has shape
|
|
429
|
+
(6, 16, width_bin); sample_info carries background_index, per-stage intensity_stats (A/B/C),
|
|
430
|
+
signal_info (rfi_* keys when inject=True, empty otherwise), slope_was_clamped, and the
|
|
431
|
+
per-observation lognorm_params (shape (6, 2): (min_log, range_log) per observation).
|
|
432
|
+
|
|
433
|
+
When inject=True, a drifting RFI signal is injected into all 6 observations; when False, the
|
|
434
|
+
background is log-normalized and returned as-is (Stage B = Stage A, signal_info empty).
|
|
435
|
+
"""
|
|
436
|
+
# Select random background from plate
|
|
437
|
+
background_index = int(plate.shape[0] * random.random())
|
|
438
|
+
base = plate[background_index, :, :, :]
|
|
439
|
+
|
|
440
|
+
# Initialize empty output array
|
|
441
|
+
n_obs = plate.shape[1]
|
|
442
|
+
n_time = plate.shape[2]
|
|
443
|
+
final = np.zeros((n_obs, n_time, width_bin))
|
|
444
|
+
|
|
445
|
+
# STAGE A: Pre-injection, pre-normalization (raw background). Memoized per background on
|
|
446
|
+
# the worker path (see _stage_a_stats) — same background, same stats, computed once.
|
|
447
|
+
stats_a = _stage_a_stats(plate, background_index, base)
|
|
448
|
+
|
|
449
|
+
# Initialize signal info (will be populated if inject=True)
|
|
450
|
+
signal_info = {}
|
|
451
|
+
|
|
452
|
+
# Per-observation log-norm parameters, recorded so the latent-traversal plot can
|
|
453
|
+
# approximately invert the normalization for display (see log_norm)
|
|
454
|
+
lognorm_params = np.zeros((n_obs, 2), dtype=np.float32)
|
|
455
|
+
|
|
456
|
+
# Inject RFI into all 6 observations
|
|
457
|
+
slope_was_clamped = False
|
|
458
|
+
if inject:
|
|
459
|
+
# Prepare data for signal injection by stacking all 6 observations vertically
|
|
460
|
+
# (6, 16, 512) -> (96, 512). base is C-contiguous, so the row-major reshape IS that
|
|
461
|
+
# obs-major stack (row i*n_time+t = base[i, t]); .astype(float64) yields the fresh,
|
|
462
|
+
# independent float64 buffer setigen injection then mutates in place (must be a copy).
|
|
463
|
+
data = base.reshape(n_obs * n_time, width_bin).astype(np.float64)
|
|
464
|
+
|
|
465
|
+
# Select a random SNR from the given range & inject RFI into all 6 observations
|
|
466
|
+
snr = random.random() * snr_range + snr_base
|
|
467
|
+
cadence, rfi_signal_info, slope_was_clamped = new_cadence(
|
|
468
|
+
data, snr, width_bin, freq_resolution, time_resolution
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# Prefix signal characteristics with rfi_ (this is RFI injection)
|
|
472
|
+
signal_info = {f"rfi_{k}": v for k, v in rfi_signal_info.items()}
|
|
473
|
+
|
|
474
|
+
# STAGE B: Post-injection, pre-normalization
|
|
475
|
+
stats_b = _compute_intensity_stats(cadence)
|
|
476
|
+
|
|
477
|
+
# Reshape stacked data back into original shape & log-normalize after signal injection
|
|
478
|
+
for i in range(n_obs):
|
|
479
|
+
final[i, :, :], lognorm_params[i] = log_norm(
|
|
480
|
+
cadence[i * n_time : (i + 1) * n_time, :], return_params=True
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
# Just return background. No signal injection
|
|
484
|
+
else:
|
|
485
|
+
# No injection: Stage B = Stage A, no signal_info
|
|
486
|
+
stats_b = stats_a.copy()
|
|
487
|
+
# Log-normalize base background
|
|
488
|
+
for i in range(n_obs):
|
|
489
|
+
final[i, :, :], lognorm_params[i] = log_norm(base[i, :, :], return_params=True)
|
|
490
|
+
|
|
491
|
+
# STAGE C: Post-injection, post-normalization
|
|
492
|
+
stats_c = _compute_intensity_stats(final)
|
|
493
|
+
|
|
494
|
+
sample_info = {
|
|
495
|
+
"background_index": background_index,
|
|
496
|
+
"intensity_stats": {"A": stats_a, "B": stats_b, "C": stats_c},
|
|
497
|
+
# NOTE: how do we handle db writes & plotting when signal_info is empty?
|
|
498
|
+
"signal_info": signal_info, # Empty if no injection, rfi_* if injected
|
|
499
|
+
"slope_was_clamped": slope_was_clamped,
|
|
500
|
+
"lognorm_params": lognorm_params,
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return final, sample_info
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def create_true_single(
|
|
507
|
+
plate: np.ndarray,
|
|
508
|
+
snr_base: float,
|
|
509
|
+
snr_range: float,
|
|
510
|
+
width_bin: int,
|
|
511
|
+
freq_resolution: float,
|
|
512
|
+
time_resolution: float,
|
|
513
|
+
inject: bool | None = None,
|
|
514
|
+
dynamic_range: float | None = None,
|
|
515
|
+
) -> tuple[np.ndarray, dict]:
|
|
516
|
+
"""
|
|
517
|
+
Create a true-single-class cadence (ETI injected into ON observations only) and return
|
|
518
|
+
(final, sample_info). final has shape (6, 16, width_bin); sample_info carries
|
|
519
|
+
background_index, per-stage intensity_stats (A/B/C), eti_*-prefixed signal_info,
|
|
520
|
+
slope_was_clamped, and per-observation lognorm_params (shape (6, 2)).
|
|
521
|
+
"""
|
|
522
|
+
# Select random background from plate
|
|
523
|
+
background_index = int(plate.shape[0] * random.random())
|
|
524
|
+
base = plate[background_index, :, :, :]
|
|
525
|
+
|
|
526
|
+
# Initialize empty output array
|
|
527
|
+
n_obs = plate.shape[1]
|
|
528
|
+
n_time = plate.shape[2]
|
|
529
|
+
final = np.zeros((n_obs, n_time, width_bin))
|
|
530
|
+
|
|
531
|
+
# STAGE A: Pre-injection, pre-normalization (raw background). Memoized per background on
|
|
532
|
+
# the worker path (see _stage_a_stats) — same background, same stats, computed once.
|
|
533
|
+
stats_a = _stage_a_stats(plate, background_index, base)
|
|
534
|
+
|
|
535
|
+
# Prepare data for signal injection by stacking all 6 observations vertically
|
|
536
|
+
# (6, 16, 512) -> (96, 512). base is C-contiguous, so the row-major reshape IS that
|
|
537
|
+
# obs-major stack (row i*n_time+t = base[i, t]); .astype(float64) yields the fresh,
|
|
538
|
+
# independent float64 buffer setigen injection then mutates in place (must be a copy).
|
|
539
|
+
data = base.reshape(n_obs * n_time, width_bin).astype(np.float64)
|
|
540
|
+
|
|
541
|
+
# Select a random SNR from the given range & inject ETI
|
|
542
|
+
snr = random.random() * snr_range + snr_base
|
|
543
|
+
cadence, eti_signal_info, slope_was_clamped = new_cadence(
|
|
544
|
+
data, snr, width_bin, freq_resolution, time_resolution
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
# Prefix signal characteristics with eti_ (this is ETI injection)
|
|
548
|
+
signal_info = {f"eti_{k}": v for k, v in eti_signal_info.items()}
|
|
549
|
+
|
|
550
|
+
# STAGE B: Post-injection, pre-normalization
|
|
551
|
+
stats_b = _compute_intensity_stats(cadence)
|
|
552
|
+
|
|
553
|
+
# Reshape stacked data back into original shape & log-normalize after signal injection
|
|
554
|
+
lognorm_params = np.zeros((n_obs, 2), dtype=np.float32)
|
|
555
|
+
for i in range(n_obs):
|
|
556
|
+
if i % 2 == 0:
|
|
557
|
+
# ONs: injected signal
|
|
558
|
+
final[i, :, :], lognorm_params[i] = log_norm(
|
|
559
|
+
cadence[i * n_time : (i + 1) * n_time, :], return_params=True
|
|
560
|
+
)
|
|
561
|
+
else:
|
|
562
|
+
# OFFs: original background
|
|
563
|
+
final[i, :, :], lognorm_params[i] = log_norm(
|
|
564
|
+
data[i * n_time : (i + 1) * n_time, :], return_params=True
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
# STAGE C: Post-injection, post-normalization
|
|
568
|
+
stats_c = _compute_intensity_stats(final)
|
|
569
|
+
|
|
570
|
+
sample_info = {
|
|
571
|
+
"background_index": background_index,
|
|
572
|
+
"intensity_stats": {"A": stats_a, "B": stats_b, "C": stats_c},
|
|
573
|
+
"signal_info": signal_info, # eti_* signal characteristics
|
|
574
|
+
"slope_was_clamped": slope_was_clamped,
|
|
575
|
+
"lognorm_params": lognorm_params,
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return final, sample_info
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# Defensive cap on create_true_double's intersection-retry loop (#118). Acceptance is i.i.d.
|
|
582
|
+
# geometric with p~=0.42, so P(a single sample needs >100 attempts) ~ 1e-24 — the cap exists so
|
|
583
|
+
# one pathological draw can never stall a batched task, not because it is expected to fire.
|
|
584
|
+
MAX_INTERSECTION_RETRIES = 100
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def create_true_double(
|
|
588
|
+
plate: np.ndarray,
|
|
589
|
+
snr_base: float,
|
|
590
|
+
snr_range: float,
|
|
591
|
+
width_bin: int,
|
|
592
|
+
freq_resolution: float,
|
|
593
|
+
time_resolution: float,
|
|
594
|
+
inject: bool | None = None,
|
|
595
|
+
dynamic_range: float = 1,
|
|
596
|
+
) -> tuple[np.ndarray, dict]:
|
|
597
|
+
"""
|
|
598
|
+
Create a true-double-class cadence (non-intersecting ETI and RFI signals injected into
|
|
599
|
+
ON-only and ON-OFF respectively) and return (final, sample_info). final has shape
|
|
600
|
+
(6, 16, width_bin); sample_info carries background_index, per-stage intensity_stats (A/B/C),
|
|
601
|
+
signal_info with both eti_* and rfi_* keys, slope_was_clamped, intersection_retries /
|
|
602
|
+
intersection_retry_capped (retry-cap telemetry, #118), and per-observation lognorm_params
|
|
603
|
+
(shape (6, 2)).
|
|
604
|
+
"""
|
|
605
|
+
# Select random background from plate
|
|
606
|
+
background_index = int(plate.shape[0] * random.random())
|
|
607
|
+
base = plate[background_index, :, :, :]
|
|
608
|
+
|
|
609
|
+
# Initialize empty output array
|
|
610
|
+
n_obs = plate.shape[1]
|
|
611
|
+
n_time = plate.shape[2]
|
|
612
|
+
final = np.zeros((n_obs, n_time, width_bin))
|
|
613
|
+
|
|
614
|
+
# STAGE A: Pre-injection, pre-normalization (raw background). Memoized per background on
|
|
615
|
+
# the worker path (see _stage_a_stats) — same background, same stats, computed once.
|
|
616
|
+
stats_a = _stage_a_stats(plate, background_index, base)
|
|
617
|
+
|
|
618
|
+
# Prepare data for signal injection by stacking all 6 observations vertically
|
|
619
|
+
# (6, 16, 512) -> (96, 512). base is C-contiguous, so the row-major reshape IS that
|
|
620
|
+
# obs-major stack (row i*n_time+t = base[i, t]); .astype(float64) yields the fresh,
|
|
621
|
+
# independent float64 buffer setigen injection then mutates in place (must be a copy).
|
|
622
|
+
data = base.reshape(n_obs * n_time, width_bin).astype(np.float64)
|
|
623
|
+
|
|
624
|
+
# Select a random SNR from the given range
|
|
625
|
+
snr = random.random() * snr_range + snr_base
|
|
626
|
+
|
|
627
|
+
# NOTE: quantified in #118 — acceptance is i.i.d. geometric with p~=0.42, so the worst
|
|
628
|
+
# sample over a full 499200-round is ~25 retries; a >100-retry sample is effectively
|
|
629
|
+
# impossible (P~1e-24). Since the draw-first split, a retry costs only the ~microsecond
|
|
630
|
+
# parameter draws — the two setigen injections are materialized once, after acceptance —
|
|
631
|
+
# so the loop is timing-noise regardless of draw luck. MAX_INTERSECTION_RETRIES is the
|
|
632
|
+
# defensive cap #118 called for: on exhaustion keep the last drawn pair and flag the sample
|
|
633
|
+
# (clamp-and-flag, mirroring slope_was_clamped) rather than raise and kill a whole round.
|
|
634
|
+
# Retry the geometry draws until they are valid non-intersecting (or the cap is hit)
|
|
635
|
+
total_time = data.shape[0]
|
|
636
|
+
intersection_retries = 0
|
|
637
|
+
intersection_retry_capped = False
|
|
638
|
+
while True:
|
|
639
|
+
intersection_retries += 1
|
|
640
|
+
# Draw both signals' geometry, consuming the exact per-attempt RNG sequence the
|
|
641
|
+
# pre-split inject-then-test loop consumed (RFI draws, then ETI draws) — but
|
|
642
|
+
# materialize nothing: the acceptance test below is a pure function of the drawn
|
|
643
|
+
# slopes/intercepts, and at p~=0.42 acceptance, injecting before testing wasted
|
|
644
|
+
# ~41% of all setigen injections in a production round.
|
|
645
|
+
rfi_params, rfi_slope_clamped = _draw_signal_params(
|
|
646
|
+
total_time, width_bin, freq_resolution, time_resolution
|
|
647
|
+
)
|
|
648
|
+
eti_params, eti_slope_clamped = _draw_signal_params(
|
|
649
|
+
total_time, width_bin, freq_resolution, time_resolution
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
# Extract slope and intercept for intersection check
|
|
653
|
+
slope_1 = rfi_params["slope_pixel"]
|
|
654
|
+
intercept_1 = rfi_params["y_intercept"]
|
|
655
|
+
slope_2 = eti_params["slope_pixel"]
|
|
656
|
+
intercept_2 = eti_params["y_intercept"]
|
|
657
|
+
|
|
658
|
+
if slope_1 != slope_2 and check_valid_intersection(
|
|
659
|
+
slope_1, slope_2, intercept_1, intercept_2
|
|
660
|
+
):
|
|
661
|
+
break
|
|
662
|
+
|
|
663
|
+
if intersection_retries >= MAX_INTERSECTION_RETRIES:
|
|
664
|
+
intersection_retry_capped = True
|
|
665
|
+
# "Last drawn pair" is the pair the acceptance check just rejected above (the cap
|
|
666
|
+
# is only reached after check_valid_intersection fails) — NOT an unconditionally
|
|
667
|
+
# accepted 100th draw. At this probability (~1e-24) the sample is already a
|
|
668
|
+
# statistical non-event; keeping a known-intersecting pair rather than drawing
|
|
669
|
+
# (and not testing) a 101st is the simpler contract to reason about.
|
|
670
|
+
logger.warning(
|
|
671
|
+
f"create_true_double: intersection retry cap ({MAX_INTERSECTION_RETRIES}) "
|
|
672
|
+
f"exhausted; keeping last drawn (rejected) signal pair and flagging the sample"
|
|
673
|
+
)
|
|
674
|
+
break
|
|
675
|
+
|
|
676
|
+
# Materialize only the accepted (or cap-flagged last-drawn) pair: RFI into the stacked
|
|
677
|
+
# background, then ETI on top of the RFI-injected cadence — same order, same arrays,
|
|
678
|
+
# same values as the pre-split inject-then-test loop.
|
|
679
|
+
cadence_1 = _inject_drawn_signal(data, snr, rfi_params, freq_resolution, time_resolution)
|
|
680
|
+
cadence_2 = _inject_drawn_signal(
|
|
681
|
+
cadence_1, snr * dynamic_range, eti_params, freq_resolution, time_resolution
|
|
682
|
+
)
|
|
683
|
+
rfi_signal_info = _signal_info_from_params(snr, rfi_params)
|
|
684
|
+
eti_signal_info = _signal_info_from_params(snr * dynamic_range, eti_params)
|
|
685
|
+
|
|
686
|
+
# Track if any slope was clamped (either RFI or ETI)
|
|
687
|
+
slope_was_clamped = rfi_slope_clamped or eti_slope_clamped
|
|
688
|
+
|
|
689
|
+
# Combine both signal infos with appropriate prefixes
|
|
690
|
+
signal_info = {
|
|
691
|
+
**{f"rfi_{k}": v for k, v in rfi_signal_info.items()},
|
|
692
|
+
**{f"eti_{k}": v for k, v in eti_signal_info.items()},
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
# STAGE B: Post-injection, pre-normalization (after both injections)
|
|
696
|
+
stats_b = _compute_intensity_stats(cadence_2)
|
|
697
|
+
|
|
698
|
+
# Reshape stacked data back into original shape & log-normalize after signal injection
|
|
699
|
+
lognorm_params = np.zeros((n_obs, 2), dtype=np.float32)
|
|
700
|
+
for i in range(n_obs):
|
|
701
|
+
if i % 2 == 0:
|
|
702
|
+
# ONs: 2 injected signals (ETI + RFI)
|
|
703
|
+
final[i, :, :], lognorm_params[i] = log_norm(
|
|
704
|
+
cadence_2[i * n_time : (i + 1) * n_time, :], return_params=True
|
|
705
|
+
)
|
|
706
|
+
else:
|
|
707
|
+
# OFFs: 1 injected signal (RFI only)
|
|
708
|
+
final[i, :, :], lognorm_params[i] = log_norm(
|
|
709
|
+
cadence_1[i * n_time : (i + 1) * n_time, :], return_params=True
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
# STAGE C: Post-injection, post-normalization
|
|
713
|
+
stats_c = _compute_intensity_stats(final)
|
|
714
|
+
|
|
715
|
+
sample_info = {
|
|
716
|
+
"background_index": background_index,
|
|
717
|
+
"intensity_stats": {"A": stats_a, "B": stats_b, "C": stats_c},
|
|
718
|
+
"signal_info": signal_info, # Both eti_* and rfi_* signal characteristics
|
|
719
|
+
"slope_was_clamped": slope_was_clamped,
|
|
720
|
+
"intersection_retries": intersection_retries,
|
|
721
|
+
"intersection_retry_capped": intersection_retry_capped,
|
|
722
|
+
"lognorm_params": lognorm_params,
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return final, sample_info
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
# Cadence generator functions addressable by name, so batched tasks stay picklable-cheap
|
|
729
|
+
# (workers resolve the callable locally instead of unpickling a function reference per task)
|
|
730
|
+
_CREATE_FUNCTIONS = {
|
|
731
|
+
"create_false": create_false,
|
|
732
|
+
"create_true_single": create_true_single,
|
|
733
|
+
"create_true_double": create_true_double,
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
@dataclass(frozen=True)
|
|
738
|
+
class ChunkSegment:
|
|
739
|
+
"""
|
|
740
|
+
One contiguous class-segment of a chunk: `count` cadences of a single signal type written
|
|
741
|
+
at rows [start_idx, start_idx + count) of the round's `array_name` memmap. Each chunk is
|
|
742
|
+
made of 8 segments (4 quarters for main, 2 halves each for false/true) — the same
|
|
743
|
+
contiguous per-chunk layout the in-RAM generator used, so the labels array preserves the
|
|
744
|
+
row -> signal-type mapping and the stratified split downstream keeps working unchanged.
|
|
745
|
+
"""
|
|
746
|
+
|
|
747
|
+
array_name: str # "main" | "false" | "true"
|
|
748
|
+
signal_class: str # "main" | "false" | "true" (DB signal_class)
|
|
749
|
+
signal_type: str # e.g. "false_no_signal"
|
|
750
|
+
create_fn_name: str # key into _CREATE_FUNCTIONS
|
|
751
|
+
inject: bool | None
|
|
752
|
+
dynamic_range: float | None
|
|
753
|
+
start_idx: int # absolute row offset into the round array
|
|
754
|
+
count: int
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
# The 4 signal types in main-array (and labels) order within each chunk
|
|
758
|
+
_SIGNAL_TYPES = ("false_no_signal", "false_with_rfi", "true_only_eti", "true_eti_rfi")
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def build_chunk_segments(chunk_start: int, chunk_size: int) -> list[ChunkSegment]:
|
|
762
|
+
"""
|
|
763
|
+
Lay out the 8 class-segments for one chunk starting at absolute row `chunk_start`.
|
|
764
|
+
|
|
765
|
+
Requires chunk_size % 4 == 0 (validated for signal_injection_chunk_size in cli.py), so
|
|
766
|
+
quarter = chunk_size // 4 and half = chunk_size // 2 are exact — no oversample/subsample
|
|
767
|
+
dance and no post-hoc stats filtering.
|
|
768
|
+
"""
|
|
769
|
+
if chunk_size % 4 != 0:
|
|
770
|
+
raise ValueError(f"chunk_size must be divisible by 4, got {chunk_size}")
|
|
771
|
+
|
|
772
|
+
quarter = chunk_size // 4
|
|
773
|
+
half = chunk_size // 2
|
|
774
|
+
|
|
775
|
+
def seg(array_name, signal_class, signal_type, fn, inject, dyn, offset, count):
|
|
776
|
+
return ChunkSegment(
|
|
777
|
+
array_name=array_name,
|
|
778
|
+
signal_class=signal_class,
|
|
779
|
+
signal_type=signal_type,
|
|
780
|
+
create_fn_name=fn,
|
|
781
|
+
inject=inject,
|
|
782
|
+
dynamic_range=dyn,
|
|
783
|
+
start_idx=chunk_start + offset,
|
|
784
|
+
count=count,
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
return [
|
|
788
|
+
# main: 4-way balanced quarters (order matches _SIGNAL_TYPES / the labels array)
|
|
789
|
+
seg("main", "main", "false_no_signal", "create_false", False, None, 0, quarter),
|
|
790
|
+
seg("main", "main", "false_with_rfi", "create_false", True, None, quarter, quarter),
|
|
791
|
+
seg(
|
|
792
|
+
"main", "main", "true_only_eti", "create_true_single", None, None, 2 * quarter, quarter
|
|
793
|
+
),
|
|
794
|
+
seg("main", "main", "true_eti_rfi", "create_true_double", None, 1.0, 3 * quarter, quarter),
|
|
795
|
+
# false: 2-way balanced halves
|
|
796
|
+
seg("false", "false", "false_no_signal", "create_false", False, None, 0, half),
|
|
797
|
+
seg("false", "false", "false_with_rfi", "create_false", True, None, half, half),
|
|
798
|
+
# true: 2-way balanced halves
|
|
799
|
+
seg("true", "true", "true_only_eti", "create_true_single", None, None, 0, half),
|
|
800
|
+
seg("true", "true", "true_eti_rfi", "create_true_double", None, 1.0, half, half),
|
|
801
|
+
]
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def build_segment_tasks(
|
|
805
|
+
segment: ChunkSegment,
|
|
806
|
+
array_path: str,
|
|
807
|
+
task_size: int,
|
|
808
|
+
snr_base: float,
|
|
809
|
+
snr_range: float,
|
|
810
|
+
width_bin: int,
|
|
811
|
+
freq_resolution: float,
|
|
812
|
+
time_resolution: float,
|
|
813
|
+
seed_rng: np.random.Generator,
|
|
814
|
+
lognorm_path: str | None = None,
|
|
815
|
+
) -> list[tuple]:
|
|
816
|
+
"""
|
|
817
|
+
Partition one segment into batched worker tasks of at most `task_size` cadences each.
|
|
818
|
+
Together the tasks cover rows [segment.start_idx, segment.start_idx + segment.count)
|
|
819
|
+
exactly once. Each task carries a fresh RNG seed drawn from `seed_rng` so results don't
|
|
820
|
+
depend on which persistent worker picks the task up. `lognorm_path`, when given, is the
|
|
821
|
+
sibling memmap the task writes each cadence's per-observation log-norm parameters into.
|
|
822
|
+
"""
|
|
823
|
+
if task_size < 1:
|
|
824
|
+
raise ValueError(f"task_size must be >= 1, got {task_size}")
|
|
825
|
+
|
|
826
|
+
tasks = []
|
|
827
|
+
for offset in range(0, segment.count, task_size):
|
|
828
|
+
count = min(task_size, segment.count - offset)
|
|
829
|
+
seed = int(seed_rng.integers(0, 2**31 - 1))
|
|
830
|
+
tasks.append(
|
|
831
|
+
(
|
|
832
|
+
array_path,
|
|
833
|
+
segment.start_idx + offset,
|
|
834
|
+
count,
|
|
835
|
+
segment.create_fn_name,
|
|
836
|
+
snr_base,
|
|
837
|
+
snr_range,
|
|
838
|
+
width_bin,
|
|
839
|
+
freq_resolution,
|
|
840
|
+
time_resolution,
|
|
841
|
+
segment.inject,
|
|
842
|
+
segment.dynamic_range,
|
|
843
|
+
seed,
|
|
844
|
+
lognorm_path,
|
|
845
|
+
)
|
|
846
|
+
)
|
|
847
|
+
return tasks
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _run_memmap_task(args: tuple, backgrounds: np.ndarray) -> tuple[float, list[dict]]:
|
|
851
|
+
"""
|
|
852
|
+
Execute one batched generation task against `backgrounds`: open the target .npy r+ (like
|
|
853
|
+
preprocessing._extract_stamps_worker), generate `count` cadences with the chosen create_*
|
|
854
|
+
function, and write each result row straight into the memmap (plus its per-observation
|
|
855
|
+
log-norm parameters into the sibling lognorm memmap). Tasks address disjoint row ranges,
|
|
856
|
+
so concurrent pool writes never collide. Returns (elapsed_seconds, [sample_info, ...]) —
|
|
857
|
+
the only data that crosses the IPC boundary.
|
|
858
|
+
"""
|
|
859
|
+
(
|
|
860
|
+
array_path,
|
|
861
|
+
start_idx,
|
|
862
|
+
count,
|
|
863
|
+
create_fn_name,
|
|
864
|
+
snr_base,
|
|
865
|
+
snr_range,
|
|
866
|
+
width_bin,
|
|
867
|
+
freq_resolution,
|
|
868
|
+
time_resolution,
|
|
869
|
+
inject,
|
|
870
|
+
dynamic_range,
|
|
871
|
+
seed,
|
|
872
|
+
lognorm_path,
|
|
873
|
+
) = args
|
|
874
|
+
|
|
875
|
+
task_start = time.time()
|
|
876
|
+
|
|
877
|
+
# Per-task seeding keeps results independent of worker scheduling (workers are
|
|
878
|
+
# persistent, so PID-only seeding from _init_worker would tie the stream to which
|
|
879
|
+
# worker happens to pick the task up).
|
|
880
|
+
# NOTE: this seeds the LEGACY global RNGs because new_cadence/create_* draw from
|
|
881
|
+
# random.random() and the np.random.* module API. The determinism therefore holds only
|
|
882
|
+
# as long as nothing else mutates the global RNG state between cadences within a task;
|
|
883
|
+
# threading an explicit np.random.Generator through the create_* functions would remove
|
|
884
|
+
# that coupling if it ever matters. In practice the coupling is not exposed: pool workers
|
|
885
|
+
# are single-threaded processes, and the only in-process (pool=None) generation is the RF
|
|
886
|
+
# dataset, which runs after the training datasets/iterators are torn down (train_round's
|
|
887
|
+
# finally: holder.clear() -> del datasets -> clear_session -> gc), so no tf.data generator
|
|
888
|
+
# thread is alive mutating global RNG state during it. Per-task seeds are drawn from the
|
|
889
|
+
# per-round seed_rng in generate_round_to_memmap — derived from config.reproducibility.seed
|
|
890
|
+
# when set (making generation reproducible across runs), OS entropy otherwise; either
|
|
891
|
+
# way this reseed keeps results independent of worker scheduling within a run.
|
|
892
|
+
random.seed(seed)
|
|
893
|
+
np.random.seed(seed % (2**32))
|
|
894
|
+
|
|
895
|
+
create_fn = _CREATE_FUNCTIONS[create_fn_name]
|
|
896
|
+
all_sample_info = []
|
|
897
|
+
|
|
898
|
+
out = np.lib.format.open_memmap(array_path, mode="r+")
|
|
899
|
+
lognorm_out = (
|
|
900
|
+
np.lib.format.open_memmap(lognorm_path, mode="r+") if lognorm_path is not None else None
|
|
901
|
+
)
|
|
902
|
+
try:
|
|
903
|
+
for i in range(count):
|
|
904
|
+
cadence, sample_info = create_fn(
|
|
905
|
+
backgrounds,
|
|
906
|
+
snr_base=snr_base,
|
|
907
|
+
snr_range=snr_range,
|
|
908
|
+
width_bin=width_bin,
|
|
909
|
+
freq_resolution=freq_resolution,
|
|
910
|
+
time_resolution=time_resolution,
|
|
911
|
+
inject=inject,
|
|
912
|
+
dynamic_range=dynamic_range,
|
|
913
|
+
)
|
|
914
|
+
out[start_idx + i] = cadence
|
|
915
|
+
# Log-norm params land in the sibling memmap, not the IPC stats payload (they're
|
|
916
|
+
# per-observation display metadata, not DB-bound injection stats)
|
|
917
|
+
lognorm_params = sample_info.pop("lognorm_params")
|
|
918
|
+
if lognorm_out is not None:
|
|
919
|
+
lognorm_out[start_idx + i] = lognorm_params
|
|
920
|
+
all_sample_info.append(sample_info)
|
|
921
|
+
finally:
|
|
922
|
+
# No per-task flush: memmap.flush() is an msync over the task's whole mapping, and at
|
|
923
|
+
# ~23,400 tasks per production round the concurrent full-mapping msyncs stack into the
|
|
924
|
+
# chunk-tail stragglers (#117/#118). Durability is unchanged: written pages live in the
|
|
925
|
+
# shared page cache regardless, and generate_round_to_memmap msyncs every array once,
|
|
926
|
+
# before the .done manifest is written — a crash before that leaves no manifest, so the
|
|
927
|
+
# dir reads as garbage either way.
|
|
928
|
+
del out
|
|
929
|
+
if lognorm_out is not None:
|
|
930
|
+
del lognorm_out
|
|
931
|
+
|
|
932
|
+
return time.time() - task_start, all_sample_info
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _memmap_task_worker(args: tuple) -> tuple[float, list[dict]]:
|
|
936
|
+
"""Pool entry point for batched memmap tasks: run against the shared-memory backgrounds."""
|
|
937
|
+
return _run_memmap_task(args, _GLOBAL_BACKGROUNDS)
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def write_segment_stats(db, tag: str, segment: dict) -> None:
|
|
941
|
+
"""
|
|
942
|
+
Write one class-segment's injection stats to the DB (main-process only — the DB queue is
|
|
943
|
+
a thread queue.Queue, not process-safe). `segment` is the dict emitted by
|
|
944
|
+
generate_round_to_memmap's stats_cb.
|
|
945
|
+
|
|
946
|
+
Per-sample rows: 6 intensity stats x 3 stages = 18 rows, plus 0-12 signal-characteristic
|
|
947
|
+
rows depending on signal_type, plus 2 intersection-retry rows for true-double samples
|
|
948
|
+
(#118). Segment-level rows: 4 metadata rows. The rows (identical in content to the old
|
|
949
|
+
per-row write_injection_stat calls) are batched into one write_injection_stats_bulk call
|
|
950
|
+
on the DB's bounded bulk lane (#277): a segment costs a handful of queue operations
|
|
951
|
+
instead of ~300K, and the enqueue blocks THIS (background) thread when the writer is
|
|
952
|
+
behind rather than growing an unbounded queue.
|
|
953
|
+
"""
|
|
954
|
+
if db is None:
|
|
955
|
+
raise RuntimeError("No database instance detected - cannot write injection stats")
|
|
956
|
+
|
|
957
|
+
round_number = segment["round_number"]
|
|
958
|
+
chunk_number = segment["chunk_number"]
|
|
959
|
+
signal_class = segment["signal_class"]
|
|
960
|
+
signal_type = segment["signal_type"]
|
|
961
|
+
timestamp = segment["timestamp"]
|
|
962
|
+
|
|
963
|
+
common = {
|
|
964
|
+
"round_number": round_number,
|
|
965
|
+
"chunk_number": chunk_number,
|
|
966
|
+
"signal_class": signal_class,
|
|
967
|
+
"signal_type": signal_type,
|
|
968
|
+
"timestamp": timestamp,
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
rows: list[dict] = []
|
|
972
|
+
for sample_idx, sample_info in enumerate(segment["stats_list"]):
|
|
973
|
+
background_index = sample_info["background_index"]
|
|
974
|
+
intensity_stats = sample_info["intensity_stats"]
|
|
975
|
+
signal_info = sample_info["signal_info"]
|
|
976
|
+
slope_was_clamped = sample_info.get("slope_was_clamped", False)
|
|
977
|
+
|
|
978
|
+
# Intensity stats for each stage
|
|
979
|
+
for stage in ["A", "B", "C"]:
|
|
980
|
+
stage_stats = intensity_stats[stage]
|
|
981
|
+
|
|
982
|
+
for stat_name in [
|
|
983
|
+
"global_mean",
|
|
984
|
+
"global_median",
|
|
985
|
+
"global_std",
|
|
986
|
+
"global_mad",
|
|
987
|
+
"global_skew",
|
|
988
|
+
"global_kurtosis",
|
|
989
|
+
]:
|
|
990
|
+
rows.append(
|
|
991
|
+
{
|
|
992
|
+
**common,
|
|
993
|
+
"stat_name": stat_name,
|
|
994
|
+
"value": stage_stats[stat_name],
|
|
995
|
+
"sample_index": sample_idx,
|
|
996
|
+
"background_index": background_index,
|
|
997
|
+
"injection_stage": stage,
|
|
998
|
+
"slope_clamped": slope_was_clamped,
|
|
999
|
+
}
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
# NOTE: what happens when signal_info is empty for false_no_rfi signal types?
|
|
1003
|
+
# Signal characteristics (eti_snr, rfi_drift_rate, etc.)
|
|
1004
|
+
# injection_stage=None since these describe the injection itself
|
|
1005
|
+
for stat_name, value in signal_info.items():
|
|
1006
|
+
rows.append(
|
|
1007
|
+
{
|
|
1008
|
+
**common,
|
|
1009
|
+
"stat_name": stat_name,
|
|
1010
|
+
"value": float(value),
|
|
1011
|
+
"sample_index": sample_idx,
|
|
1012
|
+
"background_index": background_index,
|
|
1013
|
+
"injection_stage": None,
|
|
1014
|
+
"slope_clamped": slope_was_clamped,
|
|
1015
|
+
}
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
# Straggler observability (#118): true-double samples record how many injection
|
|
1019
|
+
# attempts the intersection-retry loop took and whether it exhausted the cap.
|
|
1020
|
+
# injection_stage=None since these describe the injection itself
|
|
1021
|
+
if "intersection_retries" in sample_info:
|
|
1022
|
+
retry_stats = [
|
|
1023
|
+
("intersection_retries", float(sample_info["intersection_retries"])),
|
|
1024
|
+
("intersection_retry_capped", float(sample_info["intersection_retry_capped"])),
|
|
1025
|
+
]
|
|
1026
|
+
for stat_name, value in retry_stats:
|
|
1027
|
+
rows.append(
|
|
1028
|
+
{
|
|
1029
|
+
**common,
|
|
1030
|
+
"stat_name": stat_name,
|
|
1031
|
+
"value": value,
|
|
1032
|
+
"sample_index": sample_idx,
|
|
1033
|
+
"background_index": background_index,
|
|
1034
|
+
"injection_stage": None,
|
|
1035
|
+
"slope_clamped": slope_was_clamped,
|
|
1036
|
+
}
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
# Segment-level metadata stats (once per segment, not per sample)
|
|
1040
|
+
metadata_stats = [
|
|
1041
|
+
("snr_range_floor", segment["snr_range_floor"]),
|
|
1042
|
+
("snr_range_ceil", segment["snr_range_ceil"]),
|
|
1043
|
+
("num_samples", float(segment["num_samples"])),
|
|
1044
|
+
("inject_duration", segment["inject_duration"]),
|
|
1045
|
+
]
|
|
1046
|
+
|
|
1047
|
+
for stat_name, value in metadata_stats:
|
|
1048
|
+
rows.append(
|
|
1049
|
+
{
|
|
1050
|
+
**common,
|
|
1051
|
+
"stat_name": stat_name,
|
|
1052
|
+
"value": value,
|
|
1053
|
+
"sample_index": None,
|
|
1054
|
+
"background_index": None,
|
|
1055
|
+
"injection_stage": None,
|
|
1056
|
+
}
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
db.write_injection_stats_bulk(rows, tag=tag)
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def generate_round_to_memmap(
|
|
1063
|
+
paths: RoundDataPaths,
|
|
1064
|
+
n_samples: int,
|
|
1065
|
+
snr_base: float,
|
|
1066
|
+
snr_range: float,
|
|
1067
|
+
*,
|
|
1068
|
+
width_bin: int,
|
|
1069
|
+
num_observations: int,
|
|
1070
|
+
time_bins: int,
|
|
1071
|
+
chunk_size: int,
|
|
1072
|
+
task_size: int,
|
|
1073
|
+
freq_resolution: float,
|
|
1074
|
+
time_resolution: float,
|
|
1075
|
+
pool: Pool | None = None,
|
|
1076
|
+
backgrounds: np.ndarray | None = None,
|
|
1077
|
+
round_num: int | None = None,
|
|
1078
|
+
seed: int | None = None,
|
|
1079
|
+
stats_cb=None,
|
|
1080
|
+
progress_cb=None,
|
|
1081
|
+
array_dtype: str = "float32",
|
|
1082
|
+
) -> dict:
|
|
1083
|
+
"""
|
|
1084
|
+
Generate one round's triplet dataset straight into disk-backed .npy memmaps.
|
|
1085
|
+
|
|
1086
|
+
main: collapsed cadences, 1/4 balanced across the 4 signal types (labels array tracks the
|
|
1087
|
+
per-row type); false: 1/2 false_no_signal + 1/2 false_with_rfi; true: 1/2 true_only_eti +
|
|
1088
|
+
1/2 true_eti_rfi — each of shape (n_samples, num_observations, time_bins, width_bin)
|
|
1089
|
+
float32, laid out contiguously per chunk (see build_chunk_segments). Each array gets a
|
|
1090
|
+
sibling {name}_lognorm.npy of shape (n_samples, num_observations, 2) carrying the
|
|
1091
|
+
per-observation (min_log, range_log) normalization parameters.
|
|
1092
|
+
|
|
1093
|
+
Work is dispatched as one unified batched task list per chunk through a single pool.map
|
|
1094
|
+
barrier (instead of the old 8 sequential per-class barriers); workers write rows in-place
|
|
1095
|
+
and return only stats dicts. With `pool` None, tasks run sequentially in-process against
|
|
1096
|
+
`backgrounds`. On success the labels array is saved and an atomic .done manifest is
|
|
1097
|
+
written (a crash mid-generation leaves no manifest, so the dir reads as garbage).
|
|
1098
|
+
|
|
1099
|
+
stats_cb(segment_dict) fires once per class-segment per chunk; progress_cb(chunk,
|
|
1100
|
+
n_chunks) once per chunk. Returns the manifest dict.
|
|
1101
|
+
|
|
1102
|
+
`seed` is the pipeline root seed (config.reproducibility.seed): when set, per-task seeds derive
|
|
1103
|
+
deterministically from (seed, round_num) and the same call regenerates byte-identical
|
|
1104
|
+
data; None keeps the OS-entropy behavior.
|
|
1105
|
+
"""
|
|
1106
|
+
if n_samples % 4 != 0:
|
|
1107
|
+
raise ValueError(f"n_samples must be divisible by 4, got {n_samples}")
|
|
1108
|
+
if chunk_size % 4 != 0:
|
|
1109
|
+
raise ValueError(f"chunk_size must be divisible by 4, got {chunk_size}")
|
|
1110
|
+
if pool is None and backgrounds is None:
|
|
1111
|
+
raise ValueError("backgrounds must be provided when no pool is given")
|
|
1112
|
+
if array_dtype not in ("float32", "float16"):
|
|
1113
|
+
raise ValueError(f"array_dtype must be 'float32' or 'float16', got {array_dtype!r}")
|
|
1114
|
+
|
|
1115
|
+
wall_start = time.time()
|
|
1116
|
+
|
|
1117
|
+
# Start from a clean slate: a previous partial generation (no .done manifest) may have
|
|
1118
|
+
# left stale arrays behind.
|
|
1119
|
+
shutil.rmtree(paths.round_dir, ignore_errors=True)
|
|
1120
|
+
os.makedirs(paths.round_dir, exist_ok=True)
|
|
1121
|
+
|
|
1122
|
+
# Pre-create the three destination memmaps; workers reopen them r+ per task and numpy
|
|
1123
|
+
# downcasts on row assignment, so array_dtype="float16" needs no worker-side changes
|
|
1124
|
+
# (the config.py field documents the quantization bound and the A/B gate)
|
|
1125
|
+
shape = (n_samples, num_observations, time_bins, width_bin)
|
|
1126
|
+
array_paths = paths.array_paths
|
|
1127
|
+
for path in array_paths.values():
|
|
1128
|
+
mm = np.lib.format.open_memmap(path, mode="w+", dtype=np.dtype(array_dtype), shape=shape)
|
|
1129
|
+
del mm # Close immediately: creation only reserves the file; workers do the writing
|
|
1130
|
+
|
|
1131
|
+
# Sibling per-observation log-norm parameter arrays ((min_log, range_log) per obs —
|
|
1132
|
+
# n_samples x num_observations x 2 float32, so MBs, not GBs, per array), recorded so the
|
|
1133
|
+
# latent-traversal plot can approximately invert the normalization
|
|
1134
|
+
lognorm_paths = paths.lognorm_paths
|
|
1135
|
+
lognorm_shape = (n_samples, num_observations, 2)
|
|
1136
|
+
for path in lognorm_paths.values():
|
|
1137
|
+
mm = np.lib.format.open_memmap(path, mode="w+", dtype=np.float32, shape=lognorm_shape)
|
|
1138
|
+
del mm
|
|
1139
|
+
|
|
1140
|
+
labels = np.empty(n_samples, dtype="U20")
|
|
1141
|
+
|
|
1142
|
+
n_chunks = max(1, (n_samples + chunk_size - 1) // chunk_size)
|
|
1143
|
+
# Per-task seeds are drawn from this stream. With a root `seed` it derives
|
|
1144
|
+
# deterministically from (seed, round), so the same seed regenerates identical data;
|
|
1145
|
+
# with seed=None it falls back to OS entropy (non-reproducible, the historical
|
|
1146
|
+
# behavior). The RF dataset passes round_num=num_training_rounds+1 (the supersede
|
|
1147
|
+
# sentinel — see train.train_random_forest); beta-VAE rounds are 1-based, so the
|
|
1148
|
+
# streams never collide. The None->0 fallback below is only a signature default.
|
|
1149
|
+
seed_rng = derive_rng(seed, STREAM_DATA_GEN, round_num if round_num is not None else 0)
|
|
1150
|
+
|
|
1151
|
+
logger.info(
|
|
1152
|
+
f"Generating {n_samples} samples into {paths.round_dir} "
|
|
1153
|
+
f"({n_chunks} chunks of max {chunk_size}, task size {task_size})"
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
for chunk_idx in range(n_chunks):
|
|
1157
|
+
chunk_start = chunk_idx * chunk_size
|
|
1158
|
+
this_chunk_size = min(chunk_size, n_samples - chunk_start)
|
|
1159
|
+
if this_chunk_size <= 0:
|
|
1160
|
+
break
|
|
1161
|
+
|
|
1162
|
+
# Capture single timestamp for all stats in this chunk
|
|
1163
|
+
chunk_timestamp = time.time()
|
|
1164
|
+
|
|
1165
|
+
segments = build_chunk_segments(chunk_start, this_chunk_size)
|
|
1166
|
+
|
|
1167
|
+
# One unified task list across all 8 class-segments -> one pool.map barrier per chunk
|
|
1168
|
+
tasks: list[tuple] = []
|
|
1169
|
+
task_owners: list[int] = [] # task index -> segment index (map preserves order)
|
|
1170
|
+
for segment_idx, segment in enumerate(segments):
|
|
1171
|
+
segment_tasks = build_segment_tasks(
|
|
1172
|
+
segment,
|
|
1173
|
+
array_paths[segment.array_name],
|
|
1174
|
+
task_size,
|
|
1175
|
+
snr_base,
|
|
1176
|
+
snr_range,
|
|
1177
|
+
width_bin,
|
|
1178
|
+
freq_resolution,
|
|
1179
|
+
time_resolution,
|
|
1180
|
+
seed_rng,
|
|
1181
|
+
lognorm_path=lognorm_paths[segment.array_name],
|
|
1182
|
+
)
|
|
1183
|
+
tasks.extend(segment_tasks)
|
|
1184
|
+
task_owners.extend([segment_idx] * len(segment_tasks))
|
|
1185
|
+
|
|
1186
|
+
# Labels mirror the main array's contiguous per-chunk layout
|
|
1187
|
+
quarter = this_chunk_size // 4
|
|
1188
|
+
chunk_labels: list[str] = []
|
|
1189
|
+
for signal_type in _SIGNAL_TYPES:
|
|
1190
|
+
chunk_labels.extend([signal_type] * quarter)
|
|
1191
|
+
labels[chunk_start : chunk_start + this_chunk_size] = chunk_labels
|
|
1192
|
+
|
|
1193
|
+
logger.info(
|
|
1194
|
+
f"Generating chunk {chunk_idx + 1}/{n_chunks} "
|
|
1195
|
+
f"({this_chunk_size} samples, {len(tasks)} tasks)"
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
if pool is not None:
|
|
1199
|
+
# chunksize=1: each task is already a coarse unit of work (task_size cadences),
|
|
1200
|
+
# so per-task scheduling overhead is negligible and load balance is best
|
|
1201
|
+
results = pool.map(_memmap_task_worker, tasks, chunksize=1)
|
|
1202
|
+
else:
|
|
1203
|
+
results = [_run_memmap_task(task, backgrounds) for task in tasks]
|
|
1204
|
+
|
|
1205
|
+
# Group returned stats by segment. inject_duration is the sum of the segment's
|
|
1206
|
+
# per-task wall times (aggregate worker time, not the barrier wall time — tasks from
|
|
1207
|
+
# all segments run interleaved across the pool, so per-segment barrier walls no
|
|
1208
|
+
# longer exist).
|
|
1209
|
+
segment_stats: list[list[dict]] = [[] for _ in segments]
|
|
1210
|
+
segment_durations = [0.0] * len(segments)
|
|
1211
|
+
for owner, (task_duration, task_stats) in zip(task_owners, results, strict=True):
|
|
1212
|
+
segment_stats[owner].extend(task_stats)
|
|
1213
|
+
segment_durations[owner] += task_duration
|
|
1214
|
+
|
|
1215
|
+
if stats_cb is not None:
|
|
1216
|
+
for segment, stats_list, duration in zip(
|
|
1217
|
+
segments, segment_stats, segment_durations, strict=True
|
|
1218
|
+
):
|
|
1219
|
+
stats_cb(
|
|
1220
|
+
{
|
|
1221
|
+
"round_number": round_num,
|
|
1222
|
+
"chunk_number": chunk_idx + 1,
|
|
1223
|
+
"signal_class": segment.signal_class,
|
|
1224
|
+
"signal_type": segment.signal_type,
|
|
1225
|
+
"snr_range_floor": snr_base,
|
|
1226
|
+
"snr_range_ceil": snr_base + snr_range,
|
|
1227
|
+
"num_samples": len(stats_list),
|
|
1228
|
+
"inject_duration": duration,
|
|
1229
|
+
"timestamp": chunk_timestamp,
|
|
1230
|
+
"stats_list": stats_list,
|
|
1231
|
+
}
|
|
1232
|
+
)
|
|
1233
|
+
|
|
1234
|
+
del results, segment_stats
|
|
1235
|
+
gc.collect()
|
|
1236
|
+
|
|
1237
|
+
if progress_cb is not None:
|
|
1238
|
+
progress_cb(chunk_idx + 1, n_chunks)
|
|
1239
|
+
logger.info(f"Chunk {chunk_idx + 1}/{n_chunks} complete")
|
|
1240
|
+
|
|
1241
|
+
np.save(paths.labels_path, labels)
|
|
1242
|
+
|
|
1243
|
+
# One msync per array replaces the per-task flushes that used to live in
|
|
1244
|
+
# _run_memmap_task's finally: everything the workers wrote must reach disk before the
|
|
1245
|
+
# .done manifest can exist (the same durability contract, without ~23,400 concurrent
|
|
1246
|
+
# full-mapping msyncs per production round).
|
|
1247
|
+
for path in (*array_paths.values(), *lognorm_paths.values()):
|
|
1248
|
+
arr = np.lib.format.open_memmap(path, mode="r+")
|
|
1249
|
+
arr.flush()
|
|
1250
|
+
del arr
|
|
1251
|
+
|
|
1252
|
+
# The .done manifest is only written after every chunk has finished — the same atomicity
|
|
1253
|
+
# idea as preprocessing's .tmp -> os.replace stamp extraction
|
|
1254
|
+
manifest = build_manifest(
|
|
1255
|
+
paths,
|
|
1256
|
+
n_samples=n_samples,
|
|
1257
|
+
snr_base=snr_base,
|
|
1258
|
+
snr_range=snr_range,
|
|
1259
|
+
wall_time_s=time.time() - wall_start,
|
|
1260
|
+
chunk_count=n_chunks,
|
|
1261
|
+
array_dtype=array_dtype,
|
|
1262
|
+
)
|
|
1263
|
+
write_done_manifest(paths, manifest)
|
|
1264
|
+
|
|
1265
|
+
logger.info(
|
|
1266
|
+
f"Round data generation complete: {paths.round_dir} ({manifest['wall_time_s']:.1f}s wall)"
|
|
1267
|
+
)
|
|
1268
|
+
return manifest
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
class DataGenerator:
|
|
1272
|
+
"""Synthetic data generator"""
|
|
1273
|
+
|
|
1274
|
+
def __init__(
|
|
1275
|
+
self,
|
|
1276
|
+
background_plates: np.ndarray,
|
|
1277
|
+
):
|
|
1278
|
+
"""
|
|
1279
|
+
Initialize the generator. background_plates is an array of preprocessed background
|
|
1280
|
+
observations with shape (n_backgrounds, 6, 16, 512). Plates are copied into shared memory
|
|
1281
|
+
for worker access; the worker pool itself is created lazily on the first in-process
|
|
1282
|
+
generate_round() call (when overlap_data_generation is on, the RoundDataProducer process
|
|
1283
|
+
owns its own pool against the same shared memory, and this one is never needed for the
|
|
1284
|
+
beta-VAE rounds).
|
|
1285
|
+
"""
|
|
1286
|
+
self.config = get_config()
|
|
1287
|
+
if self.config is None:
|
|
1288
|
+
raise ValueError("get_config() returned None")
|
|
1289
|
+
|
|
1290
|
+
self.db = get_db()
|
|
1291
|
+
if self.db is None:
|
|
1292
|
+
raise ValueError("get_db() returned None")
|
|
1293
|
+
|
|
1294
|
+
self.manager = get_manager()
|
|
1295
|
+
if self.manager is None:
|
|
1296
|
+
raise ValueError("get_manager() returned None")
|
|
1297
|
+
|
|
1298
|
+
# Pool is created on demand by _ensure_pool()
|
|
1299
|
+
self.pool = None
|
|
1300
|
+
|
|
1301
|
+
# Load background plates into shared memory
|
|
1302
|
+
self._load_backgrounds(background_plates)
|
|
1303
|
+
|
|
1304
|
+
def _load_backgrounds(self, background_plates: np.ndarray):
|
|
1305
|
+
"""Load background plates into shared memory"""
|
|
1306
|
+
# Sanity check: verify no NaN or Inf values in background plates
|
|
1307
|
+
if np.isnan(background_plates).any():
|
|
1308
|
+
raise ValueError("background_plates contains NaN values")
|
|
1309
|
+
if np.isinf(background_plates).any():
|
|
1310
|
+
raise ValueError("background_plates contains Inf values")
|
|
1311
|
+
|
|
1312
|
+
self.n_backgrounds = len(background_plates)
|
|
1313
|
+
self._background_shape = background_plates.shape
|
|
1314
|
+
self._background_dtype = background_plates.dtype
|
|
1315
|
+
|
|
1316
|
+
# Sanity check: verify downsampling working as expected
|
|
1317
|
+
width_bin_downsampled = self.config.data.width_bin // self.config.data.downsample_factor
|
|
1318
|
+
if self._background_shape[3] != width_bin_downsampled:
|
|
1319
|
+
raise ValueError(
|
|
1320
|
+
f"Expected {width_bin_downsampled} channels. Got {self._background_shape[3]} instead"
|
|
1321
|
+
)
|
|
1322
|
+
|
|
1323
|
+
self.width_bin = width_bin_downsampled
|
|
1324
|
+
self.freq_resolution = self.config.data.freq_resolution
|
|
1325
|
+
self.time_resolution = self.config.data.time_resolution
|
|
1326
|
+
|
|
1327
|
+
# Get multiprocessing params from config
|
|
1328
|
+
self.n_processes = self.config.manager.n_processes
|
|
1329
|
+
|
|
1330
|
+
# Setup shared memory to avoid duplicating background data across workers
|
|
1331
|
+
if self.n_processes > 1:
|
|
1332
|
+
# Create shared memory block for background data
|
|
1333
|
+
nbytes = background_plates.nbytes
|
|
1334
|
+
self.shm = self.manager.create_shared_memory(
|
|
1335
|
+
size=nbytes,
|
|
1336
|
+
name=f"DataGen_backgrounds_{id(self)}", # NOTE: come back to this later
|
|
1337
|
+
)
|
|
1338
|
+
|
|
1339
|
+
# Copy background data into shared memory
|
|
1340
|
+
shared_array = np.ndarray(
|
|
1341
|
+
self._background_shape,
|
|
1342
|
+
dtype=self._background_dtype,
|
|
1343
|
+
buffer=self.shm.buf, # NOTE: what is self.shm.buf?
|
|
1344
|
+
)
|
|
1345
|
+
shared_array[:] = background_plates[:]
|
|
1346
|
+
self.backgrounds = shared_array
|
|
1347
|
+
else:
|
|
1348
|
+
self.shm = None
|
|
1349
|
+
self.backgrounds = background_plates
|
|
1350
|
+
|
|
1351
|
+
logger.info(f"DataGenerator initialized with {self.n_backgrounds} background plates")
|
|
1352
|
+
logger.info(f" Background shape: {self._background_shape}")
|
|
1353
|
+
logger.info(f" Background dtype: {self._background_dtype}")
|
|
1354
|
+
|
|
1355
|
+
def _ensure_pool(self):
|
|
1356
|
+
"""
|
|
1357
|
+
Lazily stand up the persistent ResourceManager-owned multiprocessing pool whose workers
|
|
1358
|
+
attach to the shared-memory background block at init time, so per-task dispatches don't
|
|
1359
|
+
have to re-serialize the plates. No-op in sequential mode (n_processes == 1, no shared
|
|
1360
|
+
memory) — generate_round() then runs tasks in-process.
|
|
1361
|
+
|
|
1362
|
+
The pool must be released via _free_managed_pool() or close() — the ResourceManager
|
|
1363
|
+
won't reap it automatically.
|
|
1364
|
+
"""
|
|
1365
|
+
# NOTE: should we explicitly guarantee only 1 shm & 1 pool can exist at a time?
|
|
1366
|
+
if self.pool is not None or not self.shm:
|
|
1367
|
+
return
|
|
1368
|
+
self.pool = self.manager.create_pool(
|
|
1369
|
+
n_processes=self.n_processes,
|
|
1370
|
+
name=f"DataGen_pool_{id(self)}", # NOTE: come back to this later
|
|
1371
|
+
initializer=_init_worker,
|
|
1372
|
+
initargs=(self.shm.name, self._background_shape, self._background_dtype),
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
def _free_managed_pool(self):
|
|
1376
|
+
"""Close multiprocessing pool"""
|
|
1377
|
+
if hasattr(self, "pool") and self.pool is not None:
|
|
1378
|
+
self.manager.close_pool(self.pool)
|
|
1379
|
+
self.pool = None
|
|
1380
|
+
|
|
1381
|
+
def reset_managed_pool(self):
|
|
1382
|
+
"""
|
|
1383
|
+
Reset multiprocessing pool
|
|
1384
|
+
|
|
1385
|
+
Should be called between training rounds, since workers can accumulate memory through
|
|
1386
|
+
memory fragmentation in long-lived processes, python's reference counter leaking in workers,
|
|
1387
|
+
and caches / global state accumulating in workers. The pool is re-created lazily by
|
|
1388
|
+
_ensure_pool() on the next generate_round() call.
|
|
1389
|
+
"""
|
|
1390
|
+
if hasattr(self, "pool") and self.pool is not None:
|
|
1391
|
+
try:
|
|
1392
|
+
self._free_managed_pool()
|
|
1393
|
+
gc.collect() # Garbage collect between resets
|
|
1394
|
+
except Exception as e:
|
|
1395
|
+
logger.warning(f"Error resetting DataGenerator pool: {e}")
|
|
1396
|
+
|
|
1397
|
+
def _free_managed_shared_memory(self):
|
|
1398
|
+
"""Close shared memory"""
|
|
1399
|
+
if hasattr(self, "shm") and self.shm is not None:
|
|
1400
|
+
self.manager.close_shared_memory(self.shm)
|
|
1401
|
+
self.shm = None
|
|
1402
|
+
|
|
1403
|
+
def close(self):
|
|
1404
|
+
"""Free managed resources & close DataGenerator"""
|
|
1405
|
+
self._free_managed_pool()
|
|
1406
|
+
self._free_managed_shared_memory()
|
|
1407
|
+
logger.info("DataGenerator closed")
|
|
1408
|
+
|
|
1409
|
+
def generate_round(
|
|
1410
|
+
self,
|
|
1411
|
+
paths,
|
|
1412
|
+
n_samples: int,
|
|
1413
|
+
snr_base: float,
|
|
1414
|
+
snr_range: float,
|
|
1415
|
+
round_num: int | None = None,
|
|
1416
|
+
) -> dict:
|
|
1417
|
+
"""
|
|
1418
|
+
Generate one round's triplet dataset into the disk-backed memmaps at `paths`
|
|
1419
|
+
(a round_data.RoundDataPaths), writing injection stats to the DB as chunks complete.
|
|
1420
|
+
Used for the sequential (non-overlapped) generation path — the RF dataset, and beta-VAE
|
|
1421
|
+
rounds when overlap_data_generation is disabled. Returns the .done manifest dict.
|
|
1422
|
+
"""
|
|
1423
|
+
self._ensure_pool()
|
|
1424
|
+
|
|
1425
|
+
tag = self.config.checkpoint.save_tag
|
|
1426
|
+
|
|
1427
|
+
def _stats_cb(segment: dict) -> None:
|
|
1428
|
+
write_segment_stats(self.db, tag, segment)
|
|
1429
|
+
|
|
1430
|
+
return generate_round_to_memmap(
|
|
1431
|
+
paths=paths,
|
|
1432
|
+
n_samples=n_samples,
|
|
1433
|
+
snr_base=snr_base,
|
|
1434
|
+
snr_range=snr_range,
|
|
1435
|
+
width_bin=self.width_bin,
|
|
1436
|
+
num_observations=self._background_shape[1],
|
|
1437
|
+
time_bins=self._background_shape[2],
|
|
1438
|
+
chunk_size=self.config.training.signal_injection_chunk_size,
|
|
1439
|
+
task_size=self.config.training.data_gen_task_size,
|
|
1440
|
+
freq_resolution=self.freq_resolution,
|
|
1441
|
+
time_resolution=self.time_resolution,
|
|
1442
|
+
pool=self.pool,
|
|
1443
|
+
backgrounds=self.backgrounds if self.pool is None else None,
|
|
1444
|
+
round_num=round_num,
|
|
1445
|
+
seed=self.config.reproducibility.seed,
|
|
1446
|
+
stats_cb=_stats_cb,
|
|
1447
|
+
array_dtype=self.config.training.round_array_dtype,
|
|
1448
|
+
)
|