Fix spectrum(): summarise every bin in each display bucket
The bug
quality.spectrum() promised to summarise Goertzel bins into display buckets, but evaluated only every step-th DFT bin and skipped the rest. On a record longer than a couple of seconds a narrow line fell between the evaluated bins and disappeared. A 260-count 10 Hz sine in noise over 20 s drew its tallest point at 35.65 Hz, about 1.7 counts. The Monitor tab’s spectrum (POST /spectrum, up to 20 s every ~500 ms) was affected the same way.
The fix
- Every transform bin in the range is evaluated, and each bucket reports the maximum amplitude of the bins it covers. A maximum keeps a narrow line at its own amplitude however long the record is; a mean of power would dilute it. The price, stated in the docstring: the noise floor of the display rises slowly with record length, and it is not a power density.
- Cost: one stdlib radix-2 FFT of the Hann-windowed record, zero-padded to the next power of two at or above twice its length, over at most the most recent 16,384 samples (65 s at 250 Hz). The padding bounds the scalloping loss at 4 %.
freqsare now bucket centres.resolution_hzkeeps its meaning (rate / samples used). New fields:bucket_hz,bin_hz,samples_used,summary. The web client needs no change.
| Record | Tallest point | Amplitude (input 260) | Time |
|---|---|---|---|
| 2 s | 10.01 Hz | 259.5 | 3 ms |
| 20 s | 9.97 Hz | 256.8 | 56 ms |
| 60 s | 9.98 Hz | 257.0 | 119 ms |
Checks
apps/server/dev_check.py: a 10 Hz sine in noise at 2 s, 20 s and 60 s must be the tallest point, within one bucket of 10 Hz and within 10 % of the input amplitude; plus a check on the sample cap.python3 scripts/check.py: all seven suites pass.check_docs.pyrun against the blog post: in step. ruff clean.
Versioning
No version bump and no model-v* tag. quality.py is in the measuring chain, but spectrum() feeds only the drawing: nothing it returns is stored in a session or an export, and assess() and bands() are unchanged. The CHANGELOG entry says so. If this should be 0.2.1 instead, that is a follow-up commit here.
🤖 Generated with Claude Code