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,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
+ )