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.
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:
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
nsamples. A timeout or an end-of-stream frame ends the read and returns what is held:k < nsamples, 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 leastn + 2pcapacity, for a read ofnand a packet ofpsamples (doppler.util.next_pow_two). A packet is written whole only when the ring's capacity is at leastn + p. A ring smaller than that is rebuilt at the smallest power of two at leastheld + 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 takesmin(n, held)samples, lends them withpeek, copies them, and releases them withconsume; it never waits on the ring. - A refused write raises. A packet the ring was sized for cannot be
refused. If one is,
read()raisesRuntimeErrorrather than drop it. - Acks (
PullSource). A frame is acked after its samples are copied into the ring and beforeread()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
finallyeven when its copy failed: awriteor 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 callssource.read; in the server,_next_frameruns on the single-worker_dsp_executor. The NATS I/O thread only fills the subscription queue. ThePullack takes a mutex, so an ack from another thread is safe; the countersduplicates,gaps,restartsandundecodableare updated on the caller's thread. - Cost. A drain of
Tsamples costs time linear inT: one copy per packet and one copy per doubling of the ring. The measured ratio of drain time at2TtoTis 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.