Skip to content

Spectrum Analyzer

doppler.analyzer.Specan (C: dp_specan_create) takes the instrument parameters an operator knows: span and RBW, plus center and reference level. From those two it derives everything underneath: the decimated rate, the transform length and the Kaiser window. This page is that derivation. It lives once, in native/src/specan/specan_core.c.

The three rules

1. Span sets the rate.

fs_out = 1.28 · span
±span/2 = ±fs_out/2.56 = bin ±k,   k = nfft/2.56

k is an integer for every power-of-two nfft ≥ 256, so the span edges are bins, not points between them. The display is the 2k + 1 bins from −k to +k. The DDC passband is ±0.4·fs_out = ±0.512·span, so the whole display sits inside it.

A span the input cannot supply (1.28·span > fs) is clamped to fs/1.28. It is not kept with fs_out = fs, which would put its edges between bins. span = 0 means auto: the whole input band, fs/1.28.

2. The window is a power of two; the transform pads it only if needed.

base RBW = 2 · fs_out / n       (Kaiser ENBW of 2 bins of the window)
n        = the SMALLEST power of two with base RBW ≤ rbw, at least 16
nfft     = max(n, 512)          →  2k + 1 ≥ 401 display bins

The base RBW is the narrowest a given window offers. An ENBW of 2 bins is Kaiser beta ≈ 12, with peak sidelobes near −90 dB. A narrow RBW needs a long window, which fills the transform exactly. A wide RBW needs a short one, and only then is it zero-padded up to the 512 points the display needs.

3. Beta widens the ENBW to meet the request.

target ENBW = rbw · n / fs_out         ∈ [2, 4) bins
beta        = cubic fit of ENBW        (below)
auto RBW    = span / 100
widest RBW  = 4 · fs_out / 16 = fs_out / 4

rbw = 0 means auto. A request wider than 4 bins of the shortest window is clamped.

Specan.rbw and Specan.span report what was realised, which a clamp can make differ from the request.

from doppler.analyzer import Specan

sa = Specan(fs=2.048e6, span=200e3, rbw=500.0)
assert sa.fs_out == 1.28 * 200e3           # rule 1
assert (sa.n, sa.nfft) == (1024, 1024)     # rule 2: 2·256e3/500, no pad
assert sa.display_size == 801              # 2·round(1024/2.56) + 1
assert abs(sa.rbw / 500.0 - 1) < 0.001     # rule 3, realised
assert sa.beta > 11.5                      # never below 2 bins of ENBW

wide = Specan(fs=2.048e6, span=0.0, rbw=0.0)   # both auto
assert wide.span == 2.048e6 / 1.28             # the whole input band
assert abs(wide.rbw / (wide.span / 100) - 1) < 0.001

coarse = Specan(fs=2.048e6, span=200e3, rbw=20e3)
assert (coarse.n, coarse.nfft) == (32, 512)    # a short window, padded

Why every RBW gets the same skirt

The rule this replaced chose the smallest power of two ≥ fs_out/rbw. That left the ENBW target anywhere in [1, 2) bins, so the sidelobe level depended on where the RBW fell against a power of two. At exactly fs_out/2^k the target was 1 bin, which only a rectangle meets: beta 0, −13 dB sidelobes. Measured on the specan demo's geometry (fs_out = 2.048 MHz):

RBW (Hz) old beta old peak sidelobe new beta new peak sidelobe
2000 0.0 −13 dB 11.9 −89 dB
3000 6.3 −46 dB 11.9 −89 dB
3999 11.9 −89 dB 11.9 −89 dB
4000 0.0 −13 dB 11.9 −89 dB
6000 6.3 −46 dB 11.9 −89 dB

The demo records at 4 kHz. When the C port clamped fs_out to the input rate, it moved the demo from 1024/1.95 bins onto 512/1.00 bins, and the trace showed rectangle sidelobes. Under these rules the ENBW target is never below 2 bins, so beta is never below ≈ 12.

The beta fit

Beta comes from a cubic least-squares fit of beta against ENBW, from np.kaiser(4096, beta) for beta from 10 to 55, restricted to ENBW from 1.98 to 4.02:

beta(e) = 0.00590559 e³ + 3.07532701 e² + 0.24521102 e − 0.960144
e       = target ENBW · (n − 1) / n

The (n − 1)/n is the window's own length. A symmetric n-point window spans n − 1 sample intervals, so its ENBW is the long-window value times n/(n − 1). Uncorrected, that was the whole fit error: 0.18% at 512 and 6.7% at 16. Corrected, the realised ENBW is within 0.03% of the target for every n ≥ 16; at n = 8 it breaks (7%), which is why 16 is the shortest window. The analyzer reports the RBW the window realises, so the fit decides how close that lands to the request, not what is reported. It replaces a 60-step bisection that built the window once per step.

Which window a call shows: the newest

Here a window is the block of decimated samples one spectrum is computed from: n * navg samples, the length dp_specan_execute waits for before it can make a spectrum. It is not the Kaiser taper of the transform.

A call to dp_specan_execute consumes every complete window of decimated input it holds and returns the spectrum of the newest one. The older complete windows are dropped, not queued. The spectrum is one window: the estimator is reset before that window is folded in, so no average runs across calls.

After a call the pending buffer holds fewer than one window, so it stays bounded at one window of samples. The state blob is sized for that bound.

dp_specan_retune clears the pending buffer, so the first spectrum after a retune is built only from samples taken at the new tuning.

A display keeps up by showing the latest spectrum. A stream that must not drop a window is a spectrogram, one row per window, which Spectrogram provides.

The source handshake

Every front end runs one loop, SpecanEngine.step(source): read a block of engine.block_size samples, process it, and then, when the frame's data_size differs from the one the source was last told, call source.set_fft_size(data_size). The terminal, the web server and the recorder all call it, so they cannot disagree about what the source was told.

The announce follows process. The first frame at a new size is therefore computed from a block read before the source was told, so its samples carry the old scale. The source hears the new size for the block after it.

Three kinds of change rebuild the chain and can change data_size: a change of input rate or centre frequency (_init_chain), a span change (zoom), and an rbw change. The announce keys on the value, not on the change.

data_size is the Kaiser window and RBW frame length. A source scales its noise by it (DemoSource); its tones are scaled by their own amplitude. It is not the display bin count, the cropped fft_size a frame carries. For a data_size below 512 the transform is padded to 512 bins, and the display count follows that transform, so a change of data_size there can leave fft_size unchanged. Keying the announce on the display bins would miss such a change. The 8 to 16 kHz step at 2.048 MHz is one (data_size 512 to 256, with fft_size 401 throughout). Before the one loop existed, the recorder sent the display bin count, and the web server never sent anything, so the demo kept its default level.

The block size already carries the 4096-sample floor, so no front end adds its own.

One dBm definition

Amplitude 1.0 is +10 dBm into 50 ohm, peak volts. The engine offsets its output by DBM_AT_UNIT_AMPLITUDE (10 dBm), and dbm_to_amplitude is the inverse that a demo source uses for its tone and noise levels. Those are the only two places the convention is written down.

The NATS sources: how a read is served

SocketSource and PullSource share one ring and differ only in how they receive. A read(n) returns the next n complex64 samples, or fewer when the stream stops first. A negative n raises ValueError.

  • Short reads. A read receives packets only while it holds fewer than n samples. A timeout or an end-of-stream frame ends the read and returns what is held: k < n samples, possibly none. Samples received but not returned stay in the ring for the next read.
  • The ring. One F32Buffer, created at the first packet with the smallest power of two at least n + 2p capacity, for a read of n and a packet of p samples (doppler.util.next_pow_two). A packet is written whole only when the ring's capacity is at least n + p. A ring smaller than that is rebuilt at the smallest power of two at least held + p + n, which copies the held samples once. A read larger than the ring grows it at the first packet that needs room, not at the call. A read takes min(n, held) samples, lends them with peek, copies them, and releases them with consume; it never waits on the ring.
  • A refused write raises. A packet the ring was sized for cannot be refused. If one is, read() raises RuntimeError rather than drop it.
  • Acks (PullSource). A frame is acked after its samples are copied into the ring and before read() returns. The packet generator suspends at the yield with the frame unacked; the ack runs when the reader resumes the generator, or closes it at the end of the read. A read that raises closes the generator the same way.
  • Carve-out. A frame is acked in that finally even when its copy failed: a write or a grow that raises while the generator is suspended at the yield. The broker then never redelivers it, and its samples are lost. No counter records this, and the behaviour is unchanged from before the ring. It is an unchecked item on #2029.
  • Threads. read() and the receive run on the caller's thread. The DSP loop calls source.read; in the server, _next_frame runs on the single-worker _dsp_executor. The NATS I/O thread only fills the subscription queue. The Pull ack takes a mutex, so an ack from another thread is safe; the counters duplicates, gaps, restarts and undecodable are updated on the caller's thread.
  • Cost. A drain of T samples costs time linear in T: one copy per packet and one copy per doubling of the ring. The measured ratio of drain time at 2T to T is in the pull request, not pinned here.
  • Checked by src/doppler/specan/tests/test_nats_source_model.py: seeded schedules of packets, timeouts, end-of-stream frames, redeliveries, gaps, restarts and undecodable frames, with read sizes drawn at random. Every read is compared with a plain-list model of the same rules, including the four counters and the ack list after each read. The fakes poison a frame when it is acked (PullSource) or at the next receive (SocketSource), so a copy made after that point reads NaN and fails. Rebuilds with samples held, refused writes and negative reads each have a test of their own.

How it is checked

All in native/tests/test_specan_core.c, over eleven RBWs from a 2048-point window (250 Hz) to the 16-point minimum (60 kHz, padded 32×). Four of them (250, 500, 1000 and 2000 Hz at fs_out = 256 kHz) are exactly the fs_out/2^k case.

Claim Check
Rule 1 fs_out = 1.28·span; nfft % 256 == 0; the edge bin is exactly span/2; span 0 and an oversize span both give fs/1.28
Rule 2 n a power of two ≥ 16, base RBW ≤ rbw and half of n would not fit; nfft = max(n, 512)
Rule 3 ENBW from 2 to 4 bins, beta > 11.5, realised RBW within 0.1%; rbw 0 is span/100; a request past 4 bins of the 16-point window clamps to fs_out/4
Skirt a noiseless tone sits ≥ 80 dB above every bin outside its main lobe, wherever the main lobe leaves room to look
ENBW, measured unit-variance white noise reads rbw/fs per bin within 0.1 dB, with equal total noise at every RBW: integrated noise power confirms the ENBW from the output, against the RBW requested

Each was proven by sabotage, and each turns the test red:

Sabotage Failures
restore the old ENBW [1, 2) rule 54
scale the beta fit by 3% 14
drop the (n − 1)/n correction 12
never pad (nfft = n) 9
solve beta for an ENBW 5% wide 25

The last one is caught by the noise check on its own at every RBW (+0.15 to +0.25 dB off N0·RBW), as well as by the realised-RBW bound.