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/pfb.py ADDED
@@ -0,0 +1,161 @@
1
+ """
2
+ Polyphase filterbank (PFB) static passband equalization for Aetherscan's energy detection.
3
+
4
+ Radio-telescope backends (e.g. the GBT/Breakthrough Listen digital backend) channelize the
5
+ band with a polyphase filterbank whose prototype filter imprints the same scalloped passband
6
+ shape onto every coarse channel. Because that shape is a static property of the instrument's
7
+ filter design — not of the data — it can be computed once from the filter parameters and
8
+ divided out, instead of fitting a spline to every coarse channel of every file (the historical
9
+ default, and a real per-channel cost inside energy detection).
10
+
11
+ This is a native NumPy port of the response-generation logic in bliss
12
+ (github.com/n-west/bliss, bliss/preprocess/passband_static_equalize.cpp); it introduces no
13
+ bliss (or TensorFlow) dependency. One intentional deviation: the C++ approximates sinc near
14
+ zero with a truncated cosine product (cos(x/2)*cos(x/4)*cos(x/8) for |x| < 0.01) purely as a
15
+ numerical shortcut, while np.sinc evaluates the same normalized sinc exactly — the unit tests
16
+ pin the two filter designs against each other.
17
+
18
+ A separate, unrelated quirk lives in the instrument itself: the CASPER GBT512 configuration
19
+ (PFBPassband.jl) carries bug=true — the hardware's coefficient generation omits the
20
+ half-sample offset from the sinc argument (sinc evaluated at (n - N/2) / nchan instead of the
21
+ bug-free (n + 0.5 - N/2) / nchan, N = taps * nchan), mis-centering the sinc by half a sample
22
+ relative to the Hamming window. firdes below is exactly the bug-free design; the two folded
23
+ power responses agree to ~1e-5 (relative) at a typical GBT geometry (64 coarse channels,
24
+ 12 taps — bounded < 1e-4 by the unit tests, shrinking as N grows), orders of magnitude below
25
+ the 5% mismatch-warning tolerance, so the exact bug-free form is kept (issue #180).
26
+
27
+ The response depends on (fine_per_coarse, num_coarse_channels, taps_per_channel) only, so
28
+ gen_coarse_channel_response is lru_cached: each process pays the one-time FFT (seconds at
29
+ full GBT resolution), after which equalizing a channel is a single vectorized divide.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import functools
35
+ import logging
36
+
37
+ import numpy as np
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # Fraction of a coarse channel treated as the "edge" band (each side) and the "mid" band by
42
+ # edge_mid_power_ratio — the validation statistic used to detect a static-response mismatch.
43
+ _RATIO_BAND_FRACTION = 16
44
+
45
+
46
+ def firdes(num_taps: int, fc: float) -> np.ndarray:
47
+ """
48
+ Design a windowed-sinc (Hamming) lowpass FIR prototype filter.
49
+
50
+ h[n] = sinc(fc * (n - (num_taps - 1) / 2)) * w[n], where w is the Hamming window
51
+ w[n] = 0.54 - 0.46 * cos(2*pi*n / (num_taps - 1)) (== np.hamming) and np.sinc is the
52
+ normalized sin(pi*t)/(pi*t). fc is the cutoff as a fraction of the sample rate
53
+ (1/num_coarse_channels for a critically-sampled PFB). Returns float64 of shape (num_taps,).
54
+ """
55
+ n = np.arange(num_taps, dtype=np.float64)
56
+ return np.sinc(fc * (n - (num_taps - 1) / 2.0)) * np.hamming(num_taps)
57
+
58
+
59
+ # maxsize=4 bounds memory if a long-lived process sees several parameter combinations
60
+ # (e.g. a test session); a production run only ever uses one.
61
+ @functools.lru_cache(maxsize=4)
62
+ def gen_coarse_channel_response(
63
+ fine_per_coarse: int, num_coarse_channels: int, taps_per_channel: int
64
+ ) -> np.ndarray:
65
+ """
66
+ Compute the static per-coarse-channel passband response H of a critically-sampled PFB.
67
+
68
+ Steps (mirroring the bliss reference): design the prototype lowpass
69
+ (taps_per_channel * num_coarse_channels taps, cutoff 1/num_coarse_channels); zero-pad to
70
+ num_coarse_channels * fine_per_coarse points; take |fftshift(fft(.))|^2; drop half a
71
+ coarse channel from each end so the remaining band is an integer number of coarse-channel
72
+ spans centered on channel boundaries; fold those (num_coarse_channels - 1) spans on top of
73
+ each other (summing in adjacent-channel leakage); normalize to a peak of 1.0.
74
+
75
+ Returns H of shape (fine_per_coarse,), float64, read-only (the array is cached and shared
76
+ across callers — copy before mutating). Cached per argument tuple, so each process pays
77
+ the FFT cost once per distinct (file resolution, coarse count, taps) combination.
78
+ """
79
+ if fine_per_coarse < 2:
80
+ raise ValueError(f"fine_per_coarse must be >= 2, got {fine_per_coarse}")
81
+ if fine_per_coarse % 2 != 0:
82
+ # Half a coarse channel (fine_per_coarse // 2) is sliced off each end below, so the
83
+ # remaining band is an exact multiple of fine_per_coarse only when it is even.
84
+ raise ValueError(f"fine_per_coarse must be even, got {fine_per_coarse}")
85
+ if num_coarse_channels < 2:
86
+ # The fold needs at least one full coarse-channel span after edge-slicing.
87
+ raise ValueError(f"num_coarse_channels must be >= 2, got {num_coarse_channels}")
88
+ if taps_per_channel < 1:
89
+ raise ValueError(f"taps_per_channel must be >= 1, got {taps_per_channel}")
90
+
91
+ h = firdes(taps_per_channel * num_coarse_channels, 1.0 / num_coarse_channels)
92
+ full_len = num_coarse_channels * fine_per_coarse
93
+ padded = np.zeros(full_len, dtype=np.float64)
94
+ padded[: h.size] = h
95
+ spectrum = np.abs(np.fft.fftshift(np.fft.fft(padded))) ** 2
96
+
97
+ half_fine = fine_per_coarse // 2
98
+ sliced = spectrum[half_fine : full_len - half_fine]
99
+ response = sliced.reshape(num_coarse_channels - 1, fine_per_coarse).sum(axis=0)
100
+ response /= response.max()
101
+ response.setflags(write=False)
102
+
103
+ logger.debug(
104
+ f"Generated PFB coarse-channel response: fine_per_coarse={fine_per_coarse}, "
105
+ f"num_coarse_channels={num_coarse_channels}, taps_per_channel={taps_per_channel}"
106
+ )
107
+ return response
108
+
109
+
110
+ def equalize_passband(channel: np.ndarray, response: np.ndarray) -> np.ndarray:
111
+ """
112
+ Divide one coarse channel by the static passband response, broadcasting over time.
113
+
114
+ channel has shape (time_bins, fine_per_coarse) and response (fine_per_coarse,) — e.g. the
115
+ output of gen_coarse_channel_response. Returns the equalized channel (float64 when
116
+ response is float64); the inputs are not modified.
117
+ """
118
+ if channel.shape[-1] != response.shape[0]:
119
+ raise ValueError(
120
+ f"channel width {channel.shape[-1]} does not match response length {response.shape[0]}"
121
+ )
122
+ # Defensive floor: the 12-tap GBT design bottoms out around 0.25-0.5 at channel edges,
123
+ # but a very high taps_per_channel (sharper rolloff) could push edge bins toward zero
124
+ # and turn the divide into inf. The floor sits far below any physical response value,
125
+ # so realistic responses pass through untouched.
126
+ return channel / np.maximum(response, 1e-10)
127
+
128
+
129
+ def edge_mid_band_slices(n: int) -> tuple[slice, slice, slice]:
130
+ """
131
+ Return the (left edge, mid, right edge) bin slices edge_mid_power_ratio evaluates over a
132
+ coarse channel of n bins: the outermost n // 16 bins on each side and a central band of
133
+ the same width (_RATIO_BAND_FRACTION). Exposed so callers that only need the ratio can
134
+ read just these bands from disk (see preprocessing._flattened_edge_mid_ratio) while
135
+ staying bin-exact with edge_mid_power_ratio.
136
+ """
137
+ band = max(1, n // _RATIO_BAND_FRACTION)
138
+ left = slice(0, band)
139
+ mid = slice(n // 2 - band // 2, n // 2 + band // 2 + 1)
140
+ right = slice(n - band, n)
141
+ return left, mid, right
142
+
143
+
144
+ def edge_mid_power_ratio(spectrum: np.ndarray) -> float:
145
+ """
146
+ Mean power in the coarse-channel edge bands relative to the mid band.
147
+
148
+ spectrum is a 1-D per-bin power array over one coarse channel (either the theoretical
149
+ response H or a time-integrated data channel). Both the edge band (outermost n // 16 bins
150
+ on each side) and the mid band (a central band of the same n // 16 width) use
151
+ _RATIO_BAND_FRACTION (see edge_mid_band_slices). Applied to a channel flattened by the
152
+ static response (data / H), a ratio near 1.0 is the cheap residual-flatness sanity check
153
+ (after the bliss `validate` flag) for whether the configured response actually matches
154
+ the recording.
155
+ """
156
+ left, mid, right = edge_mid_band_slices(spectrum.shape[0])
157
+ edge = 0.5 * (float(spectrum[left].mean()) + float(spectrum[right].mean()))
158
+ mid_mean = float(spectrum[mid].mean())
159
+ # Defensive floor (mirroring equalize_passband's): an all-zero — e.g. fully masked —
160
+ # channel would otherwise raise ZeroDivisionError on the Python-float divide.
161
+ return edge / max(mid_mean, 1e-30)