Skip to content

Commit afdcbe0

Browse files
committed
Add direct spectrogram generation to voltage backend
- Implement `to_spectrogram` method in `RawVoltageBackend` for generating spectrograms directly from voltage data without intermediate RAW file processing. - Enhance CLI options to include frequency range and fine-channel FFT method for spectrogram generation. - Update `PolyphaseFilterbank` to support selected coarse channelization methods and validate input parameters. - Modify `RawReductionSpec` to include frequency range and methods for coarse and fine channelization. - Add tests for new functionality, ensuring selected channelization methods match full slices and validating spectrogram outputs. - Introduce benchmarking script for comparing performance of full FFT slicing against selected-bin PFB channelization. - Create `spectrogram.py` module to handle spectrogram specifications and results, including writing output to files.
1 parent 4cfaf00 commit afdcbe0

14 files changed

Lines changed: 1215 additions & 17 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import os
5+
import time
6+
from collections.abc import Iterable
7+
8+
import numpy as np
9+
10+
import setigen as stg
11+
12+
13+
def _parse_counts(raw_counts: str) -> list[int]:
14+
"""Parse a comma-separated list of selected coarse-channel counts."""
15+
return [int(item.strip()) for item in raw_counts.split(",") if item.strip()]
16+
17+
18+
def _time_call(repeats: int, func: object) -> float:
19+
"""Return the best runtime across repeated calls."""
20+
timings = []
21+
for _ in range(repeats):
22+
start = time.perf_counter()
23+
func()
24+
timings.append(time.perf_counter() - start)
25+
return min(timings)
26+
27+
28+
def run_benchmark(
29+
*,
30+
num_taps: int,
31+
num_branches: int,
32+
windows: int,
33+
counts: Iterable[int],
34+
repeats: int,
35+
seed: int,
36+
) -> None:
37+
"""Benchmark full-FFT slicing against selected-bin PFB channelization."""
38+
rng = np.random.default_rng(seed)
39+
samples = rng.standard_normal(num_taps * num_branches * windows)
40+
41+
print(
42+
"backend={backend} taps={taps} branches={branches} windows={windows} repeats={repeats}".format(
43+
backend="cupy" if os.getenv("SETIGEN_ENABLE_GPU") == "1" else "numpy",
44+
taps=num_taps,
45+
branches=num_branches,
46+
windows=windows,
47+
repeats=repeats,
48+
)
49+
)
50+
print("channels,full_seconds,selected_seconds,speedup")
51+
52+
for count in counts:
53+
full_filterbank = stg.voltage.PolyphaseFilterbank(
54+
num_taps=num_taps,
55+
num_branches=num_branches,
56+
)
57+
selected_filterbank = stg.voltage.PolyphaseFilterbank(
58+
num_taps=num_taps,
59+
num_branches=num_branches,
60+
)
61+
62+
full_seconds = _time_call(
63+
repeats,
64+
lambda: full_filterbank.channelize(
65+
samples,
66+
cache=False,
67+
start_chan=0,
68+
num_chans=count,
69+
method="full",
70+
),
71+
)
72+
selected_seconds = _time_call(
73+
repeats,
74+
lambda: selected_filterbank.channelize(
75+
samples,
76+
cache=False,
77+
start_chan=0,
78+
num_chans=count,
79+
method="selected",
80+
),
81+
)
82+
speedup = full_seconds / selected_seconds if selected_seconds else float("inf")
83+
print(f"{count},{full_seconds:.8f},{selected_seconds:.8f},{speedup:.3f}")
84+
85+
86+
def main() -> None:
87+
"""Run the selected-bin voltage benchmark."""
88+
parser = argparse.ArgumentParser(description=__doc__)
89+
parser.add_argument("--num-taps", type=int, default=8)
90+
parser.add_argument("--num-branches", type=int, default=1024)
91+
parser.add_argument("--windows", type=int, default=16)
92+
parser.add_argument("--counts", default="1,2,4,8,16,64")
93+
parser.add_argument("--repeats", type=int, default=5)
94+
parser.add_argument("--seed", type=int, default=0)
95+
args = parser.parse_args()
96+
97+
run_benchmark(
98+
num_taps=args.num_taps,
99+
num_branches=args.num_branches,
100+
windows=args.windows,
101+
counts=_parse_counts(args.counts),
102+
repeats=args.repeats,
103+
seed=args.seed,
104+
)
105+
106+
107+
if __name__ == "__main__":
108+
main()

docs/source/setigen.voltage.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,18 @@ setigen.voltage.waterfall module
6262
:undoc-members:
6363
:show-inheritance:
6464

65+
setigen.voltage.reduction module
66+
--------------------------------
67+
68+
.. automodule:: setigen.voltage.reduction
69+
:members:
70+
:undoc-members:
71+
:show-inheritance:
72+
73+
setigen.voltage.spectrogram module
74+
----------------------------------
75+
76+
.. automodule:: setigen.voltage.spectrogram
77+
:members:
78+
:undoc-members:
79+
:show-inheritance:

docs/source/voltages.rst

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,61 @@ acceleration, and rawspec-style polarization semantics for ``pol_mode=1``,
139139
directly and are not wrapped in :class:`~setigen.frame.Frame`, which remains a
140140
2D total-power interface.
141141

142+
Direct voltage spectrograms
143+
---------------------------
144+
145+
When the desired product is a dynamic spectrum rather than a persistent RAW
146+
file, :meth:`~setigen.voltage.backend.RawVoltageBackend.to_spectrogram` can run
147+
the same exact voltage path and skip the intermediate RAW serialization step.
148+
The backend still generates voltage samples, applies the digitizer, coarse PFB,
149+
and complex requantizer, and then performs fine channelization and integration
150+
directly in Python.
151+
152+
.. code-block:: python
153+
154+
spec = stg.voltage.VoltageSpectrogramSpec(
155+
fftlength=1024,
156+
integration_factor=4,
157+
pol_mode=stg.voltage.PolarizationMode.TOTAL_POWER,
158+
coarse_method='auto',
159+
fine_method='auto',
160+
)
161+
162+
result = rvb.to_spectrogram(spec,
163+
num_blocks=1,
164+
length_mode='num_blocks',
165+
verbose=False)
166+
frame = result.to_frame()
167+
168+
The ``coarse_method`` and ``fine_method`` options accept ``'auto'``,
169+
``'full'``, and ``'selected'``. Automatic selection is used only for exact
170+
methods that preserve the current channel response and FFT normalization. For
171+
small coarse-channel or fine-channel selections, ``'auto'`` may compute only
172+
the requested DFT bins; otherwise it falls back to the full FFT path.
173+
174+
For targeted analysis, the direct spectrogram and RAW reduction APIs support
175+
frequency-region selection on the final fine-channel axis:
176+
177+
.. code-block:: python
178+
179+
roi = stg.voltage.VoltageSpectrogramSpec(
180+
fftlength=1024,
181+
integration_factor=4,
182+
frequency_range=(6001.0e6, 6001.5e6),
183+
fine_method='selected',
184+
)
185+
186+
frame = rvb.to_spectrogram(roi,
187+
num_blocks=1,
188+
length_mode='num_blocks',
189+
verbose=False).to_frame()
190+
191+
The frequency range is mutually exclusive with ``start_chan`` and
192+
``num_chans``. Existing RAW generation remains available through
193+
:meth:`~setigen.voltage.backend.RawVoltageBackend.record`; direct spectrogram
194+
generation does not write a RAW file unless users separately call
195+
``record``.
196+
142197
Using GPU acceleration
143198
----------------------
144199

setigen/voltage/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
reduce_raw,
2525
reduce_raw_to_frame,
2626
)
27+
from setigen.voltage.spectrogram import (
28+
VoltageSpectrogramResult,
29+
VoltageSpectrogramSpec,
30+
)
2731

2832
__all__ = [
2933
"DataStream",
@@ -52,4 +56,6 @@
5256
"RawReductionSpec",
5357
"reduce_raw",
5458
"reduce_raw_to_frame",
59+
"VoltageSpectrogramResult",
60+
"VoltageSpectrogramSpec",
5561
]

setigen/voltage/_backend/pipeline.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,9 @@ def _channelize_voltage(
227227
antenna: int,
228228
pol: int,
229229
digitize: bool,
230+
start_chan: int | None = None,
231+
num_chans: int | None = None,
232+
coarse_method: str = "auto",
230233
) -> Any:
231234
"""Digitize, PFB-channelize, and coarse-channel select one voltage stream.
232235
@@ -236,18 +239,33 @@ def _channelize_voltage(
236239
antenna: Antenna index.
237240
pol: Polarization index.
238241
digitize: Whether to digitize before PFB channelization.
242+
start_chan: Optional first coarse channel to return. Defaults to the
243+
backend recording start channel.
244+
num_chans: Optional number of coarse channels to return. Defaults to
245+
the backend recording channel count.
246+
coarse_method: Coarse-channel transform method for the PFB.
239247
240248
Returns:
241249
Complex coarse-channel voltages for the selected channel slice.
242250
"""
251+
if start_chan is None:
252+
start_chan = backend.start_chan
253+
if num_chans is None:
254+
num_chans = backend.num_chans
255+
243256
if digitize:
244257
start = time.time()
245258
v = backend.digitizer[antenna][pol].quantize(v)
246259
backend.digitizer_stage_t += time.time() - start
247260

248261
start = time.time()
249-
v = backend.filterbank[antenna][pol].channelize(v, cache=True)
250-
v = v[:, backend.start_chan : backend.start_chan + backend.num_chans]
262+
v = backend.filterbank[antenna][pol].channelize(
263+
v,
264+
cache=True,
265+
start_chan=start_chan,
266+
num_chans=num_chans,
267+
method=coarse_method,
268+
)
251269
backend.filterbank_stage_t += time.time() - start
252270
return v
253271

0 commit comments

Comments
 (0)